//! Discord protocol backend — Phase I. //! //! Implements the Discord Gateway (WebSocket) + REST API: //! - Bot token authentication (user token supported but discouraged by Discord ToS) //! - Real-time messaging via Gateway events (opcodes 0–11) //! - Heartbeat (configurable interval from Hello, typically 41.25 s) //! - Session resume (session_id + sequence number) //! - Guild (server), channel, DM, and group DM support //! - Message send / edit / delete / react //! - Typing indicators //! - Member listing, server join/leave via invite //! //! API reference: //! Gateway URL obtained via REST GET /gateway/bot. use crate::core::message::{ChatMessage, MessageKind}; use crate::core::protocol::ProtocolType; use serde::{Deserialize, Serialize}; use futures::StreamExt; use std::collections::HashMap; use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; // ─── Configuration ──────────────────────────────────────────────────── /// Configuration for a Discord connection. #[derive(Debug, Clone)] pub struct DiscordConfig { /// REST API base URL. pub api_base: String, /// Bot token (starts with "Bot ") or user token. pub bot_token: String, /// Session ID for resume (saved from previous Ready). pub session_id: Option, /// Last received sequence number for resume. pub sequence: Option, /// Outgoing messages to the TUI. pub tx: mpsc::Sender, } // ─── Commands ────────────────────────────────────────────────────────── /// Commands sent from the dispatcher to the Discord client task. #[derive(Debug)] pub enum DiscordCommand { /// Send a text message to a channel. Msg { channel_id: String, body: String }, /// Send an emote (`/me` — sent as italic text since Discord has no native /me). Emote { channel_id: String, body: String }, /// Edit a previously sent message. EditMessage { channel_id: String, message_id: String, new_body: String }, /// Delete a message. DeleteMessage { channel_id: String, message_id: String }, /// React to a message (emoji string, e.g. "🎉" or "thonk:123456"). React { channel_id: String, message_id: String, emoji: String }, /// Remove a reaction. RemoveReact { channel_id: String, message_id: String, emoji: String }, /// Join a guild by invite code. JoinGuild { invite_code: String }, /// Leave a guild. LeaveGuild { guild_id: String }, /// List members of a guild. Members { guild_id: String }, /// List guilds (servers) the bot is in. ListServers, /// Quit the Discord client task. Quit, } // ─── Gateway opcodes ────────────────────────────────────────────────── const OP_DISPATCH: u8 = 0; const OP_HEARTBEAT: u8 = 1; const OP_IDENTIFY: u8 = 2; const OP_PRESENCE_UPDATE: u8 = 3; const OP_RESUME: u8 = 6; const OP_RECONNECT: u8 = 7; const OP_REQUEST_GUILD_MEMBERS: u8 = 8; const OP_INVALID_SESSION: u8 = 9; const OP_HELLO: u8 = 10; const OP_HEARTBEAT_ACK: u8 = 11; // ─── Gateway wire types ─────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] struct GatewayPayload { op: u8, #[serde(skip_serializing_if = "Option::is_none")] d: Option, #[serde(skip_serializing_if = "Option::is_none")] s: Option, #[serde(skip_serializing_if = "Option::is_none")] t: Option, } #[derive(Debug, Clone, Serialize)] struct Identify { token: String, properties: IdentifyProperties, #[serde(skip_serializing_if = "Option::is_none")] session_id: Option, #[serde(skip_serializing_if = "Option::is_none")] seq: Option, } #[derive(Debug, Clone, Serialize)] struct IdentifyProperties { os: &'static str, browser: &'static str, device: &'static str, } #[derive(Debug, Clone, Serialize)] struct Resume { token: String, session_id: String, seq: u64, } // ─── Discord API types (minimal subset) ─────────────────────────────── #[derive(Debug, Clone, Deserialize, Default)] struct DiscordUser { #[serde(default)] id: String, #[serde(default)] username: String, #[serde(default)] discriminator: String, #[serde(default)] avatar: Option, #[serde(default)] bot: bool, } #[derive(Debug, Clone, Deserialize)] struct DiscordGuild { id: String, name: String, #[serde(default)] icon: Option, #[serde(default)] owner: bool, #[serde(default)] channels: Vec, #[serde(default)] members: Vec, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "snake_case")] struct DiscordChannel { id: String, #[serde(default)] name: Option, #[serde(default)] channel_type: u8, // 0 = guild text, 1 = DM, 2 = guild voice, 3 = group DM, 4 = guild category, // 5 = guild announcement, 10 = announcement thread, 11 = public thread, // 12 = private thread, 13 = stage channel, 14 = guild directory, 15 = forum #[serde(default)] guild_id: Option, #[serde(default)] recipient_ids: Vec, #[serde(default)] last_message_id: Option, #[serde(default)] nsfw: bool, #[serde(default)] topic: Option, } #[derive(Debug, Clone, Deserialize)] struct DiscordMember { #[serde(default)] user: Option, #[serde(default)] nick: Option, #[serde(default)] roles: Vec, #[serde(default)] joined_at: String, #[serde(default)] deaf: bool, #[serde(default)] mute: bool, } #[derive(Debug, Clone, Deserialize)] struct DiscordMessage { id: String, content: String, #[serde(default)] author: Option, #[serde(default)] channel_id: String, #[serde(default)] guild_id: Option, #[serde(default)] member: Option, #[serde(default)] mention_everyone: bool, #[serde(default)] mentions: Vec, #[serde(default)] referenced_message: Option>, #[serde(default)] edited_timestamp: Option, #[serde(default)] webhook_id: Option, } #[derive(Debug, Clone, Deserialize)] struct DiscordMemberPayload { #[serde(default)] nick: Option, #[serde(default)] roles: Vec, } #[derive(Debug, Clone, Deserialize)] struct ReadyData { #[serde(default)] user: DiscordUser, #[serde(default)] session_id: String, #[serde(default)] guilds: Vec, #[serde(default, rename = "resume_gateway_url")] resume_gateway_url: String, } // ─── Runtime state ──────────────────────────────────────────────────── struct DiscordState { /// REST client. rest: reqwest::Client, /// Auth header value ("Bot " or just the token). auth_header: String, /// Config reference (api_base, session_id, sequence). config: DiscordConfig, /// Resolved user (set after READY). self_user: Option, /// Guild cache: guild_id → guild. guilds: HashMap, /// Channel cache: channel_id → channel. channels: HashMap, /// User cache: user_id → display name. users: HashMap, /// Gateway URL (from GET /gateway/bot or resume_gateway_url). gateway_url: String, /// Last received sequence number. seq: Option, /// Session ID (from READY event). session_id: Option, /// Heartbeat interval (ms), from HELLO. heartbeat_interval: u64, /// Whether we've received the first HEARTBEAT_ACK. heartbeat_acked: bool, } impl DiscordState { fn new(config: DiscordConfig) -> Self { let auth_header = if config.bot_token.starts_with("Bot ") || config.bot_token.starts_with("bot ") { config.bot_token.clone() } else { format!("Bot {}", config.bot_token) }; let rest = reqwest::Client::builder() .default_headers({ let mut h = reqwest::header::HeaderMap::new(); h.insert("Authorization", reqwest::header::HeaderValue::from_str(&auth_header) .unwrap_or_else(|_| reqwest::header::HeaderValue::from_static(""))); h.insert("User-Agent", reqwest::header::HeaderValue::from_static("nirc-rs (https://git.dcos.net/dcosnet/nirc-rs, 0.9.0)")); h }) .build() .unwrap_or_else(|_| reqwest::Client::new()); Self { rest, auth_header, self_user: None, guilds: HashMap::new(), channels: HashMap::new(), users: HashMap::new(), gateway_url: String::new(), seq: config.sequence, session_id: config.session_id.clone(), heartbeat_interval: 41250, heartbeat_acked: true, config, } } /// Get the display name for a user ID. fn display_name(&self, user_id: &str) -> String { self.users.get(user_id).cloned().unwrap_or_else(|| user_id.to_owned()) } } // ─── REST helpers ───────────────────────────────────────────────────── async fn get_gateway_url(rest: &reqwest::Client, api_base: &str) -> anyhow::Result { let url = format!("{}/gateway/bot", api_base.trim_end_matches('/')); debug!(%url, "Fetching Discord gateway URL"); let resp: serde_json::Value = rest.get(&url).send().await?.json().await?; let ws_url = resp["url"].as_str() .ok_or_else(|| anyhow::anyhow!("Missing 'url' in gateway response"))?; // Discord returns wss://gateway.discord.gg — append ?v=10&encoding=json let sep = if ws_url.contains('?') { "&" } else { "?" }; Ok(format!("{}{}v=10&encoding=json", ws_url, sep)) } async fn rest_send_message( state: &DiscordState, channel_id: &str, content: &str, ) -> anyhow::Result<()> { let url = format!("{}/channels/{}/messages", state.config.api_base.trim_end_matches('/'), channel_id); let body = serde_json::json!({ "content": content }); state.rest.post(&url).json(&body).send().await?; Ok(()) } async fn rest_edit_message( state: &DiscordState, channel_id: &str, message_id: &str, content: &str, ) -> anyhow::Result<()> { let url = format!("{}/channels/{}/messages/{}", state.config.api_base.trim_end_matches('/'), channel_id, message_id); let body = serde_json::json!({ "content": content }); state.rest.patch(&url).json(&body).send().await?; Ok(()) } async fn rest_delete_message( state: &DiscordState, channel_id: &str, message_id: &str, ) -> anyhow::Result<()> { let url = format!("{}/channels/{}/messages/{}", state.config.api_base.trim_end_matches('/'), channel_id, message_id); state.rest.delete(&url).send().await?; Ok(()) } async fn rest_add_reaction( state: &DiscordState, channel_id: &str, message_id: &str, emoji: &str, ) -> anyhow::Result<()> { let url = format!( "{}/channels/{}/messages/{}/reactions/{}/@me", state.config.api_base.trim_end_matches('/'), channel_id, message_id, urlencoding(emoji), ); state.rest.put(&url).send().await?; Ok(()) } async fn rest_remove_reaction( state: &DiscordState, channel_id: &str, message_id: &str, emoji: &str, ) -> anyhow::Result<()> { let url = format!( "{}/channels/{}/messages/{}/reactions/{}/@me", state.config.api_base.trim_end_matches('/'), channel_id, message_id, urlencoding(emoji), ); state.rest.delete(&url).send().await?; Ok(()) } async fn rest_join_guild( state: &DiscordState, invite_code: &str, ) -> anyhow::Result<()> { let url = format!("{}/invites/{}", state.config.api_base.trim_end_matches('/'), invite_code); let body = serde_json::json!({}); state.rest.post(&url).json(&body).send().await?; Ok(()) } async fn rest_leave_guild( state: &DiscordState, guild_id: &str, ) -> anyhow::Result<()> { let url = format!("{}/users/@me/guilds/{}", state.config.api_base.trim_end_matches('/'), guild_id); state.rest.delete(&url).send().await?; Ok(()) } async fn rest_list_members( state: &DiscordState, guild_id: &str, tx: &mpsc::Sender, ) -> anyhow::Result<()> { let url = format!( "{}/guilds/{}/members?limit=100", state.config.api_base.trim_end_matches('/'), guild_id, ); let resp: Vec = state.rest.get(&url).send().await?.json().await?; let guild_name = state.guilds.get(guild_id) .map(|g| g.name.as_str()) .unwrap_or(guild_id); if resp.is_empty() { let _ = tx.send(ChatMessage::notice(ProtocolType::Discord, guild_name, "No members found.")).await; } else { let mut lines = Vec::new(); for m in &resp { if let Some(u) = &m.user { let name = m.nick.as_deref().unwrap_or(&u.username); let bot_tag = if u.bot { " [BOT]" } else { "" }; lines.push(format!(" {}{}", name, bot_tag)); } } let body = format!("Members of {} ({}):\n{}", guild_name, resp.len(), lines.join("\n")); let _ = tx.send(ChatMessage::notice(ProtocolType::Discord, guild_name, &body)).await; } Ok(()) } async fn rest_list_servers( state: &DiscordState, tx: &mpsc::Sender, ) -> anyhow::Result<()> { let url = format!("{}/users/@me/guilds", state.config.api_base.trim_end_matches('/')); let resp: Vec = state.rest.get(&url).send().await?.json().await?; if resp.is_empty() { let _ = tx.send(ChatMessage::notice(ProtocolType::Discord, "Status", "No guilds.")).await; } else { let mut lines = Vec::new(); for g in &resp { let name = g["name"].as_str().unwrap_or("?"); let gid = g["id"].as_str().unwrap_or("?"); lines.push(format!(" {} ({})", name, gid)); } let body = format!("Guilds ({}):\n{}", resp.len(), lines.join("\n")); let _ = tx.send(ChatMessage::notice(ProtocolType::Discord, "Status", &body)).await; } Ok(()) } /// Minimal URL-encoding for emoji (replaces non-alphanumeric with %XX). fn urlencoding(s: &str) -> String { let mut out = String::with_capacity(s.len()); for ch in s.chars() { if ch.is_alphanumeric() || ch == '-' || ch == '_' { out.push(ch); } else { for byte in ch.encode_utf8(&mut [0u8; 4]).as_bytes() { out.push_str(&format!("%{:02X}", byte)); } } } out } // ─── Event dispatch ─────────────────────────────────────────────────── async fn handle_dispatch( state: &mut DiscordState, event: &str, data: &serde_json::Value, tx: &mpsc::Sender, ) { match event { "READY" => { let ready: ReadyData = match serde_json::from_value(data.clone()) { Ok(r) => r, Err(e) => { warn!(%e, "Failed to parse READY"); return; } }; state.session_id = Some(ready.session_id.clone()); state.self_user = Some(ready.user.clone()); state.users.insert(ready.user.id.clone(), ready.user.username.clone()); if !ready.resume_gateway_url.is_empty() { state.gateway_url = ready.resume_gateway_url.clone(); } // Cache guilds and channels. for guild in &ready.guilds { state.guilds.insert(guild.id.clone(), guild.clone()); for ch in &guild.channels { state.channels.insert(ch.id.clone(), ch.clone()); } } let username = &ready.user.username; let guild_count = ready.guilds.len(); info!(%username, guild_count, "Discord READY"); let _ = tx.send(ChatMessage::notice( ProtocolType::Discord, "Status", &format!("Connected as {} in {} guild(s)", username, guild_count), )).await; // Emit token persistence notice (intercepted by main.rs). let _ = tx.send(ChatMessage::notice( ProtocolType::Discord, "Status", &format!("[discord-session] session_id={} user_id={}", ready.session_id, ready.user.id), )).await; } "GUILD_CREATE" => { let guild: DiscordGuild = match serde_json::from_value(data.clone()) { Ok(g) => g, Err(e) => { warn!(%e, "Failed to parse GUILD_CREATE"); return; } }; let guild_name = guild.name.clone(); let channel_count = guild.channels.len(); state.guilds.insert(guild.id.clone(), guild.clone()); for ch in &guild.channels { state.channels.insert(ch.id.clone(), ch.clone()); } info!(%guild_name, channel_count, "Guild available"); let _ = tx.send(ChatMessage::notice( ProtocolType::Discord, &guild_name, &format!("Guild available ({} channels)", channel_count), )).await; } "MESSAGE_CREATE" => { let msg: DiscordMessage = match serde_json::from_value(data.clone()) { Ok(m) => m, Err(e) => { warn!(%e, "Failed to parse MESSAGE_CREATE"); return; } }; let is_own = state.self_user.as_ref() .map(|u| u.id == msg.author.as_ref().map(|a| a.id.clone()).unwrap_or_default()) .unwrap_or(false); if is_own { return; } // Don't echo own messages. let author = msg.author.as_ref() .map(|a| state.display_name(&a.id)) .unwrap_or_else(|| "Unknown".into()); let source = msg.guild_id.as_deref() .or_else(|| state.channels.get(&msg.channel_id).and_then(|c| c.guild_id.as_deref())) .unwrap_or(&msg.channel_id); // Use channel name if available, otherwise use guild or channel ID. let display_source = state.channels.get(&msg.channel_id) .and_then(|c| c.name.clone()) .unwrap_or_else(|| source.to_owned()); let content = msg.content.clone(); if content.is_empty() { return; } // Skip empty/embed-only messages. let is_private = is_dm_channel(&msg.channel_id, state); let kind = if is_private { MessageKind::Private } else { MessageKind::Text }; let chat_msg = ChatMessage { id: msg.id, protocol: ProtocolType::Discord, kind, source: display_source, sender: author, body: content, timestamp: chrono::Utc::now(), is_own, remote_ts: true, }; let _ = tx.send(chat_msg).await; } "MESSAGE_UPDATE" => { let msg: DiscordMessage = match serde_json::from_value(data.clone()) { Ok(m) => m, Err(e) => { warn!(%e, "Failed to parse MESSAGE_UPDATE"); return; } }; if msg.edited_timestamp.is_none() { return; } let author = msg.author.as_ref() .map(|a| state.display_name(&a.id)) .unwrap_or_else(|| "Unknown".into()); let display_source = state.channels.get(&msg.channel_id) .and_then(|c| c.name.clone()) .unwrap_or_else(|| msg.channel_id.clone()); let body = format!("{} (edited)", msg.content); let _ = tx.send(ChatMessage::notice( ProtocolType::Discord, &display_source, &format!("<{}> {}", author, body), )).await; } "MESSAGE_DELETE" => { let channel_id = data["channel_id"].as_str().unwrap_or(""); let msg_id = data["id"].as_str().unwrap_or(""); let display_source = state.channels.get(channel_id) .and_then(|c| c.name.clone()) .unwrap_or_else(|| channel_id.to_owned()); let _ = tx.send(ChatMessage::notice( ProtocolType::Discord, &display_source, &format!("Message {} deleted", msg_id), )).await; } "GUILD_DELETE" => { let guild_id = data["id"].as_str().unwrap_or(""); let name = state.guilds.get(guild_id) .map(|g| g.name.clone()) .unwrap_or_else(|| guild_id.to_owned()); state.guilds.remove(guild_id); let _ = tx.send(ChatMessage::notice( ProtocolType::Discord, "Status", &format!("Removed from guild: {}", name), )).await; } "CHANNEL_CREATE" => { let ch: DiscordChannel = match serde_json::from_value(data.clone()) { Ok(c) => c, Err(e) => { warn!(%e, "Failed to parse CHANNEL_CREATE"); return; } }; let ch_name = ch.name.clone().unwrap_or_default(); state.channels.insert(ch.id.clone(), ch); debug!(?ch_name, "Channel created"); } "TYPING_START" => { let user_id = data["user_id"].as_str().unwrap_or(""); let channel_id = data["channel_id"].as_str().unwrap_or(""); let display_source = state.channels.get(channel_id) .and_then(|c| c.name.clone()) .unwrap_or_else(|| channel_id.to_owned()); let name = state.display_name(user_id); let _ = tx.send(ChatMessage::notice( ProtocolType::Discord, &display_source, &format!("{} is typing...", name), )).await; } "PRESENCE_UPDATE" => { if let Some(user) = data.get("user") { let uid = user["id"].as_str().unwrap_or(""); let uname = user["username"].as_str(); if let Some(name) = uname { state.users.insert(uid.to_owned(), name.to_owned()); } } } _ => { debug!(event, "Unhandled Discord dispatch event"); } } } /// Check if a channel is a DM or group DM. fn is_dm_channel(channel_id: &str, state: &DiscordState) -> bool { state.channels.get(channel_id) .map(|c| c.channel_type == 1 || c.channel_type == 3) .unwrap_or(false) } // ─── Main entry point ───────────────────────────────────────────────── /// Run the Discord client event loop. /// /// Connects to the Discord Gateway via WebSocket, authenticates with a bot /// token, handles heartbeat/identify/resume, and dispatches incoming events /// to the TUI via the `tx` channel. pub async fn run_discord( config: DiscordConfig, mut cmd_rx: mpsc::Receiver, ) -> anyhow::Result<()> { let mut state = DiscordState::new(config.clone()); // Fetch gateway URL. state.gateway_url = get_gateway_url(&state.rest, &state.config.api_base).await?; info!(url = %state.gateway_url, "Discord gateway URL obtained"); // Connect WebSocket. // tokio-tungstenite 0.24 returns `(WebSocket, Response)`; we keep the // response discarded and split the stream so we can read (StreamExt::next) // and write (SinkExt::send) concurrently in the select! below. let (ws_stream, _response) = tokio_tungstenite::connect_async(&state.gateway_url).await?; info!("Discord WebSocket connected"); let (mut ws_write, mut ws_read) = ws_stream.split(); // Send a session persistence notice early so main.rs can intercept. if let (Some(sid), Some(uid)) = (&state.session_id, state.self_user.as_ref().map(|u| &u.id)) { let _ = state.config.tx.send(ChatMessage::notice( ProtocolType::Discord, "Status", &format!("[discord-session] session_id={} user_id={}", sid, uid), )).await; } let tx = state.config.tx.clone(); let mut heartbeat_timer = tokio::time::interval(std::time::Duration::from_millis(state.heartbeat_interval)); loop { tokio::select! { // ── Incoming WebSocket frames ────────────────────────── msg = ws_read.next() => { match msg { Some(Ok(frame)) => { let text = match frame.into_text() { Ok(t) => t, Err(_) => continue, }; let payload: GatewayPayload = match serde_json::from_str(&text) { Ok(p) => p, Err(e) => { warn!(%e, "Failed to parse gateway payload"); continue; } }; // Update sequence number. if let Some(s) = payload.s { state.seq = Some(s); } match payload.op { OP_HELLO => { if let Some(d) = &payload.d { state.heartbeat_interval = d["heartbeat_interval"].as_u64() .unwrap_or(41250); heartbeat_timer = tokio::time::interval( std::time::Duration::from_millis(state.heartbeat_interval) ); info!(interval_ms = state.heartbeat_interval, "Discord HELLO"); // Send first heartbeat immediately. let hb = GatewayPayload { op: OP_HEARTBEAT, d: state.seq.map(|s| serde_json::json!(s)), s: None, t: None, }; if let Ok(json) = serde_json::to_string(&hb) { use futures::SinkExt; let _ = ws_write.send(tokio_tungstenite::tungstenite::Message::Text(json)).await; state.heartbeat_acked = false; } } } OP_DISPATCH => { if let (Some(event), Some(data)) = (&payload.t, &payload.d) { handle_dispatch(&mut state, event, data, &tx).await; } } OP_HEARTBEAT_ACK => { state.heartbeat_acked = true; debug!("Discord HEARTBEAT_ACK"); } OP_RECONNECT => { info!("Discord RECONNECT requested"); let _ = tx.send(ChatMessage::notice( ProtocolType::Discord, "Status", "Reconnecting...", )).await; break; } OP_INVALID_SESSION => { let resumable = payload.d.as_ref() .and_then(|d| d.as_bool()) .unwrap_or(false); warn!(resumable, "Discord INVALID_SESSION"); if !resumable { state.session_id = None; state.seq = None; } break; } _ => { debug!(op = payload.op, "Unhandled gateway opcode"); } } } Some(Err(e)) => { error!(%e, "Discord WebSocket read error"); break; } None => { info!("Discord WebSocket closed"); break; } } } // ── Heartbeat timer ──────────────────────────────────── _ = heartbeat_timer.tick() => { if !state.heartbeat_acked { warn!("Discord heartbeat not ACKed — reconnecting"); break; } let hb = GatewayPayload { op: OP_HEARTBEAT, d: state.seq.map(|s| serde_json::json!(s)), s: None, t: None, }; if let Ok(json) = serde_json::to_string(&hb) { use futures::SinkExt; let _ = ws_write.send(tokio_tungstenite::tungstenite::Message::Text(json)).await; state.heartbeat_acked = false; debug!("Discord HEARTBEAT sent"); } } // ── Commands from dispatcher ─────────────────────────── cmd = cmd_rx.recv() => { match cmd { Some(DiscordCommand::Msg { channel_id, body }) => { if let Err(e) = rest_send_message(&state, &channel_id, &body).await { let _ = tx.send(ChatMessage::error(ProtocolType::Discord, &channel_id, &e.to_string())).await; } } Some(DiscordCommand::Emote { channel_id, body }) => { // Discord has no native /me; send as *italic text*. let emote_body = format!("*{}*", body); if let Err(e) = rest_send_message(&state, &channel_id, &emote_body).await { let _ = tx.send(ChatMessage::error(ProtocolType::Discord, &channel_id, &e.to_string())).await; } } Some(DiscordCommand::EditMessage { channel_id, message_id, new_body }) => { if let Err(e) = rest_edit_message(&state, &channel_id, &message_id, &new_body).await { let _ = tx.send(ChatMessage::error(ProtocolType::Discord, &channel_id, &e.to_string())).await; } } Some(DiscordCommand::DeleteMessage { channel_id, message_id }) => { if let Err(e) = rest_delete_message(&state, &channel_id, &message_id).await { let _ = tx.send(ChatMessage::error(ProtocolType::Discord, &channel_id, &e.to_string())).await; } } Some(DiscordCommand::React { channel_id, message_id, emoji }) => { if let Err(e) = rest_add_reaction(&state, &channel_id, &message_id, &emoji).await { let _ = tx.send(ChatMessage::error(ProtocolType::Discord, &channel_id, &e.to_string())).await; } } Some(DiscordCommand::RemoveReact { channel_id, message_id, emoji }) => { if let Err(e) = rest_remove_reaction(&state, &channel_id, &message_id, &emoji).await { let _ = tx.send(ChatMessage::error(ProtocolType::Discord, &channel_id, &e.to_string())).await; } } Some(DiscordCommand::JoinGuild { invite_code }) => { if let Err(e) = rest_join_guild(&state, &invite_code).await { let _ = tx.send(ChatMessage::error(ProtocolType::Discord, "Status", &e.to_string())).await; } else { let _ = tx.send(ChatMessage::notice( ProtocolType::Discord, "Status", &format!("Accepted invite: {}", invite_code), )).await; } } Some(DiscordCommand::LeaveGuild { guild_id }) => { if let Err(e) = rest_leave_guild(&state, &guild_id).await { let _ = tx.send(ChatMessage::error(ProtocolType::Discord, "Status", &e.to_string())).await; } else { let name = state.guilds.get(&guild_id) .map(|g| g.name.clone()) .unwrap_or_else(|| guild_id.clone()); let _ = tx.send(ChatMessage::notice( ProtocolType::Discord, "Status", &format!("Left guild: {}", name), )).await; state.guilds.remove(&guild_id); } } Some(DiscordCommand::Members { guild_id }) => { if let Err(e) = rest_list_members(&state, &guild_id, &tx).await { let _ = tx.send(ChatMessage::error(ProtocolType::Discord, &guild_id, &e.to_string())).await; } } Some(DiscordCommand::ListServers) => { if let Err(e) = rest_list_servers(&state, &tx).await { let _ = tx.send(ChatMessage::error(ProtocolType::Discord, "Status", &e.to_string())).await; } } Some(DiscordCommand::Quit) | None => { info!("Discord quitting"); // Send close frame. use futures::SinkExt; let _ = ws_write.close().await; break; } } } } } Ok(()) } // ─── Tests ──────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; #[test] fn urlencoding_basic() { assert_eq!(urlencoding("hello"), "hello"); assert_eq!(urlencoding("🎉"), "%F0%9F%8E%89"); assert_eq!(urlencoding("a b"), "a%20b"); assert_eq!(urlencoding("test_123"), "test_123"); } #[test] fn gateway_payload_serialize() { let p = GatewayPayload { op: OP_HEARTBEAT, d: Some(serde_json::json!(42)), s: None, t: None, }; let json = serde_json::to_string(&p).unwrap(); assert!(json.contains("\"op\":1")); assert!(json.contains("\"d\":42")); } #[test] fn identify_serialize() { let id = Identify { token: "test_token".into(), properties: IdentifyProperties { os: "Linux", browser: "nirc-rs", device: "nirc-rs", }, session_id: Some("sess123".into()), seq: Some(99), }; let json = serde_json::to_string(&id).unwrap(); assert!(json.contains("\"token\":\"test_token\"")); assert!(json.contains("\"session_id\":\"sess123\"")); assert!(json.contains("\"seq\":99")); } #[test] fn discord_state_new() { let config = DiscordConfig { api_base: "https://discord.com/api/v10".into(), bot_token: "Bot test123".into(), session_id: None, sequence: None, tx: tokio::sync::mpsc::channel(1).0, }; let state = DiscordState::new(config); assert_eq!(state.auth_header, "Bot test123"); assert!(state.guilds.is_empty()); assert!(state.channels.is_empty()); } #[test] fn discord_state_new_auto_prefix() { let config = DiscordConfig { api_base: "https://discord.com/api/v10".into(), bot_token: "test456".into(), // No "Bot " prefix session_id: None, sequence: None, tx: tokio::sync::mpsc::channel(1).0, }; let state = DiscordState::new(config); assert_eq!(state.auth_header, "Bot test456"); } #[test] fn display_name_cached() { let config = DiscordConfig { api_base: "https://discord.com/api/v10".into(), bot_token: "Bot t".into(), session_id: None, sequence: None, tx: tokio::sync::mpsc::channel(1).0, }; let mut state = DiscordState::new(config); state.users.insert("123".into(), "Alice".into()); assert_eq!(state.display_name("123"), "Alice"); assert_eq!(state.display_name("999"), "999"); // Fallback to ID } #[test] fn is_dm_channel_test() { let config = DiscordConfig { api_base: "https://discord.com/api/v10".into(), bot_token: "Bot t".into(), session_id: None, sequence: None, tx: tokio::sync::mpsc::channel(1).0, }; let mut state = DiscordState::new(config); let mut dm_ch = DiscordChannel { id: "ch1".into(), name: None, channel_type: 1, // DM guild_id: None, recipient_ids: vec![], last_message_id: None, nsfw: false, topic: None, }; state.channels.insert("ch1".into(), dm_ch.clone()); assert!(is_dm_channel("ch1", &state)); dm_ch.channel_type = 0; // Guild text state.channels.insert("ch2".into(), dm_ch); assert!(!is_dm_channel("ch2", &state)); } }