//! Discord Gateway adapter for the OpenFang channel bridge. //! //! Uses Discord Gateway WebSocket (v10) for receiving messages and the REST API //! for sending responses. No external Discord crate — just `tokio-tungstenite` + `reqwest`. use crate::types::{ split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser, }; use async_trait::async_trait; use futures::{SinkExt, Stream, StreamExt}; use std::collections::HashMap; use std::pin::Pin; use std::sync::Arc; use std::time::Duration; use tokio::sync::{mpsc, watch, RwLock}; use tracing::{debug, error, info, warn}; use zeroize::Zeroizing; const DISCORD_API_BASE: &str = "https://discord.com/api/v10"; const MAX_BACKOFF: Duration = Duration::from_secs(60); const INITIAL_BACKOFF: Duration = Duration::from_secs(1); const DISCORD_MSG_LIMIT: usize = 2000; /// Discord Gateway opcodes. mod opcode { pub const DISPATCH: u64 = 0; pub const HEARTBEAT: u64 = 1; pub const IDENTIFY: u64 = 2; pub const RESUME: u64 = 6; pub const RECONNECT: u64 = 7; pub const INVALID_SESSION: u64 = 9; pub const HELLO: u64 = 10; pub const HEARTBEAT_ACK: u64 = 11; } /// Discord Gateway adapter using WebSocket. pub struct DiscordAdapter { /// SECURITY: Bot token is zeroized on drop to prevent memory disclosure. token: Zeroizing, client: reqwest::Client, allowed_guilds: Vec, intents: u64, shutdown_tx: Arc>, shutdown_rx: watch::Receiver, /// Bot's own user ID (populated after READY event). bot_user_id: Arc>>, /// Session ID for resume (populated after READY event). session_id: Arc>>, /// Resume gateway URL. resume_gateway_url: Arc>>, } impl DiscordAdapter { pub fn new(token: String, allowed_guilds: Vec, intents: u64) -> Self { let (shutdown_tx, shutdown_rx) = watch::channel(false); Self { token: Zeroizing::new(token), client: reqwest::Client::new(), allowed_guilds, intents, shutdown_tx: Arc::new(shutdown_tx), shutdown_rx, bot_user_id: Arc::new(RwLock::new(None)), session_id: Arc::new(RwLock::new(None)), resume_gateway_url: Arc::new(RwLock::new(None)), } } /// Get the WebSocket gateway URL from the Discord API. async fn get_gateway_url(&self) -> Result> { let url = format!("{DISCORD_API_BASE}/gateway/bot"); let resp: serde_json::Value = self .client .get(&url) .header("Authorization", format!("Bot {}", self.token.as_str())) .send() .await? .json() .await?; let ws_url = resp["url"] .as_str() .ok_or("Missing 'url' in gateway response")?; Ok(format!("{ws_url}/?v=10&encoding=json")) } /// Send a message to a Discord channel via REST API. async fn api_send_message( &self, channel_id: &str, text: &str, ) -> Result<(), Box> { let url = format!("{DISCORD_API_BASE}/channels/{channel_id}/messages"); let chunks = split_message(text, DISCORD_MSG_LIMIT); for chunk in chunks { let body = serde_json::json!({ "content": chunk }); let resp = self .client .post(&url) .header("Authorization", format!("Bot {}", self.token.as_str())) .json(&body) .send() .await?; if !resp.status().is_success() { let body_text = resp.text().await.unwrap_or_default(); warn!("Discord sendMessage failed: {body_text}"); } } Ok(()) } /// Send typing indicator to a Discord channel. async fn api_send_typing(&self, channel_id: &str) -> Result<(), Box> { let url = format!("{DISCORD_API_BASE}/channels/{channel_id}/typing"); let _ = self .client .post(&url) .header("Authorization", format!("Bot {}", self.token.as_str())) .send() .await?; Ok(()) } } #[async_trait] impl ChannelAdapter for DiscordAdapter { fn name(&self) -> &str { "discord" } fn channel_type(&self) -> ChannelType { ChannelType::Discord } async fn start( &self, ) -> Result + Send>>, Box> { let gateway_url = self.get_gateway_url().await?; info!("Discord gateway URL obtained"); let (tx, rx) = mpsc::channel::(256); let token = self.token.clone(); let intents = self.intents; let allowed_guilds = self.allowed_guilds.clone(); let bot_user_id = self.bot_user_id.clone(); let session_id_store = self.session_id.clone(); let resume_url_store = self.resume_gateway_url.clone(); let mut shutdown = self.shutdown_rx.clone(); tokio::spawn(async move { let mut backoff = INITIAL_BACKOFF; let mut connect_url = gateway_url; // Sequence persists across reconnections for RESUME let sequence: Arc>> = Arc::new(RwLock::new(None)); loop { if *shutdown.borrow() { break; } info!("Connecting to Discord gateway..."); let ws_result = tokio_tungstenite::connect_async(&connect_url).await; let ws_stream = match ws_result { Ok((stream, _)) => stream, Err(e) => { warn!("Discord gateway connection failed: {e}, retrying in {backoff:?}"); tokio::time::sleep(backoff).await; backoff = (backoff * 2).min(MAX_BACKOFF); continue; } }; backoff = INITIAL_BACKOFF; info!("Discord gateway connected"); let (mut ws_tx, mut ws_rx) = ws_stream.split(); let mut _heartbeat_interval: Option = None; // Inner message loop — returns true if we should reconnect let should_reconnect = 'inner: loop { let msg = tokio::select! { msg = ws_rx.next() => msg, _ = shutdown.changed() => { if *shutdown.borrow() { info!("Discord shutdown requested"); let _ = ws_tx.close().await; return; } continue; } }; let msg = match msg { Some(Ok(m)) => m, Some(Err(e)) => { warn!("Discord WebSocket error: {e}"); break 'inner true; } None => { info!("Discord WebSocket closed"); break 'inner true; } }; let text = match msg { tokio_tungstenite::tungstenite::Message::Text(t) => t, tokio_tungstenite::tungstenite::Message::Close(_) => { info!("Discord gateway closed by server"); break 'inner true; } _ => continue, }; let payload: serde_json::Value = match serde_json::from_str(&text) { Ok(v) => v, Err(e) => { warn!("Discord: failed to parse gateway message: {e}"); continue; } }; let op = payload["op"].as_u64().unwrap_or(999); // Update sequence number if let Some(s) = payload["s"].as_u64() { *sequence.write().await = Some(s); } match op { opcode::HELLO => { let interval = payload["d"]["heartbeat_interval"].as_u64().unwrap_or(45000); _heartbeat_interval = Some(interval); debug!("Discord HELLO: heartbeat_interval={interval}ms"); // Try RESUME if we have a session, otherwise IDENTIFY let has_session = session_id_store.read().await.is_some(); let has_seq = sequence.read().await.is_some(); let gateway_msg = if has_session && has_seq { let sid = session_id_store.read().await.clone().unwrap(); let seq = *sequence.read().await; info!("Discord: sending RESUME (session={sid})"); serde_json::json!({ "op": opcode::RESUME, "d": { "token": token.as_str(), "session_id": sid, "seq": seq } }) } else { info!("Discord: sending IDENTIFY"); serde_json::json!({ "op": opcode::IDENTIFY, "d": { "token": token.as_str(), "intents": intents, "properties": { "os": "linux", "browser": "openfang", "device": "openfang" } } }) }; if let Err(e) = ws_tx .send(tokio_tungstenite::tungstenite::Message::Text( serde_json::to_string(&gateway_msg).unwrap(), )) .await { error!("Discord: failed to send IDENTIFY/RESUME: {e}"); break 'inner true; } } opcode::DISPATCH => { let event_name = payload["t"].as_str().unwrap_or(""); let d = &payload["d"]; match event_name { "READY" => { let user_id = d["user"]["id"].as_str().unwrap_or("").to_string(); let username = d["user"]["username"].as_str().unwrap_or("unknown"); let sid = d["session_id"].as_str().unwrap_or("").to_string(); let resume_url = d["resume_gateway_url"].as_str().unwrap_or("").to_string(); *bot_user_id.write().await = Some(user_id.clone()); *session_id_store.write().await = Some(sid); if !resume_url.is_empty() { *resume_url_store.write().await = Some(resume_url); } info!("Discord bot ready: {username} ({user_id})"); } "MESSAGE_CREATE" | "MESSAGE_UPDATE" => { if let Some(msg) = parse_discord_message(d, &bot_user_id, &allowed_guilds) .await { debug!( "Discord {event_name} from {}: {:?}", msg.sender.display_name, msg.content ); if tx.send(msg).await.is_err() { return; } } } "RESUMED" => { info!("Discord session resumed successfully"); } _ => { debug!("Discord event: {event_name}"); } } } opcode::HEARTBEAT => { // Server requests immediate heartbeat let seq = *sequence.read().await; let hb = serde_json::json!({ "op": opcode::HEARTBEAT, "d": seq }); let _ = ws_tx .send(tokio_tungstenite::tungstenite::Message::Text( serde_json::to_string(&hb).unwrap(), )) .await; } opcode::HEARTBEAT_ACK => { debug!("Discord heartbeat ACK received"); } opcode::RECONNECT => { info!("Discord: server requested reconnect"); break 'inner true; } opcode::INVALID_SESSION => { let resumable = payload["d"].as_bool().unwrap_or(false); if resumable { info!("Discord: invalid session (resumable)"); } else { info!("Discord: invalid session (not resumable), clearing session"); *session_id_store.write().await = None; *sequence.write().await = None; } break 'inner true; } _ => { debug!("Discord: unknown opcode {op}"); } } }; if !should_reconnect || *shutdown.borrow() { break; } // Try resume URL if available if let Some(ref url) = *resume_url_store.read().await { connect_url = format!("{url}/?v=10&encoding=json"); } warn!("Discord: reconnecting in {backoff:?}"); tokio::time::sleep(backoff).await; backoff = (backoff * 2).min(MAX_BACKOFF); } info!("Discord gateway loop stopped"); }); let stream = tokio_stream::wrappers::ReceiverStream::new(rx); Ok(Box::pin(stream)) } async fn send( &self, user: &ChannelUser, content: ChannelContent, ) -> Result<(), Box> { // platform_id is the channel_id for Discord let channel_id = &user.platform_id; match content { ChannelContent::Text(text) => { self.api_send_message(channel_id, &text).await?; } _ => { self.api_send_message(channel_id, "(Unsupported content type)") .await?; } } Ok(()) } async fn send_typing(&self, user: &ChannelUser) -> Result<(), Box> { self.api_send_typing(&user.platform_id).await } async fn stop(&self) -> Result<(), Box> { let _ = self.shutdown_tx.send(true); Ok(()) } } /// Parse a Discord MESSAGE_CREATE or MESSAGE_UPDATE payload into a `ChannelMessage`. async fn parse_discord_message( d: &serde_json::Value, bot_user_id: &Arc>>, allowed_guilds: &[u64], ) -> Option { let author = d.get("author")?; let author_id = author["id"].as_str()?; // Filter out bot's own messages if let Some(ref bid) = *bot_user_id.read().await { if author_id == bid { return None; } } // Filter out other bots if author["bot"].as_bool() == Some(true) { return None; } // Filter by allowed guilds if !allowed_guilds.is_empty() { if let Some(guild_id) = d["guild_id"].as_str() { let gid: u64 = guild_id.parse().unwrap_or(0); if !allowed_guilds.contains(&gid) { return None; } } } let content_text = d["content"].as_str().unwrap_or(""); if content_text.is_empty() { return None; } let channel_id = d["channel_id"].as_str()?; let message_id = d["id"].as_str().unwrap_or("0"); let username = author["username"].as_str().unwrap_or("Unknown"); let discriminator = author["discriminator"].as_str().unwrap_or("0000"); let display_name = if discriminator == "0" { username.to_string() } else { format!("{username}#{discriminator}") }; let timestamp = d["timestamp"] .as_str() .and_then(|ts| chrono::DateTime::parse_from_rfc3339(ts).ok()) .map(|dt| dt.with_timezone(&chrono::Utc)) .unwrap_or_else(chrono::Utc::now); // Parse commands (messages starting with /) let content = if content_text.starts_with('/') { let parts: Vec<&str> = content_text.splitn(2, ' ').collect(); let cmd_name = &parts[0][1..]; let args = if parts.len() > 1 { parts[1].split_whitespace().map(String::from).collect() } else { vec![] }; ChannelContent::Command { name: cmd_name.to_string(), args, } } else { ChannelContent::Text(content_text.to_string()) }; Some(ChannelMessage { channel: ChannelType::Discord, platform_message_id: message_id.to_string(), sender: ChannelUser { platform_id: channel_id.to_string(), display_name, openfang_user: None, }, content, target_agent: None, timestamp, is_group: true, thread_id: None, metadata: HashMap::new(), }) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_parse_discord_message_basic() { let bot_id = Arc::new(RwLock::new(Some("bot123".to_string()))); let d = serde_json::json!({ "id": "msg1", "channel_id": "ch1", "content": "Hello agent!", "author": { "id": "user456", "username": "alice", "discriminator": "0", "bot": false }, "timestamp": "2024-01-01T00:00:00+00:00" }); let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap(); assert_eq!(msg.channel, ChannelType::Discord); assert_eq!(msg.sender.display_name, "alice"); assert_eq!(msg.sender.platform_id, "ch1"); assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Hello agent!")); } #[tokio::test] async fn test_parse_discord_message_filters_bot() { let bot_id = Arc::new(RwLock::new(Some("bot123".to_string()))); let d = serde_json::json!({ "id": "msg1", "channel_id": "ch1", "content": "My own message", "author": { "id": "bot123", "username": "openfang", "discriminator": "0" }, "timestamp": "2024-01-01T00:00:00+00:00" }); let msg = parse_discord_message(&d, &bot_id, &[]).await; assert!(msg.is_none()); } #[tokio::test] async fn test_parse_discord_message_filters_other_bots() { let bot_id = Arc::new(RwLock::new(Some("bot123".to_string()))); let d = serde_json::json!({ "id": "msg1", "channel_id": "ch1", "content": "Bot message", "author": { "id": "other_bot", "username": "somebot", "discriminator": "0", "bot": true }, "timestamp": "2024-01-01T00:00:00+00:00" }); let msg = parse_discord_message(&d, &bot_id, &[]).await; assert!(msg.is_none()); } #[tokio::test] async fn test_parse_discord_message_guild_filter() { let bot_id = Arc::new(RwLock::new(Some("bot123".to_string()))); let d = serde_json::json!({ "id": "msg1", "channel_id": "ch1", "guild_id": "999", "content": "Hello", "author": { "id": "user1", "username": "bob", "discriminator": "0" }, "timestamp": "2024-01-01T00:00:00+00:00" }); // Not in allowed guilds let msg = parse_discord_message(&d, &bot_id, &[111, 222]).await; assert!(msg.is_none()); // In allowed guilds let msg = parse_discord_message(&d, &bot_id, &[999]).await; assert!(msg.is_some()); } #[tokio::test] async fn test_parse_discord_command() { let bot_id = Arc::new(RwLock::new(None)); let d = serde_json::json!({ "id": "msg1", "channel_id": "ch1", "content": "/agent hello-world", "author": { "id": "user1", "username": "alice", "discriminator": "0" }, "timestamp": "2024-01-01T00:00:00+00:00" }); let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap(); match &msg.content { ChannelContent::Command { name, args } => { assert_eq!(name, "agent"); assert_eq!(args, &["hello-world"]); } other => panic!("Expected Command, got {other:?}"), } } #[tokio::test] async fn test_parse_discord_empty_content() { let bot_id = Arc::new(RwLock::new(None)); let d = serde_json::json!({ "id": "msg1", "channel_id": "ch1", "content": "", "author": { "id": "user1", "username": "alice", "discriminator": "0" }, "timestamp": "2024-01-01T00:00:00+00:00" }); let msg = parse_discord_message(&d, &bot_id, &[]).await; assert!(msg.is_none()); } #[tokio::test] async fn test_parse_discord_discriminator() { let bot_id = Arc::new(RwLock::new(None)); let d = serde_json::json!({ "id": "msg1", "channel_id": "ch1", "content": "Hi", "author": { "id": "user1", "username": "alice", "discriminator": "1234" }, "timestamp": "2024-01-01T00:00:00+00:00" }); let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap(); assert_eq!(msg.sender.display_name, "alice#1234"); } #[tokio::test] async fn test_parse_discord_message_update() { let bot_id = Arc::new(RwLock::new(Some("bot123".to_string()))); let d = serde_json::json!({ "id": "msg1", "channel_id": "ch1", "content": "Edited message content", "author": { "id": "user456", "username": "alice", "discriminator": "0", "bot": false }, "timestamp": "2024-01-01T00:00:00+00:00", "edited_timestamp": "2024-01-01T00:01:00+00:00" }); // MESSAGE_UPDATE uses the same parse function as MESSAGE_CREATE let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap(); assert_eq!(msg.channel, ChannelType::Discord); assert!( matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message content") ); } #[test] fn test_discord_adapter_creation() { let adapter = DiscordAdapter::new("test-token".to_string(), vec![123, 456], 33280); assert_eq!(adapter.name(), "discord"); assert_eq!(adapter.channel_type(), ChannelType::Discord); } }