diff --git a/channels-src/discord/Cargo.lock b/channels-src/discord/Cargo.lock index e3a81af1..9fee443c 100644 --- a/channels-src/discord/Cargo.lock +++ b/channels-src/discord/Cargo.lock @@ -20,33 +20,162 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "discord-channel" version = "0.1.0" dependencies = [ + "ed25519-dalek", + "hex", "serde", "serde_json", "wit-bindgen", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -68,6 +197,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "id-arena" version = "2.3.0" @@ -98,6 +233,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + [[package]] name = "log" version = "0.4.29" @@ -116,6 +257,16 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -144,6 +295,15 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "semver" version = "1.0.27" @@ -193,6 +353,23 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" + [[package]] name = "smallvec" version = "1.15.1" @@ -208,6 +385,22 @@ dependencies = [ "smallvec", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -219,6 +412,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -394,6 +593,12 @@ dependencies = [ "syn", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zmij" version = "1.0.21" diff --git a/channels-src/discord/Cargo.toml b/channels-src/discord/Cargo.toml index 81e95260..a2892494 100644 --- a/channels-src/discord/Cargo.toml +++ b/channels-src/discord/Cargo.toml @@ -10,6 +10,8 @@ publish = false serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" wit-bindgen = "0.36" +ed25519-dalek = { version = "2", default-features = false, features = ["alloc", "fast", "zeroize"] } +hex = "0.4" [lib] crate-type = ["cdylib"] diff --git a/channels-src/discord/README.md b/channels-src/discord/README.md index 6cb0199f..333e7670 100644 --- a/channels-src/discord/README.md +++ b/channels-src/discord/README.md @@ -21,11 +21,10 @@ WASM channel for Discord integration - handle slash commands and button interact ironclaw secret set discord_bot_token YOUR_BOT_TOKEN ``` - **Note:** The `discord_bot_token` secret is the only value read directly by this - Discord channel WASM component. The `discord_app_id` and `discord_public_key` - secrets are used by the IronClaw host (for example, to verify Discord - interaction signatures and manage slash command registration) and are not - accessed from the WASM module itself. + **Note:** The `discord_bot_token` secret is used for Discord REST API calls. + Interaction signature verification is performed inside the Discord channel + module and uses the channel config field `webhook_secret` (set this to your + Discord app public key hex). ## Discord Configuration @@ -87,6 +86,30 @@ If an internal error occurs (e.g., metadata serialization failure), the tool att Check the host logs for detailed error information. ## Advanced Usage +### Mention Polling + +The Discord channel can also poll configured channels for `@bot` mentions. + +Example channel config: + +```json +{ + "require_signature_verification": true, + "webhook_secret": "YOUR_DISCORD_PUBLIC_KEY_HEX", + "polling_enabled": true, + "poll_interval_ms": 30000, + "mention_channel_ids": ["123456789012345678"], + "owner_id": null, + "dm_policy": "pairing", + "allow_from": [] +} +``` + +### Access Control + +- `owner_id`: when set, only that Discord user can interact with the bot. +- `dm_policy`: `open` allows all DMs; `pairing` requires approval. +- `allow_from`: allowlist entries for DM pairing checks (`*`, user id, or username). ### Embeds @@ -96,8 +119,11 @@ To send embeds, include an `embeds` array in the `metadata_json` field of the ag ### "Invalid Signature" -- Check that `discord_public_key` is set correctly in IronClaw secrets. -- This validation happens on the host before reaching the WASM. +- Check that `webhook_secret` is set to your Discord app public key hex in the + Discord channel config. +- Validation happens inside the Discord WASM channel. +- If `require_signature_verification` is `true` and `webhook_secret` is empty, + the channel returns HTTP `500` with a configuration error. ### "401 Unauthorized" diff --git a/channels-src/discord/discord.capabilities.json b/channels-src/discord/discord.capabilities.json index fd55c685..9ff7a890 100644 --- a/channels-src/discord/discord.capabilities.json +++ b/channels-src/discord/discord.capabilities.json @@ -3,7 +3,7 @@ "wit_version": "0.3.0", "type": "channel", "name": "discord", - "description": "Discord Gateway/Webhook channel for handling slash commands, buttons, and messages", + "description": "Discord webhook channel for slash commands, components, and optional mention polling", "setup": { "required_secrets": [ { @@ -41,7 +41,7 @@ }, "channel": { "allowed_paths": ["/webhook/discord"], - "allow_polling": false, + "allow_polling": true, "callback_timeout_secs": 45, "workspace_prefix": "channels/discord/", "emit_rate_limit": { @@ -55,8 +55,12 @@ }, "config": { "require_signature_verification": true, + "webhook_secret": null, + "polling_enabled": false, + "poll_interval_ms": 30000, + "mention_channel_ids": [], "owner_id": null, "dm_policy": "pairing", "allow_from": [] } -} \ No newline at end of file +} diff --git a/channels-src/discord/src/lib.rs b/channels-src/discord/src/lib.rs index c8b37428..acb0bb41 100644 --- a/channels-src/discord/src/lib.rs +++ b/channels-src/discord/src/lib.rs @@ -14,7 +14,7 @@ //! //! # Security //! -//! - Signature validation is handled by the host (webhook secrets) +//! - 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 @@ -23,11 +23,14 @@ wit_bindgen::generate!({ path: "../../wit/channel.wit", }); +use std::{cmp::Ordering, collections::HashMap}; + +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use serde::{Deserialize, Serialize}; use exports::near::agent::channel::{ AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest, - OutgoingHttpResponse, StatusUpdate, + OutgoingHttpResponse, PollConfig, StatusUpdate, }; use near::agent::channel_host::{self, EmittedMessage}; @@ -105,23 +108,70 @@ struct DiscordMessage { author: DiscordUser, } -/// Metadata stored with emitted messages for response routing. -#[derive(Debug, Serialize, Deserialize)] -struct DiscordMessageMetadata { - /// Discord channel ID +#[derive(Debug, Deserialize)] +struct DiscordChannelMessage { + id: String, + content: String, channel_id: String, + author: DiscordChannelAuthor, + #[serde(default)] + mentions: Vec, + #[serde(default)] + webhook_id: Option, +} - /// Interaction ID for followups - interaction_id: String, +#[derive(Debug, Deserialize)] +struct DiscordChannelAuthor { + id: String, + username: String, + global_name: Option, + #[serde(default)] + bot: bool, +} - /// Interaction token for responding - token: String, +#[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, +} - /// Application ID - application_id: String, +fn default_poll_interval_ms() -> u32 { + 30_000 +} - /// Thread ID (for forum threads) - thread_id: Option, +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. @@ -133,30 +183,71 @@ 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 { +/// 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)] - #[allow(dead_code)] - require_signature_verification: bool, + interaction_id: Option, + + /// Interaction token for responding #[serde(default)] - owner_id: Option, + token: Option, + + /// Application ID #[serde(default)] - dm_policy: Option, + application_id: Option, + + /// Source message ID when handling mention-poll events. #[serde(default)] - allow_from: Option>, + 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 { - 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 + 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( @@ -167,12 +258,10 @@ impl Guest for DiscordChannel { 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()); + // 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 { @@ -180,13 +269,59 @@ impl Guest for DiscordChannel { http_endpoints: vec![HttpEndpointConfig { path: "/webhook/discord".to_string(), methods: vec!["POST".to_string()], - require_secret: true, + require_secret: false, }], - poll: None, + 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(_) => { @@ -215,9 +350,16 @@ impl Guest for DiscordChannel { // Application Command (slash command) 2 => { if handle_slash_command(&interaction) { - json_response(200, serde_json::json!({"type": 5})) + json_response( + 200, + serde_json::json!({ + "type": 5, + "data": { + "content": "🤔 Thinking..." + } + }), + ) } else { - // Permission denied — ephemeral response json_response( 200, serde_json::json!({ @@ -252,24 +394,18 @@ impl Guest for DiscordChannel { } } - fn on_poll() {} + 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))?; - // 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, - }); + 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) { @@ -285,29 +421,50 @@ impl Guest for DiscordChannel { "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!( + "https://discord.com/api/v10/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 + }); + let mention_payload = serde_json::to_vec(&payload) + .map_err(|e| format!("Failed to serialize mention payload: {}", e))?; + let mention_url = format!( + "https://discord.com/api/v10/channels/{}/messages", + metadata.channel_id + ); + let result = channel_host::http_request( + "POST", + &mention_url, + &discord_auth_headers_json(true), + Some(&mention_payload), + None, + ); + return map_discord_response(result); + } else { + return Err("Unsupported Discord response metadata".to_string()); + }; + let result = channel_host::http_request( - "POST", + method, &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)), - } + map_discord_response(result) } fn on_status(_update: StatusUpdate) {} @@ -324,7 +481,441 @@ impl Guest for DiscordChannel { } } -/// Returns true if the message was emitted, false if permission denied. +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)), + } +} + +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", + "https://discord.com/api/v10/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, + }); + + 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!( + "https://discord.com/api/v10/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!( + "https://discord.com/api/v10/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 @@ -342,10 +933,8 @@ fn handle_slash_command(interaction: &DiscordInteraction) -> bool { }) .unwrap_or_default(); - // DM if no guild member context (only direct user field set) + // 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), @@ -380,9 +969,10 @@ fn handle_slash_command(interaction: &DiscordInteraction) -> bool { let metadata = DiscordMessageMetadata { channel_id: channel_id.clone(), - interaction_id: interaction.id.clone(), - token: interaction.token.clone(), - application_id: interaction.application_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, }; @@ -393,13 +983,14 @@ fn handle_slash_command(interaction: &DiscordInteraction) -> bool { channel_host::LogLevel::Error, &format!("Failed to serialize metadata: {}", e), ); + // Attempt to notify user of internal error 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 + "flags": 64 // Ephemeral }); let _ = channel_host::http_request( "POST", @@ -408,7 +999,7 @@ fn handle_slash_command(interaction: &DiscordInteraction) -> bool { Some(&serde_json::to_vec(&payload).unwrap_or_default()), None, ); - return true; // Error, but not a permission denial + return true; } }; @@ -424,6 +1015,7 @@ fn handle_slash_command(interaction: &DiscordInteraction) -> bool { } fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordMessage) { + // Check member first (for server contexts), then user (for DMs) let user = interaction .member .as_ref() @@ -449,9 +1041,10 @@ fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordM let metadata = DiscordMessageMetadata { channel_id: channel_id.clone(), - interaction_id: interaction.id.clone(), - token: interaction.token.clone(), - application_id: interaction.application_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, }; @@ -476,10 +1069,6 @@ fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordM }); } -// ============================================================================ -// Permission & Pairing -// ============================================================================ - /// Context needed to send a pairing reply via Discord webhook followup. struct PairingReplyCtx { application_id: String, @@ -494,7 +1083,7 @@ fn check_sender_permission( is_dm: bool, reply_ctx: Option<&PairingReplyCtx>, ) -> bool { - // 1. Owner check (highest priority, applies to all contexts) + // 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 { @@ -510,28 +1099,26 @@ fn check_sender_permission( return true; } - // 2. DM policy (only for DMs when no owner_id) + // 2. DM policy (only for DMs when no owner_id). if !is_dm { - return true; // Guild interactions bypass DM policy + return true; } let dm_policy = - channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string()); - + 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 + // 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 + // 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())); @@ -540,22 +1127,18 @@ fn check_sender_permission( return true; } - // 5. Not allowed — handle by policy + // 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 - ), + &format!("Pairing request for user {}: code {}", user_id, result.code), ); if result.created { if let Some(ctx) = reply_ctx { @@ -580,20 +1163,16 @@ fn send_pairing_reply(ctx: &PairingReplyCtx, code: &str) -> Result<(), String> { "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 + "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, @@ -601,7 +1180,6 @@ fn send_pairing_reply(ctx: &PairingReplyCtx, code: &str) -> Result<(), String> { Some(&payload_bytes), None, ); - match result { Ok(response) if response.status >= 200 && response.status < 300 => Ok(()), Ok(response) => { @@ -648,6 +1226,7 @@ fn truncate_message(content: &str) -> String { #[cfg(test)] mod tests { use super::*; + use ed25519_dalek::{Signer, SigningKey}; #[test] fn test_truncate_message() { @@ -679,15 +1258,309 @@ mod tests { fn test_metadata_serialization() { let metadata = DiscordMessageMetadata { channel_id: "123".into(), - interaction_id: "456".into(), - token: "abc".into(), - application_id: "789".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, "456"); + 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]