diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4fc7cbf2..e6fe6128 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,7 +6,7 @@ ## Change Type - + - [ ] Bug fix - [ ] New feature @@ -18,16 +18,19 @@ ## Linked Issue - + ## Validation -- [ ] `cargo fmt` -- [ ] `cargo clippy --all --benches --tests --examples --all-features` +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --all --benches --tests --examples --all-features -- -D warnings` +- [ ] `cargo build` - [ ] Relevant tests pass: +- [ ] `cargo test --features integration` if database-backed or integration behavior changed - [ ] Manual testing: +- [ ] If a coding agent was used and supports it, `review-pr` or `pr-shepherd --fix` was run before requesting review ## Security Impact @@ -45,6 +48,10 @@ +## Review Follow-Through + + + --- -**Review track**: +**Review track**: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d34c4754..89dea301 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,42 @@ cd optimclaw This installs the Rust toolchain, WASM targets, git hooks, and runs initial checks. +## How to Contribute + +- Bug fixes, docs improvements, and focused cleanup tied to a concrete problem are welcome. +- Search existing issues and PRs before opening a new one to avoid duplicates. +- Keep changes scoped. One bug, one feature, or one documentation improvement per PR. + +### Creating Issues + +Open an issue when you are reporting a bug, proposing a feature, or documenting a gap in behavior. + +For bug reports, include: + +- What you expected to happen +- What actually happened +- Clear reproduction steps +- Relevant logs, screenshots, or error output +- Environment details when they matter (OS, database backend, feature flags, commit/branch) + +For feature requests: + +- Open an issue first before writing code +- Explain the problem being solved, not just the implementation idea +- Wait for maintainer feedback before investing in a large PR + +We require an issue for new features so maintainers can prioritize the work and confirm it fits the roadmap before anyone spends time implementing it. + +### Fixing Bugs + +- Small, targeted bug-fix PRs are welcome +- If there is already an issue, link it in your PR +- If the bug is non-trivial, security-sensitive, or changes behavior across subsystems, open or confirm an issue first so the approach can be aligned before implementation + +### Refactor-Only PRs + +Refactor-only PRs are not accepted from contributors outside the core team. If a refactor is necessary to land a bug fix or approved feature, keep it minimal and clearly tied to that change. + ## Development Workflow ```bash @@ -19,6 +55,45 @@ cargo test # unit tests cargo test --features integration # + PostgreSQL tests ``` +These commands are for day-to-day iteration while you are developing locally. The pre-submission checks below are intentionally stricter and use CI-style flags so you can catch formatting drift and clippy warnings before requesting review. + +## Before You Open a PR + +Run the local validation checks required before requesting a review. These are stricter than the commands for iterative development: + +```bash +cargo fmt --all -- --check +cargo clippy --all --benches --tests --examples --all-features -- -D warnings +cargo build +cargo test +``` + +Also run this when your change touches database-backed or integration behavior: + +```bash +cargo test --features integration +``` + +Before asking for review: + +- Build and exercise the changed path locally, not just the narrowest unit test +- Keep the PR focused and avoid mixing unrelated concerns +- Fill out the PR template with a clear summary, validation notes, and impact assessment +- If your change affects tracked behavior, update `FEATURE_PARITY.md` in the same branch +- If onboarding or setup behavior changes, update the relevant setup docs in the same branch +- If you are using a coding agent and it supports them, run `review-pr` or `pr-shepherd --fix` before opening or updating the PR +- `codex review --base origin/main` is also encouraged before requesting review + +## Review Follow-Through + +Review conversations are author-owned. + +- Address each review comment with a code change or a clear explanation +- Resolve conversations you have handled; leave them open only when reviewer judgment is still needed +- Do not leave review cleanup for maintainers when the follow-through belongs to the author + +If a PR is stale for more than 48 hours after review feedback is posted, maintainers may take over the follow-up work and land the changes needed to accomplish the original PR or issue intent. + ## Code Style - Zero clippy warnings policy @@ -46,7 +121,7 @@ All PRs follow a risk-based review process: | Track | Scope | Requirements | |-------|-------|-------------| | **A** | Docs, tests, chore, dependency bumps | 1 approval + CI green | -| **B** | Features, refactors, new tools/channels | 1 approval + CI green + test evidence | +| **B** | Features, maintainer-requested refactors, new tools/channels | 1 approval + CI green + test evidence | | **C** | Security (`src/safety/`, `src/secrets/`), runtime (`src/agent/`, `src/worker/`), database schema, CI workflows | 2 approvals + rollback plan documented | Select the appropriate track in the PR template based on what your changes touch. diff --git a/Cargo.lock b/Cargo.lock index 1fa12238..f52ae973 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6845,7 +6845,11 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", + "rustls 0.23.37", + "rustls-native-certs 0.8.3", + "rustls-pki-types", "tokio", + "tokio-rustls 0.26.4", "tungstenite 0.26.2", ] @@ -7206,6 +7210,8 @@ dependencies = [ "httparse", "log", "rand 0.9.2", + "rustls 0.23.37", + "rustls-pki-types", "sha1", "thiserror 2.0.18", "utf-8", diff --git a/Cargo.toml b/Cargo.toml index 6fc818bc..46e2250e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ eula = false tokio = { version = "1", features = ["full"] } tokio-stream = { version = "0.1", features = ["sync"] } futures = "0.3" +tokio-tungstenite = { version = "0.26", features = ["rustls-tls-native-roots"] } eventsource-stream = "0.2" # HTTP client @@ -208,7 +209,6 @@ zbus = "4" [dev-dependencies] tokio-test = "0.4" tracing-test = "0.2" -tokio-tungstenite = "0.26" testcontainers-modules = { version = "0.11", features = ["postgres"] } pretty_assertions = "1" tempfile = "3" diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index cf3531eb..1200812f 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -70,7 +70,7 @@ This document tracks feature parity between OptimClaw (Rust implementation) and | WASM channels | ❌ | ✅ | - | OptimClaw innovation; host resolves owner scope vs sender identity | | WhatsApp | ✅ | ❌ | P1 | Baileys (Web), same-phone mode with echo detection | | Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username, DM topics, setup-time owner auto-verification, owner-scoped persistence | -| Discord | ✅ | ❌ | P2 | discord.js, thread parent binding inheritance | +| Discord | ✅ | 🚧 | P2 | Gateway `MESSAGE_CREATE` intake restored via websocket queue + WASM poll; Gateway DMs now respect pairing; thread parent binding inheritance and reply/thread parity still incomplete | | Signal | ✅ | ✅ | P2 | signal-cli daemonPC, SSE listener HTTP/JSON-R, user/group allowlists, DM pairing | | Slack | ✅ | ✅ | - | WASM tool | | iMessage | ✅ | ❌ | P3 | BlueBubbles or Linq recommended | diff --git a/channels-src/discord/Cargo.lock b/channels-src/discord/Cargo.lock index f25ce551..f6e4a814 100644 --- a/channels-src/discord/Cargo.lock +++ b/channels-src/discord/Cargo.lock @@ -20,162 +20,33 @@ 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.2.0" +version = "0.2.1" 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" @@ -197,12 +68,6 @@ 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" @@ -223,9 +88,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "leb128" @@ -233,12 +98,6 @@ 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" @@ -253,19 +112,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" 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", -] +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "prettyplease" @@ -288,22 +137,13 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" 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" @@ -353,23 +193,6 @@ 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" @@ -385,22 +208,6 @@ 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" @@ -412,12 +219,6 @@ 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" @@ -575,30 +376,24 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", "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 925d20e9..be6b4fa9 100644 --- a/channels-src/discord/Cargo.toml +++ b/channels-src/discord/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "discord-channel" -version = "0.2.0" +version = "0.2.1" edition = "2021" description = "Discord channel for OptimClaw" license = "MIT OR Apache-2.0" @@ -10,8 +10,6 @@ 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 f426e468..f1f062a7 100644 --- a/channels-src/discord/README.md +++ b/channels-src/discord/README.md @@ -86,6 +86,24 @@ If an internal error occurs (e.g., metadata serialization failure), the tool att Check the host logs for detailed error information. ## Advanced Usage +### Gateway Mode + +The Discord channel now defaults to Discord Gateway transport for inbound message intake. +The bundled identify payload requests intents `4609`, which expands to: + +- `GUILDS` (`1`) +- `GUILD_MESSAGES` (`512`) +- `DIRECT_MESSAGES` (`4096`) + +Gateway DMs now follow the same pairing policy as webhook DMs. Unpaired users receive a pairing +instruction reply in the DM channel before the message is allowed through to the agent. If you +want stricter access control than pairing, set `owner_id`; that lock still applies to both +webhook and Gateway traffic. + +Gateway presence simply reflects a successful authenticated Gateway connection and advertises +`online`. Pairing still controls whether DMs are allowed through to the agent, but it no longer +changes the visible Discord status. + ### Mention Polling The Discord channel can also poll configured channels for `@bot` mentions. @@ -110,6 +128,7 @@ Example channel config: - `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). +- Gateway DMs respect `dm_policy` and pairing just like webhook DMs. ### Embeds diff --git a/channels-src/discord/discord.capabilities.json b/channels-src/discord/discord.capabilities.json index 9ff7a890..00ee2585 100644 --- a/channels-src/discord/discord.capabilities.json +++ b/channels-src/discord/discord.capabilities.json @@ -1,5 +1,5 @@ { - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.3.0", "type": "channel", "name": "discord", @@ -22,7 +22,8 @@ "capabilities": { "http": { "allowlist": [ - { "host": "discord.com", "path_prefix": "/api/v10" } + { "host": "discord.com", "path_prefix": "/api/v10" }, + { "host": "gateway.discord.gg", "path_prefix": "/", "methods": ["GET"] } ], "credentials": { "discord_bot_token": { @@ -36,6 +37,20 @@ "requests_per_hour": 3600 } }, + "websocket": { + "url": "wss://gateway.discord.gg/?v=10&encoding=json", + "connect_on_start": true, + "identify_secret_name": "discord_bot_token", + "identify": { + "_intents_doc": "GUILDS(1) + GUILD_MESSAGES(512) + DIRECT_MESSAGES(4096)", + "intents": 4609, + "properties": { + "os": "linux", + "browser": "ironclaw", + "device": "ironclaw" + } + } + }, "secrets": { "allowed_names": ["discord_bot_token", "discord_*"] }, diff --git a/channels-src/discord/src/lib.rs b/channels-src/discord/src/lib.rs index 9e3c9a48..8a2d8daf 100644 --- a/channels-src/discord/src/lib.rs +++ b/channels-src/discord/src/lib.rs @@ -10,11 +10,11 @@ //! - Message event parsing (@mentions, DMs) //! - Thread support for conversations //! - Response posting via Discord Web API -//! - Automatic message truncation (> 2000 chars) +//! - Markdown attachment fallback for oversized replies //! //! # Security //! -//! - Signature validation is handled in-channel using Discord's Ed25519 headers +//! - Signature validation is handled by the host (webhook secrets) //! - Bot token is injected by host during HTTP requests //! - WASM never sees raw credentials @@ -23,20 +23,18 @@ wit_bindgen::generate!({ 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 std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; use exports::near::agent::channel::{ AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest, - OutgoingHttpResponse, PollConfig, StatusUpdate, + OutgoingHttpResponse, PollConfig, StatusType, StatusUpdate, }; use near::agent::channel_host::{self, EmittedMessage}; +const DISCORD_API_BASE: &str = "https://discord.com/api/v10"; + /// Discord interaction wrapper. #[derive(Debug, Deserialize)] struct DiscordInteraction { @@ -111,6 +109,276 @@ struct DiscordMessage { author: DiscordUser, } +/// Deserialize a String that may be null or missing (backward compat with old Option fields). +fn deserialize_nullable_string<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(|opt| opt.unwrap_or_default()) +} + +/// Metadata stored with emitted messages for response routing. +#[derive(Debug, Serialize, Deserialize)] +struct DiscordMessageMetadata { + /// Discord channel ID + channel_id: String, + + /// Interaction ID for followups + #[serde(default, deserialize_with = "deserialize_nullable_string")] + interaction_id: String, + + /// Interaction token for responding + #[serde(default, deserialize_with = "deserialize_nullable_string")] + token: String, + + /// Application ID + #[serde(default, deserialize_with = "deserialize_nullable_string")] + application_id: String, + + /// Source message ID when handling mention-poll events. + #[serde(default)] + source_message_id: Option, + + /// Thread ID (for forum threads) + thread_id: Option, +} + +#[derive(Debug, PartialEq, Eq)] +enum DiscordResponseRoute { + InteractionWebhook(String), + ChannelMessage(String), +} + +fn response_route_for_metadata(metadata: &DiscordMessageMetadata) -> DiscordResponseRoute { + if !metadata.application_id.is_empty() && !metadata.token.is_empty() { + DiscordResponseRoute::InteractionWebhook(format!( + "{DISCORD_API_BASE}/webhooks/{}/{}/messages/@original", + metadata.application_id, metadata.token + )) + } else { + DiscordResponseRoute::ChannelMessage(format!( + "{DISCORD_API_BASE}/channels/{}/messages", + metadata.channel_id + )) + } +} + +fn typing_request_url_for_update(update: &StatusUpdate) -> Option { + if update.status != StatusType::Thinking { + return None; + } + + let metadata: DiscordMessageMetadata = serde_json::from_str(&update.metadata_json).ok()?; + if metadata.channel_id.is_empty() { + return None; + } + + Some(format!( + "{DISCORD_API_BASE}/channels/{}/typing", + metadata.channel_id + )) +} + +const DISCORD_MESSAGE_CHAR_LIMIT: usize = 2000; +const DISCORD_MULTIPART_BOUNDARY: &str = "ironclaw-discord-response-boundary"; +const DISCORD_ATTACHMENT_FILENAME: &str = "response.md"; +const DISCORD_ATTACHMENT_NOTICE: &str = "Response too long for Discord; attached as response.md."; +static MULTIPART_BOUNDARY_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, PartialEq, Eq)] +struct DiscordHttpRequest { + headers_json: String, + body: Vec, +} + +#[derive(Debug, PartialEq, Eq)] +enum DiscordReplyPlan { + Inline(DiscordHttpRequest), + Attachment { + upload: DiscordHttpRequest, + fallback: DiscordHttpRequest, + }, +} + +fn embeds_from_metadata_json(metadata_json: &str) -> Option { + serde_json::from_str::(metadata_json) + .ok()? + .get("embeds") + .cloned() +} + +fn build_discord_json_request( + content: &str, + embeds: Option<&serde_json::Value>, +) -> Result { + let mut payload = serde_json::json!({ + "content": content, + }); + + if let Some(embeds) = embeds { + payload["embeds"] = embeds.clone(); + } + + Ok(DiscordHttpRequest { + headers_json: serde_json::json!({ + "Content-Type": "application/json" + }) + .to_string(), + body: serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?, + }) +} + +fn build_discord_attachment_request( + content: &str, + embeds: Option<&serde_json::Value>, +) -> Result { + let boundary = next_multipart_boundary(); + let mut payload = serde_json::json!({ + "content": DISCORD_ATTACHMENT_NOTICE, + }); + + if let Some(embeds) = embeds { + payload["embeds"] = embeds.clone(); + } + + let payload_json = + serde_json::to_string(&payload).map_err(|e| format!("Failed to serialize: {}", e))?; + + let mut body = Vec::new(); + body.extend_from_slice( + format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"payload_json\"\r\nContent-Type: application/json\r\n\r\n{payload_json}\r\n", + boundary = boundary, + ) + .as_bytes(), + ); + body.extend_from_slice( + format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"files[0]\"; filename=\"{filename}\"\r\nContent-Type: text/markdown\r\n\r\n", + boundary = boundary, + filename = DISCORD_ATTACHMENT_FILENAME, + ) + .as_bytes(), + ); + body.extend_from_slice(content.as_bytes()); + body.extend_from_slice(format!("\r\n--{}--\r\n", boundary).as_bytes()); + + Ok(DiscordHttpRequest { + headers_json: serde_json::json!({ + "Content-Type": format!( + "multipart/form-data; boundary={}", + boundary + ) + }) + .to_string(), + body, + }) +} + +fn next_multipart_boundary() -> String { + let counter = MULTIPART_BOUNDARY_COUNTER.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + format!("{}-{:x}-{:x}", DISCORD_MULTIPART_BOUNDARY, nanos, counter) +} + +fn build_discord_reply_plan(response: &AgentResponse) -> Result { + let embeds = embeds_from_metadata_json(&response.metadata_json); + + if response.content.chars().count() <= DISCORD_MESSAGE_CHAR_LIMIT { + return build_discord_json_request(&response.content, embeds.as_ref()) + .map(DiscordReplyPlan::Inline); + } + + Ok(DiscordReplyPlan::Attachment { + upload: build_discord_attachment_request(&response.content, embeds.as_ref())?, + fallback: build_discord_json_request( + &truncate_message(&response.content), + embeds.as_ref(), + )?, + }) +} + +fn send_discord_request( + method: &str, + url: &str, + request: &DiscordHttpRequest, +) -> Result<(), String> { + match channel_host::http_request( + method, + url, + &request.headers_json, + Some(&request.body), + None, + ) { + Ok(http_response) => { + if http_response.status >= 200 && http_response.status < 300 { + channel_host::log(channel_host::LogLevel::Debug, "Posted followup to Discord"); + Ok(()) + } else { + let body_str = String::from_utf8_lossy(&http_response.body); + Err(format!( + "Discord API error: {} - {}", + http_response.status, body_str + )) + } + } + Err(e) => Err(format!("HTTP request failed: {}", e)), + } +} + +/// Workspace path for persisting owner_id across WASM callbacks. +const OWNER_ID_PATH: &str = "state/owner_id"; +/// Workspace path for persisting polling_enabled flag. +const POLLING_ENABLED_PATH: &str = "state/polling_enabled"; +/// Workspace path for persisting mention channel IDs (JSON array). +const MENTION_CHANNEL_IDS_PATH: &str = "state/mention_channel_ids"; +/// Workspace path for persisting dm_policy across WASM callbacks. +const DM_POLICY_PATH: &str = "state/dm_policy"; +/// Workspace path for persisting allow_from (JSON array) across WASM callbacks. +const ALLOW_FROM_PATH: &str = "state/allow_from"; +/// Workspace path for the current gateway text-frame batch prepared by the host runtime. +const GATEWAY_EVENT_QUEUE_PATH: &str = "state/gateway_event_queue_processing"; +/// Workspace path for persisting the bot user id learned from READY dispatches. +const BOT_USER_ID_PATH: &str = "state/bot_user_id"; +/// Channel name for pairing store (used by pairing host APIs). +const CHANNEL_NAME: &str = "discord"; + +#[derive(Debug, Deserialize)] +struct DiscordGatewayEvent { + op: u64, + #[serde(default)] + t: Option, + #[serde(default)] + d: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +struct DiscordGatewayReady { + user: DiscordGatewayAuthor, +} + +#[derive(Debug, Deserialize, Clone)] +struct DiscordGatewayAuthor { + id: String, + username: String, + global_name: Option, + #[serde(default)] + bot: bool, +} + +#[derive(Debug, Deserialize)] +struct DiscordGatewayMessageCreate { + channel_id: String, + #[serde(default)] + guild_id: Option, + content: String, + author: DiscordGatewayAuthor, +} + +/// A message returned by the Discord REST channel-messages endpoint. #[derive(Debug, Deserialize)] struct DiscordChannelMessage { id: String, @@ -123,6 +391,7 @@ struct DiscordChannelMessage { webhook_id: Option, } +/// Author sub-object for REST channel messages. #[derive(Debug, Deserialize)] struct DiscordChannelAuthor { id: String, @@ -132,125 +401,161 @@ struct DiscordChannelAuthor { 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, +#[derive(Debug, PartialEq, Eq)] +struct ParsedGatewayMessage { + user_id: String, + user_name: String, + channel_id: String, + content: String, + is_dm: bool, +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct GatewayPollResult { + bot_user_id: Option, + messages: Vec, +} + +fn parse_gateway_event_queue( + queue_json: &str, + known_bot_user_id: Option<&str>, +) -> GatewayPollResult { + let frames: Vec = match serde_json::from_str(queue_json) { + Ok(v) => v, + Err(e) => { + channel_host::log( + channel_host::LogLevel::Warn, + &format!("Failed to deserialize gateway event queue: {}", e), + ); + return GatewayPollResult::default(); + } + }; + let mut result = GatewayPollResult::default(); + let mut bot_user_id = known_bot_user_id.map(ToOwned::to_owned); + + for frame in frames { + let event: DiscordGatewayEvent = match serde_json::from_str(&frame) { + Ok(value) => value, + Err(_) => continue, + }; + + if event.op != 0 { + continue; + } + + match event.t.as_deref() { + Some("READY") => { + if let Ok(ready) = serde_json::from_value::(event.d) { + if !ready.user.id.is_empty() { + bot_user_id = Some(ready.user.id); + } + } + } + Some("MESSAGE_CREATE") => { + let message = match serde_json::from_value::(event.d) { + Ok(value) => value, + Err(_) => continue, + }; + + let active_bot_user_id = bot_user_id.as_deref().or(known_bot_user_id); + if message.author.bot + || active_bot_user_id.is_some_and(|bot_id| message.author.id == bot_id) + { + continue; + } + + let is_dm = message.guild_id.is_none(); + let content = + match gateway_content_for_agent(&message.content, active_bot_user_id, is_dm) { + Some(value) => value, + None => continue, + }; + + result.messages.push(ParsedGatewayMessage { + user_id: message.author.id, + user_name: message + .author + .global_name + .unwrap_or(message.author.username), + channel_id: message.channel_id, + content, + is_dm, + }); + } + _ => {} + } + } + + result.bot_user_id = bot_user_id; + result +} + +fn gateway_content_for_agent( + content: &str, + bot_user_id: Option<&str>, + is_dm: bool, +) -> Option { + let trimmed = content.trim(); + if trimmed.is_empty() { + return None; + } + + if is_dm { + return Some(trimmed.to_string()); + } + + let bot_user_id = bot_user_id?; + for mention in [ + format!("<@{}>", bot_user_id), + format!("<@!{}>", bot_user_id), + ] { + if let Some(stripped) = trimmed.strip_prefix(&mention) { + let cleaned = stripped.trim(); + return if cleaned.is_empty() { + None + } else { + Some(cleaned.to_string()) + }; + } + } + + None } fn default_poll_interval_ms() -> u32 { 30_000 } -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 +/// Channel configuration from capabilities file. +#[derive(Debug, Deserialize)] +struct DiscordConfig { #[serde(default)] - interaction_id: Option, - - /// Interaction token for responding + #[allow(dead_code)] + require_signature_verification: bool, #[serde(default)] - token: Option, - - /// Application ID + owner_id: Option, #[serde(default)] - application_id: Option, - - /// Source message ID when handling mention-poll events. + dm_policy: Option, #[serde(default)] - source_message_id: Option, - - /// Thread ID (for forum threads) - thread_id: Option, + allow_from: Option>, + #[serde(default)] + polling_enabled: bool, + #[serde(default = "default_poll_interval_ms")] + poll_interval_ms: u32, + #[serde(default)] + mention_channel_ids: Vec, } struct DiscordChannel; impl Guest for DiscordChannel { fn on_start(config_json: String) -> Result { + let config: DiscordConfig = serde_json::from_str(&config_json) + .map_err(|e| format!("Failed to parse config: {}", e))?; + channel_host::log(channel_host::LogLevel::Info, "Discord channel starting"); - 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. + // 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( @@ -261,18 +566,29 @@ impl Guest for DiscordChannel { 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()); + // Persist dm_policy and allow_from for DM pairing + let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing"); + let _ = channel_host::workspace_write(DM_POLICY_PATH, dm_policy); + + let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default()) + .unwrap_or_else(|_| "[]".to_string()); let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json); + // Persist polling config + let _ = channel_host::workspace_write( + POLLING_ENABLED_PATH, + &config.polling_enabled.to_string(), + ); + let mention_ids_json = + serde_json::to_string(&config.mention_channel_ids).unwrap_or_else(|_| "[]".to_string()); + let _ = channel_host::workspace_write(MENTION_CHANNEL_IDS_PATH, &mention_ids_json); + Ok(ChannelConfig { display_name: "Discord".to_string(), http_endpoints: vec![HttpEndpointConfig { path: "/webhook/discord".to_string(), methods: vec!["POST".to_string()], - require_secret: false, + require_secret: true, }], poll: if config.polling_enabled { Some(PollConfig { @@ -286,45 +602,6 @@ impl Guest for DiscordChannel { } 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(_) => { @@ -353,16 +630,9 @@ impl Guest for DiscordChannel { // Application Command (slash command) 2 => { if handle_slash_command(&interaction) { - json_response( - 200, - serde_json::json!({ - "type": 5, - "data": { - "content": "🤔 Thinking..." - } - }), - ) + json_response(200, serde_json::json!({"type": 5})) } else { + // Permission denied — ephemeral response json_response( 200, serde_json::json!({ @@ -398,6 +668,77 @@ impl Guest for DiscordChannel { } fn on_poll() { + // 1. Process Gateway event queue + let queue_json = channel_host::workspace_read(GATEWAY_EVENT_QUEUE_PATH).unwrap_or_default(); + let has_gateway_events = !queue_json.trim().is_empty() && queue_json.trim() != "[]"; + + if has_gateway_events { + let known_bot_user_id = channel_host::workspace_read(BOT_USER_ID_PATH); + let parsed = parse_gateway_event_queue(&queue_json, known_bot_user_id.as_deref()); + + if let Err(error) = channel_host::workspace_write(GATEWAY_EVENT_QUEUE_PATH, "[]") { + channel_host::log( + channel_host::LogLevel::Warn, + &format!("Failed to clear Discord gateway queue: {}", error), + ); + } + + if let Some(bot_user_id) = parsed.bot_user_id.as_deref() { + if let Err(error) = channel_host::workspace_write(BOT_USER_ID_PATH, bot_user_id) { + channel_host::log( + channel_host::LogLevel::Warn, + &format!("Failed to persist Discord bot user id: {}", error), + ); + } + } + + for message in parsed.messages { + if !check_sender_permission( + &message.user_id, + Some(&message.user_name), + message.is_dm, + PermissionSource::Gateway, + Some(&PairingReplyCtx { + channel_id: message.channel_id.clone(), + application_id: String::new(), + token: String::new(), + }), + ) { + continue; + } + + let metadata = DiscordMessageMetadata { + channel_id: message.channel_id, + interaction_id: String::new(), + token: String::new(), + application_id: String::new(), + source_message_id: None, + thread_id: None, + }; + + let metadata_json = match serde_json::to_string(&metadata) { + Ok(json) => json, + Err(error) => { + channel_host::log( + channel_host::LogLevel::Error, + &format!("Failed to serialize gateway metadata: {}", error), + ); + continue; + } + }; + + channel_host::emit_message(&EmittedMessage { + user_id: message.user_id, + user_name: Some(message.user_name), + content: message.content, + thread_id: None, + metadata_json, + attachments: vec![], + }); + } + } + + // 2. Run mention polling if configured poll_for_mentions(); } @@ -405,60 +746,109 @@ impl Guest for DiscordChannel { 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); + // Mention-poll replies: include message_reference so Discord renders as a reply + if let Some(ref source_id) = metadata.source_message_id { + if let DiscordResponseRoute::ChannelMessage(ref url) = + response_route_for_metadata(&metadata) + { + let embeds = embeds_from_metadata_json(&response.metadata_json); + let content = if response.content.chars().count() > DISCORD_MESSAGE_CHAR_LIMIT { + truncate_message(&response.content) + } else { + response.content.clone() + }; - let mut payload = serde_json::json!({ "content": content }); + let mut payload = serde_json::json!({ + "content": content, + "message_reference": { + "message_id": source_id + }, + "allowed_mentions": { + "replied_user": true + } + }); - // 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(); + if let Some(ref e) = embeds { + payload["embeds"] = e.clone(); + } + + let headers = discord_auth_headers_json(true); + let body = serde_json::to_vec(&payload) + .map_err(|e| format!("Failed to serialize: {}", e))?; + + return send_discord_request( + "POST", + url, + &DiscordHttpRequest { + headers_json: headers, + body, + }, + ); } } - let payload_bytes = - serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?; + let route = response_route_for_metadata(&metadata); + let plan = build_discord_reply_plan(&response)?; + + let (method, url) = match &route { + DiscordResponseRoute::InteractionWebhook(url) => ("PATCH", url.as_str()), + DiscordResponseRoute::ChannelMessage(url) => ("POST", url.as_str()), + }; + + match plan { + DiscordReplyPlan::Inline(request) => send_discord_request(method, url, &request), + DiscordReplyPlan::Attachment { upload, fallback } => { + match send_discord_request(method, url, &upload) { + Ok(()) => Ok(()), + Err(upload_error) => { + channel_host::log( + channel_host::LogLevel::Warn, + &format!( + "Discord attachment upload failed, falling back to truncated text: {}", + upload_error + ), + ); + send_discord_request(method, url, &fallback).map_err(|fallback_error| { + format!( + "Discord attachment upload failed: {}; fallback also failed: {}", + upload_error, fallback_error + ) + }) + } + } + } + } + } + + fn on_status(update: StatusUpdate) { + let Some(url) = typing_request_url_for_update(&update) else { + return; + }; let headers = serde_json::json!({ "Content-Type": "application/json" }); - 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) + match channel_host::http_request("POST", &url, &headers.to_string(), None, None) { + Ok(response) if (200..300).contains(&response.status) => {} + Ok(response) => { + channel_host::log( + channel_host::LogLevel::Warn, + &format!( + "Discord typing indicator failed with status {}", + response.status + ), + ); + } + Err(error) => { + channel_host::log( + channel_host::LogLevel::Warn, + &format!("Discord typing indicator request failed: {}", error), + ); + } + } } - fn on_status(_update: StatusUpdate) {} - fn on_broadcast(user_id: String, response: AgentResponse) -> Result<(), String> { broadcast_dm(&user_id, &response.content) } @@ -471,457 +861,7 @@ impl Guest for DiscordChannel { } } -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()) -} - +/// Returns true if the message was emitted, false if permission denied. fn handle_slash_command(interaction: &DiscordInteraction) -> bool { let user = interaction .member @@ -939,13 +879,17 @@ 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), is_dm, + PermissionSource::Webhook, Some(&PairingReplyCtx { + channel_id: interaction.channel_id.clone().unwrap_or_default(), application_id: interaction.application_id.clone(), token: interaction.token.clone(), }), @@ -975,9 +919,9 @@ fn handle_slash_command(interaction: &DiscordInteraction) -> bool { 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()), + interaction_id: interaction.id.clone(), + token: interaction.token.clone(), + application_id: interaction.application_id.clone(), source_message_id: None, thread_id: None, }; @@ -989,14 +933,13 @@ 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!( "{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 + "flags": 64 }); let _ = channel_host::http_request( "POST", @@ -1005,7 +948,7 @@ fn handle_slash_command(interaction: &DiscordInteraction) -> bool { Some(&serde_json::to_vec(&payload).unwrap_or_default()), None, ); - return true; + return true; // Error, but not a permission denial } }; @@ -1021,7 +964,6 @@ 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() @@ -1039,7 +981,13 @@ fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordM .unwrap_or_default(); let is_dm = interaction.member.is_none(); - if !check_sender_permission(&user_id, Some(&user_name), is_dm, None) { + if !check_sender_permission( + &user_id, + Some(&user_name), + is_dm, + PermissionSource::Webhook, + None, + ) { return; } @@ -1047,9 +995,9 @@ fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordM 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()), + interaction_id: interaction.id.clone(), + token: interaction.token.clone(), + application_id: interaction.application_id.clone(), source_message_id: None, thread_id: None, }; @@ -1075,21 +1023,39 @@ fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordM }); } +// ============================================================================ +// Permission & Pairing +// ============================================================================ + /// Context needed to send a pairing reply via Discord webhook followup. struct PairingReplyCtx { + channel_id: String, application_id: String, token: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PermissionSource { + Webhook, + Gateway, +} + +fn should_apply_dm_pairing(_source: PermissionSource, is_dm: bool) -> bool { + // All current permission sources (Webhook, Gateway) apply DM pairing equally. + // Kept as a function for future sources that may bypass pairing (e.g. internal). + is_dm +} + /// Check if a sender is permitted to interact with the bot. /// Returns true if allowed, false if denied (pairing reply sent if applicable). fn check_sender_permission( user_id: &str, username: Option<&str>, is_dm: bool, + source: PermissionSource, reply_ctx: Option<&PairingReplyCtx>, ) -> bool { - // 1. Owner check (highest priority, applies to all contexts). + // 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 { @@ -1105,26 +1071,28 @@ fn check_sender_permission( return true; } - // 2. DM policy (only for DMs when no owner_id). - if !is_dm { + // 2. DM policy (only for DMs when no owner_id) + if !should_apply_dm_pairing(source, is_dm) { return true; } let dm_policy = - channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(default_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. + // 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())); @@ -1133,13 +1101,14 @@ 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( @@ -1163,29 +1132,52 @@ fn check_sender_permission( false } -/// Send a pairing code as an ephemeral Discord followup message. +fn pairing_reply_route(ctx: &PairingReplyCtx) -> DiscordResponseRoute { + if !ctx.application_id.is_empty() && !ctx.token.is_empty() { + DiscordResponseRoute::InteractionWebhook(format!( + "{DISCORD_API_BASE}/webhooks/{}/{}", + ctx.application_id, ctx.token + )) + } else { + DiscordResponseRoute::ChannelMessage(format!( + "{DISCORD_API_BASE}/channels/{}/messages", + ctx.channel_id + )) + } +} + +/// Send a pairing code reply via webhook followup or channel message. fn send_pairing_reply(ctx: &PairingReplyCtx, code: &str) -> Result<(), String> { - let url = format!( - "{DISCORD_API_BASE}/webhooks/{}/{}", - ctx.application_id, ctx.token - ); - let payload = serde_json::json!({ + let route = pairing_reply_route(ctx); + + let mut payload = serde_json::json!({ "content": format!( "To pair with this bot, run: `optimclaw pairing approve discord {}`", code - ), - "flags": 64 + ) }); + + if matches!(route, DiscordResponseRoute::InteractionWebhook(_)) { + payload["flags"] = serde_json::json!(64); + } + let payload_bytes = serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?; + let headers = serde_json::json!({"Content-Type": "application/json"}); + let url = match &route { + DiscordResponseRoute::InteractionWebhook(url) => url, + DiscordResponseRoute::ChannelMessage(url) => url, + }; + let result = channel_host::http_request( "POST", - &url, + url, &headers.to_string(), Some(&payload_bytes), None, ); + match result { Ok(response) if response.status >= 200 && response.status < 300 => Ok(()), Ok(response) => { @@ -1199,10 +1191,332 @@ fn send_pairing_reply(ctx: &PairingReplyCtx, code: &str) -> Result<(), String> { } } -/// 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. +// ============================================================================ +// Mention Polling +// ============================================================================ + +/// Maximum number of processed message IDs to keep per channel for dedup. +const DEDUP_CAP: usize = 200; + +/// Poll configured channels for new messages that mention the bot. +fn poll_for_mentions() { + let enabled = channel_host::workspace_read(POLLING_ENABLED_PATH) + .map(|v| v.trim() == "true") + .unwrap_or(false); + + if !enabled { + return; + } + + let bot_id = match get_or_fetch_bot_id() { + Some(id) => id, + None => { + channel_host::log( + channel_host::LogLevel::Warn, + "Mention polling: unable to determine bot user id", + ); + return; + } + }; + + let channel_ids: Vec = channel_host::workspace_read(MENTION_CHANNEL_IDS_PATH) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + + for channel_id in &channel_ids { + poll_channel_mentions(channel_id, &bot_id); + } +} + +/// Read the bot user ID from workspace or fetch it from the Discord API. +fn get_or_fetch_bot_id() -> Option { + if let Some(id) = channel_host::workspace_read(BOT_USER_ID_PATH).filter(|s| !s.is_empty()) { + return Some(id); + } + + let headers = discord_auth_headers_json(false); + let resp = channel_host::http_request( + "GET", + "{DISCORD_API_BASE}/users/@me", + &headers, + None, + None, + ) + .ok()?; + + if resp.status < 200 || resp.status >= 300 { + return None; + } + + let body: serde_json::Value = serde_json::from_slice(&resp.body).ok()?; + let id = body["id"].as_str()?.to_string(); + + let _ = channel_host::workspace_write(BOT_USER_ID_PATH, &id); + Some(id) +} + +/// Poll a single channel for new mention messages. +fn poll_channel_mentions(channel_id: &str, bot_id: &str) { + let cursor_path = format!("state/mention_cursor/{}", channel_id); + let last_seen = channel_host::workspace_read(&cursor_path).unwrap_or_default(); + + let messages = if last_seen.is_empty() { + // First poll: initialise cursor without emitting any messages. + if let Some(latest_id) = fetch_latest_message_id(channel_id) { + let _ = channel_host::workspace_write(&cursor_path, &latest_id); + } + return; + } else { + match fetch_messages_after_cursor(channel_id, &last_seen) { + Some(msgs) => msgs, + None => return, + } + }; + + let mut processed_ids = load_recent_processed_ids(channel_id); + let mut new_cursor = last_seen.clone(); + + for msg in &messages { + if !is_new_message(&last_seen, &msg.id) { + continue; + } + if processed_ids.contains(&msg.id) { + continue; + } + if msg.author.bot || msg.author.id == bot_id { + remember_processed_id(&msg.id, &mut processed_ids); + continue; + } + if msg.webhook_id.is_some() { + remember_processed_id(&msg.id, &mut processed_ids); + continue; + } + if !message_mentions_bot(msg, bot_id) { + remember_processed_id(&msg.id, &mut processed_ids); + continue; + } + + // Permission check (API-based poll uses Webhook source) + if !check_sender_permission( + &msg.author.id, + Some(&msg.author.username), + false, + PermissionSource::Webhook, + None, + ) { + remember_processed_id(&msg.id, &mut processed_ids); + continue; + } + + let content = strip_bot_mention(&msg.content, bot_id); + if content.is_empty() { + remember_processed_id(&msg.id, &mut processed_ids); + continue; + } + + let user_name = msg + .author + .global_name + .clone() + .unwrap_or_else(|| msg.author.username.clone()); + + let metadata = DiscordMessageMetadata { + channel_id: msg.channel_id.clone(), + interaction_id: String::new(), + token: String::new(), + application_id: String::new(), + source_message_id: Some(msg.id.clone()), + thread_id: None, + }; + + let metadata_json = match serde_json::to_string(&metadata) { + Ok(json) => json, + Err(error) => { + channel_host::log( + channel_host::LogLevel::Error, + &format!("Failed to serialize mention-poll metadata: {}", error), + ); + continue; + } + }; + + channel_host::emit_message(&EmittedMessage { + user_id: msg.author.id.clone(), + user_name: Some(user_name), + content, + thread_id: None, + metadata_json, + attachments: vec![], + }); + + remember_processed_id(&msg.id, &mut processed_ids); + + if compare_message_ids(&msg.id, &new_cursor) == std::cmp::Ordering::Greater { + new_cursor = msg.id.clone(); + } + } + + if new_cursor != last_seen { + let _ = channel_host::workspace_write(&cursor_path, &new_cursor); + } + + save_recent_processed_ids(channel_id, &processed_ids); +} + +/// Fetch the latest message ID in a channel (used for cursor initialisation). +fn fetch_latest_message_id(channel_id: &str) -> Option { + let url = format!( + "{DISCORD_API_BASE}/channels/{}/messages?limit=1", + channel_id + ); + let headers = discord_auth_headers_json(false); + let resp = channel_host::http_request("GET", &url, &headers, None, None).ok()?; + + if resp.status < 200 || resp.status >= 300 { + return None; + } + + let messages: Vec = serde_json::from_slice(&resp.body).ok()?; + messages + .first() + .and_then(|m| m["id"].as_str().map(String::from)) +} + +/// Maximum number of pages to fetch when catching up on missed messages. +const MENTION_POLL_MAX_PAGES: usize = 5; + +/// Fetch messages after `last_seen` using the `after` parameter, paginating up +/// to [`MENTION_POLL_MAX_PAGES`] pages of 100 messages each. +fn fetch_messages_after_cursor( + channel_id: &str, + last_seen: &str, +) -> Option> { + let headers = discord_auth_headers_json(false); + let mut all_messages: Vec = Vec::new(); + let mut after = last_seen.to_string(); + + for _ in 0..MENTION_POLL_MAX_PAGES { + let url = format!( + "{DISCORD_API_BASE}/channels/{}/messages?after={}&limit=100", + channel_id, after + ); + let resp = channel_host::http_request("GET", &url, &headers, None, None).ok()?; + + if resp.status < 200 || resp.status >= 300 { + let body_str = String::from_utf8_lossy(&resp.body); + channel_host::log( + channel_host::LogLevel::Warn, + &format!( + "Mention poll: failed to fetch messages for channel {}: {} - {}", + channel_id, resp.status, body_str + ), + ); + return None; + } + + let page: Vec = serde_json::from_slice(&resp.body).ok()?; + let page_len = page.len(); + + if page.is_empty() { + break; + } + + // Discord returns newest-first; find the max ID for the next page cursor + let page_max_id = page + .iter() + .map(|m| m.id.as_str()) + .max_by(|a, b| compare_message_ids(a, b)) + .map(str::to_string); + + all_messages.extend(page); + + if page_len < 100 { + break; + } + + match page_max_id { + Some(max_id) if max_id != after => after = max_id, + _ => break, + } + } + + Some(all_messages) +} + +/// Compare two Discord snowflake IDs. Falls back to lexical comparison. +fn compare_message_ids(a: &str, b: &str) -> std::cmp::Ordering { + match (a.parse::(), b.parse::()) { + (Ok(a_num), Ok(b_num)) => a_num.cmp(&b_num), + _ => a.cmp(b), + } +} + +fn dedup_ids_path(channel_id: &str) -> String { + format!("state/mention_dedup/{}", channel_id) +} + +fn load_recent_processed_ids(channel_id: &str) -> Vec { + channel_host::workspace_read(&dedup_ids_path(channel_id)) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +fn save_recent_processed_ids(channel_id: &str, ids: &[String]) { + let json = serde_json::to_string(ids).unwrap_or_else(|_| "[]".to_string()); + let _ = channel_host::workspace_write(&dedup_ids_path(channel_id), &json); +} + +fn remember_processed_id(msg_id: &str, ids: &mut Vec) { + if ids.contains(&msg_id.to_string()) { + return; + } + ids.push(msg_id.to_string()); + if ids.len() > DEDUP_CAP { + let excess = ids.len() - DEDUP_CAP; + ids.drain(0..excess); + } +} + +/// Returns true when `current` is strictly newer than `last_seen`. +fn is_new_message(last_seen: &str, current: &str) -> bool { + compare_message_ids(current, last_seen) == std::cmp::Ordering::Greater +} + +/// Returns true if the message mentions the bot (by mention objects or content). +fn message_mentions_bot(msg: &DiscordChannelMessage, bot_id: &str) -> bool { + if msg.mentions.iter().any(|u| u.id == bot_id) { + return true; + } + let mention = format!("<@{}>", bot_id); + let mention_nick = format!("<@!{}>", bot_id); + msg.content.contains(&mention) || msg.content.contains(&mention_nick) +} + +/// Strip the bot mention prefix from content. +fn strip_bot_mention(content: &str, bot_id: &str) -> String { + let trimmed = content.trim(); + for mention in [format!("<@{}>", bot_id), format!("<@!{}>", bot_id)] { + if let Some(rest) = trimmed.strip_prefix(&mention) { + return rest.trim().to_string(); + } + } + trimmed.to_string() +} + +/// Build JSON headers string with Discord bot authorization. +/// When `include_content_type` is true, includes `Content-Type: application/json`. +fn discord_auth_headers_json(include_content_type: bool) -> String { + if include_content_type { + serde_json::json!({ + "Content-Type": "application/json" + }) + .to_string() + } else { + serde_json::json!({}).to_string() + } +} + +/// Send a DM to a Discord user by opening (or reusing) a DM channel. fn broadcast_dm(user_id: &str, content: &str) -> Result<(), String> { // Validate user_id is a plausible Discord snowflake (numeric, 17-20 digits) // to avoid injecting arbitrary strings into API URLs. @@ -1242,12 +1556,20 @@ fn broadcast_dm(user_id: &str, content: &str) -> Result<(), 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) + let body = + serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?; + send_discord_request( + "POST", + &format!("{DISCORD_API_BASE}/channels/{}/messages", dm_channel.id), + &DiscordHttpRequest { + headers_json: discord_auth_headers_json(true), + body, + }, + ) } fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse { @@ -1264,17 +1586,12 @@ fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse export!(DiscordChannel); fn truncate_message(content: &str) -> String { - if content.len() <= 2000 { + if content.chars().count() <= DISCORD_MESSAGE_CHAR_LIMIT { 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(); + let suffix = "\n... (truncated)"; + let allowed_chars = DISCORD_MESSAGE_CHAR_LIMIT.saturating_sub(suffix.chars().count()); + let mut truncated = content.chars().take(allowed_chars).collect::(); truncated.push_str("\n... (truncated)"); truncated } @@ -1283,7 +1600,8 @@ fn truncate_message(content: &str) -> String { #[cfg(test)] mod tests { use super::*; - use ed25519_dalek::{Signer, SigningKey}; + + const DISCORD_CAPABILITIES_JSON: &str = include_str!("../discord.capabilities.json"); #[test] fn test_truncate_message() { @@ -1292,332 +1610,299 @@ mod tests { let long = "a".repeat(2005); let truncated = truncate_message(&long); - assert_eq!(truncated.len(), 2006); // 1990 + 16 chars suffix + assert_eq!(truncated.chars().count(), 2000); 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 multi = "€".repeat(2005); 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_eq!(truncated_multi.chars().count(), 2000); assert!(truncated_multi.ends_with("\n... (truncated)")); let content_part = &truncated_multi[..truncated_multi.len() - 16]; assert!(content_part.chars().all(|c| c == '€')); } + #[test] + fn test_reply_plan_uses_character_count_for_attachment_threshold() { + let inline = + build_discord_reply_plan(&test_response(test_metadata_json(), "€".repeat(2000))) + .unwrap(); + + assert!(matches!(inline, DiscordReplyPlan::Inline(_))); + } + + fn test_response(metadata_json: String, content: String) -> AgentResponse { + AgentResponse { + message_id: "msg-1".to_string(), + content, + thread_id: None, + metadata_json, + attachments: vec![], + } + } + + fn test_metadata_json() -> String { + serde_json::json!({ + "channel_id": "chan-1", + "interaction_id": "int-1", + "token": "tok-1", + "application_id": "app-1", + "thread_id": null, + "embeds": [{"title": "embed title"}] + }) + .to_string() + } + + #[test] + fn test_reply_plan_threshold_uses_attachment_only_above_2000_chars() { + let inline = + build_discord_reply_plan(&test_response(test_metadata_json(), "a".repeat(2000))) + .unwrap(); + assert!(matches!(inline, DiscordReplyPlan::Inline(_))); + + let attachment = + build_discord_reply_plan(&test_response(test_metadata_json(), "a".repeat(2001))) + .unwrap(); + assert!(matches!(attachment, DiscordReplyPlan::Attachment { .. })); + } + + #[test] + fn test_reply_plan_preserves_short_message_content_and_embeds() { + let plan = build_discord_reply_plan(&test_response( + test_metadata_json(), + "short reply".to_string(), + )) + .unwrap(); + + let DiscordReplyPlan::Inline(request) = plan else { + panic!("expected inline plan"); + }; + + assert_eq!( + request.headers_json, + r#"{"Content-Type":"application/json"}"# + ); + + let payload: serde_json::Value = serde_json::from_slice(&request.body).unwrap(); + assert_eq!(payload["content"], "short reply"); + assert_eq!(payload["embeds"][0]["title"], "embed title"); + } + + #[test] + fn test_reply_plan_builds_markdown_attachment_multipart_payload() { + let content = "# Heading\n\nA long markdown reply".repeat(80); + let plan = build_discord_reply_plan(&test_response(test_metadata_json(), content.clone())) + .unwrap(); + + let DiscordReplyPlan::Attachment { upload, .. } = plan else { + panic!("expected attachment plan"); + }; + + assert!(upload + .headers_json + .contains("multipart/form-data; boundary=")); + + let body = String::from_utf8(upload.body).unwrap(); + assert!(body.contains("name=\"payload_json\"")); + assert!(body.contains("filename=\"response.md\"")); + assert!(body.contains("Content-Type: text/markdown")); + assert!(body.contains(DISCORD_ATTACHMENT_NOTICE)); + assert!(body.contains("embed title")); + assert!(body.contains(&content)); + } + + #[test] + fn test_reply_plan_uses_dynamic_multipart_boundary() { + let content = "# Heading\n\nA long markdown reply".repeat(80); + + let first = build_discord_reply_plan(&test_response(test_metadata_json(), content.clone())) + .unwrap(); + let second = + build_discord_reply_plan(&test_response(test_metadata_json(), content)).unwrap(); + + let DiscordReplyPlan::Attachment { + upload: first_upload, + .. + } = first + else { + panic!("expected attachment plan"); + }; + let DiscordReplyPlan::Attachment { + upload: second_upload, + .. + } = second + else { + panic!("expected attachment plan"); + }; + + let first_headers: serde_json::Value = + serde_json::from_str(&first_upload.headers_json).unwrap(); + let second_headers: serde_json::Value = + serde_json::from_str(&second_upload.headers_json).unwrap(); + + let first_boundary = first_headers["Content-Type"] + .as_str() + .unwrap() + .strip_prefix("multipart/form-data; boundary=") + .unwrap(); + let second_boundary = second_headers["Content-Type"] + .as_str() + .unwrap() + .strip_prefix("multipart/form-data; boundary=") + .unwrap(); + + assert!(first_boundary.starts_with(DISCORD_MULTIPART_BOUNDARY)); + assert!(second_boundary.starts_with(DISCORD_MULTIPART_BOUNDARY)); + assert_ne!(first_boundary, second_boundary); + + let first_body = String::from_utf8(first_upload.body).unwrap(); + let second_body = String::from_utf8(second_upload.body).unwrap(); + assert!(first_body.contains(&format!("--{first_boundary}\r\n"))); + assert!(second_body.contains(&format!("--{second_boundary}\r\n"))); + } + + #[test] + fn test_reply_plan_includes_truncated_text_fallback_for_attachment_failures() { + let content = "a".repeat(2400); + let plan = build_discord_reply_plan(&test_response(test_metadata_json(), content.clone())) + .unwrap(); + + let DiscordReplyPlan::Attachment { fallback, .. } = plan else { + panic!("expected attachment plan"); + }; + + let payload: serde_json::Value = serde_json::from_slice(&fallback.body).unwrap(); + assert_eq!(payload["content"], truncate_message(&content)); + assert_eq!(payload["embeds"][0]["title"], "embed title"); + } + #[test] fn test_metadata_serialization() { let metadata = DiscordMessageMetadata { channel_id: "123".into(), - interaction_id: Some("456".into()), - token: Some("abc".into()), - application_id: Some("789".into()), + interaction_id: "456".into(), + token: "abc".into(), + application_id: "789".into(), source_message_id: None, thread_id: None, }; let json = serde_json::to_string(&metadata).unwrap(); let parsed: DiscordMessageMetadata = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.channel_id, "123"); - assert_eq!(parsed.interaction_id.as_deref(), Some("456")); + assert_eq!(parsed.interaction_id, "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")); + fn test_metadata_backward_compat_with_old_option_format() { + // Old metadata format used Option for these fields + let old_json = r#"{ + "channel_id": "123", + "interaction_id": null, + "token": null, + "application_id": null, + "thread_id": null + }"#; + let parsed: DiscordMessageMetadata = serde_json::from_str(old_json).unwrap(); + assert_eq!(parsed.channel_id, "123"); + assert!(parsed.interaction_id.is_empty()); + + // Old format without the fields at all + let minimal_json = r#"{"channel_id": "456"}"#; + let parsed: DiscordMessageMetadata = serde_json::from_str(minimal_json).unwrap(); + assert_eq!(parsed.channel_id, "456"); + assert!(parsed.interaction_id.is_empty()); + assert!(parsed.token.is_empty()); + assert!(parsed.application_id.is_empty()); } #[test] - fn test_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, + fn test_response_route_uses_webhook_for_interactions() { + let metadata = DiscordMessageMetadata { + channel_id: "123".into(), + interaction_id: "456".into(), + token: "tok".into(), + application_id: "app".into(), + source_message_id: None, + thread_id: None, }; - assert!(message_mentions_bot(&msg, "123")); - assert!(!message_mentions_bot(&msg, "999")); + + assert_eq!( + response_route_for_metadata(&metadata), + DiscordResponseRoute::InteractionWebhook( + format!("{DISCORD_API_BASE}/webhooks/app/tok/messages/@original") + ) + ); } #[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, + fn test_response_route_uses_channel_messages_for_gateway_metadata() { + let metadata = DiscordMessageMetadata { + channel_id: "chan-1".into(), + interaction_id: String::new(), + token: String::new(), + application_id: String::new(), + source_message_id: None, + thread_id: None, }; - assert!(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") + response_route_for_metadata(&metadata), + DiscordResponseRoute::ChannelMessage( + format!("{DISCORD_API_BASE}/channels/chan-1/messages") + ) ); - 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(); + fn test_typing_request_url_uses_channel_id_for_thinking_status() { + let update = StatusUpdate { + status: StatusType::Thinking, + message: "Thinking...".to_string(), + metadata_json: serde_json::json!({ + "channel_id": "chan-42", + "interaction_id": "", + "token": "", + "application_id": "", + "thread_id": null + }) + .to_string(), + }; + assert_eq!( - 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}") + typing_request_url_for_update(&update), + Some(format!("{DISCORD_API_BASE}/channels/chan-42/typing")) ); } #[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}"#; + fn test_typing_request_url_ignores_non_thinking_status() { + let update = StatusUpdate { + status: StatusType::Done, + message: "Done".to_string(), + metadata_json: serde_json::json!({ + "channel_id": "chan-42", + "interaction_id": "", + "token": "", + "application_id": "", + "thread_id": null + }) + .to_string(), + }; - 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) - )); + assert_eq!(typing_request_url_for_update(&update), None); } #[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"; + fn test_typing_request_url_ignores_invalid_metadata() { + let update = StatusUpdate { + status: StatusType::Thinking, + message: "Thinking...".to_string(), + metadata_json: "not-json".to_string(), + }; - 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(""))); + assert_eq!(typing_request_url_for_update(&update), None); } #[test] @@ -1651,41 +1936,319 @@ mod tests { } #[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(); + fn test_capabilities_default_to_gateway_mode() { + let caps: serde_json::Value = + serde_json::from_str(DISCORD_CAPABILITIES_JSON).expect("capabilities parse"); + let allowlist = caps["capabilities"]["http"]["allowlist"] + .as_array() + .expect("http allowlist array"); + assert_eq!( - parsed.get("recipient_id").and_then(|v| v.as_str()), - Some(user_id) + caps["capabilities"]["channel"]["allow_polling"], + serde_json::Value::Bool(true) + ); + assert!(allowlist.iter().any(|entry| { + entry["host"] == serde_json::Value::String("gateway.discord.gg".to_string()) + && entry["methods"] == serde_json::json!(["GET"]) + })); + assert_eq!( + caps["capabilities"]["websocket"]["url"], + serde_json::Value::String("wss://gateway.discord.gg/?v=10&encoding=json".to_string()) + ); + assert_eq!( + caps["capabilities"]["websocket"]["connect_on_start"], + serde_json::Value::Bool(true) + ); + assert_eq!( + caps["capabilities"]["websocket"]["identify_secret_name"], + serde_json::Value::String("discord_bot_token".to_string()) + ); + assert_eq!( + caps["capabilities"]["websocket"]["identify"]["intents"], + serde_json::Value::Number(4609u64.into()) ); } #[test] - fn test_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); + fn test_parse_gateway_event_queue_emits_message_create_after_ready() { + let queue_json = serde_json::json!([ + serde_json::json!({ + "op": 0, + "t": "READY", + "d": { + "user": { + "id": "bot-1", + "username": "ironclaw", + "global_name": "IronClaw", + "bot": true + } + } + }) + .to_string(), + serde_json::json!({ + "op": 0, + "t": "MESSAGE_CREATE", + "d": { + "channel_id": "chan-1", + "guild_id": "guild-1", + "content": "<@bot-1> hello from discord", + "author": { + "id": "user-1", + "username": "alice", + "global_name": "Alice", + "bot": false + } + } + }) + .to_string() + ]) + .to_string(); - let long = "x".repeat(2500); - let result = truncate_message(&long); - assert!(result.len() <= 2006); // 1990 content + 16 suffix - assert!(result.ends_with("\n... (truncated)")); + let result = parse_gateway_event_queue(&queue_json, None); + + assert_eq!(result.bot_user_id.as_deref(), Some("bot-1")); + assert_eq!( + result.messages, + vec![ParsedGatewayMessage { + user_id: "user-1".to_string(), + user_name: "Alice".to_string(), + channel_id: "chan-1".to_string(), + content: "hello from discord".to_string(), + is_dm: false, + }] + ); } #[test] - fn test_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 + fn test_parse_gateway_event_queue_ignores_bot_and_unmentioned_guild_messages() { + let queue_json = serde_json::json!([ + serde_json::json!({ + "op": 0, + "t": "MESSAGE_CREATE", + "d": { + "channel_id": "chan-1", + "guild_id": "guild-1", + "content": "this should not trigger", + "author": { + "id": "user-1", + "username": "alice", + "global_name": "Alice", + "bot": false + } + } + }) + .to_string(), + serde_json::json!({ + "op": 0, + "t": "MESSAGE_CREATE", + "d": { + "channel_id": "dm-1", + "content": "bot echo", + "author": { + "id": "bot-1", + "username": "ironclaw", + "global_name": "IronClaw", + "bot": true + } + } + }) + .to_string(), + serde_json::json!({ + "op": 0, + "t": "MESSAGE_CREATE", + "d": { + "channel_id": "dm-2", + "content": "direct message", + "author": { + "id": "user-2", + "username": "bob", + "global_name": null, + "bot": false + } + } + }) + .to_string() + ]) + .to_string(); + + let result = parse_gateway_event_queue(&queue_json, Some("bot-1")); + + assert_eq!(result.bot_user_id.as_deref(), Some("bot-1")); + assert_eq!( + result.messages, + vec![ParsedGatewayMessage { + user_id: "user-2".to_string(), + user_name: "bob".to_string(), + channel_id: "dm-2".to_string(), + content: "direct message".to_string(), + is_dm: true, + }] + ); + } + + #[test] + fn test_non_gateway_dm_pairing_behavior_is_unchanged() { + assert!(should_apply_dm_pairing(PermissionSource::Webhook, true)); + assert!(!should_apply_dm_pairing(PermissionSource::Webhook, false)); + } + + #[test] + fn test_gateway_dm_pairing_behavior_matches_webhook_dm() { + assert!(should_apply_dm_pairing(PermissionSource::Gateway, true)); + assert!(!should_apply_dm_pairing(PermissionSource::Gateway, false)); + } + + #[test] + fn test_pairing_reply_route_uses_channel_messages_for_gateway_metadata() { + let route = pairing_reply_route(&PairingReplyCtx { + channel_id: "chan-1".to_string(), + application_id: String::new(), + token: String::new(), + }); + + assert_eq!( + route, + DiscordResponseRoute::ChannelMessage( + format!("{DISCORD_API_BASE}/channels/chan-1/messages") + ) + ); + } + + #[test] + fn test_pairing_reply_route_uses_webhook_for_interactions() { + let route = pairing_reply_route(&PairingReplyCtx { + channel_id: "chan-1".to_string(), + application_id: "app-1".to_string(), + token: "tok-1".to_string(), + }); + + assert_eq!( + route, + DiscordResponseRoute::InteractionWebhook( + format!("{DISCORD_API_BASE}/webhooks/app-1/tok-1") + ) + ); + } + + // ====================================================================== + // Mention polling tests + // ====================================================================== + + #[test] + fn test_is_new_message() { + assert!(is_new_message("100", "200")); + assert!(!is_new_message("200", "100")); + assert!(!is_new_message("100", "100")); + // Large snowflake-like IDs + assert!(is_new_message("1234567890123456789", "1234567890123456790")); + assert!(!is_new_message( + "1234567890123456790", + "1234567890123456789" + )); + } + + #[test] + fn test_strip_bot_mention() { + assert_eq!( + strip_bot_mention("<@bot-123> hello world", "bot-123"), + "hello world" + ); + assert_eq!( + strip_bot_mention("<@!bot-123> hi there", "bot-123"), + "hi there" + ); + // No mention prefix — return content as-is + assert_eq!( + strip_bot_mention("no mention here", "bot-123"), + "no mention here" + ); + // Only mention, no content after stripping + assert_eq!(strip_bot_mention("<@bot-123>", "bot-123"), ""); + assert_eq!(strip_bot_mention("<@bot-123> ", "bot-123"), ""); + } + + #[test] + fn test_message_mentions_bot() { + // Via mentions array + let msg = DiscordChannelMessage { + id: "1".to_string(), + content: "hello".to_string(), + channel_id: "ch-1".to_string(), + author: DiscordChannelAuthor { + id: "user-1".to_string(), + username: "alice".to_string(), + global_name: None, + bot: false, + }, + mentions: vec![DiscordUser { + id: "bot-1".to_string(), + username: "ironclaw".to_string(), + global_name: None, + }], + webhook_id: None, + }; + assert!(message_mentions_bot(&msg, "bot-1")); + assert!(!message_mentions_bot(&msg, "other-bot")); + + // Via content + let msg2 = DiscordChannelMessage { + id: "2".to_string(), + content: "<@bot-2> do something".to_string(), + channel_id: "ch-1".to_string(), + author: DiscordChannelAuthor { + id: "user-1".to_string(), + username: "alice".to_string(), + global_name: None, + bot: false, + }, + mentions: vec![], + webhook_id: None, + }; + assert!(message_mentions_bot(&msg2, "bot-2")); + assert!(!message_mentions_bot(&msg2, "other-bot")); + } + + #[test] + fn test_compare_message_ids() { + use std::cmp::Ordering; + assert_eq!(compare_message_ids("100", "200"), Ordering::Less); + assert_eq!(compare_message_ids("200", "100"), Ordering::Greater); + assert_eq!(compare_message_ids("100", "100"), Ordering::Equal); + // Non-numeric fallback + assert_eq!(compare_message_ids("abc", "abd"), Ordering::Less); + assert_eq!(compare_message_ids("abd", "abc"), Ordering::Greater); + } + + #[test] + fn test_remember_processed_id_dedup_and_cap() { + let mut ids = Vec::new(); + + // Basic add + remember_processed_id("msg-1", &mut ids); + assert_eq!(ids, vec!["msg-1".to_string()]); + + // Duplicate is ignored + remember_processed_id("msg-1", &mut ids); + assert_eq!(ids.len(), 1); + + // Fill beyond DEDUP_CAP + for i in 2..=(DEDUP_CAP + 5) { + remember_processed_id(&format!("msg-{}", i), &mut ids); + } + assert_eq!(ids.len(), DEDUP_CAP); + // Oldest entries should have been drained + assert!(!ids.contains(&"msg-1".to_string())); + assert!(ids.contains(&format!("msg-{}", DEDUP_CAP + 5))); + } + + #[test] + fn test_discord_auth_headers_json_shape() { + let with_ct = discord_auth_headers_json(true); + let parsed: serde_json::Value = serde_json::from_str(&with_ct).unwrap(); + assert_eq!(parsed["Content-Type"], "application/json"); + + let without_ct = discord_auth_headers_json(false); + let parsed: serde_json::Value = serde_json::from_str(&without_ct).unwrap(); + assert!(parsed.get("Content-Type").is_none()); } } diff --git a/src/channels/wasm/host.rs b/src/channels/wasm/host.rs index eeaccb20..e8a59a88 100644 --- a/src/channels/wasm/host.rs +++ b/src/channels/wasm/host.rs @@ -539,6 +539,65 @@ impl ChannelWorkspaceStore { } } } + + /// Append a text frame to a JSON queue stored at `path`. + /// + /// The queue is stored as a JSON array of strings and bounded to the most + /// recent `max_items` entries so websocket runtimes cannot grow it without + /// limit. + pub fn append_json_text_queue( + &self, + path: &str, + text: &str, + max_items: usize, + ) -> Result<(), String> { + let mut data = self + .data + .write() + .map_err(|_| "workspace store lock poisoned".to_string())?; + + let mut queue: Vec = data + .get(path) + .and_then(|raw| serde_json::from_str(raw).ok()) + .unwrap_or_default(); + + queue.push(text.to_string()); + + if queue.len() > max_items { + let overflow = queue.len() - max_items; + queue.drain(0..overflow); + } + + let serialized = serde_json::to_string(&queue) + .map_err(|error| format!("failed to serialize websocket queue: {error}"))?; + + data.insert(path.to_string(), serialized); + Ok(()) + } + + /// Atomically move a queued JSON array of text frames from `source_path` to `dest_path`. + pub fn move_json_text_queue(&self, source_path: &str, dest_path: &str) -> Result { + let mut data = self + .data + .write() + .map_err(|_| "workspace store lock poisoned".to_string())?; + + let Some(raw_queue) = data.remove(source_path) else { + data.remove(dest_path); + return Ok(false); + }; + + let queue: Vec = serde_json::from_str(&raw_queue) + .map_err(|error| format!("failed to deserialize websocket queue: {error}"))?; + + if queue.is_empty() { + data.remove(dest_path); + return Ok(false); + } + + data.insert(dest_path.to_string(), raw_queue); + Ok(true) + } } impl crate::tools::wasm::WorkspaceReader for ChannelWorkspaceStore { @@ -818,6 +877,51 @@ mod tests { ); } + #[test] + fn test_channel_workspace_store_append_json_text_queue_is_bounded() { + use crate::channels::wasm::host::ChannelWorkspaceStore; + use crate::tools::wasm::WorkspaceReader; + + let store = ChannelWorkspaceStore::new(); + let path = "channels/discord/state/gateway_event_queue"; + + store.append_json_text_queue(path, "frame-1", 2).unwrap(); + store.append_json_text_queue(path, "frame-2", 2).unwrap(); + store.append_json_text_queue(path, "frame-3", 2).unwrap(); + + let queue: Vec = serde_json::from_str(&store.read(path).unwrap()).unwrap(); + assert_eq!(queue, vec!["frame-2".to_string(), "frame-3".to_string()]); + } + + #[test] + fn test_channel_workspace_store_move_json_text_queue_is_atomic() { + use crate::channels::wasm::host::ChannelWorkspaceStore; + use crate::tools::wasm::WorkspaceReader; + + let store = ChannelWorkspaceStore::new(); + let live_path = "channels/discord/state/gateway_event_queue"; + let drain_path = "channels/discord/state/gateway_event_queue_processing"; + + store + .append_json_text_queue(live_path, "frame-1", 4) + .unwrap(); + store + .append_json_text_queue(live_path, "frame-2", 4) + .unwrap(); + + assert!(store.move_json_text_queue(live_path, drain_path).unwrap()); + assert_eq!(store.read(live_path), None); + + let drained: Vec = serde_json::from_str(&store.read(drain_path).unwrap()).unwrap(); + assert_eq!(drained, vec!["frame-1".to_string(), "frame-2".to_string()]); + + store + .append_json_text_queue(live_path, "frame-3", 4) + .unwrap(); + let live: Vec = serde_json::from_str(&store.read(live_path).unwrap()).unwrap(); + assert_eq!(live, vec!["frame-3".to_string()]); + } + // === QA Plan P2 - 2.3: WASM channel lifecycle tests === #[test] diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index 9966b68c..77328d84 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -33,8 +33,10 @@ use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; -use tokio::sync::{RwLock, mpsc, oneshot}; +use futures::{SinkExt, StreamExt}; +use tokio::sync::{Mutex, RwLock, mpsc, oneshot}; use tokio_stream::wrappers::ReceiverStream; +use tokio_tungstenite::tungstenite::protocol::Message as WebsocketMessage; use uuid::Uuid; use wasmtime::Store; use wasmtime::component::Linker; @@ -59,6 +61,10 @@ use crate::tools::wasm::credential_injector::{ InjectedCredentials, host_matches_pattern, inject_credential, }; +const WEBSOCKET_EVENT_QUEUE_RELATIVE_PATH: &str = "state/gateway_event_queue"; +const WEBSOCKET_EVENT_PROCESSING_QUEUE_RELATIVE_PATH: &str = "state/gateway_event_queue_processing"; +const WEBSOCKET_EVENT_QUEUE_MAX_ITEMS: usize = 100; + // Generate component model bindings from the WIT file wasmtime::component::bindgen!({ path: "wit/channel.wit", @@ -691,6 +697,12 @@ pub struct WasmChannel { /// Polling shutdown signal sender (keeps polling alive while held). poll_shutdown_tx: RwLock>>, + /// Websocket runtime shutdown signal sender. + websocket_shutdown_tx: RwLock>>, + + /// Serializes websocket-triggered poll executions. + websocket_poll_lock: Arc>, + /// Registered HTTP endpoints. endpoints: RwLock>, @@ -839,6 +851,8 @@ impl WasmChannel { rate_limiter: Arc::new(RwLock::new(rate_limiter)), shutdown_tx: RwLock::new(None), poll_shutdown_tx: RwLock::new(None), + websocket_shutdown_tx: RwLock::new(None), + websocket_poll_lock: Arc::new(Mutex::new(())), endpoints: RwLock::new(Vec::new()), credentials: Arc::new(RwLock::new(HashMap::new())), typing_task: RwLock::new(None), @@ -1070,6 +1084,228 @@ impl WasmChannel { Ok(()) } + fn start_websocket_runtime( + &self, + config: WebsocketRuntimeConfig, + shutdown_rx: oneshot::Receiver<()>, + ) { + let channel_name = self.name.clone(); + let runtime = Arc::clone(&self.runtime); + let prepared = Arc::clone(&self.prepared); + let capabilities = Self::inject_workspace_reader(&self.capabilities, &self.workspace_store); + let poll_capabilities = self.capabilities.clone(); + let message_tx = self.message_tx.clone(); + let rate_limiter = self.rate_limiter.clone(); + let credentials = self.credentials.clone(); + let pairing_store = self.pairing_store.clone(); + let callback_timeout = self.runtime.config().callback_timeout; + let workspace_store = self.workspace_store.clone(); + let last_broadcast_metadata = self.last_broadcast_metadata.clone(); + let settings_store = self.settings_store.clone(); + let owner_scope_id = self.owner_scope_id.clone(); + let owner_actor_id = self.owner_actor_id.clone(); + let websocket_secrets_store = self.secrets_store.clone(); + let websocket_poll_lock = Arc::clone(&self.websocket_poll_lock); + + tokio::spawn(async move { + let mut shutdown = std::pin::pin!(shutdown_rx); + let mut reconnect_attempt = 0u32; + let (outbound_tx, mut outbound_rx) = mpsc::unbounded_channel::(); + + tracing::info!( + channel = %channel_name, + url = %config.url, + "Starting websocket runtime" + ); + let queue_path = websocket_queue_path(&channel_name); + let processing_queue_path = websocket_processing_queue_path(&channel_name); + let identify_payload = + resolve_websocket_identify_message(&config, websocket_secrets_store.as_deref()) + .await; + let mut session_state = WebsocketSessionState::new(identify_payload.as_deref()); + + 'reconnect: loop { + let connect_url = session_state.connect_url(&config.url); + let connect_result = tokio_tungstenite::connect_async(connect_url).await; + let (stream, _) = match connect_result { + Ok(parts) => { + reconnect_attempt = 0; + tracing::info!(channel = %channel_name, "Websocket runtime connected"); + parts + } + Err(error) => { + let backoff = websocket_reconnect_backoff(reconnect_attempt); + reconnect_attempt = reconnect_attempt.saturating_add(1); + tracing::warn!( + channel = %channel_name, + url = %config.url, + error = %error, + backoff_secs = backoff.as_secs(), + "Websocket runtime connection failed; retrying" + ); + tokio::select! { + _ = tokio::time::sleep(backoff) => continue 'reconnect, + _ = &mut shutdown => { + tracing::info!(channel = %channel_name, "Stopping websocket runtime"); + break 'reconnect; + } + } + } + }; + + let (mut write, mut read) = stream.split(); + let mut next_heartbeat: Option>> = None; + session_state.reset_connection(); + + loop { + tokio::select! { + _ = async { + if let Some(sleep) = next_heartbeat.as_mut() { + sleep.as_mut().await; + } else { + std::future::pending::<()>().await; + } + } => { + if let Some(payload) = build_websocket_heartbeat_message(session_state.last_sequence.clone()) + && let Err(error) = write.send(WebsocketMessage::Text(payload.into())).await + { + tracing::warn!(channel = %channel_name, error = %error, "Websocket heartbeat send failed"); + break; + } + + next_heartbeat = session_state.heartbeat_interval_ms + .map(|interval_ms| Box::pin(tokio::time::sleep(websocket_heartbeat_sleep_duration(interval_ms)))); + } + outbound = outbound_rx.recv() => { + if let Some(payload) = outbound + && let Err(error) = write.send(WebsocketMessage::Text(payload.into())).await + { + tracing::warn!(channel = %channel_name, error = %error, "Websocket outbound control send failed"); + break; + } + } + _ = &mut shutdown => { + tracing::info!(channel = %channel_name, "Stopping websocket runtime"); + break 'reconnect; + } + message = read.next() => { + match message { + Some(Ok(WebsocketMessage::Text(text))) => { + log_websocket_diagnostic(&channel_name, &WebsocketMessage::Text(text.clone())); + let text = text.to_string(); + + let actions = session_state.process_text_frame( + &text, + &channel_name, + identify_payload.as_deref(), + workspace_store.as_ref(), + pairing_store.as_ref(), + ); + + let mut should_break = false; + let mut should_reconnect = false; + for action in actions { + match action { + WebsocketFrameAction::SetHeartbeat { interval_ms } => { + next_heartbeat = Some(Box::pin(tokio::time::sleep( + websocket_heartbeat_sleep_duration(interval_ms), + ))); + } + WebsocketFrameAction::Send(payload) => { + if let Err(error) = write.send(WebsocketMessage::Text(payload.into())).await { + tracing::warn!(channel = %channel_name, error = %error, "Websocket send failed"); + should_break = true; + break; + } + } + WebsocketFrameAction::Enqueue(raw_text) => { + if let Err(error) = workspace_store.append_json_text_queue( + &queue_path, + &raw_text, + WEBSOCKET_EVENT_QUEUE_MAX_ITEMS, + ) { + tracing::warn!(channel = %channel_name, error = %error, "Failed to enqueue websocket text frame"); + continue; + } + + if let Ok(poll_guard) = Arc::clone(&websocket_poll_lock).try_lock_owned() { + spawn_websocket_poll( + poll_guard, + WebsocketPollContext { + channel_name: channel_name.clone(), + runtime: Arc::clone(&runtime), + prepared: Arc::clone(&prepared), + capabilities: capabilities.clone(), + poll_capabilities: poll_capabilities.clone(), + credentials: Arc::clone(&credentials), + pairing_store: pairing_store.clone(), + workspace_store: workspace_store.clone(), + message_tx: message_tx.clone(), + rate_limiter: Arc::clone(&rate_limiter), + last_broadcast_metadata: Arc::clone(&last_broadcast_metadata), + settings_store: settings_store.clone(), + owner_scope_id: owner_scope_id.clone(), + owner_actor_id: owner_actor_id.clone(), + secrets_store: websocket_secrets_store.clone(), + outbound_tx: outbound_tx.clone(), + queue_path: queue_path.clone(), + processing_queue_path: processing_queue_path.clone(), + callback_timeout, + }, + ); + } + } + WebsocketFrameAction::InvalidateAndReconnect => { + should_reconnect = true; + break; + } + } + } + if should_reconnect { + break; + } + if should_break { + break; + } + } + Some(Ok(other)) => { + log_websocket_diagnostic(&channel_name, &other); + } + Some(Err(error)) => { + tracing::warn!( + channel = %channel_name, + error = %error, + "Websocket runtime receive error" + ); + break; + } + None => { + tracing::info!(channel = %channel_name, "Websocket runtime closed by peer"); + break; + } + } + } + } + } + + let backoff = websocket_reconnect_backoff(reconnect_attempt); + reconnect_attempt = reconnect_attempt.saturating_add(1); + tracing::info!( + channel = %channel_name, + backoff_secs = backoff.as_secs(), + "Websocket runtime disconnected; reconnect scheduled" + ); + tokio::select! { + _ = tokio::time::sleep(backoff) => {} + _ = &mut shutdown => { + tracing::info!(channel = %channel_name, "Stopping websocket runtime"); + break 'reconnect; + } + } + } + }); + } + /// Create a fresh store configured for WASM execution. fn create_store( runtime: &WasmChannelRuntime, @@ -1504,6 +1740,8 @@ impl WasmChannel { let channel_name = self.name.clone(); match result { Ok(Ok(((), mut host_state))) => { + let _ = drain_guest_logs(&channel_name, "on_poll", &mut host_state); + // Process emitted messages let emitted = host_state.take_emitted_messages(); self.process_emitted_messages(emitted).await?; @@ -2439,6 +2677,7 @@ impl WasmChannel { match result { Ok(Ok(mut host_state)) => { + let _ = drain_guest_logs(channel_name, "on_poll", &mut host_state); let emitted = host_state.take_emitted_messages(); tracing::debug!( channel = %channel_name, @@ -2660,6 +2899,15 @@ impl Channel for WasmChannel { self.start_polling(Duration::from_millis(interval as u64), poll_shutdown_rx); } + if let Some(websocket_config) = + WebsocketRuntimeConfig::from_capabilities(&self.capabilities) + && websocket_config.connect_on_start + { + let (websocket_shutdown_tx, websocket_shutdown_rx) = oneshot::channel(); + *self.websocket_shutdown_tx.write().await = Some(websocket_shutdown_tx); + self.start_websocket_runtime(websocket_config, websocket_shutdown_rx); + } + tracing::info!( channel = %self.name, display_name = %config.display_name, @@ -2776,6 +3024,9 @@ impl Channel for WasmChannel { // Stop polling by dropping the sender (receiver will complete) let _ = self.poll_shutdown_tx.write().await.take(); + // Stop websocket runtime by dropping the sender (receiver will complete) + let _ = self.websocket_shutdown_tx.write().await.take(); + // Clear the message sender *self.message_tx.write().await = None; @@ -2788,6 +3039,564 @@ impl Channel for WasmChannel { } } +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct WebsocketRuntimeConfig { + pub(crate) url: String, + pub(crate) connect_on_start: bool, + pub(crate) identify: Option, + pub(crate) identify_secret_name: Option, +} + +impl WebsocketRuntimeConfig { + pub(crate) fn from_capabilities(capabilities: &ChannelCapabilities) -> Option { + let raw = capabilities.tool_capabilities.websocket.as_ref()?; + let url = raw.get("url")?.as_str()?.trim(); + if url.is_empty() { + return None; + } + + let parsed = url::Url::parse(url).ok()?; + let scheme = parsed.scheme(); + if scheme != "ws" && scheme != "wss" { + return None; + } + + let host = parsed.host_str()?; + let path = parsed.path(); + let http = capabilities.tool_capabilities.http.as_ref()?; + if !http + .allowlist + .iter() + .any(|pattern| pattern.matches(host, path, "GET")) + { + return None; + } + + Some(Self { + url: url.to_string(), + connect_on_start: raw + .get("connect_on_start") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + identify: raw.get("identify").cloned(), + identify_secret_name: raw + .get("identify_secret_name") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned), + }) + } +} + +fn websocket_queue_path(channel_name: &str) -> String { + format!("channels/{channel_name}/{WEBSOCKET_EVENT_QUEUE_RELATIVE_PATH}") +} + +fn websocket_processing_queue_path(channel_name: &str) -> String { + format!("channels/{channel_name}/{WEBSOCKET_EVENT_PROCESSING_QUEUE_RELATIVE_PATH}") +} + +async fn resolve_websocket_identify_message( + config: &WebsocketRuntimeConfig, + store: Option<&(dyn SecretsStore + Send + Sync)>, +) -> Option { + let identify = config.identify.clone()?; + let secret_name = config.identify_secret_name.as_ref()?; + let store = store?; + let secret = store.get_decrypted("default", secret_name).await.ok()?; + build_websocket_identify_message(&identify, secret.expose()) +} + +fn build_websocket_identify_message(identify: &serde_json::Value, token: &str) -> Option { + let mut payload = identify.as_object()?.clone(); + payload.insert( + "token".to_string(), + serde_json::Value::String(token.to_string()), + ); + + serde_json::to_string(&serde_json::json!({ + "op": 2, + "d": serde_json::Value::Object(payload), + })) + .ok() +} + +fn build_websocket_heartbeat_message(sequence: Option) -> Option { + serde_json::to_string(&serde_json::json!({ + "op": 1, + "d": sequence.unwrap_or(serde_json::Value::Null), + })) + .ok() +} + +fn build_discord_gateway_presence_update(status: &str) -> Option { + serde_json::to_string(&serde_json::json!({ + "op": 3, + "d": { + "since": serde_json::Value::Null, + "activities": [], + "status": status, + "afk": false + } + })) + .ok() +} + +fn build_gateway_presence_update( + channel_name: &str, + workspace_store: &crate::channels::wasm::host::ChannelWorkspaceStore, + pairing_store: &PairingStore, +) -> Option { + if channel_name != "discord" { + return None; + } + + build_discord_gateway_presence_update(discord_gateway_presence_status( + channel_name, + workspace_store, + pairing_store, + )) +} + +fn discord_gateway_presence_status( + channel_name: &str, + workspace_store: &crate::channels::wasm::host::ChannelWorkspaceStore, + pairing_store: &PairingStore, +) -> &'static str { + use crate::tools::wasm::WorkspaceReader; + + let owner_key = format!("channels/{}/state/owner_id", channel_name); + if workspace_store + .read(&owner_key) + .filter(|s| !s.is_empty()) + .is_some() + { + return "online"; + } + + if pairing_store + .read_allow_from(channel_name) + .ok() + .is_some_and(|v| !v.is_empty()) + { + return "online"; + } + + "dnd" +} + +fn parse_websocket_hello_heartbeat_interval_ms(text: &str) -> Option { + let payload: serde_json::Value = serde_json::from_str(text).ok()?; + if payload.get("op")?.as_u64()? != 10 { + return None; + } + + payload.get("d")?.get("heartbeat_interval")?.as_u64() +} + +fn websocket_reconnect_backoff(attempt: u32) -> Duration { + use rand::Rng; + + let exponent = attempt.min(6); + let base_ms = (1u64 << exponent) * 1_000; + // Add 0-25% jitter per Discord's reconnection recommendations to avoid + // thundering-herd when many bots reconnect after a Discord deploy. + let jitter_ms = rand::thread_rng().gen_range(0..=base_ms / 4); + Duration::from_millis(base_ms + jitter_ms) +} + +fn websocket_heartbeat_sleep_duration(interval_ms: u64) -> Duration { + Duration::from_millis(interval_ms.max(1)) +} + +fn should_warn_on_heartbeat_interval(interval_ms: u64) -> bool { + interval_ms < 1_000 +} + +fn parse_websocket_sequence(text: &str) -> Option { + let payload: serde_json::Value = serde_json::from_str(text).ok()?; + payload.get("s")?.as_u64() +} + +fn parse_websocket_ready_session(text: &str) -> Option<(String, Option)> { + let payload: serde_json::Value = serde_json::from_str(text).ok()?; + if payload.get("op")?.as_u64()? != 0 { + return None; + } + if payload.get("t")?.as_str()? != "READY" { + return None; + } + let d = payload.get("d")?; + let sid = d.get("session_id")?.as_str()?.to_string(); + let resume_url = d + .get("resume_gateway_url") + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned); + Some((sid, resume_url)) +} + +fn build_websocket_resume_message( + token: &str, + session_id: &str, + sequence: Option<&serde_json::Value>, +) -> Option { + serde_json::to_string(&serde_json::json!({ + "op": 6, + "d": { + "token": token, + "session_id": session_id, + "seq": sequence.cloned().unwrap_or(serde_json::Value::Null), + } + })) + .ok() +} + +fn parse_websocket_invalid_session(text: &str) -> Option { + let payload: serde_json::Value = serde_json::from_str(text).ok()?; + if payload.get("op")?.as_u64()? != 9 { + return None; + } + Some(payload.get("d")?.as_bool().unwrap_or(false)) +} + +fn extract_token_from_identify_payload(identify_payload: &str) -> Option { + let payload: serde_json::Value = serde_json::from_str(identify_payload).ok()?; + payload + .get("d")? + .get("token")? + .as_str() + .map(ToOwned::to_owned) +} + +fn drain_guest_logs( + channel_name: &str, + callback: &str, + host_state: &mut ChannelHostState, +) -> Vec { + let entries = host_state.take_logs(); + + for entry in &entries { + match entry.level { + crate::tools::wasm::LogLevel::Error => { + tracing::error!(channel = %channel_name, callback = callback, "{}", entry.message); + } + crate::tools::wasm::LogLevel::Warn => { + tracing::warn!(channel = %channel_name, callback = callback, "{}", entry.message); + } + crate::tools::wasm::LogLevel::Info => { + tracing::info!(channel = %channel_name, callback = callback, "{}", entry.message); + } + crate::tools::wasm::LogLevel::Debug => { + tracing::debug!(channel = %channel_name, callback = callback, "{}", entry.message); + } + crate::tools::wasm::LogLevel::Trace => { + tracing::trace!(channel = %channel_name, callback = callback, "{}", entry.message); + } + } + } + + entries +} + +/// Shared state for websocket-triggered poll tasks. +/// +/// Groups the many `Arc` handles needed by [`spawn_websocket_poll`] into a +/// single cloneable context so the call site stays readable. +struct WebsocketPollContext { + channel_name: String, + runtime: Arc, + prepared: Arc, + capabilities: ChannelCapabilities, + poll_capabilities: ChannelCapabilities, + credentials: Arc>>, + pairing_store: Arc, + workspace_store: Arc, + message_tx: Arc>>>, + rate_limiter: Arc>, + last_broadcast_metadata: Arc>>, + settings_store: Option>, + owner_scope_id: String, + owner_actor_id: Option, + secrets_store: Option>, + outbound_tx: mpsc::UnboundedSender, + queue_path: String, + processing_queue_path: String, + callback_timeout: Duration, +} + +/// Spawn the websocket-triggered poll task. +/// +/// Extracted from the select loop to reduce nesting. Moves items from the +/// event queue to a processing queue and runs the WASM `on_poll` callback. +fn spawn_websocket_poll(poll_guard: tokio::sync::OwnedMutexGuard<()>, ctx: WebsocketPollContext) { + tokio::spawn(async move { + let _poll_guard = poll_guard; + + loop { + let moved = match ctx + .workspace_store + .move_json_text_queue(&ctx.queue_path, &ctx.processing_queue_path) + { + Ok(value) => value, + Err(error) => { + tracing::warn!(channel = %ctx.channel_name, error = %error, "Failed to snapshot websocket queue for polling"); + break; + } + }; + + if !moved { + break; + } + + let host_credentials = resolve_channel_host_credentials( + &ctx.poll_capabilities, + ctx.secrets_store.as_deref(), + &ctx.owner_scope_id, + ) + .await; + + match WasmChannel::execute_poll( + &ctx.channel_name, + &ctx.runtime, + &ctx.prepared, + &ctx.capabilities, + &ctx.credentials, + host_credentials, + ctx.pairing_store.clone(), + ctx.callback_timeout, + &ctx.workspace_store, + ) + .await + { + Ok(emitted_messages) => { + if !emitted_messages.is_empty() + && let Err(error) = WasmChannel::dispatch_emitted_messages( + EmitDispatchContext { + channel_name: &ctx.channel_name, + owner_scope_id: &ctx.owner_scope_id, + owner_actor_id: ctx.owner_actor_id.as_deref(), + message_tx: &ctx.message_tx, + rate_limiter: &ctx.rate_limiter, + last_broadcast_metadata: &ctx.last_broadcast_metadata, + settings_store: ctx.settings_store.as_ref(), + }, + emitted_messages, + ) + .await + { + tracing::warn!(channel = %ctx.channel_name, error = %error, "Failed to dispatch emitted websocket poll messages"); + } + } + Err(error) => { + tracing::warn!(channel = %ctx.channel_name, error = %error, "Websocket-triggered poll failed"); + } + } + + if let Some(payload) = build_gateway_presence_update( + &ctx.channel_name, + ctx.workspace_store.as_ref(), + ctx.pairing_store.as_ref(), + ) { + let _ = ctx.outbound_tx.send(payload); + } + } + }); +} + +/// Actions produced by websocket text frame processing. +/// +/// Returned from [`WebsocketSessionState::process_text_frame`] so the caller +/// can perform the actual I/O (send messages, break loops) while keeping the +/// parsing logic synchronous and testable. +enum WebsocketFrameAction { + /// Update the heartbeat timer to fire after `interval_ms` milliseconds. + SetHeartbeat { interval_ms: u64 }, + /// Send a text payload over the websocket. + Send(String), + /// Enqueue the raw text into the workspace event queue. + Enqueue(String), + /// Clear session state and reconnect with a fresh identify. + InvalidateAndReconnect, +} + +/// Tracks websocket session state across reconnects. +/// +/// Keeps heartbeat interval, sequence counter, and Discord Gateway session +/// resumption fields. The [`process_text_frame`] method parses incoming frames +/// and returns a list of [`WebsocketFrameAction`]s the caller should execute. +struct WebsocketSessionState { + heartbeat_interval_ms: Option, + last_sequence: Option, + session_id: Option, + resume_gateway_url: Option, + /// Raw bot token extracted from the identify payload. + token: Option, + /// Whether we attempted a resume on this connection. + attempted_resume: bool, +} + +impl WebsocketSessionState { + fn new(identify_payload: Option<&str>) -> Self { + let token = identify_payload.and_then(extract_token_from_identify_payload); + Self { + heartbeat_interval_ms: None, + last_sequence: None, + session_id: None, + resume_gateway_url: None, + token, + attempted_resume: false, + } + } + + /// Determine the URL to use for the next connection attempt. + fn connect_url<'a>(&'a self, default_url: &'a str) -> &'a str { + if self.session_id.is_some() + && let Some(ref url) = self.resume_gateway_url + { + return url.as_str(); + } + default_url + } + + /// Reset per-connection state when starting a fresh connection. + fn reset_connection(&mut self) { + self.heartbeat_interval_ms = None; + self.attempted_resume = false; + } + + /// Clear all session state so the next reconnect performs a fresh identify. + fn invalidate_session(&mut self) { + self.session_id = None; + self.resume_gateway_url = None; + self.last_sequence = None; + } + + /// Process a text frame and return a list of actions for the caller to + /// execute. This keeps the select loop thin and the parsing logic testable. + fn process_text_frame( + &mut self, + text: &str, + channel_name: &str, + identify_payload: Option<&str>, + workspace_store: &crate::channels::wasm::host::ChannelWorkspaceStore, + pairing_store: &PairingStore, + ) -> Vec { + let mut actions = Vec::new(); + + // OP 10 Hello: extract heartbeat interval, send identify or resume + if let Some(interval_ms) = parse_websocket_hello_heartbeat_interval_ms(text) { + if should_warn_on_heartbeat_interval(interval_ms) { + tracing::warn!( + channel = %channel_name, + heartbeat_interval_ms = interval_ms, + "Websocket hello provided unexpectedly low heartbeat interval" + ); + } + + self.heartbeat_interval_ms = Some(interval_ms); + actions.push(WebsocketFrameAction::SetHeartbeat { interval_ms }); + + // Try resume if we have a session, otherwise fresh identify + let sent_resume = if let (Some(token), Some(sid)) = (&self.token, &self.session_id) { + if let Some(payload) = + build_websocket_resume_message(token, sid, self.last_sequence.as_ref()) + { + self.attempted_resume = true; + actions.push(WebsocketFrameAction::Send(payload)); + true + } else { + false + } + } else { + false + }; + + if !sent_resume && let Some(payload) = identify_payload { + actions.push(WebsocketFrameAction::Send(payload.to_string())); + } + } + + // OP 0 Dispatch READY: capture session_id and resume_gateway_url. + // Presence update is sent here (after READY) rather than on Hello, + // because Discord's gateway protocol requires waiting for READY/RESUMED + // before sending non-Identify commands. + if let Some((sid, resume_url)) = parse_websocket_ready_session(text) { + self.session_id = Some(sid); + self.resume_gateway_url = resume_url; + + if let Some(payload) = + build_gateway_presence_update(channel_name, workspace_store, pairing_store) + { + actions.push(WebsocketFrameAction::Send(payload)); + } + } + + // Track sequence number from any dispatch + if let Some(sequence) = parse_websocket_sequence(text) { + self.last_sequence = Some(serde_json::Value::Number(sequence.into())); + } + + // OP 9 Invalid Session: if not resumable, clear state and reconnect + if let Some(resumable) = parse_websocket_invalid_session(text) + && !resumable + { + tracing::info!( + channel = %channel_name, + "Received non-resumable invalid session; will reconnect with fresh identify" + ); + self.invalidate_session(); + actions.push(WebsocketFrameAction::InvalidateAndReconnect); + return actions; + } + + // Always enqueue the raw frame for the poll callback + actions.push(WebsocketFrameAction::Enqueue(text.to_string())); + + actions + } +} + +fn log_websocket_diagnostic(channel_name: &str, message: &WebsocketMessage) { + match message { + WebsocketMessage::Text(text) => { + tracing::trace!( + channel = %channel_name, + bytes = text.len(), + "Websocket runtime received text frame" + ); + } + WebsocketMessage::Binary(bytes) => { + tracing::debug!( + channel = %channel_name, + bytes = bytes.len(), + "Websocket runtime received binary frame" + ); + } + WebsocketMessage::Close(frame) => { + tracing::info!( + channel = %channel_name, + code = ?frame.as_ref().map(|f| f.code), + reason = ?frame.as_ref().map(|f| f.reason.to_string()), + "Websocket runtime received close frame" + ); + } + WebsocketMessage::Ping(payload) => { + tracing::trace!( + channel = %channel_name, + bytes = payload.len(), + "Websocket runtime received ping" + ); + } + WebsocketMessage::Pong(payload) => { + tracing::trace!( + channel = %channel_name, + bytes = payload.len(), + "Websocket runtime received pong" + ); + } + WebsocketMessage::Frame(_) => {} + } +} + impl std::fmt::Debug for WasmChannel { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("WasmChannel") @@ -3326,19 +4135,29 @@ fn read_attachments(paths: &[String]) -> Result, St #[cfg(test)] mod tests { use std::sync::Arc; + use std::time::Duration; use crate::channels::Channel; use crate::channels::OutgoingResponse; use crate::channels::wasm::capabilities::ChannelCapabilities; + use crate::channels::wasm::host::{ChannelHostState, PendingWorkspaceWrite}; use crate::channels::wasm::runtime::{ PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig, }; use crate::channels::wasm::wrapper::{ - EmitDispatchContext, HttpResponse, WasmChannel, uses_owner_broadcast_target, + EmitDispatchContext, HttpResponse, WasmChannel, WebsocketRuntimeConfig, + build_discord_gateway_presence_update, build_websocket_identify_message, + build_websocket_resume_message, discord_gateway_presence_status, drain_guest_logs, + parse_websocket_invalid_session, parse_websocket_ready_session, + should_warn_on_heartbeat_interval, uses_owner_broadcast_target, + websocket_heartbeat_sleep_duration, websocket_reconnect_backoff, }; use crate::pairing::PairingStore; use crate::testing::credentials::TEST_TELEGRAM_BOT_TOKEN; - use crate::tools::wasm::ResourceLimits; + use crate::tools::wasm::{ + Capabilities as ToolCapabilities, EndpointPattern, HttpCapability, LogLevel, ResourceLimits, + }; + use tempfile::tempdir; fn create_test_channel() -> WasmChannel { create_test_channel_with_owner_scope("default") @@ -3368,6 +4187,219 @@ mod tests { ) } + #[test] + fn test_websocket_runtime_config_reads_capability_payload() { + let mut tool_capabilities = ToolCapabilities::default(); + let mut http = HttpCapability::new(vec![EndpointPattern::host("gateway.discord.gg")]); + http.credentials.insert( + "discord_bot_token".to_string(), + crate::secrets::CredentialMapping { + secret_name: "discord_bot_token".to_string(), + location: crate::secrets::CredentialLocation::Header { + name: "Authorization".to_string(), + prefix: Some("Bot ".to_string()), + }, + host_patterns: vec!["discord.com".to_string()], + }, + ); + tool_capabilities.http = Some(http); + tool_capabilities.websocket = Some(serde_json::json!({ + "url": "wss://gateway.discord.gg/?v=10&encoding=json", + "connect_on_start": true, + "identify_secret_name": "discord_bot_token", + "identify": { + "intents": 513, + "properties": { + "os": "linux", + "browser": "ironclaw", + "device": "ironclaw" + } + } + })); + + let capabilities = + ChannelCapabilities::for_channel("discord").with_tool_capabilities(tool_capabilities); + + let config = WebsocketRuntimeConfig::from_capabilities(&capabilities) + .expect("websocket config should be parsed"); + + assert_eq!(config.url, "wss://gateway.discord.gg/?v=10&encoding=json"); + assert!(config.connect_on_start); + assert_eq!( + config.identify_secret_name.as_deref(), + Some("discord_bot_token") + ); + assert_eq!( + config.identify, + Some(serde_json::json!({ + "intents": 513, + "properties": { + "os": "linux", + "browser": "ironclaw", + "device": "ironclaw" + } + })) + ); + } + + #[test] + fn test_build_websocket_identify_message_includes_token() { + let identify = serde_json::json!({ + "intents": 513, + "properties": { + "os": "linux", + "browser": "ironclaw", + "device": "ironclaw" + } + }); + + let payload = build_websocket_identify_message(&identify, "bot-token").unwrap(); + let json: serde_json::Value = serde_json::from_str(&payload).unwrap(); + + assert_eq!(json["op"], serde_json::json!(2)); + assert_eq!(json["d"]["token"], serde_json::json!("bot-token")); + assert_eq!(json["d"]["intents"], serde_json::json!(513)); + } + + #[test] + fn test_websocket_runtime_config_requires_allowlisted_host() { + let tool_capabilities = ToolCapabilities { + http: Some(HttpCapability::new(vec![EndpointPattern::host( + "discord.com", + )])), + websocket: Some(serde_json::json!({ + "url": "wss://gateway.discord.gg/?v=10&encoding=json", + "connect_on_start": true + })), + ..Default::default() + }; + + let capabilities = + ChannelCapabilities::for_channel("discord").with_tool_capabilities(tool_capabilities); + + assert!(WebsocketRuntimeConfig::from_capabilities(&capabilities).is_none()); + } + + #[test] + fn test_drain_guest_logs_collects_poll_entries() { + let mut host_state = ChannelHostState::new("poll-test", ChannelCapabilities::default()); + host_state + .log(LogLevel::Warn, "poll warning".to_string()) + .expect("log entry should be stored"); + + let logs = drain_guest_logs("poll-test", "on_poll", &mut host_state); + + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].message, "poll warning"); + assert_eq!(logs[0].level, LogLevel::Warn); + assert!(host_state.take_logs().is_empty(), "logs should be drained"); + } + + #[test] + fn test_websocket_reconnect_backoff_caps_at_sixty_four_seconds_with_jitter() { + // Backoff = base + 0-25% jitter, so check range [base, base * 1.25]. + let check = |attempt: u32, base_secs: u64| { + let d = websocket_reconnect_backoff(attempt); + let base = Duration::from_secs(base_secs); + let max = base + base / 4; + assert!( + d >= base && d <= max, + "attempt {attempt}: {d:?} not in [{base:?}, {max:?}]" + ); + }; + check(0, 1); + check(1, 2); + check(5, 32); + check(6, 64); + check(10, 64); // capped at 2^6 + } + + #[test] + fn test_websocket_heartbeat_helpers_guard_low_intervals() { + assert!(should_warn_on_heartbeat_interval(0)); + assert!(should_warn_on_heartbeat_interval(999)); + assert!(!should_warn_on_heartbeat_interval(1_000)); + assert_eq!( + websocket_heartbeat_sleep_duration(0), + Duration::from_millis(1) + ); + assert_eq!( + websocket_heartbeat_sleep_duration(42), + Duration::from_millis(42) + ); + } + + #[test] + fn test_discord_gateway_presence_defaults_to_dnd() { + let store = crate::channels::wasm::host::ChannelWorkspaceStore::new(); + let pairing_dir = tempdir().unwrap(); + let pairing_store = PairingStore::with_base_dir(pairing_dir.path().to_path_buf()); + + assert_eq!( + discord_gateway_presence_status("discord", &store, &pairing_store), + "dnd" + ); + } + + #[test] + fn test_discord_gateway_presence_empty_owner_id_is_dnd() { + let store = crate::channels::wasm::host::ChannelWorkspaceStore::new(); + let pairing_dir = tempdir().unwrap(); + let pairing_store = PairingStore::with_base_dir(pairing_dir.path().to_path_buf()); + // Simulate on_start writing empty string when no owner_id is configured + store.commit_writes(&[PendingWorkspaceWrite { + path: "channels/discord/state/owner_id".to_string(), + content: String::new(), + }]); + + assert_eq!( + discord_gateway_presence_status("discord", &store, &pairing_store), + "dnd" + ); + } + + #[test] + fn test_discord_gateway_presence_pairing_approved_is_online() { + let store = crate::channels::wasm::host::ChannelWorkspaceStore::new(); + let pairing_dir = tempdir().unwrap(); + let pairing_store = PairingStore::with_base_dir(pairing_dir.path().to_path_buf()); + let request = pairing_store + .upsert_request("discord", "user-1", None) + .unwrap(); + pairing_store.approve("discord", &request.code).unwrap(); + + assert_eq!( + discord_gateway_presence_status("discord", &store, &pairing_store), + "online" + ); + } + + #[test] + fn test_discord_gateway_presence_owner_id_is_online() { + let store = crate::channels::wasm::host::ChannelWorkspaceStore::new(); + let pairing_dir = tempdir().unwrap(); + let pairing_store = PairingStore::with_base_dir(pairing_dir.path().to_path_buf()); + store.commit_writes(&[PendingWorkspaceWrite { + path: "channels/discord/state/owner_id".to_string(), + content: "owner-1".to_string(), + }]); + + assert_eq!( + discord_gateway_presence_status("discord", &store, &pairing_store), + "online" + ); + } + + #[test] + fn test_build_discord_gateway_presence_update_uses_status() { + let payload = build_discord_gateway_presence_update("dnd").unwrap(); + let json: serde_json::Value = serde_json::from_str(&payload).unwrap(); + + assert_eq!(json["op"], serde_json::json!(3)); + assert_eq!(json["d"]["status"], serde_json::json!("dnd")); + assert_eq!(json["d"]["afk"], serde_json::json!(false)); + } + #[test] fn test_channel_name() { let channel = create_test_channel(); @@ -4762,6 +5794,77 @@ mod tests { assert!(msg.attachments.is_empty()); // safety: test-only assertion } + #[test] + fn test_parse_websocket_ready_session() { + let ready = serde_json::json!({ + "op": 0, + "s": 1, + "t": "READY", + "d": { + "session_id": "abc123", + "resume_gateway_url": "wss://gateway-resume.discord.gg", + "user": {"id": "12345"} + } + }); + let (sid, resume_url) = parse_websocket_ready_session(&ready.to_string()).unwrap(); + assert_eq!(sid, "abc123"); + assert_eq!( + resume_url.as_deref(), + Some("wss://gateway-resume.discord.gg") + ); + + // Non-READY dispatch returns None + let message_create = serde_json::json!({ + "op": 0, + "s": 2, + "t": "MESSAGE_CREATE", + "d": {"content": "hello"} + }); + assert!(parse_websocket_ready_session(&message_create.to_string()).is_none()); + + // Non-dispatch opcode returns None + let hello = serde_json::json!({"op": 10, "d": {"heartbeat_interval": 41250}}); + assert!(parse_websocket_ready_session(&hello.to_string()).is_none()); + } + + #[test] + fn test_build_websocket_resume_message() { + let seq = serde_json::Value::Number(42.into()); + let payload = build_websocket_resume_message("bot-token", "session-1", Some(&seq)).unwrap(); + let json: serde_json::Value = serde_json::from_str(&payload).unwrap(); + + assert_eq!(json["op"], serde_json::json!(6)); + assert_eq!(json["d"]["token"], serde_json::json!("bot-token")); + assert_eq!(json["d"]["session_id"], serde_json::json!("session-1")); + assert_eq!(json["d"]["seq"], serde_json::json!(42)); + + // With no sequence, seq should be null + let payload_null = build_websocket_resume_message("bot-token", "session-1", None).unwrap(); + let json_null: serde_json::Value = serde_json::from_str(&payload_null).unwrap(); + assert!(json_null["d"]["seq"].is_null()); + } + + #[test] + fn test_parse_websocket_invalid_session() { + // Non-resumable invalid session (d: false) + let not_resumable = serde_json::json!({"op": 9, "d": false}); + assert_eq!( + parse_websocket_invalid_session(¬_resumable.to_string()), + Some(false) + ); + + // Resumable invalid session (d: true) + let resumable = serde_json::json!({"op": 9, "d": true}); + assert_eq!( + parse_websocket_invalid_session(&resumable.to_string()), + Some(true) + ); + + // Different opcode returns None + let hello = serde_json::json!({"op": 10, "d": {"heartbeat_interval": 41250}}); + assert!(parse_websocket_invalid_session(&hello.to_string()).is_none()); + } + #[test] fn test_mime_from_extension() { use super::mime_from_extension; diff --git a/src/channels/web/handlers/extensions.rs b/src/channels/web/handlers/extensions.rs index d705591e..34982f74 100644 --- a/src/channels/web/handlers/extensions.rs +++ b/src/channels/web/handlers/extensions.rs @@ -12,6 +12,37 @@ use crate::channels::web::auth::AuthenticatedUser; use crate::channels::web::server::GatewayState; use crate::channels::web::types::*; +pub(crate) fn derive_activation_status( + ext: &crate::extensions::InstalledExtension, + pairing_store: &crate::pairing::PairingStore, + has_owner_binding: bool, +) -> Option { + if ext.kind == crate::extensions::ExtensionKind::WasmChannel { + let allowlist_exists = pairing_store + .has_allow_from_file(&ext.name) + .unwrap_or(false); + let has_paired = pairing_store + .read_allow_from(&ext.name) + .map(|list| !list.is_empty()) + .unwrap_or(false); + classify_wasm_channel_activation( + ext, + has_paired, + has_owner_binding || (ext.active && !allowlist_exists), + ) + } else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay { + Some(if ext.active { + ExtensionActivationStatus::Active + } else if ext.authenticated { + ExtensionActivationStatus::Configured + } else { + ExtensionActivationStatus::Installed + }) + } else { + None + } +} + pub async fn extensions_list_handler( State(state): State>, AuthenticatedUser(user): AuthenticatedUser, @@ -38,27 +69,11 @@ pub async fn extensions_list_handler( let extensions = installed .into_iter() .map(|ext| { - let activation_status = if ext.kind == crate::extensions::ExtensionKind::WasmChannel { - let has_paired = pairing_store - .read_allow_from(&ext.name) - .map(|list| !list.is_empty()) - .unwrap_or(false); - crate::channels::web::types::classify_wasm_channel_activation( - &ext, - has_paired, - owner_bound_channels.contains(&ext.name), - ) - } else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay { - Some(if ext.active { - crate::channels::web::types::ExtensionActivationStatus::Active - } else if ext.authenticated { - crate::channels::web::types::ExtensionActivationStatus::Configured - } else { - crate::channels::web::types::ExtensionActivationStatus::Installed - }) - } else { - None - }; + let activation_status = derive_activation_status( + &ext, + &pairing_store, + owner_bound_channels.contains(&ext.name), + ); ExtensionInfo { name: ext.name, display_name: ext.display_name, @@ -143,3 +158,63 @@ pub async fn extensions_remove_handler( Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))), } } + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::derive_activation_status; + use crate::channels::web::types::ExtensionActivationStatus; + use crate::extensions::{ExtensionKind, InstalledExtension}; + use crate::pairing::PairingStore; + + fn active_authenticated_wasm_channel(name: &str) -> InstalledExtension { + InstalledExtension { + name: name.to_string(), + kind: ExtensionKind::WasmChannel, + display_name: None, + description: None, + url: None, + authenticated: true, + active: true, + tools: Vec::new(), + needs_setup: false, + has_auth: false, + installed: true, + activation_error: None, + version: None, + } + } + + #[test] + fn active_authenticated_wasm_channel_without_allowlist_file_is_active() { + let temp_dir = TempDir::new().expect("temp dir"); + let pairing_store = PairingStore::with_base_dir(temp_dir.path().to_path_buf()); + let ext = active_authenticated_wasm_channel("discord"); + + assert_eq!( + derive_activation_status(&ext, &pairing_store, false), + Some(ExtensionActivationStatus::Active) + ); + } + + #[test] + fn active_authenticated_wasm_channel_with_empty_allowlist_file_is_pairing() { + let temp_dir = TempDir::new().expect("temp dir"); + let pairing_store = PairingStore::with_base_dir(temp_dir.path().to_path_buf()); + let ext = active_authenticated_wasm_channel("discord"); + + fs::write( + temp_dir.path().join("discord-allowFrom.json"), + r#"{"version":1,"allowFrom":[]}"#, + ) + .expect("write empty allowlist"); + + assert_eq!( + derive_activation_status(&ext, &pairing_store, false), + Some(ExtensionActivationStatus::Pairing) + ); + } +} diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 766044f8..a9e0d331 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -2159,27 +2159,12 @@ async fn extensions_list_handler( let extensions = installed .into_iter() .map(|ext| { - let activation_status = if ext.kind == crate::extensions::ExtensionKind::WasmChannel { - let has_paired = pairing_store - .read_allow_from(&ext.name) - .map(|list| !list.is_empty()) - .unwrap_or(false); - crate::channels::web::types::classify_wasm_channel_activation( + let activation_status = + crate::channels::web::handlers::extensions::derive_activation_status( &ext, - has_paired, + &pairing_store, owner_bound_channels.contains(&ext.name), - ) - } else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay { - Some(if ext.active { - ExtensionActivationStatus::Active - } else if ext.authenticated { - ExtensionActivationStatus::Configured - } else { - ExtensionActivationStatus::Installed - }) - } else { - None - }; + ); ExtensionInfo { name: ext.name, display_name: ext.display_name, diff --git a/src/pairing/store.rs b/src/pairing/store.rs index e026761e..a0545c71 100644 --- a/src/pairing/store.rs +++ b/src/pairing/store.rs @@ -420,6 +420,12 @@ impl PairingStore { Ok(Some(entry)) } + /// Read the allowFrom list for a channel. + pub fn has_allow_from_file(&self, channel: &str) -> Result { + let path = allow_from_path(&self.base_dir, channel)?; + Ok(path.exists()) + } + /// Read the allowFrom list for a channel. pub fn read_allow_from(&self, channel: &str) -> Result, PairingStoreError> { let path = allow_from_path(&self.base_dir, channel)?; diff --git a/src/tools/wasm/capabilities.rs b/src/tools/wasm/capabilities.rs index ff98ae03..608a5307 100644 --- a/src/tools/wasm/capabilities.rs +++ b/src/tools/wasm/capabilities.rs @@ -34,6 +34,8 @@ pub struct Capabilities { pub secrets: Option, /// Webhook authentication and signature verification. pub webhook: Option, + /// Arbitrary websocket configuration preserved from capabilities JSON. + pub websocket: Option, } impl Capabilities { @@ -341,6 +343,7 @@ mod tests { assert!(caps.tool_invoke.is_none()); assert!(caps.secrets.is_none()); assert!(caps.webhook.is_none()); + assert!(caps.websocket.is_none()); } #[test] diff --git a/src/tools/wasm/capabilities_schema.rs b/src/tools/wasm/capabilities_schema.rs index ff49e21f..e8c283cc 100644 --- a/src/tools/wasm/capabilities_schema.rs +++ b/src/tools/wasm/capabilities_schema.rs @@ -75,6 +75,10 @@ pub struct CapabilitiesFile { #[serde(default)] pub webhook: Option, + /// Arbitrary websocket configuration preserved for runtime consumers. + #[serde(default)] + pub websocket: Option, + /// Authentication setup instructions. /// Used by `optimclaw config` to guide users through auth setup. #[serde(default)] @@ -155,6 +159,7 @@ impl CapabilitiesFile { self.tool_invoke = self.tool_invoke.or(inner.tool_invoke); self.workspace = self.workspace.or(inner.workspace); self.webhook = self.webhook.or(inner.webhook); + self.websocket = self.websocket.or(inner.websocket); self.auth = self.auth.or(inner.auth); self.setup = self.setup.or(inner.setup); } @@ -250,6 +255,8 @@ impl CapabilitiesFile { caps.webhook = Some(webhook.to_webhook_capability()); } + caps.websocket = self.websocket.clone(); + caps } } @@ -745,6 +752,8 @@ fn default_tool_setup_field_input_type() -> ToolSetupFieldInputType { #[cfg(test)] mod tests { + use serde_json::json; + use crate::tools::wasm::capabilities_schema::{CapabilitiesFile, CredentialLocationSchema}; #[test] @@ -1402,6 +1411,48 @@ mod tests { ); } + #[test] + fn test_discord_websocket_config_preserved_in_runtime_capabilities() { + let json = r#"{ + "capabilities": { + "http": { + "allowlist": [{ "host": "discord.com", "path_prefix": "/api/v10" }] + }, + "websocket": { + "url": "wss://gateway.discord.gg/?v=10&encoding=json", + "connect_on_start": true, + "identify": { + "intents": 513, + "properties": { + "os": "linux", + "browser": "ironclaw", + "device": "ironclaw" + } + } + } + } + }"#; + + let file = CapabilitiesFile::from_json(json).unwrap(); + let caps = file.to_capabilities(); + + assert_eq!( + caps.websocket, + Some(json!({ + "url": "wss://gateway.discord.gg/?v=10&encoding=json", + "connect_on_start": true, + "identify": { + "intents": 513, + "properties": { + "os": "linux", + "browser": "ironclaw", + "device": "ironclaw" + } + } + })) + ); + } + // ── Tool description ──────────────────────────────────────────────── #[test]