Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Discord Channel Specification

Overview

Full Discord Bot API implementation using serenity crate for Rust.

Dependencies

[dependencies]
serenity = { version = "0.12", features = ["client", "gateway", "cache"] }
tokio = { version = "1", features = ["full"] }

Authentication

#![allow(unused)]
fn main() {
pub struct DiscordConfig {
    /// Bot token from Discord Developer Portal
    pub token: SecretString,

    /// Application ID
    pub application_id: u64,

    /// Gateway intents
    pub intents: GatewayIntents,

    /// Guild IDs to listen to (empty = all)
    pub guild_ids: Vec<u64>,

    /// Whether to enable slash commands
    pub slash_commands: bool,
}

impl Default for DiscordConfig {
    fn default() -> Self {
        Self {
            token: SecretString::new(String::new()),
            application_id: 0,
            intents: GatewayIntents::GUILDS
                | GatewayIntents::GUILD_MESSAGES
                | GatewayIntents::DIRECT_MESSAGES
                | GatewayIntents::MESSAGE_CONTENT,
            guild_ids: vec![],
            slash_commands: true,
        }
    }
}
}

Channel Implementation

#![allow(unused)]
fn main() {
pub struct DiscordChannel {
    config: DiscordConfig,
    client: Option<Client>,
    http: Option<Arc<Http>>,
    cache: Option<Arc<Cache>>,
    message_tx: mpsc::Sender<InboundMessage>,
    message_rx: mpsc::Receiver<InboundMessage>,
    connected: AtomicBool,
}

#[async_trait]
impl Channel for DiscordChannel {
    fn id(&self) -> &str { "discord" }
    fn name(&self) -> &str { "Discord" }

    fn capabilities(&self) -> ChannelCapabilities {
        ChannelCapabilities {
            chat_types: vec![ChatType::Direct, ChatType::Group, ChatType::Channel, ChatType::Thread],
            media: MediaCapabilities {
                images: true,
                audio: true,
                video: true,
                files: true,
                stickers: true,
                voice_notes: false,
                max_file_size_mb: 25, // 100MB for Nitro
            },
            features: ChannelFeatures {
                reactions: true,
                threads: true,
                edits: true,
                deletes: true,
                typing_indicators: true,
                read_receipts: false,
                mentions: true,
                polls: true,
                buttons: true,
                inline_queries: false,
                commands: true,
                markdown: true,
                html: false,
            },
            limits: ChannelLimits {
                max_message_length: 2000,
                max_caption_length: 2000,
                max_buttons_per_row: 5,
                max_button_rows: 5,
            },
        }
    }

    async fn connect(&mut self) -> Result<()>;
    async fn disconnect(&mut self) -> Result<()>;
    fn is_connected(&self) -> bool;
    async fn health(&self) -> Result<ChannelHealth>;
    async fn send(&self, message: OutboundMessage) -> Result<SendResult>;
    async fn edit(&self, target: MessageTarget, new_content: &str) -> Result<()>;
    async fn delete(&self, target: MessageTarget) -> Result<()>;
    async fn react(&self, target: MessageTarget, emoji: &str) -> Result<()>;
    async fn unreact(&self, target: MessageTarget, emoji: &str) -> Result<()>;
    async fn typing(&self, chat_id: &str) -> Result<()>;
    fn messages(&self) -> Pin<Box<dyn Stream<Item = InboundMessage> + Send>>;
}
}

Message Handling

Inbound Message Conversion

