generalbots/botserver/src/channels/discord.rs
Rodrigo Rodriguez (Pragmatismo) 037db5c381 feat: Major workspace reorganization and documentation update
- Add comprehensive documentation in botbook/ with 12 chapters
- Add botapp/ Tauri desktop application
- Add botdevice/ IoT device support
- Add botlib/ shared library crate
- Add botmodels/ Python ML models service
- Add botplugin/ browser extension
- Add botserver/ reorganized server code
- Add bottemplates/ bot templates
- Add bottest/ integration tests
- Add botui/ web UI server
- Add CI/CD workflows in .forgejo/workflows/
- Add AGENTS.md and PROD.md documentation
- Add dependency management scripts (DEPENDENCIES.sh/ps1)
- Remove legacy src/ structure and migrations
- Clean up temporary and backup files
2026-04-19 08:14:25 -03:00

537 lines
16 KiB
Rust

use crate::channels::{
ChannelAccount, ChannelCredentials, ChannelError, ChannelProvider, ChannelType,
PostContent, PostResult,
};
use serde::{Deserialize, Serialize};
pub struct DiscordProvider {
client: reqwest::Client,
base_url: String,
}
impl DiscordProvider {
pub fn new() -> Self {
Self {
client: reqwest::Client::new(),
base_url: "https://discord.com/api/v10".to_string(),
}
}
async fn send_message(
&self,
bot_token: &str,
channel_id: &str,
content: &MessageContent,
) -> Result<DiscordMessage, ChannelError> {
let response = self
.client
.post(format!("{}/channels/{}/messages", self.base_url, channel_id))
.header("Authorization", format!("Bot {}", bot_token))
.header("Content-Type", "application/json")
.json(content)
.send()
.await
.map_err(|e| ChannelError::NetworkError(e.to_string()))?;
let status = response.status();
if status.as_u16() == 429 {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok());
return Err(ChannelError::RateLimited { retry_after });
}
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(ChannelError::ApiError {
code: Some(status.to_string()),
message: error_text,
});
}
response
.json::<DiscordMessage>()
.await
.map_err(|e| ChannelError::ApiError {
code: None,
message: e.to_string(),
})
}
async fn send_webhook_message(
&self,
webhook_url: &str,
content: &WebhookContent,
) -> Result<DiscordMessage, ChannelError> {
let url = format!("{}?wait=true", webhook_url);
let response = self
.client
.post(&url)
.header("Content-Type", "application/json")
.json(content)
.send()
.await
.map_err(|e| ChannelError::NetworkError(e.to_string()))?;
let status = response.status();
if status.as_u16() == 429 {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok());
return Err(ChannelError::RateLimited { retry_after });
}
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(ChannelError::ApiError {
code: Some(status.to_string()),
message: error_text,
});
}
response
.json::<DiscordMessage>()
.await
.map_err(|e| ChannelError::ApiError {
code: None,
message: e.to_string(),
})
}
async fn get_current_user(&self, bot_token: &str) -> Result<DiscordUser, ChannelError> {
let response = self
.client
.get(format!("{}/users/@me", self.base_url))
.header("Authorization", format!("Bot {}", bot_token))
.send()
.await
.map_err(|e| ChannelError::NetworkError(e.to_string()))?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(ChannelError::AuthenticationFailed(error_text));
}
response
.json::<DiscordUser>()
.await
.map_err(|e| ChannelError::ApiError {
code: None,
message: e.to_string(),
})
}
fn build_message_url(guild_id: &str, channel_id: &str, message_id: &str) -> String {
format!(
"https://discord.com/channels/{}/{}/{}",
guild_id, channel_id, message_id
)
}
fn create_embeds(content: &PostContent) -> Vec<DiscordEmbed> {
let mut embeds = Vec::new();
if content.link.is_some() || !content.image_urls.is_empty() {
let mut embed = DiscordEmbed {
title: None,
description: None,
url: content.link.clone(),
color: Some(0x5865F2),
image: None,
thumbnail: None,
fields: vec![],
footer: None,
timestamp: None,
};
if let Some(first_image) = content.image_urls.first() {
embed.image = Some(EmbedImage {
url: first_image.clone(),
});
}
embeds.push(embed);
for image_url in content.image_urls.iter().skip(1).take(3) {
embeds.push(DiscordEmbed {
title: None,
description: None,
url: None,
color: None,
image: Some(EmbedImage {
url: image_url.clone(),
}),
thumbnail: None,
fields: vec![],
footer: None,
timestamp: None,
});
}
}
embeds
}
}
impl Default for DiscordProvider {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl ChannelProvider for DiscordProvider {
fn channel_type(&self) -> ChannelType {
ChannelType::Discord
}
fn max_text_length(&self) -> usize {
2000
}
fn supports_images(&self) -> bool {
true
}
fn supports_video(&self) -> bool {
true
}
fn supports_links(&self) -> bool {
true
}
async fn post(
&self,
account: &ChannelAccount,
content: &PostContent,
) -> Result<PostResult, ChannelError> {
let text = content.text.as_deref().unwrap_or("");
if text.len() > self.max_text_length() {
return Err(ChannelError::ContentTooLong {
max_length: self.max_text_length(),
actual_length: text.len(),
});
}
let mut full_text = text.to_string();
if !content.hashtags.is_empty() {
let tags = content
.hashtags
.iter()
.map(|t| {
if t.starts_with('#') {
t.clone()
} else {
format!("#{}", t)
}
})
.collect::<Vec<_>>()
.join(" ");
full_text = format!("{}\n\n{}", full_text, tags);
}
match &account.credentials {
ChannelCredentials::ApiKey { api_key, api_secret } => {
let channel_id = api_secret
.as_ref()
.ok_or_else(|| ChannelError::AuthenticationFailed(
"Channel ID required in api_secret".to_string(),
))?;
let embeds = Self::create_embeds(content);
let message_content = MessageContent {
content: if full_text.is_empty() { None } else { Some(full_text) },
embeds: if embeds.is_empty() { None } else { Some(embeds) },
tts: Some(false),
};
let message = self.send_message(api_key, channel_id, &message_content).await?;
let guild_id = account
.settings
.custom
.get("guild_id")
.and_then(|v| v.as_str())
.unwrap_or("@me");
let url = Self::build_message_url(guild_id, channel_id, &message.id);
Ok(PostResult::success(
ChannelType::Discord,
message.id,
Some(url),
))
}
ChannelCredentials::Custom { data } => {
let webhook_url = data
.get("webhook_url")
.ok_or_else(|| ChannelError::AuthenticationFailed(
"Webhook URL required".to_string(),
))?;
let username = data.get("username").cloned();
let avatar_url = data.get("avatar_url").cloned();
let embeds = Self::create_embeds(content);
let webhook_content = WebhookContent {
content: if full_text.is_empty() { None } else { Some(full_text) },
username,
avatar_url,
embeds: if embeds.is_empty() { None } else { Some(embeds) },
tts: Some(false),
};
let message = self.send_webhook_message(webhook_url, &webhook_content).await?;
Ok(PostResult::success(
ChannelType::Discord,
message.id,
None,
))
}
_ => Err(ChannelError::AuthenticationFailed(
"Invalid credentials type for Discord".to_string(),
)),
}
}
async fn validate_credentials(
&self,
credentials: &ChannelCredentials,
) -> Result<bool, ChannelError> {
match credentials {
ChannelCredentials::ApiKey { api_key, .. } => {
match self.get_current_user(api_key).await {
Ok(_) => Ok(true),
Err(ChannelError::AuthenticationFailed(_)) => Ok(false),
Err(e) => Err(e),
}
}
ChannelCredentials::Custom { data } => {
let webhook_url = data.get("webhook_url");
Ok(webhook_url.is_some())
}
_ => Ok(false),
}
}
async fn refresh_token(&self, _account: &mut ChannelAccount) -> Result<(), ChannelError> {
Ok(())
}
}
#[derive(Debug, Serialize)]
struct MessageContent {
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
embeds: Option<Vec<DiscordEmbed>>,
#[serde(skip_serializing_if = "Option::is_none")]
tts: Option<bool>,
}
#[derive(Debug, Serialize)]
struct WebhookContent {
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
avatar_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
embeds: Option<Vec<DiscordEmbed>>,
#[serde(skip_serializing_if = "Option::is_none")]
tts: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
struct DiscordEmbed {
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
color: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
image: Option<EmbedImage>,
#[serde(skip_serializing_if = "Option::is_none")]
thumbnail: Option<EmbedImage>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
fields: Vec<EmbedField>,
#[serde(skip_serializing_if = "Option::is_none")]
footer: Option<EmbedFooter>,
#[serde(skip_serializing_if = "Option::is_none")]
timestamp: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct EmbedImage {
url: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct EmbedField {
name: String,
value: String,
#[serde(skip_serializing_if = "Option::is_none")]
inline: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
struct EmbedFooter {
text: String,
#[serde(skip_serializing_if = "Option::is_none")]
icon_url: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct DiscordMessage {
pub id: String,
pub channel_id: String,
#[serde(default)]
pub content: String,
pub timestamp: String,
pub author: Option<DiscordUser>,
}
#[derive(Debug, Deserialize)]
pub struct DiscordUser {
pub id: String,
pub username: String,
pub discriminator: String,
#[serde(default)]
pub bot: bool,
}
#[derive(Debug, Serialize)]
struct CreateDMChannel {
recipient_id: String,
}
pub struct DiscordBotConfig {
pub token: String,
pub application_id: String,
pub guild_ids: Vec<String>,
}
impl DiscordProvider {
pub async fn send_dm(
&self,
bot_token: &str,
user_id: &str,
message: &str,
) -> Result<DiscordMessage, ChannelError> {
let dm_channel = self
.client
.post(format!("{}/users/@me/channels", self.base_url))
.header("Authorization", format!("Bot {}", bot_token))
.json(&CreateDMChannel {
recipient_id: user_id.to_string(),
})
.send()
.await
.map_err(|e| ChannelError::NetworkError(e.to_string()))?;
if !dm_channel.status().is_success() {
let error_text = dm_channel.text().await.unwrap_or_default();
return Err(ChannelError::ApiError {
code: None,
message: format!("Failed to create DM channel: {}", error_text),
});
}
#[derive(Deserialize)]
struct DMChannel {
id: String,
}
let channel: DMChannel = dm_channel
.json()
.await
.map_err(|e| ChannelError::ApiError {
code: None,
message: e.to_string(),
})?;
let content = MessageContent {
content: Some(message.to_string()),
embeds: None,
tts: Some(false),
};
self.send_message(bot_token, &channel.id, &content).await
}
pub async fn get_guild_channels(
&self,
bot_token: &str,
guild_id: &str,
) -> Result<Vec<GuildChannel>, ChannelError> {
let response = self
.client
.get(format!("{}/guilds/{}/channels", self.base_url, guild_id))
.header("Authorization", format!("Bot {}", bot_token))
.send()
.await
.map_err(|e| ChannelError::NetworkError(e.to_string()))?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(ChannelError::ApiError {
code: None,
message: error_text,
});
}
response
.json::<Vec<GuildChannel>>()
.await
.map_err(|e| ChannelError::ApiError {
code: None,
message: e.to_string(),
})
}
}
#[derive(Debug, Deserialize)]
pub struct GuildChannel {
pub id: String,
pub name: String,
#[serde(rename = "type")]
pub channel_type: u8,
pub guild_id: Option<String>,
pub position: Option<i32>,
pub topic: Option<String>,
}
impl GuildChannel {
pub fn is_text_channel(&self) -> bool {
self.channel_type == 0
}
pub fn is_voice_channel(&self) -> bool {
self.channel_type == 2
}
pub fn is_category(&self) -> bool {
self.channel_type == 4
}
pub fn is_announcement_channel(&self) -> bool {
self.channel_type == 5
}
}