//! Discord Gateway/Webhook channel for IronClaw. //! //! 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 //! - Automatic message truncation (> 2000 chars) //! //! # Security //! //! - Signature validation is handled in-channel using Discord's Ed25519 headers //! - 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 std::{cmp::Ordering, collections::HashMap}; use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use serde::{Deserialize, Serialize}; /// Discord REST API v10 base URL. const DISCORD_API_BASE: &str = "https://discord.com/api/v10"; use exports::near::agent::channel::{ AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest, OutgoingHttpResponse, PollConfig, StatusUpdate, }; use near::agent::channel_host::{self, EmittedMessage}; /// 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, } #[derive(Debug, Deserialize)] struct DiscordChannelMessage { id: String, content: String, channel_id: String, author: DiscordChannelAuthor, #[serde(default)] mentions: Vec, #[serde(default)] webhook_id: Option, } #[derive(Debug, Deserialize)] struct DiscordChannelAuthor { id: String, username: String, global_name: Option, #[serde(default)] bot: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] struct DiscordRuntimeConfig { #[serde(default = "default_require_signature_verification")] require_signature_verification: bool, #[serde(default)] webhook_secret: Option, #[serde(default)] polling_enabled: bool, #[serde(default = "default_poll_interval_ms")] poll_interval_ms: u32, #[serde(default)] mention_channel_ids: Vec, #[serde(default)] owner_id: Option, #[serde(default = "default_dm_policy")] dm_policy: String, #[serde(default)] allow_from: Vec, } fn default_poll_interval_ms() -> u32 { 30_000 } fn default_require_signature_verification() -> bool { true } fn default_dm_policy() -> String { "pairing".to_string() } fn default_runtime_config() -> DiscordRuntimeConfig { DiscordRuntimeConfig { require_signature_verification: default_require_signature_verification(), webhook_secret: None, polling_enabled: false, poll_interval_ms: default_poll_interval_ms(), mention_channel_ids: Vec::new(), owner_id: None, dm_policy: default_dm_policy(), allow_from: Vec::new(), } } /// Workspace path for persisting owner_id across WASM callbacks. const OWNER_ID_PATH: &str = "state/owner_id"; /// 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"; /// Channel name for pairing store (used by pairing host APIs). const CHANNEL_NAME: &str = "discord"; /// 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)] interaction_id: Option, /// Interaction token for responding #[serde(default)] token: Option, /// Application ID #[serde(default)] application_id: Option, /// Source message ID when handling mention-poll events. #[serde(default)] source_message_id: Option, /// Thread ID (for forum threads) thread_id: Option, } struct DiscordChannel; impl Guest for DiscordChannel { fn on_start(config_json: String) -> Result { channel_host::log(channel_host::LogLevel::Info, "Discord channel starting"); let config = serde_json::from_str::(&config_json).unwrap_or_else(|e| { channel_host::log( channel_host::LogLevel::Warn, &format!("Invalid config JSON, using defaults: {}", e), ); default_runtime_config() }); if let Ok(serialized) = serde_json::to_string(&config) { let _ = channel_host::workspace_write("config.json", &serialized); } if config.require_signature_verification && config .webhook_secret .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) .is_none() { channel_host::log( channel_host::LogLevel::Error, "Discord channel misconfigured: require_signature_verification=true but webhook_secret is empty", ); } else if !config.require_signature_verification { channel_host::log( channel_host::LogLevel::Warn, "Discord signature verification is disabled; webhook endpoint is unprotected", ); } // 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 _ = channel_host::workspace_write(DM_POLICY_PATH, &config.dm_policy); let allow_from_json = serde_json::to_string(&config.allow_from).unwrap_or_else(|_| "[]".to_string()); let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json); Ok(ChannelConfig { display_name: "Discord".to_string(), http_endpoints: vec![HttpEndpointConfig { path: "/webhook/discord".to_string(), methods: vec!["POST".to_string()], require_secret: false, }], 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 config = load_runtime_config(); let headers: HashMap = serde_json::from_str(&req.headers_json).unwrap_or_default(); if config.require_signature_verification { if config .webhook_secret .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) .is_none() { channel_host::log( channel_host::LogLevel::Error, "Discord channel misconfigured: webhook_secret not set while verification is required", ); return json_response( 500, serde_json::json!({"error": "Channel misconfigured: webhook_secret not set"}), ); } if !verify_discord_request_signature( headers, &req.body, config.webhook_secret.as_deref(), ) { channel_host::log( channel_host::LogLevel::Warn, "Discord signature verification failed", ); return json_response(401, serde_json::json!({"error": "Invalid signature"})); } } else { channel_host::log( channel_host::LogLevel::Warn, "Discord signature verification is disabled; accepting unverified webhook request", ); } 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, "data": { "content": "🤔 Thinking..." } }), ) } else { 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() { 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))?; // Truncate content to 2000 characters to comply with Discord limits let content = truncate_message(&response.content); let mut payload = serde_json::json!({ "content": content }); // Check for embeds in metadata if let Ok(meta_json) = serde_json::from_str::(&response.metadata_json) { if let Some(embeds) = meta_json.get("embeds") { payload["embeds"] = embeds.clone(); } } 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 (method, url) = if let (Some(application_id), Some(token)) = (metadata.application_id.as_ref(), metadata.token.as_ref()) { ( "PATCH", format!( "{DISCORD_API_BASE}/webhooks/{}/{}/messages/@original", application_id, token ), ) } else if let Some(source_message_id) = metadata.source_message_id.as_ref() { payload["message_reference"] = serde_json::json!({ "message_id": source_message_id }); payload["allowed_mentions"] = serde_json::json!({ "replied_user": true }); return send_channel_message(&metadata.channel_id, payload); } else { return Err("Unsupported Discord response metadata".to_string()); }; let result = channel_host::http_request( method, &url, &headers.to_string(), Some(&payload_bytes), None, ); map_discord_response(result) } fn on_status(_update: StatusUpdate) {} 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", ); } } fn map_discord_response( result: Result, ) -> Result<(), String> { match result { Ok(http_response) => { if http_response.status >= 200 && http_response.status < 300 { channel_host::log(channel_host::LogLevel::Debug, "Posted response 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)), } } /// Post a JSON payload to a Discord channel as a new message. fn send_channel_message(channel_id: &str, payload: serde_json::Value) -> Result<(), String> { let payload_bytes = serde_json::to_vec(&payload) .map_err(|e| format!("Failed to serialize message: {}", e))?; let url = format!("{DISCORD_API_BASE}/channels/{}/messages", channel_id); let result = channel_host::http_request( "POST", &url, &discord_auth_headers_json(true), Some(&payload_bytes), None, ); map_discord_response(result) } fn load_runtime_config() -> DiscordRuntimeConfig { channel_host::workspace_read("config.json") .and_then(|raw| serde_json::from_str::(&raw).ok()) .unwrap_or_else(default_runtime_config) } fn poll_for_mentions() { let config = load_runtime_config(); if !config.polling_enabled || config.mention_channel_ids.is_empty() { return; } let bot_id = match get_or_fetch_bot_id() { Some(id) => id, None => { channel_host::log( channel_host::LogLevel::Warn, "Skipping mention polling: failed to resolve bot user id", ); return; } }; for channel_id in &config.mention_channel_ids { poll_channel_mentions(channel_id, &bot_id); } } fn get_or_fetch_bot_id() -> Option { if let Some(id) = channel_host::workspace_read("bot_user_id.txt") { let trimmed = id.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } } let response = channel_host::http_request( "GET", &format!("{DISCORD_API_BASE}/users/@me"), &discord_auth_headers_json(false), None, Some(10_000), ) .ok()?; if !(200..300).contains(&response.status) { return None; } let value: serde_json::Value = serde_json::from_slice(&response.body).ok()?; let id = value.get("id")?.as_str()?.to_string(); let _ = channel_host::workspace_write("bot_user_id.txt", &id); Some(id) } fn poll_channel_mentions(channel_id: &str, bot_id: &str) { let cursor_path = format!("cursor_{}.txt", channel_id); let last_seen = channel_host::workspace_read(&cursor_path).map(|s| s.trim().to_string()); // On first run for a channel, initialize the cursor to "latest seen" and // skip back-processing historical messages. if last_seen.is_none() { if let Some(latest) = fetch_latest_message_id(channel_id) { let _ = channel_host::workspace_write(&cursor_path, &latest); } return; } let Some(mut messages) = fetch_messages_after_cursor(channel_id, last_seen.as_deref().unwrap_or("")) else { return; }; if messages.is_empty() { return; } messages.sort_by(|a, b| compare_message_ids(&a.id, &b.id)); let mut max_seen = last_seen.clone(); let mut recent_ids = load_recent_processed_ids(channel_id); let mut dedup_updated = false; for msg in messages { if is_new_message(max_seen.as_deref(), &msg.id) { max_seen = Some(msg.id.clone()); } if msg.webhook_id.is_some() || msg.author.bot || msg.author.id == bot_id { continue; } if !message_mentions_bot(&msg, bot_id) { continue; } if recent_ids.iter().any(|id| id == &msg.id) { continue; } let user_name = msg .author .global_name .as_ref() .filter(|s| !s.is_empty()) .unwrap_or(&msg.author.username) .clone(); if !check_sender_permission(&msg.author.id, Some(&user_name), false, None) { continue; } let content = strip_bot_mention(&msg.content, bot_id); let metadata = DiscordMessageMetadata { channel_id: msg.channel_id.clone(), interaction_id: None, token: None, application_id: None, source_message_id: Some(msg.id.clone()), thread_id: None, }; let metadata_json = match serde_json::to_string(&metadata) { Ok(v) => v, Err(e) => { channel_host::log( channel_host::LogLevel::Warn, &format!("Failed to serialize mention metadata: {}", e), ); continue; } }; channel_host::emit_message(&EmittedMessage { user_id: msg.author.id.clone(), user_name: Some(user_name.clone()), content: if content.is_empty() { "mention".to_string() } else { content }, thread_id: None, metadata_json, attachments: vec![], }); remember_processed_id(&mut recent_ids, &msg.id); dedup_updated = true; } if let Some(cursor) = max_seen { let _ = channel_host::workspace_write(&cursor_path, &cursor); } if dedup_updated { let _ = save_recent_processed_ids(channel_id, &recent_ids); } } fn fetch_latest_message_id(channel_id: &str) -> Option { let url = format!( "{DISCORD_API_BASE}/channels/{}/messages?limit=1", channel_id ); let response = channel_host::http_request( "GET", &url, &discord_auth_headers_json(false), None, Some(10_000), ) .ok()?; if !(200..300).contains(&response.status) { let body = String::from_utf8_lossy(&response.body); channel_host::log( channel_host::LogLevel::Warn, &format!( "Discord initial poll failed for channel {}: status={} body={}", channel_id, response.status, body ), ); return None; } let messages: Vec = serde_json::from_slice(&response.body).ok()?; messages.first().map(|m| m.id.clone()) } fn fetch_messages_after_cursor( channel_id: &str, last_seen: &str, ) -> Option> { const PAGE_LIMIT: usize = 100; const MAX_PAGES: usize = 50; let mut all_messages = Vec::new(); let mut after = last_seen.to_string(); for page in 0..MAX_PAGES { let url = format!( "{DISCORD_API_BASE}/channels/{}/messages?limit={}&after={}", channel_id, PAGE_LIMIT, after ); let response = match channel_host::http_request( "GET", &url, &discord_auth_headers_json(false), None, Some(10_000), ) { Ok(r) => r, Err(e) => { channel_host::log( channel_host::LogLevel::Warn, &format!( "Discord poll request failed for channel {}: {}", channel_id, e ), ); return None; } }; if !(200..300).contains(&response.status) { let body = String::from_utf8_lossy(&response.body); channel_host::log( channel_host::LogLevel::Warn, &format!( "Discord poll failed for channel {}: status={} body={}", channel_id, response.status, body ), ); return None; } let messages: Vec = match serde_json::from_slice(&response.body) { Ok(v) => v, Err(e) => { channel_host::log( channel_host::LogLevel::Warn, &format!("Failed to parse polled Discord messages: {}", e), ); return None; } }; let page_len = messages.len(); if messages.is_empty() { break; } let page_max_id = messages .iter() .map(|m| m.id.as_str()) .max_by(|a, b| compare_message_ids(a, b)) .map(str::to_string); all_messages.extend(messages.into_iter()); if page_len < PAGE_LIMIT { break; } if let Some(max_id) = page_max_id { if max_id == after { break; } after = max_id; } else { break; } if page + 1 == MAX_PAGES { channel_host::log( channel_host::LogLevel::Warn, &format!( "Discord poll pagination limit reached for channel {}; processing partial batch", channel_id ), ); } } Some(all_messages) } fn compare_message_ids(a: &str, b: &str) -> Ordering { match (a.parse::(), b.parse::()) { (Ok(left), Ok(right)) => left.cmp(&right), _ => a.cmp(b), } } fn dedup_ids_path(channel_id: &str) -> String { format!("dedup_{}.json", channel_id) } fn load_recent_processed_ids(channel_id: &str) -> Vec { let path = dedup_ids_path(channel_id); channel_host::workspace_read(&path) .and_then(|raw| serde_json::from_str::>(&raw).ok()) .unwrap_or_default() } fn save_recent_processed_ids(channel_id: &str, ids: &[String]) -> Result<(), String> { let path = dedup_ids_path(channel_id); let raw = serde_json::to_string(ids).map_err(|e| format!("Failed to serialize dedup ids: {}", e))?; channel_host::workspace_write(&path, &raw) } fn remember_processed_id(ids: &mut Vec, message_id: &str) { const MAX_RECENT_IDS: usize = 200; if ids.iter().any(|id| id == message_id) { return; } ids.push(message_id.to_string()); if ids.len() > MAX_RECENT_IDS { let drop_count = ids.len() - MAX_RECENT_IDS; ids.drain(0..drop_count); } } fn is_new_message(last_seen: Option<&str>, current: &str) -> bool { match last_seen { None => true, Some(prev) => { let prev_num = prev.parse::().ok(); let cur_num = current.parse::().ok(); match (prev_num, cur_num) { (Some(p), Some(c)) => c > p, _ => current > prev, } } } } fn message_mentions_bot(msg: &DiscordChannelMessage, bot_id: &str) -> bool { msg.mentions.iter().any(|u| u.id == bot_id) || msg.content.contains(&format!("<@{}>", bot_id)) || msg.content.contains(&format!("<@!{}>", bot_id)) } fn strip_bot_mention(content: &str, bot_id: &str) -> String { content .replace(&format!("<@{}>", bot_id), "") .replace(&format!("<@!{}>", bot_id), "") .trim() .to_string() } fn discord_auth_headers_json(include_content_type: bool) -> String { if include_content_type { serde_json::json!({ "Content-Type": "application/json", "Authorization": "Bot {DISCORD_BOT_TOKEN}" }) .to_string() } else { serde_json::json!({ "Authorization": "Bot {DISCORD_BOT_TOKEN}" }) .to_string() } } fn verify_discord_request_signature( headers: HashMap, body: &[u8], public_key_hex: Option<&str>, ) -> bool { let Some(public_key_hex) = public_key_hex.map(str::trim).filter(|s| !s.is_empty()) else { return false; }; let Some(signature_hex) = header_case_insensitive(&headers, "x-signature-ed25519") else { return false; }; let Some(timestamp) = header_case_insensitive(&headers, "x-signature-timestamp") else { return false; }; let public_key_bytes = match hex::decode(public_key_hex) { Ok(v) => v, Err(_) => return false, }; let public_key_arr: [u8; 32] = match public_key_bytes.try_into() { Ok(v) => v, Err(_) => return false, }; let verifying_key = match VerifyingKey::from_bytes(&public_key_arr) { Ok(v) => v, Err(_) => return false, }; let sig_bytes = match hex::decode(signature_hex.trim()) { Ok(v) => v, Err(_) => return false, }; let sig_arr: [u8; 64] = match sig_bytes.try_into() { Ok(v) => v, Err(_) => return false, }; let signature = Signature::from_bytes(&sig_arr); let mut signed_message = Vec::with_capacity(timestamp.len() + body.len()); signed_message.extend_from_slice(timestamp.as_bytes()); signed_message.extend_from_slice(body); verifying_key.verify(&signed_message, &signature).is_ok() } fn header_case_insensitive<'a>( headers: &'a HashMap, name: &str, ) -> Option<&'a str> { headers .iter() .find(|(k, _)| k.eq_ignore_ascii_case(name)) .map(|(_, v)| v.as_str()) } 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(); if !check_sender_permission( &user_id, Some(&user_name), is_dm, Some(&PairingReplyCtx { 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: Some(interaction.id.clone()), token: Some(interaction.token.clone()), application_id: Some(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), ); // Attempt to notify user of internal error 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 // Ephemeral }); 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; } }; 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) { // Check member first (for server contexts), then user (for DMs) 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, None) { return; } let channel_id = message.channel_id.clone(); let metadata = DiscordMessageMetadata { channel_id: channel_id.clone(), interaction_id: Some(interaction.id.clone()), token: Some(interaction.token.clone()), application_id: Some(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![], }); } /// Context needed to send a pairing reply via Discord webhook followup. struct PairingReplyCtx { application_id: String, token: String, } /// 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, 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 !is_dm { return true; } let dm_policy = channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(default_dm_policy); 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 } /// Send a pairing code as an ephemeral Discord followup message. fn send_pairing_reply(ctx: &PairingReplyCtx, code: &str) -> Result<(), String> { let url = format!( "{DISCORD_API_BASE}/webhooks/{}/{}", ctx.application_id, ctx.token ); let payload = serde_json::json!({ "content": format!( "To pair with this bot, run: `ironclaw pairing approve discord {}`", code ), "flags": 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 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)), } } /// Send a broadcast message to a Discord user via DM. /// /// Creates a DM channel with the user (Discord caches this, so repeated calls /// for the same user reuse the existing channel) and then posts the message. 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))?; let channel_id = &dm_channel.id; // Step 2: Send the message to the DM channel. let truncated = truncate_message(content); let payload = serde_json::json!({ "content": truncated }); send_channel_message(channel_id, payload) } 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.len() <= 2000 { content.to_string() } else { let max_bytes = 1990; let cutoff = content .char_indices() .map(|(i, c)| i + c.len_utf8()) .take_while(|&end| end <= max_bytes) .last() .unwrap_or(0); let mut truncated = content[..cutoff].to_string(); truncated.push_str("\n... (truncated)"); truncated } } #[cfg(test)] mod tests { use super::*; use ed25519_dalek::{Signer, SigningKey}; #[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.len(), 2006); // 1990 + 16 chars suffix assert!(truncated.ends_with("\n... (truncated)")); // Test with multibyte characters (Euro sign is 3 bytes) // 1000 chars * 3 bytes = 3000 bytes let multi = "€".repeat(1000); let truncated_multi = truncate_message(&multi); // 1990 bytes limit. 1990 / 3 = 663 with remainder 1. // Should truncate at 663 chars (1989 bytes). // Suffix is 16 bytes. Total: 1989 + 16 = 2005 bytes. assert!(truncated_multi.len() <= 2006); assert!(truncated_multi.len() >= 2006 - 4); // Allow for max utf8 char width variance 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_metadata_serialization() { let metadata = DiscordMessageMetadata { channel_id: "123".into(), interaction_id: Some("456".into()), token: Some("abc".into()), application_id: Some("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.as_deref(), Some("456")); } #[test] fn test_is_new_message() { assert!(is_new_message(None, "100")); assert!(is_new_message(Some("100"), "200")); assert!(!is_new_message(Some("200"), "100")); assert!(!is_new_message(Some("100"), "100")); assert!(is_new_message(Some("abc"), "abd")); assert!(!is_new_message(Some("abd"), "abc")); } #[test] fn test_strip_bot_mention() { assert_eq!(strip_bot_mention("<@123> hello", "123"), "hello"); assert_eq!(strip_bot_mention("<@!123> hello", "123"), "hello"); assert_eq!(strip_bot_mention("<@123>", "123"), ""); assert_eq!( strip_bot_mention("hello <@123> world <@!123>", "123"), "hello world" ); } #[test] fn test_message_mentions_bot() { let msg = DiscordChannelMessage { id: "1".to_string(), content: "hello <@123>".to_string(), channel_id: "10".to_string(), author: DiscordChannelAuthor { id: "u1".to_string(), username: "alice".to_string(), global_name: None, bot: false, }, mentions: vec![], webhook_id: None, }; assert!(message_mentions_bot(&msg, "123")); assert!(!message_mentions_bot(&msg, "999")); } #[test] fn test_message_mentions_bot_via_mentions_array() { let msg = DiscordChannelMessage { id: "2".to_string(), content: "hello".to_string(), channel_id: "10".to_string(), author: DiscordChannelAuthor { id: "u1".to_string(), username: "alice".to_string(), global_name: None, bot: false, }, mentions: vec![DiscordUser { id: "777".to_string(), username: "bot".to_string(), global_name: None, }], webhook_id: None, }; assert!(message_mentions_bot(&msg, "777")); } #[test] fn test_compare_message_ids_numeric_and_lexical_fallback() { assert_eq!(compare_message_ids("100", "20"), Ordering::Greater); assert_eq!(compare_message_ids("20", "100"), Ordering::Less); 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(); for i in 0..220 { remember_processed_id(&mut ids, &format!("{}", i)); } assert_eq!(ids.len(), 200); assert_eq!(ids.first().map(String::as_str), Some("20")); assert_eq!(ids.last().map(String::as_str), Some("219")); remember_processed_id(&mut ids, "219"); assert_eq!(ids.len(), 200); assert_eq!(ids.last().map(String::as_str), Some("219")); } #[test] fn test_header_case_insensitive() { let mut headers = HashMap::new(); headers.insert("X-Signature-Timestamp".to_string(), "123".to_string()); assert_eq!( header_case_insensitive(&headers, "x-signature-timestamp"), Some("123") ); assert_eq!(header_case_insensitive(&headers, "missing"), None); } #[test] fn test_discord_auth_headers_json_shape() { let with_ct: serde_json::Value = serde_json::from_str(&discord_auth_headers_json(true)).unwrap(); assert_eq!( with_ct.get("Content-Type").and_then(|v| v.as_str()), Some("application/json") ); assert_eq!( with_ct.get("Authorization").and_then(|v| v.as_str()), Some("Bot {DISCORD_BOT_TOKEN}") ); let no_ct: serde_json::Value = serde_json::from_str(&discord_auth_headers_json(false)).unwrap(); assert!(no_ct.get("Content-Type").is_none()); assert_eq!( no_ct.get("Authorization").and_then(|v| v.as_str()), Some("Bot {DISCORD_BOT_TOKEN}") ); } #[test] fn test_verify_discord_request_signature_valid() { let signing_key = SigningKey::from_bytes(&[7u8; 32]); let public_key_hex = hex::encode(signing_key.verifying_key().to_bytes()); let timestamp = "1234567890"; let body = br#"{"type":1}"#; let mut signed = Vec::new(); signed.extend_from_slice(timestamp.as_bytes()); signed.extend_from_slice(body); let signature = signing_key.sign(&signed); let mut headers = HashMap::new(); headers.insert( "x-signature-ed25519".to_string(), hex::encode(signature.to_bytes()), ); headers.insert("x-signature-timestamp".to_string(), timestamp.to_string()); assert!(verify_discord_request_signature( headers, body, Some(&public_key_hex) )); } #[test] fn test_verify_discord_request_signature_tampered_body() { let signing_key = SigningKey::from_bytes(&[9u8; 32]); let public_key_hex = hex::encode(signing_key.verifying_key().to_bytes()); let timestamp = "1234567890"; let body = b"hello"; let mut signed = Vec::new(); signed.extend_from_slice(timestamp.as_bytes()); signed.extend_from_slice(body); let signature = signing_key.sign(&signed); let mut headers = HashMap::new(); headers.insert( "x-signature-ed25519".to_string(), hex::encode(signature.to_bytes()), ); headers.insert("x-signature-timestamp".to_string(), timestamp.to_string()); assert!(!verify_discord_request_signature( headers, b"hello-modified", Some(&public_key_hex) )); } #[test] fn test_verify_discord_request_signature_wrong_public_key() { let signing_key = SigningKey::from_bytes(&[11u8; 32]); let wrong_key = SigningKey::from_bytes(&[12u8; 32]); let timestamp = "1234567890"; let body = b"payload"; let mut signed = Vec::new(); signed.extend_from_slice(timestamp.as_bytes()); signed.extend_from_slice(body); let signature = signing_key.sign(&signed); let mut headers = HashMap::new(); headers.insert( "x-signature-ed25519".to_string(), hex::encode(signature.to_bytes()), ); headers.insert("x-signature-timestamp".to_string(), timestamp.to_string()); assert!(!verify_discord_request_signature( headers, body, Some(&hex::encode(wrong_key.verifying_key().to_bytes())) )); } #[test] fn test_verify_discord_request_signature_missing_headers() { let headers = HashMap::new(); assert!(!verify_discord_request_signature( headers, b"abc", Some("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff") )); } #[test] fn test_verify_discord_request_signature_invalid_signature_hex() { let mut headers = HashMap::new(); headers.insert("x-signature-ed25519".to_string(), "not-hex".to_string()); headers.insert( "x-signature-timestamp".to_string(), "1234567890".to_string(), ); assert!(!verify_discord_request_signature( headers, b"abc", Some("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff") )); } #[test] fn test_verify_discord_request_signature_invalid_public_key_hex() { let mut headers = HashMap::new(); headers.insert("x-signature-ed25519".to_string(), "00".repeat(64)); headers.insert( "x-signature-timestamp".to_string(), "1234567890".to_string(), ); assert!(!verify_discord_request_signature( headers, b"abc", Some("not-hex") )); } #[test] fn test_verify_discord_request_signature_invalid_lengths() { let mut headers = HashMap::new(); headers.insert("x-signature-ed25519".to_string(), "00".repeat(10)); headers.insert( "x-signature-timestamp".to_string(), "1234567890".to_string(), ); assert!(!verify_discord_request_signature( headers.clone(), b"abc", Some("00".repeat(31).as_str()) )); assert!(!verify_discord_request_signature( headers, b"abc", Some("00".repeat(32).as_str()) )); } #[test] fn test_verify_discord_request_signature_case_insensitive_headers() { let signing_key = SigningKey::from_bytes(&[13u8; 32]); let public_key_hex = hex::encode(signing_key.verifying_key().to_bytes()); let timestamp = "1234567890"; let body = b"case-header"; let mut signed = Vec::new(); signed.extend_from_slice(timestamp.as_bytes()); signed.extend_from_slice(body); let signature = signing_key.sign(&signed); let mut headers = HashMap::new(); headers.insert( "X-Signature-Ed25519".to_string(), hex::encode(signature.to_bytes()), ); headers.insert("X-Signature-Timestamp".to_string(), timestamp.to_string()); assert!(verify_discord_request_signature( headers, body, Some(&public_key_hex) )); } #[test] fn test_verify_discord_request_signature_empty_public_key() { let mut headers = HashMap::new(); headers.insert("x-signature-ed25519".to_string(), "00".repeat(64)); headers.insert( "x-signature-timestamp".to_string(), "1234567890".to_string(), ); assert!(!verify_discord_request_signature(headers, b"abc", Some(""))); } #[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_broadcast_dm_payload_format() { // Verify the DM channel creation payload is well-formed JSON that // Discord's API expects. let user_id = "123456789012345678"; let payload = serde_json::json!({ "recipient_id": user_id }); let serialized = serde_json::to_vec(&payload).unwrap(); let parsed: serde_json::Value = serde_json::from_slice(&serialized).unwrap(); assert_eq!( parsed.get("recipient_id").and_then(|v| v.as_str()), Some(user_id) ); } #[test] fn test_broadcast_message_truncation() { // Broadcast uses truncate_message, verify it handles content within // Discord's 2000-char limit for DMs. let short = "Hello from broadcast"; assert_eq!(truncate_message(short), short); let long = "x".repeat(2500); let result = truncate_message(&long); assert!(result.len() <= 2006); // 1990 content + 16 suffix assert!(result.ends_with("\n... (truncated)")); } #[test] fn test_broadcast_dm_validates_snowflake() { // broadcast_dm rejects invalid Discord snowflake IDs before making // any API calls. We can call it directly since invalid IDs are // rejected before any host function is invoked. assert!(broadcast_dm("", "hi").is_err()); assert!(broadcast_dm("abc", "hi").is_err()); assert!(broadcast_dm("12345", "hi").is_err()); // too short assert!(broadcast_dm("123456789012345678901", "hi").is_err()); // too long assert!(broadcast_dm("12345678901234567x", "hi").is_err()); // non-digit } }