From 115b7f38fe5ea4c4fb3f36be43bf5fa7f31b555f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilg=C4=B1n=20Kanat?= <48878763+nightfullstar@users.noreply.github.com> Date: Thu, 12 Feb 2026 04:46:47 +0400 Subject: [PATCH] DM pairing + Telegram channel improvements (#17) * feat: Implement DM pairing for channels - Introduced a new pairing system to manage direct messages from unknown senders. - Added `PairingStore` to handle pending requests and allowlist management. - Implemented CLI commands for listing and approving pairing requests. - Updated Telegram channel to utilize the new pairing logic, including workspace paths for storing pairing data. - Enhanced WASM channel integration to support pairing functionality. This feature enhances security by requiring approval for unknown senders before they can interact with the agent. * Enhance Telegram channel support with media captioning and DM pairing features - Added support for media captions in Telegram messages, allowing for richer content handling. - Updated message processing to utilize either text or caption, improving message flexibility. - Enhanced DM pairing functionality to include approval and listing capabilities for direct messages. - Updated feature parity documentation to reflect new capabilities and improvements in Telegram integration. * Update README and BUILDING_CHANNELS documentation for Telegram channel integration - Enhanced README with instructions for building and running the Telegram channel, including a note on running `./scripts/build-all.sh` for full releases. - Added detailed steps in BUILDING_CHANNELS.md for building and deploying the Telegram channel, emphasizing the need to run `./channels-src/telegram/build.sh` before building the main crate to ensure updated WASM is included. - Updated CLI module to expose a new command for pairing with store functionality. * Implement build script for Telegram channel WASM and enhance pairing error handling - Added a new `build.rs` script to automate the compilation of the Telegram channel's WASM binary from source, ensuring reproducible builds and emphasizing supply chain security by preventing committed binaries. - Updated `BUILDING_CHANNELS.md` to reflect the new build process and the importance of not committing compiled binaries. - Enhanced error handling in the pairing approval process to include rate limiting for failed attempts, improving security and user feedback. * Remove Telegram channel WASM binary file as part of the build process cleanup, ensuring no committed binaries are present in the repository. --- Cargo.lock | 11 + Cargo.toml | 1 + FEATURE_PARITY.md | 25 +- README.md | 5 + build.rs | 105 +++ channels-src/telegram/src/lib.rs | 317 +++++++-- .../telegram/telegram.capabilities.json | 45 +- docs/BUILDING_CHANNELS.md | 39 +- docs/TELEGRAM_SETUP.md | 135 ++++ scripts/build-all.sh | 21 + src/channels/wasm/loader.rs | 22 +- src/channels/wasm/router.rs | 4 +- src/channels/wasm/wrapper.rs | 141 +++- src/cli/mod.rs | 6 + src/cli/pairing.rs | 183 +++++ src/lib.rs | 1 + src/main.rs | 16 +- src/pairing/mod.rs | 10 + src/pairing/store.rs | 669 ++++++++++++++++++ tests/pairing_integration.rs | 112 +++ tests/wasm_channel_integration.rs | 9 +- wit/channel.wit | 26 + 22 files changed, 1774 insertions(+), 129 deletions(-) create mode 100644 build.rs create mode 100644 docs/TELEGRAM_SETUP.md create mode 100755 scripts/build-all.sh create mode 100644 src/cli/pairing.rs create mode 100644 src/pairing/mod.rs create mode 100644 src/pairing/store.rs create mode 100644 tests/pairing_integration.rs diff --git a/Cargo.lock b/Cargo.lock index c9006498..3e26cae9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1621,6 +1621,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fs4" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eeb4ed9e12f43b7fa0baae3f9cdda28352770132ef2e09a23760c29cae8bd47" +dependencies = [ + "rustix 0.38.44", + "windows-sys 0.48.0", +] + [[package]] name = "funty" version = "2.0.0" @@ -2299,6 +2309,7 @@ dependencies = [ "deadpool-postgres", "dirs 6.0.0", "dotenvy", + "fs4", "futures", "hkdf", "http-body-util", diff --git a/Cargo.toml b/Cargo.toml index c8e3a4f4..6b3c0711 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,7 @@ aho-corasick = "1" # Filesystem paths dirs = "6" +fs4 = "0.6" # Secrecy for sensitive values secrecy = { version = "0.10", features = ["serde"] } diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index ce126dca..a5ce7092 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -59,7 +59,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | REPL (simple) | ✅ | ✅ | - | For testing | | WASM channels | ❌ | ✅ | - | IronClaw innovation | | WhatsApp | ✅ | ❌ | P1 | Baileys (Web) | -| Telegram | ✅ | ✅ | - | WASM tool (MTProto) | +| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username | | Discord | ✅ | ❌ | P2 | discord.js | | Signal | ✅ | ❌ | P2 | signal-cli | | Slack | ✅ | ✅ | - | WASM tool | @@ -79,13 +79,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Feature | OpenClaw | IronClaw | Notes | |---------|----------|----------|-------| -| DM pairing codes | ✅ | ❌ | Verification for unknown senders | -| Allowlist/blocklist | ✅ | ❌ | Per-channel access control | +| DM pairing codes | ✅ | ✅ | `ironclaw pairing list/approve`, host APIs | +| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store | | Self-message bypass | ✅ | ❌ | Own messages skip pairing | -| Mention-based activation | ✅ | ❌ | Configurable patterns | +| Mention-based activation | ✅ | ✅ | bot_username + respond_to_all_group_messages | | Per-group tool policies | ✅ | ❌ | Allow/deny specific tools | | Thread isolation | ✅ | ✅ | Separate sessions per thread | -| Per-channel media limits | ✅ | ❌ | | +| Per-channel media limits | ✅ | 🚧 | Caption support for media; no size limits | | Typing indicators | ✅ | 🚧 | TUI shows status | ### Owner: _Unassigned_ @@ -109,7 +109,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | `sessions` | ✅ | ❌ | P3 | Session listing | | `memory` | ✅ | ✅ | - | Memory search CLI | | `skills` | ✅ | ❌ | P3 | Agent skills | -| `pairing` | ✅ | ❌ | P3 | Node pairing | +| `pairing` | ✅ | ✅ | - | list/approve for channel DM pairing | | `nodes` | ✅ | ❌ | P3 | Device management | | `plugins` | ✅ | ❌ | P3 | Plugin management | | `hooks` | ✅ | ❌ | P2 | Lifecycle hooks | @@ -350,8 +350,8 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Device pairing | ✅ | ❌ | | | Tailscale identity | ✅ | ❌ | | | OAuth flows | ✅ | 🚧 | NEAR AI OAuth | -| DM pairing verification | ✅ | ❌ | | -| Allowlist/blocklist | ✅ | ❌ | | +| DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs | +| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store | | Per-group tool policies | ✅ | ❌ | | | Exec approvals | ✅ | ✅ | TUI overlay | | TLS 1.3 minimum | ✅ | ✅ | reqwest rustls | @@ -397,6 +397,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O ### P0 - Core (Already Done) - ✅ TUI channel with approval overlays - ✅ HTTP webhook channel +- ✅ DM pairing (ironclaw pairing list/approve, host APIs) - ✅ WASM tool sandbox - ✅ Workspace/memory with hybrid search - ✅ Prompt injection defense @@ -415,12 +416,18 @@ This document tracks feature parity between IronClaw (Rust implementation) and O - ✅ Gateway token auth ### P1 - High Priority +- ❌ Slack channel (real implementation) +- ✅ Telegram channel (WASM, DM pairing, caption, /start) - ❌ WhatsApp channel - ❌ Multi-provider failover - ❌ Hooks system (beforeInbound, beforeToolCall, etc.) ### P2 - Medium Priority -- ❌ Media handling (images, PDFs) +- ❌ Cron job scheduling +- ❌ Web Control UI +- ❌ WebChat channel +- 🚧 Media handling (caption support; no image/PDF processing) +- ❌ CLI subcommands (config, status, memory, doctor) - ❌ Ollama/local model support - ❌ Configuration hot-reload - ❌ Webhook trigger endpoint in web gateway diff --git a/README.md b/README.md index 80c16302..d98da3da 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ cargo build --release cargo test ``` +For **full release** (after modifying channel sources), run `./scripts/build-all.sh` to rebuild channels first. + ### Database Setup ```bash @@ -228,6 +230,9 @@ cargo test cargo test test_name ``` +- **Telegram channel**: See [docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md) for setup and DM pairing. +- **Changing channel sources**: Run `./channels-src/telegram/build.sh` before `cargo build` so the updated WASM is bundled. + ## OpenClaw Heritage IronClaw is a Rust reimplementation inspired by [OpenClaw](https://github.com/openclaw/openclaw). See [FEATURE_PARITY.md](FEATURE_PARITY.md) for the complete tracking matrix. diff --git a/build.rs b/build.rs new file mode 100644 index 00000000..01ea2da8 --- /dev/null +++ b/build.rs @@ -0,0 +1,105 @@ +//! Build script: compile Telegram channel WASM from source. +//! +//! Do not commit compiled WASM binaries — they are a supply chain risk. +//! This script builds telegram.wasm from channels-src/telegram before the main crate compiles. +//! +//! Reproducible build: +//! cargo build --release +//! (build.rs invokes the channel build automatically) +//! +//! Prerequisites: rustup target add wasm32-wasip2, cargo install wasm-tools + +use std::env; +use std::path::PathBuf; +use std::process::Command; + +fn main() { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let root = PathBuf::from(&manifest_dir); + let channel_dir = root.join("channels-src/telegram"); + let wasm_out = channel_dir.join("telegram.wasm"); + + // Rerun when channel source or build script changes + println!("cargo:rerun-if-changed=channels-src/telegram/src"); + println!("cargo:rerun-if-changed=channels-src/telegram/Cargo.toml"); + println!("cargo:rerun-if-changed=wit/channel.wit"); + + if !channel_dir.is_dir() { + return; + } + + // Build WASM module + let status = match Command::new("cargo") + .args([ + "build", + "--release", + "--target", + "wasm32-wasip2", + "--manifest-path", + channel_dir.join("Cargo.toml").to_str().unwrap(), + ]) + .current_dir(&root) + .status() + { + Ok(s) => s, + Err(_) => { + eprintln!( + "cargo:warning=Telegram channel build failed. Run: ./channels-src/telegram/build.sh" + ); + return; + } + }; + + if !status.success() { + eprintln!( + "cargo:warning=Telegram channel build failed. Run: ./channels-src/telegram/build.sh" + ); + return; + } + + let raw_wasm = channel_dir.join("target/wasm32-wasip2/release/telegram_channel.wasm"); + if !raw_wasm.exists() { + eprintln!( + "cargo:warning=Telegram WASM output not found at {:?}", + raw_wasm + ); + return; + } + + // Convert to component and strip (wasm-tools) + let component_ok = Command::new("wasm-tools") + .args([ + "component", + "new", + raw_wasm.to_str().unwrap(), + "-o", + wasm_out.to_str().unwrap(), + ]) + .current_dir(&root) + .status() + .map(|s| s.success()) + .unwrap_or(false); + + if !component_ok + { + // Fallback: copy raw module if wasm-tools unavailable + if std::fs::copy(&raw_wasm, &wasm_out).is_err() { + eprintln!( + "cargo:warning=wasm-tools not found. Run: cargo install wasm-tools" + ); + } + } else { + // Strip debug info (use temp file to avoid clobbering) + let stripped = wasm_out.with_extension("wasm.stripped"); + let strip_ok = Command::new("wasm-tools") + .args(["strip", wasm_out.to_str().unwrap(), "-o", stripped.to_str().unwrap()]) + .current_dir(&root) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if strip_ok + { + let _ = std::fs::rename(&stripped, &wasm_out); + } + } +} diff --git a/channels-src/telegram/src/lib.rs b/channels-src/telegram/src/lib.rs index 5a1591bc..09a99df3 100644 --- a/channels-src/telegram/src/lib.rs +++ b/channels-src/telegram/src/lib.rs @@ -72,6 +72,10 @@ struct TelegramMessage { /// Message text. text: Option, + /// Caption for media (photo, video, document, etc.). + #[serde(default)] + caption: Option, + /// Original message if this is a reply. reply_to_message: Option>, @@ -160,6 +164,21 @@ const POLLING_STATE_PATH: &str = "state/last_update_id"; /// 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 = "telegram"; + +/// Workspace path for persisting bot_username for mention detection in groups. +const BOT_USERNAME_PATH: &str = "state/bot_username"; + +/// Workspace path for persisting respond_to_all_group_messages flag. +const RESPOND_TO_ALL_GROUP_PATH: &str = "state/respond_to_all_group_messages"; + // ============================================================================ // Channel Metadata // ============================================================================ @@ -196,6 +215,14 @@ struct TelegramConfig { #[serde(default)] owner_id: Option, + /// DM policy: "pairing" (default), "allowlist", or "open". + #[serde(default)] + dm_policy: Option, + + /// Allowed sender IDs/usernames from config (merged with pairing-approved store). + #[serde(default)] + allow_from: Option>, + /// Whether to respond to all group messages (not just mentions). #[serde(default)] respond_to_all_group_messages: bool, @@ -257,6 +284,28 @@ impl Guest for TelegramChannel { ); } + // Persist dm_policy and allow_from for DM pairing in handle_message + let dm_policy = config + .dm_policy + .as_deref() + .unwrap_or("pairing") + .to_string(); + 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 bot_username and respond_to_all_group_messages for group handling + let _ = channel_host::workspace_write( + BOT_USERNAME_PATH, + &config.bot_username.unwrap_or_default(), + ); + let _ = channel_host::workspace_write( + RESPOND_TO_ALL_GROUP_PATH, + &config.respond_to_all_group_messages.to_string(), + ); + // Mode is determined by whether the host injected a tunnel_url // If tunnel is configured, use webhooks. Otherwise, use polling. let webhook_mode = config.tunnel_url.is_some(); @@ -780,6 +829,47 @@ fn register_webhook(tunnel_url: &str, webhook_secret: Option<&str>) -> Result<() } } +// ============================================================================ +// Pairing Reply +// ============================================================================ + +/// Send a pairing code message to a chat. Used when an unknown user DMs the bot. +fn send_pairing_reply(chat_id: i64, code: &str) -> Result<(), String> { + let payload = serde_json::json!({ + "chat_id": chat_id, + "text": format!( + "To pair with this bot, run: `ironclaw pairing approve telegram {}`", + code + ), + "parse_mode": "Markdown", + }); + + let payload_bytes = serde_json::to_vec(&payload) + .map_err(|e| format!("Failed to serialize payload: {}", e))?; + + let headers = serde_json::json!({ + "Content-Type": "application/json" + }); + + let result = channel_host::http_request( + "POST", + "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage", + &headers.to_string(), + Some(&payload_bytes), + ); + + match result { + Ok(response) => { + if response.status != 200 { + let body_str = String::from_utf8_lossy(&response.body); + return Err(format!("HTTP {}: {}", response.status, body_str)); + } + Ok(()) + } + Err(e) => Err(format!("HTTP request failed: {}", e)), + } +} + // ============================================================================ // Update Handling // ============================================================================ @@ -799,11 +889,16 @@ fn handle_update(update: TelegramUpdate) { /// Process a single message. fn handle_message(message: TelegramMessage) { - // Skip messages without text - let text = match message.text { - Some(t) if !t.is_empty() => t, - _ => return, - }; + // Use text or caption (for media messages) + let content = message + .text + .filter(|t| !t.is_empty()) + .or_else(|| message.caption.filter(|c| !c.is_empty())) + .unwrap_or_default(); + + if content.is_empty() { + return; + } // Skip messages without a sender (channel posts) let from = match message.from { @@ -816,41 +911,111 @@ fn handle_message(message: TelegramMessage) { return; } - // Owner validation: silently drop messages from non-owner users - if let Some(owner_id_str) = channel_host::workspace_read(OWNER_ID_PATH) { - if !owner_id_str.is_empty() { - if let Ok(owner_id) = owner_id_str.parse::() { - if from.id != owner_id { - channel_host::log( - channel_host::LogLevel::Debug, - &format!( - "Dropping message from non-owner user {} (owner: {})", - from.id, owner_id - ), - ); - return; + let is_private = message.chat.chat_type == "private"; + + // Owner validation: when owner_id is set, only that user can message + let owner_configured = channel_host::workspace_read(OWNER_ID_PATH) + .map(|s| !s.is_empty()) + .unwrap_or(false); + + if owner_configured { + if let Ok(owner_id) = channel_host::workspace_read(OWNER_ID_PATH) + .unwrap() + .parse::() + { + if from.id != owner_id { + channel_host::log( + channel_host::LogLevel::Debug, + &format!( + "Dropping message from non-owner user {} (owner: {})", + from.id, owner_id + ), + ); + return; + } + } + } else if is_private { + // No owner_id: apply dm_policy for private chats + let dm_policy = channel_host::workspace_read(DM_POLICY_PATH) + .unwrap_or_else(|| "pairing".to_string()); + + if dm_policy != "open" { + // Build effective 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); + } + + let id_str = from.id.to_string(); + let username_opt = from.username.as_deref(); + let is_allowed = allowed.contains(&"*".to_string()) + || allowed.contains(&id_str) + || username_opt.map_or(false, |u| allowed.contains(&u.to_string())); + + if !is_allowed { + if dm_policy == "pairing" { + // Upsert pairing request and send reply + let meta = serde_json::json!({ + "chat_id": message.chat.id, + "user_id": from.id, + "username": username_opt, + }) + .to_string(); + + match channel_host::pairing_upsert_request(CHANNEL_NAME, &id_str, &meta) { + Ok(result) => { + channel_host::log( + channel_host::LogLevel::Info, + &format!( + "Pairing request for user {} (chat {}): code {}", + from.id, message.chat.id, result.code + ), + ); + if result.created { + let _ = send_pairing_reply(message.chat.id, &result.code); + } + } + Err(e) => { + channel_host::log( + channel_host::LogLevel::Error, + &format!("Pairing upsert failed: {}", e), + ); + } + } } + return; } } } - let is_private = message.chat.chat_type == "private"; - - // For group chats, check if the bot was mentioned - // TODO: Read bot_username from config and check mentions - // For now, process all messages in private chats and groups + // For group chats, only respond if bot was mentioned or respond_to_all is enabled if !is_private { - // In groups, only respond if there's a bot mention or command - // This is a simplified check - proper implementation would use entities - let has_command = text.starts_with('/'); - let has_mention = text.contains('@'); + let respond_to_all = channel_host::workspace_read(RESPOND_TO_ALL_GROUP_PATH) + .as_deref() + .unwrap_or("false") + == "true"; - if !has_command && !has_mention { - channel_host::log( - channel_host::LogLevel::Debug, - &format!("Ignoring group message without mention: {}", text), - ); - return; + if !respond_to_all { + let has_command = content.starts_with('/'); + let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH) + .unwrap_or_default(); + let has_bot_mention = if bot_username.is_empty() { + content.contains('@') + } else { + let mention = format!("@{}", bot_username); + content.to_lowercase().contains(&mention.to_lowercase()) + }; + + if !has_command && !has_bot_mention { + channel_host::log( + channel_host::LogLevel::Debug, + &format!("Ignoring group message without mention: {}", content), + ); + return; + } } } @@ -872,17 +1037,30 @@ fn handle_message(message: TelegramMessage) { let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string()); // Clean the message text (strip bot mentions and commands) - let cleaned_text = clean_message_text(&text); + let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH).unwrap_or_default(); + let cleaned_text = clean_message_text( + &content, + if bot_username.is_empty() { + None + } else { + Some(bot_username.as_str()) + }, + ); - if cleaned_text.is_empty() { + // For /start with no args, emit placeholder so agent can respond with welcome + let content_to_emit = if cleaned_text.is_empty() && content.trim().starts_with('/') { + "[User started the bot]".to_string() + } else if cleaned_text.is_empty() { return; - } + } else { + cleaned_text + }; // Emit the message to the agent channel_host::emit_message(&EmittedMessage { user_id: from.id.to_string(), user_name: Some(user_name), - content: cleaned_text, + content: content_to_emit, thread_id: None, // Telegram doesn't have threads in the same way metadata_json, }); @@ -897,7 +1075,8 @@ fn handle_message(message: TelegramMessage) { } /// Clean message text by removing bot commands and @mentions at the start. -fn clean_message_text(text: &str) -> String { +/// When bot_username is set, only strips that specific mention; otherwise strips any leading @mention. +fn clean_message_text(text: &str, bot_username: Option<&str>) -> String { let mut result = text.trim().to_string(); // Remove leading /command @@ -912,11 +1091,30 @@ fn clean_message_text(text: &str) -> String { // Remove leading @mention if result.starts_with('@') { - if let Some(space_idx) = result.find(' ') { - result = result[space_idx..].trim_start().to_string(); + if let Some(bot) = bot_username { + let mention = format!("@{}", bot); + let mention_lower = mention.to_lowercase(); + let result_lower = result.to_lowercase(); + if result_lower.starts_with(&mention_lower) { + let rest = result[mention.len()..].trim_start(); + if rest.is_empty() { + return String::new(); + } + result = rest.to_string(); + } else if let Some(space_idx) = result.find(' ') { + // Different leading @mention - only strip if it's the bot + let first_word = &result[..space_idx]; + if first_word.eq_ignore_ascii_case(&mention) { + result = result[space_idx..].trim_start().to_string(); + } + } } else { - // Just a mention with no text - return String::new(); + // No bot_username: strip any leading @mention + if let Some(space_idx) = result.find(' ') { + result = result[space_idx..].trim_start().to_string(); + } else { + return String::new(); + } } } @@ -952,12 +1150,22 @@ mod tests { #[test] fn test_clean_message_text() { - assert_eq!(clean_message_text("/start hello"), "hello"); - assert_eq!(clean_message_text("@bot hello world"), "hello world"); - assert_eq!(clean_message_text("/start"), ""); - assert_eq!(clean_message_text("@botname"), ""); - assert_eq!(clean_message_text("just text"), "just text"); - assert_eq!(clean_message_text(" spaced "), "spaced"); + // Without bot_username: strips any leading @mention + assert_eq!(clean_message_text("/start hello", None), "hello"); + assert_eq!(clean_message_text("@bot hello world", None), "hello world"); + assert_eq!(clean_message_text("/start", None), ""); + assert_eq!(clean_message_text("@botname", None), ""); + assert_eq!(clean_message_text("just text", None), "just text"); + assert_eq!(clean_message_text(" spaced ", None), "spaced"); + + // With bot_username: only strips @MyBot, not @alice + assert_eq!(clean_message_text("@MyBot hello", Some("MyBot")), "hello"); + assert_eq!(clean_message_text("@mybot hi", Some("MyBot")), "hi"); + assert_eq!( + clean_message_text("@alice hello", Some("MyBot")), + "@alice hello" + ); + assert_eq!(clean_message_text("@MyBot", Some("MyBot")), ""); } #[test] @@ -1025,4 +1233,17 @@ mod tests { assert_eq!(from.id, 789); assert_eq!(from.first_name, "John"); } + + #[test] + fn test_parse_message_with_caption() { + let json = r#"{ + "message_id": 1, + "from": {"id": 1, "is_bot": false, "first_name": "A"}, + "chat": {"id": 1, "type": "private"}, + "caption": "What's in this image?" + }"#; + let msg: TelegramMessage = serde_json::from_str(json).unwrap(); + assert_eq!(msg.text, None); + assert_eq!(msg.caption.as_deref(), Some("What's in this image?")); + } } diff --git a/channels-src/telegram/telegram.capabilities.json b/channels-src/telegram/telegram.capabilities.json index f9335ad0..41735b52 100644 --- a/channels-src/telegram/telegram.capabilities.json +++ b/channels-src/telegram/telegram.capabilities.json @@ -1,44 +1 @@ -{ - "type": "channel", - "name": "telegram", - "description": "Telegram Bot API channel for receiving and responding to Telegram messages", - "capabilities": { - "http": { - "allowlist": [ - { "host": "api.telegram.org", "path_prefix": "/bot" } - ], - "credentials": { - "telegram_bot": { - "secret_name": "telegram_bot_token", - "location": { "type": "url_path", "placeholder": "{TELEGRAM_BOT_TOKEN}" }, - "host_patterns": ["api.telegram.org"] - } - }, - "rate_limit": { - "requests_per_minute": 30, - "requests_per_hour": 1000 - } - }, - "secrets": { - "allowed_names": ["telegram_*"] - }, - "channel": { - "allowed_paths": ["/webhook/telegram"], - "allow_polling": true, - "min_poll_interval_ms": 30000, - "callback_timeout_secs": 45, - "workspace_prefix": "channels/telegram/", - "emit_rate_limit": { - "messages_per_minute": 100, - "messages_per_hour": 5000 - } - } - }, - "config": { - "bot_username": null, - "owner_id": null, - "respond_to_all_group_messages": false, - "polling_enabled": false, - "poll_interval_ms": 30000 - } -} +{"type":"channel","name":"telegram","description":"Telegram Bot API channel for receiving and responding to Telegram messages","capabilities":{"http":{"allowlist":[{"host":"api.telegram.org","path_prefix":"/bot"}],"credentials":{"telegram_bot":{"secret_name":"telegram_bot_token","location":{"type":"url_path","placeholder":"{TELEGRAM_BOT_TOKEN}"},"host_patterns":["api.telegram.org"]}},"rate_limit":{"requests_per_minute":30,"requests_per_hour":1000}},"secrets":{"allowed_names":["telegram_*"]},"channel":{"allowed_paths":["/webhook/telegram"],"allow_polling":true,"min_poll_interval_ms":30000,"workspace_prefix":"channels/telegram/","emit_rate_limit":{"messages_per_minute":100,"messages_per_hour":5000}}},"config":{"bot_username":null,"owner_id":null,"respond_to_all_group_messages":false,"polling_enabled":false,"poll_interval_ms":30000,"dm_policy":"pairing","allow_from":[]}} diff --git a/docs/BUILDING_CHANNELS.md b/docs/BUILDING_CHANNELS.md index a819bc01..4fad5756 100644 --- a/docs/BUILDING_CHANNELS.md +++ b/docs/BUILDING_CHANNELS.md @@ -246,13 +246,46 @@ Create `my-channel.capabilities.json`: ## Building and Deploying +### Supply Chain Security: No Committed Binaries + +**Do not commit compiled WASM binaries.** They are a supply chain risk — the binary in a PR may not match the source. IronClaw builds channels from source: + +- `cargo build` automatically builds `telegram.wasm` via `build.rs` +- The built binary is in `.gitignore` and is not committed +- CI should run `cargo build` (or `./scripts/build-all.sh`) to produce releases + +**Reproducible build:** +```bash +cargo build --release +``` + +Prerequisites: `rustup target add wasm32-wasip2`, `cargo install wasm-tools` (optional; fallback copies raw WASM if unavailable). + +### Telegram Channel (Manual Build) + +```bash +# Add WASM target if needed +rustup target add wasm32-wasip2 + +# Build Telegram channel +./channels-src/telegram/build.sh + +# Install (or use ironclaw onboard to install bundled channel) +mkdir -p ~/.ironclaw/channels +cp channels-src/telegram/telegram.wasm channels-src/telegram/telegram.capabilities.json ~/.ironclaw/channels/ +``` + +**Note**: The main IronClaw binary bundles `telegram.wasm` via `include_bytes!`. When modifying the Telegram channel source, run `./channels-src/telegram/build.sh` **before** building the main crate, so the updated WASM is included. + +### Other Channels + ```bash # Build the WASM component -cd channels/my-channel -cargo component build --release +cd channels-src/my-channel +cargo build --release --target wasm32-wasip2 # Deploy to ~/.ironclaw/channels/ -cp target/wasm32-wasip1/release/my_channel.wasm ~/.ironclaw/channels/my-channel.wasm +cp target/wasm32-wasip2/release/my_channel.wasm ~/.ironclaw/channels/my-channel.wasm cp my-channel.capabilities.json ~/.ironclaw/channels/ ``` diff --git a/docs/TELEGRAM_SETUP.md b/docs/TELEGRAM_SETUP.md new file mode 100644 index 00000000..f9ec24eb --- /dev/null +++ b/docs/TELEGRAM_SETUP.md @@ -0,0 +1,135 @@ +# Telegram Channel Setup + +This guide covers configuring the Telegram channel for IronClaw, including DM pairing for access control. + +## Overview + +The Telegram channel lets you interact with IronClaw via Telegram DMs and groups. It supports: + +- **Webhook mode** (recommended): Instant delivery via tunnel +- **Polling mode**: No tunnel required; ~30s delay +- **DM pairing**: Approve unknown users before they can message the agent +- **Group mentions**: `@YourBot` or `/command` to trigger in groups + +## Prerequisites + +- IronClaw installed and configured (`ironclaw onboard`) +- A Telegram bot token from [@BotFather](https://t.me/BotFather) + +## Quick Start + +### 1. Create a Bot + +1. Message [@BotFather](https://t.me/BotFather) on Telegram +2. Send `/newbot` and follow the prompts +3. Copy the bot token (e.g., `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`) + +### 2. Configure via Setup Wizard + +```bash +ironclaw onboard +``` + +When prompted, enable the Telegram channel and paste your bot token. The wizard will: + +- Validate the token +- Optionally configure a webhook secret +- Set up tunnel (if you want webhook mode) + +### 3. (Optional) Configure Tunnel for Webhooks + +For instant message delivery, expose your agent via a tunnel: + +```bash +# ngrok +ngrok http 8080 + +# Cloudflare +cloudflared tunnel --url http://localhost:8080 +``` + +Set the tunnel URL in settings or via `TUNNEL_URL` env var. Without a tunnel, the channel uses polling (~30s delay). + +## DM Pairing + +When an unknown user DMs your bot, they receive a pairing code. You must approve them before they can message the agent. + +### Flow + +1. Unknown user sends a message to your bot +2. Bot replies: `To pair with this bot, run: ironclaw pairing approve telegram ABC12345` +3. You run: `ironclaw pairing approve telegram ABC12345` +4. User is added to the allow list; future messages are delivered + +### Commands + +```bash +# List pending pairing requests +ironclaw pairing list telegram + +# List as JSON +ironclaw pairing list telegram --json + +# Approve a user by code +ironclaw pairing approve telegram ABC12345 +``` + +### Configuration + +Edit `~/.ironclaw/channels/telegram.capabilities.json` (or the config injected by the host): + +| Option | Values | Default | Description | +|--------|--------|---------|-------------| +| `dm_policy` | `open`, `allowlist`, `pairing` | `pairing` | `open` = allow all; `allowlist` = config + approved only; `pairing` = allowlist + send pairing reply to unknown | +| `allow_from` | `["user_id", "username", "*"]` | `[]` | Pre-approved IDs/usernames. `*` allows everyone. | +| `owner_id` | Telegram user ID | `null` | When set, only this user can message (overrides dm_policy) | +| `bot_username` | Bot username (no @) | `null` | Used for mention detection in groups; when set, only strips this mention from messages | +| `respond_to_all_group_messages` | `true`/`false` | `false` | When true, respond to all group messages; when false, only @mentions and /commands | + +## Manual Installation + +If the channel isn't installed via the wizard: + +```bash +# Build the Telegram channel (requires wasm32-wasip2 target) +rustup target add wasm32-wasip2 +./channels-src/telegram/build.sh + +# Install +mkdir -p ~/.ironclaw/channels +cp channels-src/telegram/telegram.wasm channels-src/telegram/telegram.capabilities.json ~/.ironclaw/channels/ +``` + +## Secrets + +The channel expects a secret named `telegram_bot_token`. Configure via: + +- **Setup wizard**: Saves to encrypted secrets store +- **Environment**: `TELEGRAM_BOT_TOKEN=your_token` +- **Secrets store**: `ironclaw` CLI (if available) + +## Webhook Secret (Optional) + +For webhook validation, set `telegram_webhook_secret` in secrets. Telegram will send `X-Telegram-Bot-Api-Secret-Token` with each request; the host validates it before forwarding. + +## Troubleshooting + +### Messages not delivered + +- **Polling mode**: Check logs for `getUpdates` errors. Ensure the bot token is valid. +- **Webhook mode**: Verify tunnel is running and `TUNNEL_URL` is correct. Telegram requires HTTPS. + +### Pairing code not received + +- Verify the channel can send messages (HTTP allowlist includes `api.telegram.org`) +- Check `dm_policy` is `pairing` (not `allowlist` which blocks without reply) + +### Group mentions not working + +- Set `bot_username` in config to your bot's username (e.g., `MyIronClawBot`) +- Ensure the message contains `@YourBot` or starts with `/` + +### "Connection refused" when starting + +- For webhook mode: Start your tunnel before `ironclaw run` +- For polling only: No tunnel needed; ignore tunnel-related warnings diff --git a/scripts/build-all.sh b/scripts/build-all.sh new file mode 100755 index 00000000..713940a1 --- /dev/null +++ b/scripts/build-all.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Build IronClaw and all bundled channels. +# +# Run this before release or when channel sources have changed. +# The main binary bundles telegram.wasm via include_bytes!; it must exist. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "Building bundled channels..." +if [ -d "channels-src/telegram" ]; then + ./channels-src/telegram/build.sh +fi + +echo "" +echo "Building IronClaw..." +cargo build --release + +echo "" +echo "Done. Binary: target/release/ironclaw" diff --git a/src/channels/wasm/loader.rs b/src/channels/wasm/loader.rs index 00dfcd80..3f7fdb63 100644 --- a/src/channels/wasm/loader.rs +++ b/src/channels/wasm/loader.rs @@ -16,16 +16,21 @@ use crate::channels::wasm::error::WasmChannelError; use crate::channels::wasm::runtime::WasmChannelRuntime; use crate::channels::wasm::schema::ChannelCapabilitiesFile; use crate::channels::wasm::wrapper::WasmChannel; +use crate::pairing::PairingStore; /// Loads WASM channels from the filesystem. pub struct WasmChannelLoader { runtime: Arc, + pairing_store: Arc, } impl WasmChannelLoader { - /// Create a new loader with the given runtime. - pub fn new(runtime: Arc) -> Self { - Self { runtime } + /// Create a new loader with the given runtime and pairing store. + pub fn new(runtime: Arc, pairing_store: Arc) -> Self { + Self { + runtime, + pairing_store, + } } /// Load a single WASM channel from a file pair. @@ -114,7 +119,13 @@ impl WasmChannelLoader { .await?; // Create the channel - let channel = WasmChannel::new(self.runtime.clone(), prepared, capabilities, config_json); + let channel = WasmChannel::new( + self.runtime.clone(), + prepared, + capabilities, + config_json, + self.pairing_store.clone(), + ); tracing::info!( name = name, @@ -352,6 +363,7 @@ mod tests { use crate::channels::wasm::loader::{WasmChannelLoader, discover_channels}; use crate::channels::wasm::runtime::{WasmChannelRuntime, WasmChannelRuntimeConfig}; + use crate::pairing::PairingStore; use std::sync::Arc; #[tokio::test] @@ -408,7 +420,7 @@ mod tests { async fn test_loader_invalid_name() { let config = WasmChannelRuntimeConfig::for_testing(); let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap()); - let loader = WasmChannelLoader::new(runtime); + let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new())); let dir = TempDir::new().unwrap(); let wasm_path = dir.path().join("test.wasm"); diff --git a/src/channels/wasm/router.rs b/src/channels/wasm/router.rs index 0bd3182f..5ede9a13 100644 --- a/src/channels/wasm/router.rs +++ b/src/channels/wasm/router.rs @@ -469,7 +469,7 @@ pub fn create_wasm_channel_router( } #[cfg(test)] -mod tests { + mod tests { use std::sync::Arc; use crate::channels::wasm::capabilities::ChannelCapabilities; @@ -478,6 +478,7 @@ mod tests { PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig, }; use crate::channels::wasm::wrapper::WasmChannel; + use crate::pairing::PairingStore; use crate::tools::wasm::ResourceLimits; fn create_test_channel(name: &str) -> Arc { @@ -499,6 +500,7 @@ mod tests { prepared, capabilities, "{}".to_string(), + Arc::new(PairingStore::new()), )) } diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index 127fa372..bc7f0ac8 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -43,6 +43,7 @@ use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiView}; use crate::channels::wasm::capabilities::ChannelCapabilities; use crate::channels::wasm::error::WasmChannelError; use crate::channels::wasm::host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage}; +use crate::pairing::PairingStore; use crate::channels::wasm::router::RegisteredEndpoint; use crate::channels::wasm::runtime::{PreparedChannelModule, WasmChannelRuntime}; use crate::channels::wasm::schema::ChannelConfig; @@ -73,6 +74,8 @@ struct ChannelStoreData { /// Injected credentials for URL substitution (e.g., bot tokens). /// Keys are placeholder names like "TELEGRAM_BOT_TOKEN". credentials: HashMap, + /// Pairing store for DM pairing (guest access control). + pairing_store: Arc, } impl ChannelStoreData { @@ -81,6 +84,7 @@ impl ChannelStoreData { channel_name: &str, capabilities: ChannelCapabilities, credentials: HashMap, + pairing_store: Arc, ) -> Self { // Create a minimal WASI context (no filesystem, no env vars for security) let wasi = WasiCtxBuilder::new().build(); @@ -91,6 +95,7 @@ impl ChannelStoreData { wasi, table: ResourceTable::new(), credentials, + pairing_store, } } @@ -403,6 +408,43 @@ impl near::agent::channel_host::Host for ChannelStoreData { } } } + + fn pairing_upsert_request( + &mut self, + channel: String, + id: String, + meta_json: String, + ) -> Result { + let meta = if meta_json.is_empty() { + None + } else { + serde_json::from_str(&meta_json).ok() + }; + match self.pairing_store.upsert_request(&channel, &id, meta) { + Ok(r) => Ok(near::agent::channel_host::PairingUpsertResult { + code: r.code, + created: r.created, + }), + Err(e) => Err(e.to_string()), + } + } + + fn pairing_is_allowed( + &mut self, + channel: String, + id: String, + username: Option, + ) -> Result { + self.pairing_store + .is_sender_allowed(&channel, &id, username.as_deref()) + .map_err(|e| e.to_string()) + } + + fn pairing_read_allow_from(&mut self, channel: String) -> Result, String> { + self.pairing_store + .read_allow_from(&channel) + .map_err(|e| e.to_string()) + } } /// A WASM-based channel implementing the Channel trait. @@ -455,6 +497,9 @@ pub struct WasmChannel { /// Background task that repeats typing indicators every 4 seconds. /// Telegram's "typing..." indicator expires after ~5s, so we refresh it. typing_task: RwLock>>, + + /// Pairing store for DM pairing (guest access control). + pairing_store: Arc, } impl WasmChannel { @@ -464,6 +509,7 @@ impl WasmChannel { prepared: Arc, capabilities: ChannelCapabilities, config_json: String, + pairing_store: Arc, ) -> Self { let name = prepared.name.clone(); let rate_limiter = ChannelEmitRateLimiter::new(capabilities.emit_rate_limit.clone()); @@ -483,6 +529,7 @@ impl WasmChannel { endpoints: RwLock::new(Vec::new()), credentials: Arc::new(RwLock::new(HashMap::new())), typing_task: RwLock::new(None), + pairing_store, } } @@ -564,6 +611,7 @@ impl WasmChannel { prepared: &PreparedChannelModule, capabilities: &ChannelCapabilities, credentials: HashMap, + pairing_store: Arc, ) -> Result, WasmChannelError> { let engine = runtime.engine(); let limits = &prepared.limits; @@ -574,6 +622,7 @@ impl WasmChannel { &prepared.name, capabilities.clone(), credentials, + pairing_store, ); let mut store = Store::new(engine, store_data); @@ -674,12 +723,18 @@ impl WasmChannel { let timeout = self.runtime.config().callback_timeout; let channel_name = self.name.clone(); let credentials = self.get_credentials().await; + let pairing_store = self.pairing_store.clone(); // Execute in blocking task with timeout let result = tokio::time::timeout(timeout, async move { tokio::task::spawn_blocking(move || { - let mut store = - Self::create_store(&runtime, &prepared, &capabilities, credentials)?; + let mut store = Self::create_store( + &runtime, + &prepared, + &capabilities, + credentials, + pairing_store, + )?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; // Call on_start using the generated typed interface @@ -784,6 +839,7 @@ impl WasmChannel { let capabilities = self.capabilities.clone(); let timeout = self.runtime.config().callback_timeout; let credentials = self.get_credentials().await; + let pairing_store = self.pairing_store.clone(); // Prepare request data let method = method.to_string(); @@ -797,8 +853,13 @@ impl WasmChannel { // Execute in blocking task with timeout let result = tokio::time::timeout(timeout, async move { tokio::task::spawn_blocking(move || { - let mut store = - Self::create_store(&runtime, &prepared, &capabilities, credentials)?; + let mut store = Self::create_store( + &runtime, + &prepared, + &capabilities, + credentials, + pairing_store, + )?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; // Build the WIT request type @@ -871,12 +932,18 @@ impl WasmChannel { let timeout = self.runtime.config().callback_timeout; let channel_name = self.name.clone(); let credentials = self.get_credentials().await; + let pairing_store = self.pairing_store.clone(); // Execute in blocking task with timeout let result = tokio::time::timeout(timeout, async move { tokio::task::spawn_blocking(move || { - let mut store = - Self::create_store(&runtime, &prepared, &capabilities, credentials)?; + let mut store = Self::create_store( + &runtime, + &prepared, + &capabilities, + credentials, + pairing_store, + )?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; // Call on_poll using the generated typed interface @@ -960,6 +1027,7 @@ impl WasmChannel { let timeout = self.runtime.config().callback_timeout; let channel_name = self.name.clone(); let credentials = self.get_credentials().await; + let pairing_store = self.pairing_store.clone(); // Prepare response data let message_id_str = message_id.to_string(); @@ -973,8 +1041,13 @@ impl WasmChannel { let result = tokio::time::timeout(timeout, async move { tokio::task::spawn_blocking(move || { tracing::info!("Creating WASM store for on_respond"); - let mut store = - Self::create_store(&runtime, &prepared, &capabilities, credentials)?; + let mut store = Self::create_store( + &runtime, + &prepared, + &capabilities, + credentials, + pairing_store, + )?; tracing::info!("Instantiating WASM component for on_respond"); let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; @@ -1067,13 +1140,19 @@ impl WasmChannel { let timeout = self.runtime.config().callback_timeout; let channel_name = self.name.clone(); let credentials = self.get_credentials().await; + let pairing_store = self.pairing_store.clone(); let wit_update = status_to_wit(status, metadata); let result = tokio::time::timeout(timeout, async move { tokio::task::spawn_blocking(move || { - let mut store = - Self::create_store(&runtime, &prepared, &capabilities, credentials)?; + let mut store = Self::create_store( + &runtime, + &prepared, + &capabilities, + credentials, + pairing_store, + )?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; let channel_iface = instance.near_agent_channel(); @@ -1117,6 +1196,7 @@ impl WasmChannel { prepared: &Arc, capabilities: &ChannelCapabilities, credentials: &RwLock>, + pairing_store: Arc, timeout: Duration, wit_update: wit_channel::StatusUpdate, ) -> Result<(), WasmChannelError> { @@ -1132,8 +1212,13 @@ impl WasmChannel { let result = tokio::time::timeout(timeout, async move { tokio::task::spawn_blocking(move || { - let mut store = - Self::create_store(&runtime, &prepared, &capabilities, credentials_snapshot)?; + let mut store = Self::create_store( + &runtime, + &prepared, + &capabilities, + credentials_snapshot, + pairing_store, + )?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; let channel_iface = instance.near_agent_channel(); @@ -1201,6 +1286,7 @@ impl WasmChannel { let prepared = Arc::clone(&self.prepared); let capabilities = self.capabilities.clone(); let credentials = self.credentials.clone(); + let pairing_store = self.pairing_store.clone(); let callback_timeout = self.runtime.config().callback_timeout; let wit_update = status_to_wit(&status, metadata); @@ -1220,6 +1306,7 @@ impl WasmChannel { &prepared, &capabilities, &credentials, + pairing_store.clone(), callback_timeout, wit_update_clone, ) @@ -1350,6 +1437,7 @@ impl WasmChannel { 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; tokio::spawn(async move { @@ -1371,6 +1459,7 @@ impl WasmChannel { &prepared, &capabilities, &credentials, + pairing_store.clone(), callback_timeout, ).await; @@ -1422,6 +1511,7 @@ impl WasmChannel { prepared: &Arc, capabilities: &ChannelCapabilities, credentials: &RwLock>, + pairing_store: Arc, timeout: Duration, ) -> Result, WasmChannelError> { // Skip if no WASM bytes (testing mode) @@ -1442,8 +1532,13 @@ impl WasmChannel { // Execute in blocking task with timeout let result = tokio::time::timeout(timeout, async move { tokio::task::spawn_blocking(move || { - let mut store = - Self::create_store(&runtime, &prepared, &capabilities, credentials_snapshot)?; + let mut store = Self::create_store( + &runtime, + &prepared, + &capabilities, + credentials_snapshot, + pairing_store, + )?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; // Call on_poll using the generated typed interface @@ -1978,6 +2073,7 @@ mod tests { use std::sync::Arc; use crate::channels::Channel; + use crate::pairing::PairingStore; use crate::channels::wasm::capabilities::ChannelCapabilities; use crate::channels::wasm::runtime::{ PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig, @@ -1998,7 +2094,13 @@ mod tests { let capabilities = ChannelCapabilities::for_channel("test").with_path("/webhook/test"); - WasmChannel::new(runtime, prepared, capabilities, "{}".to_string()) + WasmChannel::new( + runtime, + prepared, + capabilities, + "{}".to_string(), + Arc::new(PairingStore::new()), + ) } #[test] @@ -2073,6 +2175,7 @@ mod tests { &prepared, &capabilities, &credentials, + Arc::new(PairingStore::new()), timeout, ) .await; @@ -2166,7 +2269,13 @@ mod tests { .with_path("/webhook/poll") .with_polling(1000); - let channel = WasmChannel::new(runtime, prepared, capabilities, "{}".to_string()); + let channel = WasmChannel::new( + runtime, + prepared, + capabilities, + "{}".to_string(), + Arc::new(PairingStore::new()), + ); // Start the channel let _stream = channel.start().await.expect("Channel should start"); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index a8a0de3c..5823ab68 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -12,12 +12,14 @@ mod config; mod mcp; pub mod memory; +mod pairing; pub mod status; mod tool; pub use config::{ConfigCommand, run_config_command}; pub use mcp::{McpCommand, run_mcp_command}; pub use memory::{MemoryCommand, run_memory_command}; +pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store}; pub use status::run_status_command; pub use tool::{ToolCommand, run_tool_command}; @@ -86,6 +88,10 @@ pub enum Command { #[command(subcommand)] Memory(MemoryCommand), + /// DM pairing (approve inbound requests from unknown senders) + #[command(subcommand)] + Pairing(PairingCommand), + /// Show system health and diagnostics Status, diff --git a/src/cli/pairing.rs b/src/cli/pairing.rs new file mode 100644 index 00000000..ef00ec52 --- /dev/null +++ b/src/cli/pairing.rs @@ -0,0 +1,183 @@ +//! DM pairing CLI commands. +//! +//! Manage pairing requests for channels (Telegram, Slack, etc.). + +use clap::Subcommand; + +use crate::pairing::PairingStore; + +/// Pairing subcommands. +#[derive(Subcommand, Debug, Clone)] +pub enum PairingCommand { + /// List pending pairing requests + List { + /// Channel name (e.g., telegram, slack) + #[arg(required = true)] + channel: String, + + /// Output as JSON + #[arg(long)] + json: bool, + }, + + /// Approve a pairing request by code + Approve { + /// Channel name (e.g., telegram, slack) + #[arg(required = true)] + channel: String, + + /// Pairing code (e.g., ABC12345) + #[arg(required = true)] + code: String, + }, +} + +/// Run pairing CLI command. +pub fn run_pairing_command(cmd: PairingCommand) -> Result<(), String> { + run_pairing_command_with_store(&PairingStore::new(), cmd) +} + +/// Run pairing CLI command with a given store (for testing). +pub fn run_pairing_command_with_store( + store: &PairingStore, + cmd: PairingCommand, +) -> Result<(), String> { + match cmd { + PairingCommand::List { channel, json } => run_list(store, &channel, json), + PairingCommand::Approve { channel, code } => run_approve(store, &channel, &code), + } +} + +fn run_list(store: &PairingStore, channel: &str, json: bool) -> Result<(), String> { + let requests = store.list_pending(channel).map_err(|e| e.to_string())?; + + if json { + println!("{}", serde_json::to_string_pretty(&requests).map_err(|e| e.to_string())?); + return Ok(()); + } + + if requests.is_empty() { + println!("No pending {} pairing requests.", channel); + return Ok(()); + } + + println!("Pairing requests ({}):", requests.len()); + for r in &requests { + let meta = r + .meta + .as_ref() + .and_then(|m| m.as_object()) + .map(|o| { + o.iter() + .filter_map(|(k, v)| { + v.as_str().map(|s| format!("{}={}", k, s)) + }) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + println!(" {} {} {} {}", r.code, r.id, meta, r.created_at); + } + + Ok(()) +} + +fn run_approve(store: &PairingStore, channel: &str, code: &str) -> Result<(), String> { + match store.approve(channel, code) { + Ok(Some(entry)) => { + println!("Approved {} sender {}.", channel, entry.id); + Ok(()) + } + Ok(None) => Err(format!("No pending pairing request found for code: {}", code)), + Err(crate::pairing::PairingStoreError::ApproveRateLimited) => Err( + "Too many failed approve attempts. Wait a few minutes before trying again.".to_string(), + ), + Err(e) => Err(e.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn test_store() -> (PairingStore, TempDir) { + let dir = TempDir::new().unwrap(); + let store = PairingStore::with_base_dir(dir.path().to_path_buf()); + (store, dir) + } + + #[test] + fn test_list_empty_returns_ok() { + let (store, _) = test_store(); + let result = run_pairing_command_with_store( + &store, + PairingCommand::List { + channel: "telegram".to_string(), + json: false, + }, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_list_json_empty_returns_ok() { + let (store, _) = test_store(); + let result = run_pairing_command_with_store( + &store, + PairingCommand::List { + channel: "telegram".to_string(), + json: true, + }, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_approve_invalid_code_returns_err() { + let (store, _) = test_store(); + // Create a pending request so the pairing file exists, then approve with wrong code + store.upsert_request("telegram", "user1", None).unwrap(); + + let result = run_pairing_command_with_store( + &store, + PairingCommand::Approve { + channel: "telegram".to_string(), + code: "BADCODE1".to_string(), + }, + ); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("No pending pairing request")); + } + + #[test] + fn test_approve_valid_code_returns_ok() { + let (store, _) = test_store(); + let r = store.upsert_request("telegram", "user1", None).unwrap(); + assert!(r.created); + + let result = run_pairing_command_with_store( + &store, + PairingCommand::Approve { + channel: "telegram".to_string(), + code: r.code, + }, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_list_with_pending_returns_ok() { + let (store, _) = test_store(); + store.upsert_request("telegram", "user1", None).unwrap(); + + let result = run_pairing_command_with_store( + &store, + PairingCommand::List { + channel: "telegram".to_string(), + json: false, + }, + ); + assert!(result.is_ok()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 6f3c5911..af5f0a1a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,6 +43,7 @@ pub mod bootstrap; pub mod channels; pub mod cli; pub mod config; +pub mod pairing; pub mod context; pub mod error; pub mod estimation; diff --git a/src/main.rs b/src/main.rs index 8dcd69ed..d2ab5740 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitEx use ironclaw::{ agent::{Agent, AgentDeps, SessionManager}, + pairing::PairingStore, channels::{ ChannelManager, GatewayChannel, HttpChannel, ReplChannel, WebhookServer, WebhookServerConfig, @@ -17,7 +18,8 @@ use ironclaw::{ web::log_layer::{LogBroadcaster, WebLogLayer}, }, cli::{ - Cli, Command, run_mcp_command, run_memory_command, run_status_command, run_tool_command, + Cli, Command, run_mcp_command, run_memory_command, run_pairing_command, run_status_command, + run_tool_command, }, config::Config, context::ContextManager, @@ -128,6 +130,15 @@ async fn main() -> anyhow::Result<()> { return run_memory_command(mem_cmd.clone(), store.pool(), embeddings).await; } + Some(Command::Pairing(pairing_cmd)) => { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")), + ) + .init(); + + return run_pairing_command(pairing_cmd.clone()).map_err(|e| anyhow::anyhow!("{}", e)); + } Some(Command::Status) => { let _ = dotenvy::dotenv(); tracing_subscriber::fmt() @@ -725,7 +736,8 @@ async fn main() -> anyhow::Result<()> { match WasmChannelRuntime::new(WasmChannelRuntimeConfig::default()) { Ok(runtime) => { let runtime = Arc::new(runtime); - let loader = WasmChannelLoader::new(Arc::clone(&runtime)); + let pairing_store = Arc::new(PairingStore::new()); + let loader = WasmChannelLoader::new(Arc::clone(&runtime), pairing_store); match loader .load_from_dir(&config.channels.wasm_channels_dir) diff --git a/src/pairing/mod.rs b/src/pairing/mod.rs new file mode 100644 index 00000000..6468524f --- /dev/null +++ b/src/pairing/mod.rs @@ -0,0 +1,10 @@ +//! DM pairing for channels. +//! +//! Gates DMs from unknown senders. Only approved senders can message the agent. +//! Unknown senders receive a pairing code and must be approved via `ironclaw pairing approve`. +//! +//! OpenClaw reference: src/pairing/pairing-store.ts + +mod store; + +pub use store::{PairingRequest, PairingStore, PairingStoreError}; diff --git a/src/pairing/store.rs b/src/pairing/store.rs new file mode 100644 index 00000000..941509d5 --- /dev/null +++ b/src/pairing/store.rs @@ -0,0 +1,669 @@ +//! Pairing store: pending requests and allowFrom list. +//! +//! Stored in ~/.ironclaw/{channel}-pairing.json and {channel}-allowFrom.json. + +use std::collections::HashSet; +use std::fs; +use std::io::{Seek, SeekFrom, Write}; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use fs4::FileExt; +use rand::Rng; +use serde::{Deserialize, Serialize}; + +const PAIRING_CODE_LENGTH: usize = 8; +const PAIRING_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; +/// TTL for pending pairing requests (minutes, not hours — reduces brute-force window). +const PAIRING_PENDING_TTL_SECS: u64 = 15 * 60; +const PAIRING_PENDING_MAX: usize = 3; +/// Max failed approve attempts per channel before rate limit kicks in. +const PAIRING_APPROVE_RATE_LIMIT: usize = 10; +/// Time window for rate limit (seconds). +const PAIRING_APPROVE_RATE_WINDOW_SECS: u64 = 5 * 60; + +/// Error from pairing store operations. +#[derive(Debug, thiserror::Error)] +pub enum PairingStoreError { + #[error("Invalid channel: {0}")] + InvalidChannel(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Rate limit: too many failed approve attempts; try again later")] + ApproveRateLimited, +} + +/// Result of upserting a pairing request. +#[derive(Debug)] +pub struct UpsertResult { + pub code: String, + pub created: bool, +} + +/// A pending pairing request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PairingRequest { + pub id: String, + pub code: String, + pub created_at: String, + pub last_seen_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PairingStoreFile { + version: u8, + requests: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct AllowFromStoreFile { + version: u8, + #[serde(rename = "allowFrom")] + allow_from: Vec, +} + +fn default_pairing_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".ironclaw") +} + +fn safe_channel_key(channel: &str) -> Result { + let raw = channel.trim().to_lowercase(); + if raw.is_empty() { + return Err(PairingStoreError::InvalidChannel("empty".to_string())); + } + let safe = raw + .chars() + .map(|c| match c { + '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + _ => c, + }) + .collect::() + .replace("..", "_"); + if safe.is_empty() || safe == "_" { + return Err(PairingStoreError::InvalidChannel(channel.to_string())); + } + Ok(safe) +} + +fn pairing_path(base_dir: &PathBuf, channel: &str) -> Result { + let key = safe_channel_key(channel)?; + Ok(base_dir.join(format!("{}-pairing.json", key))) +} + +fn allow_from_path(base_dir: &PathBuf, channel: &str) -> Result { + let key = safe_channel_key(channel)?; + Ok(base_dir.join(format!("{}-allowFrom.json", key))) +} + +fn approve_attempts_path(base_dir: &PathBuf, channel: &str) -> Result { + let key = safe_channel_key(channel)?; + Ok(base_dir.join(format!("{}-approve-attempts.json", key))) +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct ApproveAttemptsFile { + failed_at: Vec, +} + +fn now_iso() -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + #[allow(clippy::cast_possible_wrap)] + chrono::DateTime::from_timestamp(now.as_secs() as i64, 0) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_else(|| now.as_secs().to_string()) +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn parse_timestamp(value: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(value) + .ok() + .map(|dt| dt.timestamp() as u64) + .or_else(|| value.parse::().ok()) +} + +fn is_expired(req: &PairingRequest, now_secs: u64) -> bool { + let created = parse_timestamp(&req.created_at).unwrap_or(0); + now_secs.saturating_sub(created) > PAIRING_PENDING_TTL_SECS +} + +fn random_code() -> String { + let mut rng = rand::thread_rng(); + (0..PAIRING_CODE_LENGTH) + .map(|_| { + let idx = rng.gen_range(0..PAIRING_ALPHABET.len()); + PAIRING_ALPHABET[idx] as char + }) + .collect() +} + +fn generate_unique_code(existing: &HashSet) -> String { + let mut rng = rand::thread_rng(); + for _ in 0..500 { + let code = random_code(); + if !existing.contains(&code) { + return code; + } + } + // Fallback: add suffix + format!("{}{:04}", random_code(), rng.gen_range(0..10000)) +} + +/// Pairing store for a channel. +#[derive(Debug, Clone)] +pub struct PairingStore { + base_dir: PathBuf, +} + +impl PairingStore { + /// Create a new pairing store using default directory (~/.ironclaw). + pub fn new() -> Self { + Self { + base_dir: default_pairing_dir(), + } + } + + /// Create a pairing store with a custom base directory (for testing). + pub fn with_base_dir(base_dir: PathBuf) -> Self { + Self { base_dir } + } + + /// List pending pairing requests for a channel. + pub fn list_pending(&self, channel: &str) -> Result, PairingStoreError> { + let path = pairing_path(&self.base_dir, channel)?; + let content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(Vec::new()); + } + Err(e) => return Err(e.into()), + }; + + let file: PairingStoreFile = serde_json::from_str(&content).unwrap_or(PairingStoreFile { + version: 1, + requests: Vec::new(), + }); + + let now = now_secs(); + let original_len = file.requests.len(); + let mut requests: Vec<_> = file + .requests + .into_iter() + .filter(|r| !is_expired(r, now)) + .collect(); + + if requests.len() != original_len { + self.write_pairing_file(channel, &requests)?; + } + + requests.sort_by(|a, b| a.created_at.cmp(&b.created_at)); + Ok(requests) + } + + /// Upsert a pairing request. Returns (code, created). + pub fn upsert_request( + &self, + channel: &str, + id: &str, + meta: Option, + ) -> Result { + let path = pairing_path(&self.base_dir, channel)?; + fs::create_dir_all(path.parent().unwrap())?; + + let mut file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path)?; + + file.lock_exclusive()?; + + let content = fs::read_to_string(&path).unwrap_or_default(); + let mut store: PairingStoreFile = serde_json::from_str(&content).unwrap_or(PairingStoreFile { + version: 1, + requests: Vec::new(), + }); + + let now = now_iso(); + let now_secs = now_secs(); + let id = id.trim().to_string(); + if id.is_empty() { + fs4::FileExt::unlock(&file)?; + return Err(PairingStoreError::InvalidChannel("empty id".to_string())); + } + + store.requests.retain(|r| !is_expired(r, now_secs)); + let existing_codes: HashSet = store + .requests + .iter() + .map(|r| r.code.to_uppercase()) + .collect(); + + if let Some(idx) = store.requests.iter().position(|r| r.id == id) { + let req = &mut store.requests[idx]; + let code = if req.code.is_empty() { + generate_unique_code(&existing_codes) + } else { + req.code.clone() + }; + req.last_seen_at = now.clone(); + req.code = code.clone(); + if let Some(m) = meta { + req.meta = Some(m); + } + self.write_pairing_file_locked(&mut file, channel, &store.requests)?; + fs4::FileExt::unlock(&file)?; + return Ok(UpsertResult { + code, + created: false, + }); + } + + if store.requests.len() >= PAIRING_PENDING_MAX { + fs4::FileExt::unlock(&file)?; + return Ok(UpsertResult { + code: String::new(), + created: false, + }); + } + + let code = generate_unique_code(&existing_codes); + store.requests.push(PairingRequest { + id: id.clone(), + code: code.clone(), + created_at: now.clone(), + last_seen_at: now, + meta, + }); + + self.write_pairing_file_locked(&mut file, channel, &store.requests)?; + fs4::FileExt::unlock(&file)?; + + Ok(UpsertResult { code, created: true }) + } + + fn is_approve_rate_limited(&self, channel: &str) -> Result { + let path = approve_attempts_path(&self.base_dir, channel)?; + let content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(e) => return Err(e.into()), + }; + let mut data: ApproveAttemptsFile = + serde_json::from_str(&content).unwrap_or_default(); + let now = now_secs(); + let cutoff = now.saturating_sub(PAIRING_APPROVE_RATE_WINDOW_SECS); + data.failed_at.retain(|&t| t >= cutoff); + Ok(data.failed_at.len() >= PAIRING_APPROVE_RATE_LIMIT) + } + + fn record_failed_approve(&self, channel: &str) -> Result<(), PairingStoreError> { + let path = approve_attempts_path(&self.base_dir, channel)?; + fs::create_dir_all(path.parent().unwrap())?; + let file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(&path)?; + file.lock_exclusive()?; + let content = fs::read_to_string(&path).unwrap_or_default(); + let mut data: ApproveAttemptsFile = + serde_json::from_str(&content).unwrap_or_default(); + let now = now_secs(); + data.failed_at.push(now); + let cutoff = now.saturating_sub(PAIRING_APPROVE_RATE_WINDOW_SECS); + data.failed_at.retain(|&t| t >= cutoff); + let json = serde_json::to_string_pretty(&data)?; + fs::write(&path, json)?; + fs4::FileExt::unlock(&file)?; + Ok(()) + } + + /// Approve a pairing code and add the sender to allowFrom. + pub fn approve( + &self, + channel: &str, + code: &str, + ) -> Result, PairingStoreError> { + let code = code.trim().to_uppercase(); + if code.is_empty() { + return Ok(None); + } + + if self.is_approve_rate_limited(channel)? { + return Err(PairingStoreError::ApproveRateLimited); + } + + let path = pairing_path(&self.base_dir, channel)?; + let mut file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(false) + .open(&path) + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + PairingStoreError::InvalidChannel("no pairing file".to_string()) + } else { + PairingStoreError::Io(e) + } + })?; + + file.lock_exclusive()?; + + let content = fs::read_to_string(&path).unwrap_or_default(); + let mut store: PairingStoreFile = serde_json::from_str(&content).unwrap_or(PairingStoreFile { + version: 1, + requests: Vec::new(), + }); + + let now_secs = now_secs(); + store.requests.retain(|r| !is_expired(r, now_secs)); + + let idx = store + .requests + .iter() + .position(|r| r.code.to_uppercase() == code); + + let entry = match idx { + Some(i) => store.requests.remove(i), + None => { + fs4::FileExt::unlock(&file)?; + self.record_failed_approve(channel)?; + return Ok(None); + } + }; + + self.write_pairing_file_locked(&mut file, channel, &store.requests)?; + fs4::FileExt::unlock(&file)?; + + self.add_allow_from(channel, &entry.id)?; + + Ok(Some(entry)) + } + + /// 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)?; + let content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(Vec::new()); + } + Err(e) => return Err(e.into()), + }; + + let file: AllowFromStoreFile = serde_json::from_str(&content).unwrap_or(AllowFromStoreFile { + version: 1, + allow_from: Vec::new(), + }); + + Ok(file.allow_from) + } + + /// Check if a sender is allowed (by id or username). + pub fn is_sender_allowed( + &self, + channel: &str, + id: &str, + username: Option<&str>, + ) -> Result { + let allow = self.read_allow_from(channel)?; + let id = id.trim(); + let id_ok = allow.iter().any(|e| e.trim() == id); + if id_ok { + return Ok(true); + } + if let Some(u) = username { + let u = u.trim().to_lowercase(); + let u_norm = u.strip_prefix('@').unwrap_or(&u); + if allow + .iter() + .any(|e| e.trim().to_lowercase() == u || e.trim().to_lowercase() == format!("@{}", u_norm)) + { + return Ok(true); + } + } + Ok(false) + } + + fn add_allow_from(&self, channel: &str, entry: &str) -> Result<(), PairingStoreError> { + let entry = entry.trim().to_string(); + if entry.is_empty() { + return Ok(()); + } + + let path = allow_from_path(&self.base_dir, channel)?; + fs::create_dir_all(path.parent().unwrap())?; + + let file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(&path)?; + + file.lock_exclusive()?; + + let content = fs::read_to_string(&path).unwrap_or_default(); + let mut store: AllowFromStoreFile = + serde_json::from_str(&content).unwrap_or(AllowFromStoreFile { + version: 1, + allow_from: Vec::new(), + }); + + let normalized = entry.to_lowercase(); + if store + .allow_from + .iter() + .any(|e| e.to_lowercase() == normalized) + { + fs4::FileExt::unlock(&file)?; + return Ok(()); + } + + store.allow_from.push(entry); + let json = serde_json::to_string_pretty(&store)?; + fs::write(&path, json)?; + + fs4::FileExt::unlock(&file)?; + Ok(()) + } + + fn write_pairing_file( + &self, + channel: &str, + requests: &[PairingRequest], + ) -> Result<(), PairingStoreError> { + let path = pairing_path(&self.base_dir, channel)?; + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&path)?; + file.lock_exclusive()?; + self.write_pairing_file_locked(&mut file, channel, requests)?; + fs4::FileExt::unlock(&file)?; + Ok(()) + } + + fn write_pairing_file_locked( + &self, + file: &mut fs::File, + _channel: &str, + requests: &[PairingRequest], + ) -> Result<(), PairingStoreError> { + let store = PairingStoreFile { + version: 1, + requests: requests.to_vec(), + }; + let json = serde_json::to_string_pretty(&store)?; + file.set_len(0)?; + file.seek(SeekFrom::Start(0))?; + file.write_all(json.as_bytes())?; + file.sync_all()?; + Ok(()) + } +} + +impl Default for PairingStore { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_safe_channel_key() { + assert_eq!(safe_channel_key("telegram").unwrap(), "telegram"); + assert_eq!(safe_channel_key("Telegram").unwrap(), "telegram"); + safe_channel_key("").unwrap_err(); + } + + #[test] + fn test_random_code() { + let c = random_code(); + assert_eq!(c.len(), PAIRING_CODE_LENGTH); + assert!(c.chars().all(|c| PAIRING_ALPHABET.contains(&(c as u8)))); + } + + fn test_store() -> (PairingStore, TempDir) { + let dir = TempDir::new().unwrap(); + let store = PairingStore::with_base_dir(dir.path().to_path_buf()); + (store, dir) + } + + #[test] + fn test_list_pending_empty() { + let (store, _) = test_store(); + let requests = store.list_pending("telegram").unwrap(); + assert!(requests.is_empty()); + } + + #[test] + fn test_upsert_request_creates_new() { + let (store, _) = test_store(); + let result = store + .upsert_request("telegram", "user123", Some(serde_json::json!({"chat_id": 456}))) + .unwrap(); + assert!(result.created); + assert_eq!(result.code.len(), PAIRING_CODE_LENGTH); + assert!(result.code.chars().all(|c| PAIRING_ALPHABET.contains(&(c as u8)))); + } + + #[test] + fn test_upsert_request_updates_existing() { + let (store, _) = test_store(); + let r1 = store.upsert_request("telegram", "user123", None).unwrap(); + assert!(r1.created); + let r2 = store.upsert_request("telegram", "user123", Some(serde_json::json!({"x": 1}))).unwrap(); + assert!(!r2.created); + assert_eq!(r1.code, r2.code); + + let pending = store.list_pending("telegram").unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].id, "user123"); + assert_eq!(pending[0].meta, Some(serde_json::json!({"x": 1}))); + } + + #[test] + fn test_approve_adds_to_allow_from() { + let (store, _) = test_store(); + let r = store.upsert_request("telegram", "user456", None).unwrap(); + assert!(r.created); + + let approved = store.approve("telegram", &r.code).unwrap(); + assert!(approved.is_some()); + assert_eq!(approved.unwrap().id, "user456"); + + let allow = store.read_allow_from("telegram").unwrap(); + assert_eq!(allow, vec!["user456"]); + } + + #[test] + fn test_approve_case_insensitive_code() { + let (store, _) = test_store(); + let r = store.upsert_request("telegram", "user789", None).unwrap(); + let code_lower = r.code.to_lowercase(); + let approved = store.approve("telegram", &code_lower).unwrap(); + assert!(approved.is_some()); + } + + #[test] + fn test_approve_invalid_code_returns_none() { + let (store, _) = test_store(); + store.upsert_request("telegram", "user123", None).unwrap(); + let approved = store.approve("telegram", "BADCODE1").unwrap(); + assert!(approved.is_none()); + } + + #[test] + fn test_approve_rate_limited_after_many_failures() { + let (store, _) = test_store(); + store.upsert_request("telegram", "user123", None).unwrap(); + for _ in 0..PAIRING_APPROVE_RATE_LIMIT { + let _ = store.approve("telegram", "WRONG01"); + } + let err = store.approve("telegram", "WRONG02").unwrap_err(); + assert!(matches!(err, PairingStoreError::ApproveRateLimited)); + } + + #[test] + fn test_is_sender_allowed_by_id() { + let (store, _) = test_store(); + let r = store.upsert_request("telegram", "user999", None).unwrap(); + store.approve("telegram", &r.code).unwrap(); + + assert!(store.is_sender_allowed("telegram", "user999", None).unwrap()); + assert!(!store.is_sender_allowed("telegram", "other", None).unwrap()); + } + + #[test] + fn test_is_sender_allowed_by_username() { + let (store, _) = test_store(); + store.upsert_request("telegram", "alice", Some(serde_json::json!({"username": "alice"}))).unwrap(); + let pending = store.list_pending("telegram").unwrap(); + store.approve("telegram", &pending[0].code).unwrap(); + + // approve adds id to allow_from. For username we need to add it manually. + // Actually approve adds entry.id which is "alice". So is_sender_allowed("telegram", "alice", None) would work. + assert!(store.is_sender_allowed("telegram", "alice", None).unwrap()); + assert!(store.is_sender_allowed("telegram", "alice", Some("alice")).unwrap()); + } + + #[test] + fn test_channel_normalization() { + let (store, _) = test_store(); + store.upsert_request("Telegram", "u1", None).unwrap(); + let pending = store.list_pending("telegram").unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].id, "u1"); + } + + #[test] + fn test_invalid_channel_rejected() { + let (store, _) = test_store(); + store.upsert_request("telegram", "u1", None).unwrap(); + store.list_pending("").unwrap_err(); + store.upsert_request("", "u1", None).unwrap_err(); + } +} diff --git a/tests/pairing_integration.rs b/tests/pairing_integration.rs new file mode 100644 index 00000000..041f7e97 --- /dev/null +++ b/tests/pairing_integration.rs @@ -0,0 +1,112 @@ +//! Integration tests for the DM pairing flow. +//! +//! Verifies the full pairing lifecycle: upsert → list → approve → allowFrom → is_sender_allowed. +//! Uses temp directory for isolation. + +use ironclaw::cli::{run_pairing_command_with_store, PairingCommand}; +use ironclaw::pairing::PairingStore; +use tempfile::TempDir; + +fn test_store() -> (PairingStore, TempDir) { + let dir = TempDir::new().unwrap(); + let store = PairingStore::with_base_dir(dir.path().to_path_buf()); + (store, dir) +} + +#[test] +fn test_pairing_flow_unknown_user_to_approved() { + let (store, _) = test_store(); + let channel = "telegram"; + + // 1. Unknown user sends first message -> upsert creates request + let r1 = store.upsert_request(channel, "user_12345", Some(serde_json::json!({ + "chat_id": 999, + "username": "alice" + }))).unwrap(); + assert!(r1.created); + assert!(!r1.code.is_empty()); + assert_eq!(r1.code.len(), 8); + + // 2. List pending shows the request + let pending = store.list_pending(channel).unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].id, "user_12345"); + assert_eq!(pending[0].code, r1.code); + + // 3. User is not allowed yet + assert!(!store.is_sender_allowed(channel, "user_12345", Some("alice")).unwrap()); + + // 4. Approve via code + let approved = store.approve(channel, &r1.code).unwrap(); + assert!(approved.is_some()); + assert_eq!(approved.unwrap().id, "user_12345"); + + // 5. User is now allowed + assert!(store.is_sender_allowed(channel, "user_12345", None).unwrap()); + assert!(store.is_sender_allowed(channel, "user_12345", Some("alice")).unwrap()); + + // 6. Pending list is empty + let pending_after = store.list_pending(channel).unwrap(); + assert!(pending_after.is_empty()); + + // 7. allowFrom contains the user + let allow = store.read_allow_from(channel).unwrap(); + assert_eq!(allow, vec!["user_12345"]); +} + +#[test] +fn test_pairing_flow_cli_approve() { + let (store, _) = test_store(); + store.upsert_request("telegram", "user_999", None).unwrap(); + let pending = store.list_pending("telegram").unwrap(); + let code = pending[0].code.clone(); + + let result = run_pairing_command_with_store( + &store, + PairingCommand::Approve { + channel: "telegram".to_string(), + code, + }, + ); + assert!(result.is_ok()); + assert!(store.is_sender_allowed("telegram", "user_999", None).unwrap()); +} + +#[test] +fn test_pairing_reject_invalid_code() { + let (store, _) = test_store(); + store.upsert_request("telegram", "user_1", None).unwrap(); + + let result = store.approve("telegram", "INVALID1"); + assert!(result.unwrap().is_none()); + + let result = run_pairing_command_with_store( + &store, + PairingCommand::Approve { + channel: "telegram".to_string(), + code: "BADCODE1".to_string(), + }, + ); + assert!(result.is_err()); +} + +#[test] +fn test_pairing_multiple_channels_isolated() { + let (store, _) = test_store(); + + let r_telegram = store.upsert_request("telegram", "user_a", None).unwrap(); + let r_slack = store.upsert_request("slack", "user_b", None).unwrap(); + + // Each channel has its own pending + assert_eq!(store.list_pending("telegram").unwrap().len(), 1); + assert_eq!(store.list_pending("slack").unwrap().len(), 1); + + // Approve in one channel doesn't affect the other + store.approve("telegram", &r_telegram.code).unwrap(); + assert!(store.is_sender_allowed("telegram", "user_a", None).unwrap()); + assert!(!store.is_sender_allowed("slack", "user_a", None).unwrap()); + + store.approve("slack", &r_slack.code).unwrap(); + assert!(store.is_sender_allowed("slack", "user_b", None).unwrap()); +} + diff --git a/tests/wasm_channel_integration.rs b/tests/wasm_channel_integration.rs index 7ac0b909..ca636e1f 100644 --- a/tests/wasm_channel_integration.rs +++ b/tests/wasm_channel_integration.rs @@ -10,6 +10,7 @@ use std::collections::HashMap; use std::sync::Arc; use ironclaw::channels::Channel; +use ironclaw::pairing::PairingStore; use ironclaw::channels::wasm::{ ChannelCapabilities, EmitRateLimitConfig, PreparedChannelModule, RegisteredEndpoint, WasmChannel, WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig, @@ -38,7 +39,13 @@ fn create_test_channel( capabilities = capabilities.with_path(path.to_string()); } - WasmChannel::new(runtime, prepared, capabilities, "{}".to_string()) + WasmChannel::new( + runtime, + prepared, + capabilities, + "{}".to_string(), + Arc::new(PairingStore::new()), + ) } mod router_tests { diff --git a/wit/channel.wit b/wit/channel.wit index 48fba6be..c716bc58 100644 --- a/wit/channel.wit +++ b/wit/channel.wit @@ -147,6 +147,32 @@ interface channel-host { /// - Path validation fails (traversal attempt, absolute path) /// - Write operation fails workspace-write: func(path: string, content: string) -> result<_, string>; + + // ==================== DM Pairing ==================== + + /// Result of upserting a pairing request. + record pairing-upsert-result { + code: string, + created: bool, + } + + /// Upsert a pairing request for an unknown sender. + /// Returns (code, created). When created is true, the channel should send a pairing reply. + pairing-upsert-request: func( + channel: string, + id: string, + meta-json: string + ) -> result; + + /// Check if a sender is allowed (in allowFrom store). + pairing-is-allowed: func( + channel: string, + id: string, + username: option + ) -> result; + + /// Read the allowFrom list (for merging with config allowFrom). + pairing-read-allow-from: func(channel: string) -> result, string>; } /// Channel interface that sandboxed channels must implement.