//! Discord Gateway/Webhook channel for OptimClaw. //! //! This WASM component implements the channel interface for handling Discord //! interactions via webhooks and sending messages back to Discord. //! //! # Features //! //! - URL verification for Discord interactions //! - Slash command handling //! - Message event parsing (@mentions, DMs) //! - Thread support for conversations //! - Response posting via Discord Web API //! - Markdown attachment fallback for oversized replies //! //! # Security //! //! - Signature validation is handled by the host (webhook secrets) //! - Bot token is injected by host during HTTP requests //! - WASM never sees raw credentials wit_bindgen::generate!({ world: "sandboxed-channel", path: "../../wit/channel.wit", }); use serde::{Deserialize, Serialize}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use exports::near::agent::channel::{ AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest, OutgoingHttpResponse, PollConfig, StatusType, StatusUpdate, }; use near::agent::channel_host::{self, EmittedMessage}; const DISCORD_API_BASE: &str = "https://discord.com/api/v10"; /// Discord interaction wrapper. #[derive(Debug, Deserialize)] struct DiscordInteraction { /// Interaction type (1=Ping, 2=ApplicationCommand, 3=MessageComponent) #[serde(rename = "type")] interaction_type: u8, /// Interaction ID id: String, /// Application ID application_id: String, /// Guild ID (if in server) #[allow(dead_code)] // Part of API payload, currently unused guild_id: Option, /// Channel ID channel_id: Option, /// Member info (if in server) member: Option, /// User info (if DM) user: Option, /// Command data (for slash commands) data: Option, /// Message (for component interactions) message: Option, /// Token for responding token: String, } #[derive(Debug, Deserialize, Clone)] struct DiscordMember { user: DiscordUser, #[allow(dead_code)] // Part of API payload, currently unused nick: Option, } #[derive(Debug, Deserialize, Clone)] struct DiscordUser { id: String, username: String, global_name: Option, } #[derive(Debug, Deserialize, Clone)] struct DiscordCommandData { #[allow(dead_code)] // Part of API payload, currently unused id: String, name: String, options: Option>, } #[derive(Debug, Deserialize, Clone)] struct DiscordCommandOption { name: String, value: serde_json::Value, } #[derive(Debug, Deserialize, Clone)] struct DiscordMessage { #[allow(dead_code)] // Part of API payload, currently unused id: String, content: String, channel_id: String, #[allow(dead_code)] // Part of API payload, currently unused author: DiscordUser, } /// Deserialize a String that may be null or missing (backward compat with old Option fields). fn deserialize_nullable_string<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, { Option::::deserialize(deserializer).map(|opt| opt.unwrap_or_default()) } /// Metadata stored with emitted messages for response routing. #[derive(Debug, Serialize, Deserialize)] struct DiscordMessageMetadata { /// Discord channel ID channel_id: String, /// Interaction ID for followups #[serde(default, deserialize_with = "deserialize_nullable_string")] interaction_id: String, /// Interaction token for responding #[serde(default, deserialize_with = "deserialize_nullable_string")] token: String, /// Application ID #[serde(default, deserialize_with = "deserialize_nullable_string")] application_id: String, /// Source message ID when handling mention-poll events. #[serde(default)] source_message_id: Option, /// Thread ID (for forum threads) thread_id: Option, } #[derive(Debug, PartialEq, Eq)] enum DiscordResponseRoute { InteractionWebhook(String), ChannelMessage(String), } fn response_route_for_metadata(metadata: &DiscordMessageMetadata) -> DiscordResponseRoute { if !metadata.application_id.is_empty() && !metadata.token.is_empty() { DiscordResponseRoute::InteractionWebhook(format!( "{DISCORD_API_BASE}/webhooks/{}/{}/messages/@original", metadata.application_id, metadata.token )) } else { DiscordResponseRoute::ChannelMessage(format!( "{DISCORD_API_BASE}/channels/{}/messages", metadata.channel_id )) } } fn typing_request_url_for_update(update: &StatusUpdate) -> Option { if update.status != StatusType::Thinking { return None; } let metadata: DiscordMessageMetadata = serde_json::from_str(&update.metadata_json).ok()?; if metadata.channel_id.is_empty() { return None; } Some(format!( "{DISCORD_API_BASE}/channels/{}/typing", metadata.channel_id )) } const DISCORD_MESSAGE_CHAR_LIMIT: usize = 2000; const DISCORD_MULTIPART_BOUNDARY: &str = "ironclaw-discord-response-boundary"; const DISCORD_ATTACHMENT_FILENAME: &str = "response.md"; const DISCORD_ATTACHMENT_NOTICE: &str = "Response too long for Discord; attached as response.md."; static MULTIPART_BOUNDARY_COUNTER: AtomicU64 = AtomicU64::new(0); #[derive(Debug, PartialEq, Eq)] struct DiscordHttpRequest { headers_json: String, body: Vec, } #[derive(Debug, PartialEq, Eq)] enum DiscordReplyPlan { Inline(DiscordHttpRequest), Attachment { upload: DiscordHttpRequest, fallback: DiscordHttpRequest, }, } fn embeds_from_metadata_json(metadata_json: &str) -> Option { serde_json::from_str::(metadata_json) .ok()? .get("embeds") .cloned() } fn build_discord_json_request( content: &str, embeds: Option<&serde_json::Value>, ) -> Result { let mut payload = serde_json::json!({ "content": content, }); if let Some(embeds) = embeds { payload["embeds"] = embeds.clone(); } Ok(DiscordHttpRequest { headers_json: serde_json::json!({ "Content-Type": "application/json" }) .to_string(), body: serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?, }) } fn build_discord_attachment_request( content: &str, embeds: Option<&serde_json::Value>, ) -> Result { let boundary = next_multipart_boundary(); let mut payload = serde_json::json!({ "content": DISCORD_ATTACHMENT_NOTICE, }); if let Some(embeds) = embeds { payload["embeds"] = embeds.clone(); } let payload_json = serde_json::to_string(&payload).map_err(|e| format!("Failed to serialize: {}", e))?; let mut body = Vec::new(); body.extend_from_slice( format!( "--{boundary}\r\nContent-Disposition: form-data; name=\"payload_json\"\r\nContent-Type: application/json\r\n\r\n{payload_json}\r\n", boundary = boundary, ) .as_bytes(), ); body.extend_from_slice( format!( "--{boundary}\r\nContent-Disposition: form-data; name=\"files[0]\"; filename=\"{filename}\"\r\nContent-Type: text/markdown\r\n\r\n", boundary = boundary, filename = DISCORD_ATTACHMENT_FILENAME, ) .as_bytes(), ); body.extend_from_slice(content.as_bytes()); body.extend_from_slice(format!("\r\n--{}--\r\n", boundary).as_bytes()); Ok(DiscordHttpRequest { headers_json: serde_json::json!({ "Content-Type": format!( "multipart/form-data; boundary={}", boundary ) }) .to_string(), body, }) } fn next_multipart_boundary() -> String { let counter = MULTIPART_BOUNDARY_COUNTER.fetch_add(1, Ordering::Relaxed); let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_nanos()) .unwrap_or(0); format!("{}-{:x}-{:x}", DISCORD_MULTIPART_BOUNDARY, nanos, counter) } fn build_discord_reply_plan(response: &AgentResponse) -> Result { let embeds = embeds_from_metadata_json(&response.metadata_json); if response.content.chars().count() <= DISCORD_MESSAGE_CHAR_LIMIT { return build_discord_json_request(&response.content, embeds.as_ref()) .map(DiscordReplyPlan::Inline); } Ok(DiscordReplyPlan::Attachment { upload: build_discord_attachment_request(&response.content, embeds.as_ref())?, fallback: build_discord_json_request( &truncate_message(&response.content), embeds.as_ref(), )?, }) } fn send_discord_request( method: &str, url: &str, request: &DiscordHttpRequest, ) -> Result<(), String> { match channel_host::http_request( method, url, &request.headers_json, Some(&request.body), None, ) { Ok(http_response) => { if http_response.status >= 200 && http_response.status < 300 { channel_host::log(channel_host::LogLevel::Debug, "Posted followup to Discord"); Ok(()) } else { let body_str = String::from_utf8_lossy(&http_response.body); Err(format!( "Discord API error: {} - {}", http_response.status, body_str )) } } Err(e) => Err(format!("HTTP request failed: {}", e)), } } /// Workspace path for persisting owner_id across WASM callbacks. const OWNER_ID_PATH: &str = "state/owner_id"; /// Workspace path for persisting polling_enabled flag. const POLLING_ENABLED_PATH: &str = "state/polling_enabled"; /// Workspace path for persisting mention channel IDs (JSON array). const MENTION_CHANNEL_IDS_PATH: &str = "state/mention_channel_ids"; /// Workspace path for persisting dm_policy across WASM callbacks. const DM_POLICY_PATH: &str = "state/dm_policy"; /// Workspace path for persisting allow_from (JSON array) across WASM callbacks. const ALLOW_FROM_PATH: &str = "state/allow_from"; /// Workspace path for the current gateway text-frame batch prepared by the host runtime. const GATEWAY_EVENT_QUEUE_PATH: &str = "state/gateway_event_queue_processing"; /// Workspace path for persisting the bot user id learned from READY dispatches. const BOT_USER_ID_PATH: &str = "state/bot_user_id"; /// Channel name for pairing store (used by pairing host APIs). const CHANNEL_NAME: &str = "discord"; #[derive(Debug, Deserialize)] struct DiscordGatewayEvent { op: u64, #[serde(default)] t: Option, #[serde(default)] d: serde_json::Value, } #[derive(Debug, Deserialize)] struct DiscordGatewayReady { user: DiscordGatewayAuthor, } #[derive(Debug, Deserialize, Clone)] struct DiscordGatewayAuthor { id: String, username: String, global_name: Option, #[serde(default)] bot: bool, } #[derive(Debug, Deserialize)] struct DiscordGatewayMessageCreate { channel_id: String, #[serde(default)] guild_id: Option, content: String, author: DiscordGatewayAuthor, } /// A message returned by the Discord REST channel-messages endpoint. #[derive(Debug, Deserialize)] struct DiscordChannelMessage { id: String, content: String, channel_id: String, author: DiscordChannelAuthor, #[serde(default)] mentions: Vec, #[serde(default)] webhook_id: Option, } /// Author sub-object for REST channel messages. #[derive(Debug, Deserialize)] struct DiscordChannelAuthor { id: String, username: String, global_name: Option, #[serde(default)] bot: bool, } #[derive(Debug, PartialEq, Eq)] struct ParsedGatewayMessage { user_id: String, user_name: String, channel_id: String, content: String, is_dm: bool, } #[derive(Debug, Default, PartialEq, Eq)] struct GatewayPollResult { bot_user_id: Option, messages: Vec, } fn parse_gateway_event_queue( queue_json: &str, known_bot_user_id: Option<&str>, ) -> GatewayPollResult { let frames: Vec = match serde_json::from_str(queue_json) { Ok(v) => v, Err(e) => { channel_host::log( channel_host::LogLevel::Warn, &format!("Failed to deserialize gateway event queue: {}", e), ); return GatewayPollResult::default(); } }; let mut result = GatewayPollResult::default(); let mut bot_user_id = known_bot_user_id.map(ToOwned::to_owned); for frame in frames { let event: DiscordGatewayEvent = match serde_json::from_str(&frame) { Ok(value) => value, Err(_) => continue, }; if event.op != 0 { continue; } match event.t.as_deref() { Some("READY") => { if let Ok(ready) = serde_json::from_value::(event.d) { if !ready.user.id.is_empty() { bot_user_id = Some(ready.user.id); } } } Some("MESSAGE_CREATE") => { let message = match serde_json::from_value::(event.d) { Ok(value) => value, Err(_) => continue, }; let active_bot_user_id = bot_user_id.as_deref().or(known_bot_user_id); if message.author.bot || active_bot_user_id.is_some_and(|bot_id| message.author.id == bot_id) { continue; } let is_dm = message.guild_id.is_none(); let content = match gateway_content_for_agent(&message.content, active_bot_user_id, is_dm) { Some(value) => value, None => continue, }; result.messages.push(ParsedGatewayMessage { user_id: message.author.id, user_name: message .author .global_name .unwrap_or(message.author.username), channel_id: message.channel_id, content, is_dm, }); } _ => {} } } result.bot_user_id = bot_user_id; result } fn gateway_content_for_agent( content: &str, bot_user_id: Option<&str>, is_dm: bool, ) -> Option { let trimmed = content.trim(); if trimmed.is_empty() { return None; } if is_dm { return Some(trimmed.to_string()); } let bot_user_id = bot_user_id?; for mention in [ format!("<@{}>", bot_user_id), format!("<@!{}>", bot_user_id), ] { if let Some(stripped) = trimmed.strip_prefix(&mention) { let cleaned = stripped.trim(); return if cleaned.is_empty() { None } else { Some(cleaned.to_string()) }; } } None } fn default_poll_interval_ms() -> u32 { 30_000 } /// Channel configuration from capabilities file. #[derive(Debug, Deserialize)] struct DiscordConfig { #[serde(default)] #[allow(dead_code)] require_signature_verification: bool, #[serde(default)] owner_id: Option, #[serde(default)] dm_policy: Option, #[serde(default)] allow_from: Option>, #[serde(default)] polling_enabled: bool, #[serde(default = "default_poll_interval_ms")] poll_interval_ms: u32, #[serde(default)] mention_channel_ids: Vec, } struct DiscordChannel; impl Guest for DiscordChannel { fn on_start(config_json: String) -> Result { let config: DiscordConfig = serde_json::from_str(&config_json) .map_err(|e| format!("Failed to parse config: {}", e))?; channel_host::log(channel_host::LogLevel::Info, "Discord channel starting"); // Persist owner_id so subsequent callbacks can read it if let Some(ref owner_id) = config.owner_id { let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id); channel_host::log( channel_host::LogLevel::Info, &format!("Owner restriction enabled: user {}", owner_id), ); } else { let _ = channel_host::workspace_write(OWNER_ID_PATH, ""); } // Persist dm_policy and allow_from for DM pairing let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing"); let _ = channel_host::workspace_write(DM_POLICY_PATH, dm_policy); let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default()) .unwrap_or_else(|_| "[]".to_string()); let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json); // Persist polling config let _ = channel_host::workspace_write( POLLING_ENABLED_PATH, &config.polling_enabled.to_string(), ); let mention_ids_json = serde_json::to_string(&config.mention_channel_ids).unwrap_or_else(|_| "[]".to_string()); let _ = channel_host::workspace_write(MENTION_CHANNEL_IDS_PATH, &mention_ids_json); Ok(ChannelConfig { display_name: "Discord".to_string(), http_endpoints: vec![HttpEndpointConfig { path: "/webhook/discord".to_string(), methods: vec!["POST".to_string()], require_secret: true, }], poll: if config.polling_enabled { Some(PollConfig { interval_ms: config.poll_interval_ms.max(30_000), enabled: true, }) } else { None }, }) } fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse { let body_str = match std::str::from_utf8(&req.body) { Ok(s) => s, Err(_) => { return json_response(400, serde_json::json!({"error": "Invalid UTF-8 body"})); } }; let interaction: DiscordInteraction = match serde_json::from_str(body_str) { Ok(i) => i, Err(e) => { channel_host::log( channel_host::LogLevel::Error, &format!("Failed to parse Discord interaction: {}", e), ); return json_response(400, serde_json::json!({"error": "Invalid interaction"})); } }; match interaction.interaction_type { // Ping - Discord verification 1 => { channel_host::log(channel_host::LogLevel::Info, "Responding to Discord ping"); json_response(200, serde_json::json!({"type": 1})) } // Application Command (slash command) 2 => { if handle_slash_command(&interaction) { json_response(200, serde_json::json!({"type": 5})) } else { // Permission denied — ephemeral response json_response( 200, serde_json::json!({ "type": 4, "data": { "content": "You are not authorized to use this bot.", "flags": 64 } }), ) } } // Message Component (buttons, selects) 3 => { if let Some(ref message) = interaction.message { handle_message_component(&interaction, message); } json_response(200, serde_json::json!({"type": 6})) } _ => { channel_host::log( channel_host::LogLevel::Warn, &format!( "Unknown Discord interaction type: {}", interaction.interaction_type ), ); json_response(200, serde_json::json!({"type": 6})) } } } fn on_poll() { // 1. Process Gateway event queue let queue_json = channel_host::workspace_read(GATEWAY_EVENT_QUEUE_PATH).unwrap_or_default(); let has_gateway_events = !queue_json.trim().is_empty() && queue_json.trim() != "[]"; if has_gateway_events { let known_bot_user_id = channel_host::workspace_read(BOT_USER_ID_PATH); let parsed = parse_gateway_event_queue(&queue_json, known_bot_user_id.as_deref()); if let Err(error) = channel_host::workspace_write(GATEWAY_EVENT_QUEUE_PATH, "[]") { channel_host::log( channel_host::LogLevel::Warn, &format!("Failed to clear Discord gateway queue: {}", error), ); } if let Some(bot_user_id) = parsed.bot_user_id.as_deref() { if let Err(error) = channel_host::workspace_write(BOT_USER_ID_PATH, bot_user_id) { channel_host::log( channel_host::LogLevel::Warn, &format!("Failed to persist Discord bot user id: {}", error), ); } } for message in parsed.messages { if !check_sender_permission( &message.user_id, Some(&message.user_name), message.is_dm, PermissionSource::Gateway, Some(&PairingReplyCtx { channel_id: message.channel_id.clone(), application_id: String::new(), token: String::new(), }), ) { continue; } let metadata = DiscordMessageMetadata { channel_id: message.channel_id, interaction_id: String::new(), token: String::new(), application_id: String::new(), source_message_id: None, thread_id: None, }; let metadata_json = match serde_json::to_string(&metadata) { Ok(json) => json, Err(error) => { channel_host::log( channel_host::LogLevel::Error, &format!("Failed to serialize gateway metadata: {}", error), ); continue; } }; channel_host::emit_message(&EmittedMessage { user_id: message.user_id, user_name: Some(message.user_name), content: message.content, thread_id: None, metadata_json, attachments: vec![], }); } } // 2. Run mention polling if configured poll_for_mentions(); } fn on_respond(response: AgentResponse) -> Result<(), String> { let metadata: DiscordMessageMetadata = serde_json::from_str(&response.metadata_json) .map_err(|e| format!("Failed to parse metadata: {}", e))?; // Mention-poll replies: include message_reference so Discord renders as a reply if let Some(ref source_id) = metadata.source_message_id { if let DiscordResponseRoute::ChannelMessage(ref url) = response_route_for_metadata(&metadata) { let embeds = embeds_from_metadata_json(&response.metadata_json); let content = if response.content.chars().count() > DISCORD_MESSAGE_CHAR_LIMIT { truncate_message(&response.content) } else { response.content.clone() }; let mut payload = serde_json::json!({ "content": content, "message_reference": { "message_id": source_id }, "allowed_mentions": { "replied_user": true } }); if let Some(ref e) = embeds { payload["embeds"] = e.clone(); } let headers = discord_auth_headers_json(true); let body = serde_json::to_vec(&payload) .map_err(|e| format!("Failed to serialize: {}", e))?; return send_discord_request( "POST", url, &DiscordHttpRequest { headers_json: headers, body, }, ); } } let route = response_route_for_metadata(&metadata); let plan = build_discord_reply_plan(&response)?; let (method, url) = match &route { DiscordResponseRoute::InteractionWebhook(url) => ("PATCH", url.as_str()), DiscordResponseRoute::ChannelMessage(url) => ("POST", url.as_str()), }; match plan { DiscordReplyPlan::Inline(request) => send_discord_request(method, url, &request), DiscordReplyPlan::Attachment { upload, fallback } => { match send_discord_request(method, url, &upload) { Ok(()) => Ok(()), Err(upload_error) => { channel_host::log( channel_host::LogLevel::Warn, &format!( "Discord attachment upload failed, falling back to truncated text: {}", upload_error ), ); send_discord_request(method, url, &fallback).map_err(|fallback_error| { format!( "Discord attachment upload failed: {}; fallback also failed: {}", upload_error, fallback_error ) }) } } } } } fn on_status(update: StatusUpdate) { let Some(url) = typing_request_url_for_update(&update) else { return; }; let headers = serde_json::json!({ "Content-Type": "application/json" }); match channel_host::http_request("POST", &url, &headers.to_string(), None, None) { Ok(response) if (200..300).contains(&response.status) => {} Ok(response) => { channel_host::log( channel_host::LogLevel::Warn, &format!( "Discord typing indicator failed with status {}", response.status ), ); } Err(error) => { channel_host::log( channel_host::LogLevel::Warn, &format!("Discord typing indicator request failed: {}", error), ); } } } fn on_broadcast(user_id: String, response: AgentResponse) -> Result<(), String> { broadcast_dm(&user_id, &response.content) } fn on_shutdown() { channel_host::log( channel_host::LogLevel::Info, "Discord channel shutting down", ); } } /// Returns true if the message was emitted, false if permission denied. fn handle_slash_command(interaction: &DiscordInteraction) -> bool { let user = interaction .member .as_ref() .map(|m| &m.user) .or(interaction.user.as_ref()); let user_id = user.map(|u| u.id.clone()).unwrap_or_default(); let user_name = user .map(|u| { u.global_name .as_ref() .filter(|s| !s.is_empty()) .unwrap_or(&u.username) .clone() }) .unwrap_or_default(); // DM if no guild member context (only direct user field set) let is_dm = interaction.member.is_none(); // Permission check if !check_sender_permission( &user_id, Some(&user_name), is_dm, PermissionSource::Webhook, Some(&PairingReplyCtx { channel_id: interaction.channel_id.clone().unwrap_or_default(), application_id: interaction.application_id.clone(), token: interaction.token.clone(), }), ) { return false; } let channel_id = interaction.channel_id.clone().unwrap_or_default(); let command_name = interaction .data .as_ref() .map(|d| d.name.clone()) .unwrap_or_default(); let options = interaction.data.as_ref().and_then(|d| d.options.clone()); let content = if let Some(opts) = options { let opt_str = opts .iter() .map(|o| format!("{}: {}", o.name, o.value)) .collect::>() .join(", "); format!("/{} {}", command_name, opt_str) } else { format!("/{}", command_name) }; let metadata = DiscordMessageMetadata { channel_id: channel_id.clone(), interaction_id: interaction.id.clone(), token: interaction.token.clone(), application_id: interaction.application_id.clone(), source_message_id: None, thread_id: None, }; let metadata_json = match serde_json::to_string(&metadata) { Ok(json) => json, Err(e) => { channel_host::log( channel_host::LogLevel::Error, &format!("Failed to serialize metadata: {}", e), ); let url = format!( "{DISCORD_API_BASE}/webhooks/{}/{}", interaction.application_id, interaction.token ); let payload = serde_json::json!({ "content": "❌ Internal Error: Failed to process command metadata.", "flags": 64 }); let _ = channel_host::http_request( "POST", &url, &serde_json::json!({"Content-Type": "application/json"}).to_string(), Some(&serde_json::to_vec(&payload).unwrap_or_default()), None, ); return true; // Error, but not a permission denial } }; channel_host::emit_message(&EmittedMessage { user_id, user_name: Some(user_name), content, thread_id: None, metadata_json, attachments: vec![], }); true } fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordMessage) { let user = interaction .member .as_ref() .map(|m| &m.user) .or(interaction.user.as_ref()); let user_id = user.map(|u| u.id.clone()).unwrap_or_default(); let user_name = user .map(|u| { u.global_name .as_ref() .filter(|s| !s.is_empty()) .unwrap_or(&u.username) .clone() }) .unwrap_or_default(); let is_dm = interaction.member.is_none(); if !check_sender_permission( &user_id, Some(&user_name), is_dm, PermissionSource::Webhook, None, ) { return; } let channel_id = message.channel_id.clone(); let metadata = DiscordMessageMetadata { channel_id: channel_id.clone(), interaction_id: interaction.id.clone(), token: interaction.token.clone(), application_id: interaction.application_id.clone(), source_message_id: None, thread_id: None, }; let metadata_json = match serde_json::to_string(&metadata) { Ok(json) => json, Err(e) => { channel_host::log( channel_host::LogLevel::Error, &format!("Failed to serialize metadata: {}", e), ); return; // Don't emit message if metadata can't be serialized } }; channel_host::emit_message(&EmittedMessage { user_id, user_name: Some(user_name), content: format!("[Button clicked] {}", message.content), thread_id: None, metadata_json, attachments: vec![], }); } // ============================================================================ // Permission & Pairing // ============================================================================ /// Context needed to send a pairing reply via Discord webhook followup. struct PairingReplyCtx { channel_id: String, application_id: String, token: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PermissionSource { Webhook, Gateway, } fn should_apply_dm_pairing(_source: PermissionSource, is_dm: bool) -> bool { // All current permission sources (Webhook, Gateway) apply DM pairing equally. // Kept as a function for future sources that may bypass pairing (e.g. internal). is_dm } /// Check if a sender is permitted to interact with the bot. /// Returns true if allowed, false if denied (pairing reply sent if applicable). fn check_sender_permission( user_id: &str, username: Option<&str>, is_dm: bool, source: PermissionSource, reply_ctx: Option<&PairingReplyCtx>, ) -> bool { // 1. Owner check (highest priority, applies to all contexts) let owner_id = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty()); if let Some(ref owner) = owner_id { if user_id != owner { channel_host::log( channel_host::LogLevel::Debug, &format!( "Dropping interaction from non-owner user {} (owner: {})", user_id, owner ), ); return false; } return true; } // 2. DM policy (only for DMs when no owner_id) if !should_apply_dm_pairing(source, is_dm) { return true; } let dm_policy = channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string()); if dm_policy == "open" { return true; } // 3. Build merged allow list: config allow_from + pairing store let mut allowed: Vec = channel_host::workspace_read(ALLOW_FROM_PATH) .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) { allowed.extend(store_allowed); } // 4. Check sender against allow list let is_allowed = allowed.contains(&"*".to_string()) || allowed.contains(&user_id.to_string()) || username.is_some_and(|u| allowed.contains(&u.to_string())); if is_allowed { return true; } // 5. Not allowed — handle by policy if dm_policy == "pairing" { let meta = serde_json::json!({ "user_id": user_id, "username": username, }) .to_string(); match channel_host::pairing_upsert_request(CHANNEL_NAME, user_id, &meta) { Ok(result) => { channel_host::log( channel_host::LogLevel::Info, &format!("Pairing request for user {}: code {}", user_id, result.code), ); if result.created { if let Some(ctx) = reply_ctx { let _ = send_pairing_reply(ctx, &result.code); } } } Err(e) => { channel_host::log( channel_host::LogLevel::Error, &format!("Pairing upsert failed: {}", e), ); } } } false } fn pairing_reply_route(ctx: &PairingReplyCtx) -> DiscordResponseRoute { if !ctx.application_id.is_empty() && !ctx.token.is_empty() { DiscordResponseRoute::InteractionWebhook(format!( "{DISCORD_API_BASE}/webhooks/{}/{}", ctx.application_id, ctx.token )) } else { DiscordResponseRoute::ChannelMessage(format!( "{DISCORD_API_BASE}/channels/{}/messages", ctx.channel_id )) } } /// Send a pairing code reply via webhook followup or channel message. fn send_pairing_reply(ctx: &PairingReplyCtx, code: &str) -> Result<(), String> { let route = pairing_reply_route(ctx); let mut payload = serde_json::json!({ "content": format!( "To pair with this bot, run: `optimclaw pairing approve discord {}`", code ) }); if matches!(route, DiscordResponseRoute::InteractionWebhook(_)) { payload["flags"] = serde_json::json!(64); } let payload_bytes = serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?; let headers = serde_json::json!({"Content-Type": "application/json"}); let url = match &route { DiscordResponseRoute::InteractionWebhook(url) => url, DiscordResponseRoute::ChannelMessage(url) => url, }; let result = channel_host::http_request( "POST", url, &headers.to_string(), Some(&payload_bytes), None, ); match result { Ok(response) if response.status >= 200 && response.status < 300 => Ok(()), Ok(response) => { let body_str = String::from_utf8_lossy(&response.body); Err(format!( "Discord API error: {} - {}", response.status, body_str )) } Err(e) => Err(format!("HTTP request failed: {}", e)), } } // ============================================================================ // Mention Polling // ============================================================================ /// Maximum number of processed message IDs to keep per channel for dedup. const DEDUP_CAP: usize = 200; /// Poll configured channels for new messages that mention the bot. fn poll_for_mentions() { let enabled = channel_host::workspace_read(POLLING_ENABLED_PATH) .map(|v| v.trim() == "true") .unwrap_or(false); if !enabled { return; } let bot_id = match get_or_fetch_bot_id() { Some(id) => id, None => { channel_host::log( channel_host::LogLevel::Warn, "Mention polling: unable to determine bot user id", ); return; } }; let channel_ids: Vec = channel_host::workspace_read(MENTION_CHANNEL_IDS_PATH) .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); for channel_id in &channel_ids { poll_channel_mentions(channel_id, &bot_id); } } /// Read the bot user ID from workspace or fetch it from the Discord API. fn get_or_fetch_bot_id() -> Option { if let Some(id) = channel_host::workspace_read(BOT_USER_ID_PATH).filter(|s| !s.is_empty()) { return Some(id); } let headers = discord_auth_headers_json(false); let resp = channel_host::http_request( "GET", "{DISCORD_API_BASE}/users/@me", &headers, None, None, ) .ok()?; if resp.status < 200 || resp.status >= 300 { return None; } let body: serde_json::Value = serde_json::from_slice(&resp.body).ok()?; let id = body["id"].as_str()?.to_string(); let _ = channel_host::workspace_write(BOT_USER_ID_PATH, &id); Some(id) } /// Poll a single channel for new mention messages. fn poll_channel_mentions(channel_id: &str, bot_id: &str) { let cursor_path = format!("state/mention_cursor/{}", channel_id); let last_seen = channel_host::workspace_read(&cursor_path).unwrap_or_default(); let messages = if last_seen.is_empty() { // First poll: initialise cursor without emitting any messages. if let Some(latest_id) = fetch_latest_message_id(channel_id) { let _ = channel_host::workspace_write(&cursor_path, &latest_id); } return; } else { match fetch_messages_after_cursor(channel_id, &last_seen) { Some(msgs) => msgs, None => return, } }; let mut processed_ids = load_recent_processed_ids(channel_id); let mut new_cursor = last_seen.clone(); for msg in &messages { if !is_new_message(&last_seen, &msg.id) { continue; } if processed_ids.contains(&msg.id) { continue; } if msg.author.bot || msg.author.id == bot_id { remember_processed_id(&msg.id, &mut processed_ids); continue; } if msg.webhook_id.is_some() { remember_processed_id(&msg.id, &mut processed_ids); continue; } if !message_mentions_bot(msg, bot_id) { remember_processed_id(&msg.id, &mut processed_ids); continue; } // Permission check (API-based poll uses Webhook source) if !check_sender_permission( &msg.author.id, Some(&msg.author.username), false, PermissionSource::Webhook, None, ) { remember_processed_id(&msg.id, &mut processed_ids); continue; } let content = strip_bot_mention(&msg.content, bot_id); if content.is_empty() { remember_processed_id(&msg.id, &mut processed_ids); continue; } let user_name = msg .author .global_name .clone() .unwrap_or_else(|| msg.author.username.clone()); let metadata = DiscordMessageMetadata { channel_id: msg.channel_id.clone(), interaction_id: String::new(), token: String::new(), application_id: String::new(), source_message_id: Some(msg.id.clone()), thread_id: None, }; let metadata_json = match serde_json::to_string(&metadata) { Ok(json) => json, Err(error) => { channel_host::log( channel_host::LogLevel::Error, &format!("Failed to serialize mention-poll metadata: {}", error), ); continue; } }; channel_host::emit_message(&EmittedMessage { user_id: msg.author.id.clone(), user_name: Some(user_name), content, thread_id: None, metadata_json, attachments: vec![], }); remember_processed_id(&msg.id, &mut processed_ids); if compare_message_ids(&msg.id, &new_cursor) == std::cmp::Ordering::Greater { new_cursor = msg.id.clone(); } } if new_cursor != last_seen { let _ = channel_host::workspace_write(&cursor_path, &new_cursor); } save_recent_processed_ids(channel_id, &processed_ids); } /// Fetch the latest message ID in a channel (used for cursor initialisation). fn fetch_latest_message_id(channel_id: &str) -> Option { let url = format!( "{DISCORD_API_BASE}/channels/{}/messages?limit=1", channel_id ); let headers = discord_auth_headers_json(false); let resp = channel_host::http_request("GET", &url, &headers, None, None).ok()?; if resp.status < 200 || resp.status >= 300 { return None; } let messages: Vec = serde_json::from_slice(&resp.body).ok()?; messages .first() .and_then(|m| m["id"].as_str().map(String::from)) } /// Maximum number of pages to fetch when catching up on missed messages. const MENTION_POLL_MAX_PAGES: usize = 5; /// Fetch messages after `last_seen` using the `after` parameter, paginating up /// to [`MENTION_POLL_MAX_PAGES`] pages of 100 messages each. fn fetch_messages_after_cursor( channel_id: &str, last_seen: &str, ) -> Option> { let headers = discord_auth_headers_json(false); let mut all_messages: Vec = Vec::new(); let mut after = last_seen.to_string(); for _ in 0..MENTION_POLL_MAX_PAGES { let url = format!( "{DISCORD_API_BASE}/channels/{}/messages?after={}&limit=100", channel_id, after ); let resp = channel_host::http_request("GET", &url, &headers, None, None).ok()?; if resp.status < 200 || resp.status >= 300 { let body_str = String::from_utf8_lossy(&resp.body); channel_host::log( channel_host::LogLevel::Warn, &format!( "Mention poll: failed to fetch messages for channel {}: {} - {}", channel_id, resp.status, body_str ), ); return None; } let page: Vec = serde_json::from_slice(&resp.body).ok()?; let page_len = page.len(); if page.is_empty() { break; } // Discord returns newest-first; find the max ID for the next page cursor let page_max_id = page .iter() .map(|m| m.id.as_str()) .max_by(|a, b| compare_message_ids(a, b)) .map(str::to_string); all_messages.extend(page); if page_len < 100 { break; } match page_max_id { Some(max_id) if max_id != after => after = max_id, _ => break, } } Some(all_messages) } /// Compare two Discord snowflake IDs. Falls back to lexical comparison. fn compare_message_ids(a: &str, b: &str) -> std::cmp::Ordering { match (a.parse::(), b.parse::()) { (Ok(a_num), Ok(b_num)) => a_num.cmp(&b_num), _ => a.cmp(b), } } fn dedup_ids_path(channel_id: &str) -> String { format!("state/mention_dedup/{}", channel_id) } fn load_recent_processed_ids(channel_id: &str) -> Vec { channel_host::workspace_read(&dedup_ids_path(channel_id)) .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } fn save_recent_processed_ids(channel_id: &str, ids: &[String]) { let json = serde_json::to_string(ids).unwrap_or_else(|_| "[]".to_string()); let _ = channel_host::workspace_write(&dedup_ids_path(channel_id), &json); } fn remember_processed_id(msg_id: &str, ids: &mut Vec) { if ids.contains(&msg_id.to_string()) { return; } ids.push(msg_id.to_string()); if ids.len() > DEDUP_CAP { let excess = ids.len() - DEDUP_CAP; ids.drain(0..excess); } } /// Returns true when `current` is strictly newer than `last_seen`. fn is_new_message(last_seen: &str, current: &str) -> bool { compare_message_ids(current, last_seen) == std::cmp::Ordering::Greater } /// Returns true if the message mentions the bot (by mention objects or content). fn message_mentions_bot(msg: &DiscordChannelMessage, bot_id: &str) -> bool { if msg.mentions.iter().any(|u| u.id == bot_id) { return true; } let mention = format!("<@{}>", bot_id); let mention_nick = format!("<@!{}>", bot_id); msg.content.contains(&mention) || msg.content.contains(&mention_nick) } /// Strip the bot mention prefix from content. fn strip_bot_mention(content: &str, bot_id: &str) -> String { let trimmed = content.trim(); for mention in [format!("<@{}>", bot_id), format!("<@!{}>", bot_id)] { if let Some(rest) = trimmed.strip_prefix(&mention) { return rest.trim().to_string(); } } trimmed.to_string() } /// Build JSON headers string with Discord bot authorization. /// When `include_content_type` is true, includes `Content-Type: application/json`. fn discord_auth_headers_json(include_content_type: bool) -> String { if include_content_type { serde_json::json!({ "Content-Type": "application/json" }) .to_string() } else { serde_json::json!({}).to_string() } } /// Send a DM to a Discord user by opening (or reusing) a DM channel. fn broadcast_dm(user_id: &str, content: &str) -> Result<(), String> { // Validate user_id is a plausible Discord snowflake (numeric, 17-20 digits) // to avoid injecting arbitrary strings into API URLs. if user_id.is_empty() || !user_id.chars().all(|c| c.is_ascii_digit()) || user_id.len() < 17 || user_id.len() > 20 { return Err(format!("Invalid Discord user ID: '{}'", user_id)); } // Step 1: Open (or reuse) a DM channel with the target user. let create_dm_payload = serde_json::json!({ "recipient_id": user_id }); let create_dm_bytes = serde_json::to_vec(&create_dm_payload) .map_err(|e| format!("Failed to serialize DM channel request: {}", e))?; let dm_response = channel_host::http_request( "POST", &format!("{DISCORD_API_BASE}/users/@me/channels"), &discord_auth_headers_json(true), Some(&create_dm_bytes), Some(10_000), ) .map_err(|e| format!("Failed to create DM channel: {}", e))?; if !(200..300).contains(&dm_response.status) { let body = String::from_utf8_lossy(&dm_response.body); return Err(format!( "Discord create-DM failed: {} - {}", dm_response.status, body )); } #[derive(Deserialize)] struct DmChannelResponse { id: String, } let dm_channel: DmChannelResponse = serde_json::from_slice(&dm_response.body) .map_err(|e| format!("Failed to parse DM channel response: {}", e))?; // Step 2: Send the message to the DM channel. let truncated = truncate_message(content); let payload = serde_json::json!({ "content": truncated }); let body = serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?; send_discord_request( "POST", &format!("{DISCORD_API_BASE}/channels/{}/messages", dm_channel.id), &DiscordHttpRequest { headers_json: discord_auth_headers_json(true), body, }, ) } fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse { let body = serde_json::to_vec(&value).unwrap_or_default(); let headers = serde_json::json!({"Content-Type": "application/json"}); OutgoingHttpResponse { status, headers_json: headers.to_string(), body, } } export!(DiscordChannel); fn truncate_message(content: &str) -> String { if content.chars().count() <= DISCORD_MESSAGE_CHAR_LIMIT { content.to_string() } else { let suffix = "\n... (truncated)"; let allowed_chars = DISCORD_MESSAGE_CHAR_LIMIT.saturating_sub(suffix.chars().count()); let mut truncated = content.chars().take(allowed_chars).collect::(); truncated.push_str("\n... (truncated)"); truncated } } #[cfg(test)] mod tests { use super::*; const DISCORD_CAPABILITIES_JSON: &str = include_str!("../discord.capabilities.json"); #[test] fn test_truncate_message() { let short = "Hello world"; assert_eq!(truncate_message(short), short); let long = "a".repeat(2005); let truncated = truncate_message(&long); assert_eq!(truncated.chars().count(), 2000); assert!(truncated.ends_with("\n... (truncated)")); // Test with multibyte characters (Euro sign is 3 bytes) let multi = "€".repeat(2005); let truncated_multi = truncate_message(&multi); assert_eq!(truncated_multi.chars().count(), 2000); assert!(truncated_multi.ends_with("\n... (truncated)")); let content_part = &truncated_multi[..truncated_multi.len() - 16]; assert!(content_part.chars().all(|c| c == '€')); } #[test] fn test_reply_plan_uses_character_count_for_attachment_threshold() { let inline = build_discord_reply_plan(&test_response(test_metadata_json(), "€".repeat(2000))) .unwrap(); assert!(matches!(inline, DiscordReplyPlan::Inline(_))); } fn test_response(metadata_json: String, content: String) -> AgentResponse { AgentResponse { message_id: "msg-1".to_string(), content, thread_id: None, metadata_json, attachments: vec![], } } fn test_metadata_json() -> String { serde_json::json!({ "channel_id": "chan-1", "interaction_id": "int-1", "token": "tok-1", "application_id": "app-1", "thread_id": null, "embeds": [{"title": "embed title"}] }) .to_string() } #[test] fn test_reply_plan_threshold_uses_attachment_only_above_2000_chars() { let inline = build_discord_reply_plan(&test_response(test_metadata_json(), "a".repeat(2000))) .unwrap(); assert!(matches!(inline, DiscordReplyPlan::Inline(_))); let attachment = build_discord_reply_plan(&test_response(test_metadata_json(), "a".repeat(2001))) .unwrap(); assert!(matches!(attachment, DiscordReplyPlan::Attachment { .. })); } #[test] fn test_reply_plan_preserves_short_message_content_and_embeds() { let plan = build_discord_reply_plan(&test_response( test_metadata_json(), "short reply".to_string(), )) .unwrap(); let DiscordReplyPlan::Inline(request) = plan else { panic!("expected inline plan"); }; assert_eq!( request.headers_json, r#"{"Content-Type":"application/json"}"# ); let payload: serde_json::Value = serde_json::from_slice(&request.body).unwrap(); assert_eq!(payload["content"], "short reply"); assert_eq!(payload["embeds"][0]["title"], "embed title"); } #[test] fn test_reply_plan_builds_markdown_attachment_multipart_payload() { let content = "# Heading\n\nA long markdown reply".repeat(80); let plan = build_discord_reply_plan(&test_response(test_metadata_json(), content.clone())) .unwrap(); let DiscordReplyPlan::Attachment { upload, .. } = plan else { panic!("expected attachment plan"); }; assert!(upload .headers_json .contains("multipart/form-data; boundary=")); let body = String::from_utf8(upload.body).unwrap(); assert!(body.contains("name=\"payload_json\"")); assert!(body.contains("filename=\"response.md\"")); assert!(body.contains("Content-Type: text/markdown")); assert!(body.contains(DISCORD_ATTACHMENT_NOTICE)); assert!(body.contains("embed title")); assert!(body.contains(&content)); } #[test] fn test_reply_plan_uses_dynamic_multipart_boundary() { let content = "# Heading\n\nA long markdown reply".repeat(80); let first = build_discord_reply_plan(&test_response(test_metadata_json(), content.clone())) .unwrap(); let second = build_discord_reply_plan(&test_response(test_metadata_json(), content)).unwrap(); let DiscordReplyPlan::Attachment { upload: first_upload, .. } = first else { panic!("expected attachment plan"); }; let DiscordReplyPlan::Attachment { upload: second_upload, .. } = second else { panic!("expected attachment plan"); }; let first_headers: serde_json::Value = serde_json::from_str(&first_upload.headers_json).unwrap(); let second_headers: serde_json::Value = serde_json::from_str(&second_upload.headers_json).unwrap(); let first_boundary = first_headers["Content-Type"] .as_str() .unwrap() .strip_prefix("multipart/form-data; boundary=") .unwrap(); let second_boundary = second_headers["Content-Type"] .as_str() .unwrap() .strip_prefix("multipart/form-data; boundary=") .unwrap(); assert!(first_boundary.starts_with(DISCORD_MULTIPART_BOUNDARY)); assert!(second_boundary.starts_with(DISCORD_MULTIPART_BOUNDARY)); assert_ne!(first_boundary, second_boundary); let first_body = String::from_utf8(first_upload.body).unwrap(); let second_body = String::from_utf8(second_upload.body).unwrap(); assert!(first_body.contains(&format!("--{first_boundary}\r\n"))); assert!(second_body.contains(&format!("--{second_boundary}\r\n"))); } #[test] fn test_reply_plan_includes_truncated_text_fallback_for_attachment_failures() { let content = "a".repeat(2400); let plan = build_discord_reply_plan(&test_response(test_metadata_json(), content.clone())) .unwrap(); let DiscordReplyPlan::Attachment { fallback, .. } = plan else { panic!("expected attachment plan"); }; let payload: serde_json::Value = serde_json::from_slice(&fallback.body).unwrap(); assert_eq!(payload["content"], truncate_message(&content)); assert_eq!(payload["embeds"][0]["title"], "embed title"); } #[test] fn test_metadata_serialization() { let metadata = DiscordMessageMetadata { channel_id: "123".into(), interaction_id: "456".into(), token: "abc".into(), application_id: "789".into(), source_message_id: None, thread_id: None, }; let json = serde_json::to_string(&metadata).unwrap(); let parsed: DiscordMessageMetadata = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.channel_id, "123"); assert_eq!(parsed.interaction_id, "456"); } #[test] fn test_metadata_backward_compat_with_old_option_format() { // Old metadata format used Option for these fields let old_json = r#"{ "channel_id": "123", "interaction_id": null, "token": null, "application_id": null, "thread_id": null }"#; let parsed: DiscordMessageMetadata = serde_json::from_str(old_json).unwrap(); assert_eq!(parsed.channel_id, "123"); assert!(parsed.interaction_id.is_empty()); // Old format without the fields at all let minimal_json = r#"{"channel_id": "456"}"#; let parsed: DiscordMessageMetadata = serde_json::from_str(minimal_json).unwrap(); assert_eq!(parsed.channel_id, "456"); assert!(parsed.interaction_id.is_empty()); assert!(parsed.token.is_empty()); assert!(parsed.application_id.is_empty()); } #[test] fn test_response_route_uses_webhook_for_interactions() { let metadata = DiscordMessageMetadata { channel_id: "123".into(), interaction_id: "456".into(), token: "tok".into(), application_id: "app".into(), source_message_id: None, thread_id: None, }; assert_eq!( response_route_for_metadata(&metadata), DiscordResponseRoute::InteractionWebhook( format!("{DISCORD_API_BASE}/webhooks/app/tok/messages/@original") ) ); } #[test] fn test_response_route_uses_channel_messages_for_gateway_metadata() { let metadata = DiscordMessageMetadata { channel_id: "chan-1".into(), interaction_id: String::new(), token: String::new(), application_id: String::new(), source_message_id: None, thread_id: None, }; assert_eq!( response_route_for_metadata(&metadata), DiscordResponseRoute::ChannelMessage( format!("{DISCORD_API_BASE}/channels/chan-1/messages") ) ); } #[test] fn test_typing_request_url_uses_channel_id_for_thinking_status() { let update = StatusUpdate { status: StatusType::Thinking, message: "Thinking...".to_string(), metadata_json: serde_json::json!({ "channel_id": "chan-42", "interaction_id": "", "token": "", "application_id": "", "thread_id": null }) .to_string(), }; assert_eq!( typing_request_url_for_update(&update), Some(format!("{DISCORD_API_BASE}/channels/chan-42/typing")) ); } #[test] fn test_typing_request_url_ignores_non_thinking_status() { let update = StatusUpdate { status: StatusType::Done, message: "Done".to_string(), metadata_json: serde_json::json!({ "channel_id": "chan-42", "interaction_id": "", "token": "", "application_id": "", "thread_id": null }) .to_string(), }; assert_eq!(typing_request_url_for_update(&update), None); } #[test] fn test_typing_request_url_ignores_invalid_metadata() { let update = StatusUpdate { status: StatusType::Thinking, message: "Thinking...".to_string(), metadata_json: "not-json".to_string(), }; assert_eq!(typing_request_url_for_update(&update), None); } #[test] fn test_parse_slash_command_interaction() { // Verify that a slash command interaction deserializes correctly. let json = r#"{ "type": 2, "id": "int_1", "application_id": "app_1", "channel_id": "ch_1", "member": { "user": { "id": "user_1", "username": "testuser", "global_name": "Test User" } }, "data": { "id": "cmd_1", "name": "ask", "options": [ {"name": "question", "value": "What is rust?"} ] }, "token": "token_abc" }"#; let interaction: DiscordInteraction = serde_json::from_str(json).unwrap(); assert_eq!(interaction.interaction_type, 2); assert!(interaction.data.is_some()); } #[test] fn test_capabilities_default_to_gateway_mode() { let caps: serde_json::Value = serde_json::from_str(DISCORD_CAPABILITIES_JSON).expect("capabilities parse"); let allowlist = caps["capabilities"]["http"]["allowlist"] .as_array() .expect("http allowlist array"); assert_eq!( caps["capabilities"]["channel"]["allow_polling"], serde_json::Value::Bool(true) ); assert!(allowlist.iter().any(|entry| { entry["host"] == serde_json::Value::String("gateway.discord.gg".to_string()) && entry["methods"] == serde_json::json!(["GET"]) })); assert_eq!( caps["capabilities"]["websocket"]["url"], serde_json::Value::String("wss://gateway.discord.gg/?v=10&encoding=json".to_string()) ); assert_eq!( caps["capabilities"]["websocket"]["connect_on_start"], serde_json::Value::Bool(true) ); assert_eq!( caps["capabilities"]["websocket"]["identify_secret_name"], serde_json::Value::String("discord_bot_token".to_string()) ); assert_eq!( caps["capabilities"]["websocket"]["identify"]["intents"], serde_json::Value::Number(4609u64.into()) ); } #[test] fn test_parse_gateway_event_queue_emits_message_create_after_ready() { let queue_json = serde_json::json!([ serde_json::json!({ "op": 0, "t": "READY", "d": { "user": { "id": "bot-1", "username": "ironclaw", "global_name": "IronClaw", "bot": true } } }) .to_string(), serde_json::json!({ "op": 0, "t": "MESSAGE_CREATE", "d": { "channel_id": "chan-1", "guild_id": "guild-1", "content": "<@bot-1> hello from discord", "author": { "id": "user-1", "username": "alice", "global_name": "Alice", "bot": false } } }) .to_string() ]) .to_string(); let result = parse_gateway_event_queue(&queue_json, None); assert_eq!(result.bot_user_id.as_deref(), Some("bot-1")); assert_eq!( result.messages, vec![ParsedGatewayMessage { user_id: "user-1".to_string(), user_name: "Alice".to_string(), channel_id: "chan-1".to_string(), content: "hello from discord".to_string(), is_dm: false, }] ); } #[test] fn test_parse_gateway_event_queue_ignores_bot_and_unmentioned_guild_messages() { let queue_json = serde_json::json!([ serde_json::json!({ "op": 0, "t": "MESSAGE_CREATE", "d": { "channel_id": "chan-1", "guild_id": "guild-1", "content": "this should not trigger", "author": { "id": "user-1", "username": "alice", "global_name": "Alice", "bot": false } } }) .to_string(), serde_json::json!({ "op": 0, "t": "MESSAGE_CREATE", "d": { "channel_id": "dm-1", "content": "bot echo", "author": { "id": "bot-1", "username": "ironclaw", "global_name": "IronClaw", "bot": true } } }) .to_string(), serde_json::json!({ "op": 0, "t": "MESSAGE_CREATE", "d": { "channel_id": "dm-2", "content": "direct message", "author": { "id": "user-2", "username": "bob", "global_name": null, "bot": false } } }) .to_string() ]) .to_string(); let result = parse_gateway_event_queue(&queue_json, Some("bot-1")); assert_eq!(result.bot_user_id.as_deref(), Some("bot-1")); assert_eq!( result.messages, vec![ParsedGatewayMessage { user_id: "user-2".to_string(), user_name: "bob".to_string(), channel_id: "dm-2".to_string(), content: "direct message".to_string(), is_dm: true, }] ); } #[test] fn test_non_gateway_dm_pairing_behavior_is_unchanged() { assert!(should_apply_dm_pairing(PermissionSource::Webhook, true)); assert!(!should_apply_dm_pairing(PermissionSource::Webhook, false)); } #[test] fn test_gateway_dm_pairing_behavior_matches_webhook_dm() { assert!(should_apply_dm_pairing(PermissionSource::Gateway, true)); assert!(!should_apply_dm_pairing(PermissionSource::Gateway, false)); } #[test] fn test_pairing_reply_route_uses_channel_messages_for_gateway_metadata() { let route = pairing_reply_route(&PairingReplyCtx { channel_id: "chan-1".to_string(), application_id: String::new(), token: String::new(), }); assert_eq!( route, DiscordResponseRoute::ChannelMessage( format!("{DISCORD_API_BASE}/channels/chan-1/messages") ) ); } #[test] fn test_pairing_reply_route_uses_webhook_for_interactions() { let route = pairing_reply_route(&PairingReplyCtx { channel_id: "chan-1".to_string(), application_id: "app-1".to_string(), token: "tok-1".to_string(), }); assert_eq!( route, DiscordResponseRoute::InteractionWebhook( format!("{DISCORD_API_BASE}/webhooks/app-1/tok-1") ) ); } // ====================================================================== // Mention polling tests // ====================================================================== #[test] fn test_is_new_message() { assert!(is_new_message("100", "200")); assert!(!is_new_message("200", "100")); assert!(!is_new_message("100", "100")); // Large snowflake-like IDs assert!(is_new_message("1234567890123456789", "1234567890123456790")); assert!(!is_new_message( "1234567890123456790", "1234567890123456789" )); } #[test] fn test_strip_bot_mention() { assert_eq!( strip_bot_mention("<@bot-123> hello world", "bot-123"), "hello world" ); assert_eq!( strip_bot_mention("<@!bot-123> hi there", "bot-123"), "hi there" ); // No mention prefix — return content as-is assert_eq!( strip_bot_mention("no mention here", "bot-123"), "no mention here" ); // Only mention, no content after stripping assert_eq!(strip_bot_mention("<@bot-123>", "bot-123"), ""); assert_eq!(strip_bot_mention("<@bot-123> ", "bot-123"), ""); } #[test] fn test_message_mentions_bot() { // Via mentions array let msg = DiscordChannelMessage { id: "1".to_string(), content: "hello".to_string(), channel_id: "ch-1".to_string(), author: DiscordChannelAuthor { id: "user-1".to_string(), username: "alice".to_string(), global_name: None, bot: false, }, mentions: vec![DiscordUser { id: "bot-1".to_string(), username: "ironclaw".to_string(), global_name: None, }], webhook_id: None, }; assert!(message_mentions_bot(&msg, "bot-1")); assert!(!message_mentions_bot(&msg, "other-bot")); // Via content let msg2 = DiscordChannelMessage { id: "2".to_string(), content: "<@bot-2> do something".to_string(), channel_id: "ch-1".to_string(), author: DiscordChannelAuthor { id: "user-1".to_string(), username: "alice".to_string(), global_name: None, bot: false, }, mentions: vec![], webhook_id: None, }; assert!(message_mentions_bot(&msg2, "bot-2")); assert!(!message_mentions_bot(&msg2, "other-bot")); } #[test] fn test_compare_message_ids() { use std::cmp::Ordering; assert_eq!(compare_message_ids("100", "200"), Ordering::Less); assert_eq!(compare_message_ids("200", "100"), Ordering::Greater); assert_eq!(compare_message_ids("100", "100"), Ordering::Equal); // Non-numeric fallback assert_eq!(compare_message_ids("abc", "abd"), Ordering::Less); assert_eq!(compare_message_ids("abd", "abc"), Ordering::Greater); } #[test] fn test_remember_processed_id_dedup_and_cap() { let mut ids = Vec::new(); // Basic add remember_processed_id("msg-1", &mut ids); assert_eq!(ids, vec!["msg-1".to_string()]); // Duplicate is ignored remember_processed_id("msg-1", &mut ids); assert_eq!(ids.len(), 1); // Fill beyond DEDUP_CAP for i in 2..=(DEDUP_CAP + 5) { remember_processed_id(&format!("msg-{}", i), &mut ids); } assert_eq!(ids.len(), DEDUP_CAP); // Oldest entries should have been drained assert!(!ids.contains(&"msg-1".to_string())); assert!(ids.contains(&format!("msg-{}", DEDUP_CAP + 5))); } #[test] fn test_discord_auth_headers_json_shape() { let with_ct = discord_auth_headers_json(true); let parsed: serde_json::Value = serde_json::from_str(&with_ct).unwrap(); assert_eq!(parsed["Content-Type"], "application/json"); let without_ct = discord_auth_headers_json(false); let parsed: serde_json::Value = serde_json::from_str(&without_ct).unwrap(); assert!(parsed.get("Content-Type").is_none()); } }