//! 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 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 exports::near::agent::channel::{ AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest, OutgoingHttpResponse, 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, } /// Metadata stored with emitted messages for response routing. #[derive(Debug, Serialize, Deserialize)] struct DiscordMessageMetadata { /// Discord channel ID channel_id: String, /// Interaction ID for followups interaction_id: String, /// Interaction token for responding token: String, /// Application ID application_id: String, /// Thread ID (for forum threads) thread_id: Option, } /// 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"; /// 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>, } 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); 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: 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() {} 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))?; // Use webhook endpoint for followup let url = format!( "https://discord.com/api/v10/webhooks/{}/{}", metadata.application_id, metadata.token ); // 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 result = channel_host::http_request( "POST", &url, &headers.to_string(), Some(&payload_bytes), None, ); match result { 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)), } } fn on_status(_update: StatusUpdate) {} fn on_broadcast(_user_id: String, _response: AgentResponse) -> Result<(), String> { Err("broadcast not yet implemented for Discord channel".to_string()) } 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, 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: interaction.id.clone(), token: interaction.token.clone(), application_id: interaction.application_id.clone(), 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!( "https://discord.com/api/v10/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, 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(), 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 { 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; // Guild interactions bypass DM policy } 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 } /// Send a pairing code as an ephemeral Discord followup message. fn send_pairing_reply(ctx: &PairingReplyCtx, code: &str) -> Result<(), String> { let url = format!( "https://discord.com/api/v10/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 // Ephemeral — only visible to the sender }); 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)), } } 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::*; #[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: "456".into(), token: "abc".into(), application_id: "789".into(), 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_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()); } }