#![allow(unused)]
fn main() {
impl DiscordChannel {
    fn convert_message(&self, msg: serenity::model::channel::Message) -> InboundMessage {
        InboundMessage {
            id: MessageId::new(msg.id.to_string()),
            timestamp: msg.timestamp.to_utc(),
            channel: "discord".to_string(),
            account_id: self.config.application_id.to_string(),
            sender: SenderInfo {
                id: msg.author.id.to_string(),
                username: Some(msg.author.name.clone()),
                display_name: msg.author.global_name.clone(),
                phone_number: None,
                is_bot: msg.author.bot,
            },
            chat: ChatInfo {
                id: msg.channel_id.to_string(),
                chat_type: self.determine_chat_type(&msg),
                title: None, // Fetched separately for guilds
                guild_id: msg.guild_id.map(|id| id.to_string()),
            },
            text: msg.content.clone(),
            media: self.extract_media(&msg),
            quote: msg.referenced_message.as_ref().map(|r| QuotedMessage {
                id: r.id.to_string(),
                text: Some(r.content.clone()),
                sender_id: Some(r.author.id.to_string()),
            }),
            thread: msg.thread.as_ref().map(|t| ThreadInfo {
                id: t.id.to_string(),
                name: Some(t.name.clone()),
            }),
            metadata: serde_json::json!({
                "guild_id": msg.guild_id,
                "channel_type": format!("{:?}", msg.kind),
                "webhook_id": msg.webhook_id,
                "components": msg.components,
            }),
        }
    }

    fn determine_chat_type(&self, msg: &serenity::model::channel::Message) -> ChatType {
        match msg.guild_id {
            Some(_) => match msg.channel(&self.cache) {
                Some(Channel::Guild(gc)) => match gc.kind {
                    ChannelType::Text => ChatType::Channel,
                    ChannelType::PublicThread | ChannelType::PrivateThread => ChatType::Thread,
                    _ => ChatType::Group,
                },
                _ => ChatType::Group,
            },
            None => ChatType::Direct,
        }
    }

    fn extract_media(&self, msg: &serenity::model::channel::Message) -> Vec<MediaAttachment> {
        let mut media = Vec::new();

        for attachment in &msg.attachments {
            let media_type = self.guess_media_type(&attachment.content_type);
            media.push(MediaAttachment {
                attachment_type: media_type,
                url: Some(attachment.url.clone()),
                file_id: Some(attachment.id.to_string()),
                file_path: None,
                mime_type: attachment.content_type.clone(),
                file_name: Some(attachment.filename.clone()),
                file_size: Some(attachment.size as u64),
                caption: None,
            });
        }

        for embed in &msg.embeds {
            if let Some(ref image) = embed.image {
                media.push(MediaAttachment {
                    attachment_type: MediaType::Image,
                    url: Some(image.url.clone()),
                    file_id: None,
                    file_path: None,
                    mime_type: None,
                    file_name: None,
                    file_size: None,
                    caption: embed.description.clone(),
                });
            }
        }

        media
    }

    fn guess_media_type(&self, content_type: &Option<String>) -> MediaType {
        match content_type.as_deref() {
            Some(ct) if ct.starts_with("image/") => MediaType::Image,
            Some(ct) if ct.starts_with("video/") => MediaType::Video,
            Some(ct) if ct.starts_with("audio/") => MediaType::Audio,
            _ => MediaType::Document,
        }
    }
}
}

Outbound Message Sending

#![allow(unused)]
fn main() {
impl DiscordChannel {
    async fn send_text(&self, channel_id: ChannelId, message: &OutboundMessage) -> Result<Message> {
        let http = self.http.as_ref().ok_or(ChannelError::NotConnected)?;

        channel_id.send_message(http, |m| {
            m.content(&message.text);

            if let Some(reply_to) = &message.reply_to {
                if let Ok(msg_id) = reply_to.parse::<u64>() {
                    m.reference_message(MessageId::new(msg_id));
                }
            }

            if let Some(buttons) = &message.buttons {
                m.components(|c| {
                    for row in buttons {
                        c.create_action_row(|r| {
                            for btn in row {
                                r.create_button(|b| {
                                    b.label(&btn.text);
                                    match &btn.action {
                                        ButtonAction::Callback(data) => {
                                            b.custom_id(data);
                                            b.style(ButtonStyle::Primary);
                                        }
                                        ButtonAction::Url(url) => {
                                            b.url(url);
                                            b.style(ButtonStyle::Link);
                                        }
                                    }
                                    b
                                });
                            }
                            r
                        });
                    }
                    c
                });
            }

            m
        }).await.map_err(|e| ChannelError::SendFailed(e.to_string()))
    }

    async fn send_media(&self, channel_id: ChannelId, message: &OutboundMessage) -> Result<Message> {
        let http = self.http.as_ref().ok_or(ChannelError::NotConnected)?;

        let files: Vec<_> = message.media.iter()
            .filter_map(|m| {
                m.file_path.as_ref().map(|path| {
                    CreateAttachment::path(path)
                })
            })
            .collect();

        channel_id.send_files(http, files, |m| {
            if !message.text.is_empty() {
                m.content(&message.text);
            }
            m
        }).await.map_err(|e| ChannelError::SendFailed(e.to_string()))
    }
}
}

Discord-Specific Actions

