Skip to main content

rig_agent/integrations/
discord_bot.rs

1//! Integration for deploying your Rig agents (and more) as Discord bots.
2//! This feature is not WASM-compatible (and as such, is incompatible with the `worker` feature).
3use crate::agent::Agent;
4use crate::completion::Chat;
5use rig_core::message::Message as RigMessage;
6use serenity::all::{
7    Command, CommandInteraction, Context, CreateCommand, CreateThread, EventHandler,
8    GatewayIntents, Interaction, Message, Ready, async_trait,
9};
10use std::collections::HashMap;
11use std::env;
12use std::sync::Arc;
13use thiserror::Error;
14use tokio::sync::RwLock;
15
16#[derive(Debug, Error)]
17pub enum DiscordBotError {
18    #[error("Discord bot token missing from environment: {0}")]
19    MissingToken(#[from] env::VarError),
20    #[error("Failed to build Discord client: {0}")]
21    ClientBuild(#[from] serenity::Error),
22}
23
24// Bot state containing the agent and conversation histories
25struct BotState {
26    agent: Agent,
27    conversations: Arc<RwLock<HashMap<u64, Vec<RigMessage>>>>,
28}
29
30impl BotState {
31    fn new(agent: Agent) -> Self {
32        Self {
33            agent,
34            conversations: Arc::new(RwLock::new(HashMap::new())),
35        }
36    }
37}
38
39// Event handler for the Discord bot
40struct Handler {
41    state: Arc<BotState>,
42}
43
44#[async_trait]
45impl EventHandler for Handler {
46    async fn ready(&self, ctx: Context, ready: Ready) {
47        println!("{} is connected!", ready.user.name);
48
49        let register_cmd =
50            CreateCommand::new("new").description("Start a new chat session with the bot");
51
52        // Register slash command globally
53        let command = Command::create_global_command(&ctx.http, register_cmd).await;
54
55        match command {
56            Ok(cmd) => println!("Registered global command: {}", cmd.name),
57            Err(e) => eprintln!("Failed to register command: {}", e),
58        }
59    }
60
61    async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
62        if let Interaction::Command(command) = interaction {
63            self.handle_command(&ctx, &command).await;
64        }
65    }
66
67    async fn message(&self, ctx: Context, msg: Message) {
68        // Ignore bot's own messages
69        if msg.author.bot {
70            return;
71        }
72
73        // Only respond to messages in threads created by the bot
74        let conversations = self.state.conversations.read().await;
75        if conversations.contains_key(&msg.channel_id.get()) {
76            drop(conversations);
77            self.handle_thread_message(&ctx, &msg).await;
78        }
79    }
80}
81
82impl Handler {
83    async fn handle_command(&self, ctx: &Context, command: &CommandInteraction) {
84        if command.data.name.as_str() == "new" {
85            // Defer the response to prevent timeout
86            if let Err(e) = command.defer(&ctx.http).await {
87                eprintln!("Failed to defer command: {}", e);
88                return;
89            }
90
91            // Create a new thread
92            let thread_name = format!("AI Conversation - {}", command.user.name);
93
94            let thread = match command
95                .channel_id
96                .create_thread(
97                    &ctx.http,
98                    CreateThread::new(thread_name)
99                        .kind(serenity::all::ChannelType::PublicThread)
100                        .auto_archive_duration(serenity::all::AutoArchiveDuration::OneDay),
101                )
102                .await
103            {
104                Ok(t) => t,
105                Err(e) => {
106                    eprintln!("Failed to create thread: {}", e);
107                    let _ = command
108                        .edit_response(
109                            &ctx.http,
110                            serenity::all::EditInteractionResponse::new()
111                                .content("Failed to create thread. Please try again."),
112                        )
113                        .await;
114                    return;
115                }
116            };
117
118            // Initialize conversation history for this thread
119            let mut conversations = self.state.conversations.write().await;
120            conversations.insert(thread.id.get(), Vec::new());
121            drop(conversations);
122
123            // Edit the deferred response
124            if let Err(e) = command
125                .edit_response(
126                    &ctx.http,
127                    serenity::all::EditInteractionResponse::new()
128                        .content(format!(
129                            "Started a new conversation in <#{}>! Send messages there to chat with the AI.",
130                            thread.id
131                        ))
132                )
133                .await
134            {
135                eprintln!("Failed to edit response: {}", e);
136            }
137
138            // Send welcome message to the thread
139            if let Err(e) = thread
140                .send_message(
141                    &ctx.http,
142                    serenity::all::CreateMessage::new()
143                        .content("Hello! I'm ready to help. What would you like to talk about?"),
144                )
145                .await
146            {
147                eprintln!("Failed to send welcome message: {}", e);
148            }
149        }
150    }
151
152    async fn handle_thread_message(&self, ctx: &Context, msg: &Message) {
153        let thread_id = msg.channel_id.get();
154
155        // Show typing indicator
156        let _ = msg.channel_id.broadcast_typing(&ctx.http).await;
157
158        // Get conversation history snapshot.
159        let conversations = self.state.conversations.read().await;
160        let mut history = if let Some(history) = conversations.get(&thread_id) {
161            history.clone()
162        } else {
163            vec![]
164        };
165        drop(conversations);
166
167        // Generate response. `chat` appends the user prompt and generated
168        // assistant/tool messages onto the history snapshot.
169        let response = match self.state.agent.chat(&msg.content, &mut history).await {
170            Ok(resp) => resp,
171            Err(e) => {
172                eprintln!("Agent error: {}", e);
173                let _ = msg
174                    .channel_id
175                    .say(
176                        &ctx.http,
177                        "Sorry, I encountered an error processing your message.",
178                    )
179                    .await;
180                return;
181            }
182        };
183
184        // Persist the round-tripped history back into the conversations map.
185        {
186            let mut conversations = self.state.conversations.write().await;
187            conversations.insert(thread_id, history);
188        }
189
190        // Send response (split if too long for Discord's 2000 char limit)
191        let chunks: Vec<String> = response
192            .chars()
193            .collect::<Vec<_>>()
194            .chunks(1900)
195            .map(|c| c.iter().collect())
196            .collect();
197
198        for chunk in chunks {
199            if let Err(e) = msg.channel_id.say(&ctx.http, &chunk).await {
200                eprintln!("Failed to send message: {}", e);
201            }
202        }
203    }
204}
205
206/// A trait for turning a type into a `serenity` client.
207///
208pub trait DiscordExt: Sized + Send + Sync
209where
210    Self: 'static,
211{
212    fn into_discord_bot(
213        self,
214        token: &str,
215    ) -> impl std::future::Future<Output = Result<serenity::Client, DiscordBotError>> + Send;
216
217    fn into_discord_bot_from_env(
218        self,
219    ) -> impl std::future::Future<Output = Result<serenity::Client, DiscordBotError>> + Send {
220        async move {
221            let token = std::env::var("DISCORD_BOT_TOKEN")?;
222            DiscordExt::into_discord_bot(self, &token).await
223        }
224    }
225}
226
227impl DiscordExt for Agent {
228    async fn into_discord_bot(self, token: &str) -> Result<serenity::Client, DiscordBotError> {
229        let intents = GatewayIntents::GUILDS
230            | GatewayIntents::GUILD_MESSAGES
231            | GatewayIntents::MESSAGE_CONTENT;
232
233        let state = Arc::new(BotState::new(self));
234        let handler = Handler {
235            state: state.clone(),
236        };
237
238        serenity::Client::builder(token, intents)
239            .event_handler(handler)
240            .await
241            .map_err(DiscordBotError::from)
242    }
243}