#![allow(unused)]
fn main() {
impl DiscordChannel {
    /// Create a thread from a message
    pub async fn create_thread(
        &self,
        channel_id: ChannelId,
        message_id: MessageId,
        name: &str,
        auto_archive_duration: AutoArchiveDuration,
    ) -> Result<GuildChannel> {
        let http = self.http.as_ref().ok_or(ChannelError::NotConnected)?;
        channel_id.create_public_thread(http, message_id, |t| {
            t.name(name).auto_archive_duration(auto_archive_duration)
        }).await.map_err(|e| ChannelError::ActionFailed(e.to_string()))
    }

    /// Get guild member information
    pub async fn get_member(&self, guild_id: GuildId, user_id: UserId) -> Result<Member> {
        let http = self.http.as_ref().ok_or(ChannelError::NotConnected)?;
        guild_id.member(http, user_id)
            .await
            .map_err(|e| ChannelError::ActionFailed(e.to_string()))
    }

    /// Get guild role information
    pub async fn get_roles(&self, guild_id: GuildId) -> Result<Vec<Role>> {
        let http = self.http.as_ref().ok_or(ChannelError::NotConnected)?;
        guild_id.roles(http)
            .await
            .map_err(|e| ChannelError::ActionFailed(e.to_string()))
    }

    /// Timeout a member
    pub async fn timeout_member(
        &self,
        guild_id: GuildId,
        user_id: UserId,
        duration: Duration,
        reason: Option<&str>,
    ) -> Result<()> {
        let http = self.http.as_ref().ok_or(ChannelError::NotConnected)?;
        let timeout_until = Utc::now() + duration;
        guild_id.edit_member(http, user_id, |m| {
            m.disable_communication_until(timeout_until.to_rfc3339());
            if let Some(r) = reason {
                m.audit_log_reason(r);
            }
            m
        }).await.map_err(|e| ChannelError::ActionFailed(e.to_string()))?;
        Ok(())
    }

    /// Kick a member
    pub async fn kick_member(
        &self,
        guild_id: GuildId,
        user_id: UserId,
        reason: Option<&str>,
    ) -> Result<()> {
        let http = self.http.as_ref().ok_or(ChannelError::NotConnected)?;
        guild_id.kick_with_reason(http, user_id, reason.unwrap_or(""))
            .await
            .map_err(|e| ChannelError::ActionFailed(e.to_string()))
    }

    /// Ban a member
    pub async fn ban_member(
        &self,
        guild_id: GuildId,
        user_id: UserId,
        delete_message_days: u8,
        reason: Option<&str>,
    ) -> Result<()> {
        let http = self.http.as_ref().ok_or(ChannelError::NotConnected)?;
        guild_id.ban_with_reason(http, user_id, delete_message_days, reason.unwrap_or(""))
            .await
            .map_err(|e| ChannelError::ActionFailed(e.to_string()))
    }

    /// List guild channels
    pub async fn list_channels(&self, guild_id: GuildId) -> Result<Vec<GuildChannel>> {
        let http = self.http.as_ref().ok_or(ChannelError::NotConnected)?;
        guild_id.channels(http)
            .await
            .map(|m| m.into_values().collect())
            .map_err(|e| ChannelError::ActionFailed(e.to_string()))
    }

    /// Create a scheduled event
    pub async fn create_event(
        &self,
        guild_id: GuildId,
        name: &str,
        description: Option<&str>,
        start_time: DateTime<Utc>,
        end_time: Option<DateTime<Utc>>,
        location: Option<&str>,
    ) -> Result<ScheduledEvent> {
        let http = self.http.as_ref().ok_or(ChannelError::NotConnected)?;
        guild_id.create_scheduled_event(http, |e| {
            e.name(name)
                .start_time(start_time)
                .entity_type(ScheduledEventType::External);
            if let Some(desc) = description {
                e.description(desc);
            }
            if let Some(end) = end_time {
                e.end_time(end);
            }
            if let Some(loc) = location {
                e.location(loc);
            }
            e
        }).await.map_err(|e| ChannelError::ActionFailed(e.to_string()))
    }
}
}

Slash Commands

#![allow(unused)]
fn main() {
impl DiscordChannel {
    pub async fn register_slash_commands(&self, guild_id: Option<GuildId>) -> Result<()> {
        let http = self.http.as_ref().ok_or(ChannelError::NotConnected)?;

        let commands = vec![
            CreateCommand::new("ask")
                .description("Ask the AI a question")
                .add_option(
                    CreateCommandOption::new(CommandOptionType::String, "question", "Your question")
                        .required(true)
                ),
            CreateCommand::new("status")
                .description("Get bot status"),
        ];

        match guild_id {
            Some(id) => {
                id.set_commands(http, commands).await?;
            }
            None => {
                Command::set_global_commands(http, commands).await?;
            }
        }

        Ok(())
    }
}
}

Error Handling

#![allow(unused)]
fn main() {
#[derive(Debug, thiserror::Error)]
pub enum DiscordError {
    #[error("Bot not connected")]
    NotConnected,

    #[error("Invalid channel ID: {0}")]
    InvalidChannelId(String),

    #[error("Invalid guild ID: {0}")]
    InvalidGuildId(String),

    #[error("Message not found: {0}")]
    MessageNotFound(String),

    #[error("Rate limited, retry after {0} seconds")]
    RateLimited(u32),

    #[error("Insufficient permissions: {0}")]
    InsufficientPermissions(String),

    #[error("Guild not found")]
    GuildNotFound,

    #[error("Member not found")]
    MemberNotFound,

    #[error("API error: {0}")]
    ApiError(String),
}

impl From<serenity::Error> for DiscordError {
    fn from(err: serenity::Error) -> Self {
        match err {
            serenity::Error::Http(http_err) => {
                DiscordError::ApiError(http_err.to_string())
            }
            serenity::Error::Model(model_err) => {
                DiscordError::ApiError(model_err.to_string())
            }
            _ => DiscordError::ApiError(err.to_string()),
        }
    }
}
}