diff --git a/AGENTS.md b/AGENTS.md index a052a065..b7975ca0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Project-level guidance for coding agents working in this repository. - Benchmarks: `benches/message_bus.rs` - Integration tests: `tests/integration.rs` - Codebase: ~106,000+ lines of Rust -- Channels: 9 (Telegram, Slack, Discord, WhatsApp, WhatsApp Cloud, Lark, Email, Webhook, Serial) +- Channels: 10 (Telegram, Slack, Discord, WhatsApp, WhatsApp Web, WhatsApp Cloud, Lark, Email, Webhook, Serial) - Runtimes: 6 (Native, Docker, Apple Container, Landlock, Firejail, Bubblewrap) - Peripherals: 4 boards (ESP32, RPi, Arduino, Nucleo) with GPIO, I2C, NVS, Serial - Skills: OpenClaw-compatible (reads `metadata.zeptoclaw` > `metadata.openclaw` > raw) @@ -20,7 +20,7 @@ Project-level guidance for coding agents working in this repository. - Provider introspection CLI: `zeptoclaw provider status` prints resolved providers, wrapper config (retry/fallback), and quota usage snapshot - Channel dispatch: avoids holding the channels map `RwLock` across async `send()` awaits - Channel supervisor: polling (15s) detects dead channels, restarts with 60s cooldown, max 5 restarts -- Channel panic isolation: Slack/Discord/Webhook/WhatsApp/WhatsApp Cloud/Lark/Email/MQTT/Serial spawned tasks are wrapped with `catch_unwind` and panic logging +- Channel panic isolation: Slack/Discord/Webhook/WhatsApp/WhatsApp Web/WhatsApp Cloud/Lark/Email/MQTT/Serial spawned tasks are wrapped with `catch_unwind` and panic logging - Telegram outbound formatting: sends HTML parse mode with `||spoiler||` → `` conversion - Discord outbound delivery: supports reply references and thread-create metadata (`discord_thread_*`) in `OutboundMessage` - Cron scheduling hardening: dispatch timeout + exponential error backoff + one-shot delete-after-run only on success @@ -39,7 +39,7 @@ Project-level guidance for coding agents working in this repository. - MCP transport: supports both HTTP and stdio MCP servers (`url` or `command` + args/env) with tool registration during `create_agent()` - Hands-lite: `HAND.toml` + bundled hands (`researcher`, `coder`, `monitor`) + `hand` CLI - Process exit codes: explicit `main` mapping for success (0) and error (1); uncaught panic/crash remains Rust default (101) -- Tests: 2956 lib + 92 main + 23 cli_smoke + 13 e2e + 70 integration + 126 doc (27 ignored) +- Tests: default build runs 2956 lib + 92 main + 23 cli_smoke + 13 e2e + 70 integration + 126 doc (27 ignored); optional features such as `whatsapp-web` add feature-gated coverage ## Task Tracking Protocol diff --git a/CLAUDE.md b/CLAUDE.md index 5e35778e..6bad4ef1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ cargo clippy -- -D warnings cargo fmt # Test counts (cargo test) -# lib: 2956, main: 92, cli_smoke: 23, e2e: 13, integration: 70, doc: 126 passed (27 ignored) +# default build: lib 2956, main 92, cli_smoke 23, e2e 13, integration 70, doc 126 passed (27 ignored); optional features such as whatsapp-web add feature-gated coverage # Version ./target/release/zeptoclaw --version @@ -98,8 +98,8 @@ cargo fmt # Channel management ./target/release/zeptoclaw channel list -./target/release/zeptoclaw channel setup whatsapp -./target/release/zeptoclaw channel test whatsapp +./target/release/zeptoclaw channel setup whatsapp_web +./target/release/zeptoclaw channel test whatsapp_web # Onboard (express setup by default) ./target/release/zeptoclaw onboard @@ -249,7 +249,7 @@ src/ │ ├── slack.rs # Slack outbound channel │ ├── discord.rs # Discord Gateway WebSocket + REST (reply + thread create) │ ├── webhook.rs # Generic HTTP webhook inbound -│ ├── whatsapp.rs # WhatsApp via whatsmeow-rs bridge (WebSocket) +│ ├── whatsapp_web.rs # WhatsApp Web via wa-rs native (feature: whatsapp-web) │ ├── whatsapp_cloud.rs # WhatsApp Cloud API (official webhook + REST) │ ├── lark.rs # Lark/Feishu messaging (WS long-connection) │ ├── email_channel.rs # Email channel (IMAP IDLE + SMTP) @@ -407,8 +407,6 @@ Containerized agent proxy for full request isolation: - Stdin/stdout IPC with containerized agent - Semaphore-based concurrency limiting (`max_concurrent` config) - Mount allowlist validation, docker binary verification -- **Auto-installs channel dependencies** (e.g., whatsmeow-bridge for WhatsApp) -- Dependencies installed at gateway startup via DepManager - Warn-and-continue on dependency failures (non-blocking) ### Providers (`src/providers/`) @@ -433,7 +431,7 @@ Message input channels via `Channel` trait: - `SlackChannel` - Slack outbound messaging - `DiscordChannel` - Discord Gateway WebSocket + REST API messaging (replies + thread creation) - `WebhookChannel` - Generic HTTP POST inbound with optional Bearer auth -- `WhatsAppChannel` - WhatsApp via whatsmeow-rs bridge (WebSocket JSON protocol) +- `WhatsAppWebChannel` - WhatsApp Web via wa-rs native client (QR pairing, feature: whatsapp-web) - `WhatsAppCloudChannel` - WhatsApp Cloud API (webhook inbound + REST outbound, no bridge) - `MqttChannel` - MQTT messaging for IoT devices over WiFi/network (rumqttc, feature: mqtt) - `SerialChannel` - UART serial messaging (line-delimited JSON, feature: hardware) @@ -631,12 +629,15 @@ Environment variables override config: - `ZEPTOCLAW_TOOLS_WEB_SEARCH_PROVIDER` — search provider: "brave", "searxng", "ddg" (default: auto-detect) - `ZEPTOCLAW_TOOLS_WEB_SEARCH_API_URL` — SearXNG instance URL (required when provider is "searxng") - `ZEPTOCLAW_TOOLS_CODING_TOOLS` — enable coding-specific tools: grep, find (default: false; auto-enabled by coder template) +- `ZEPTOCLAW_CHANNELS_WHATSAPP_WEB_ENABLED` — enable WhatsApp Web channel (default: false) +- `ZEPTOCLAW_CHANNELS_WHATSAPP_WEB_AUTH_DIR` — session persistence directory (default: ~/.zeptoclaw/state/whatsapp_web) ### Cargo Features - `android` — Enable Android device control tool via ADB - `google` — Enable Google Workspace tools (Gmail + Calendar) via gogcli-rs - `mqtt` — Enable MQTT channel for IoT device communication (rumqttc async client) +- `whatsapp-web` — Enable native WhatsApp Web channel via wa-rs (QR code pairing) - `memory-bm25` — Enable BM25 keyword scoring for memory search - `peripheral-esp32` — Enable ESP32 peripheral with I2C + NVS tools (implies `hardware`) - `peripheral-rpi` — Enable Raspberry Pi GPIO + native I2C tools via rppal (Linux only) @@ -647,6 +648,9 @@ Environment variables override config: ```bash cargo build --release --features android +# Native WhatsApp Web channel +cargo build --release --features whatsapp-web + # Linux sandbox runtimes cargo build --release --features sandbox-landlock cargo build --release --features sandbox-firejail diff --git a/Cargo.lock b/Cargo.lock index bf0e605f..3da1850e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,20 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -171,6 +185,12 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "async-channel" version = "1.9.0" @@ -247,6 +267,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -542,6 +573,9 @@ name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] [[package]] name = "cast" @@ -857,6 +891,35 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -937,6 +1000,15 @@ dependencies = [ "itertools 0.13.0", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -1023,6 +1095,42 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[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 = "darling" version = "0.20.11" @@ -1093,6 +1201,20 @@ dependencies = [ "syn", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -1176,6 +1298,53 @@ dependencies = [ "syn", ] +[[package]] +name = "diesel" +version = "2.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b6c2fc184a6fb6ebcf5f9a5e3bbfa84d8fd268cdfcce4ed508979a6259494d" +dependencies = [ + "diesel_derives", + "downcast-rs", + "libsqlite3-sys", + "r2d2", + "sqlite-wasm-rs", + "time", +] + +[[package]] +name = "diesel_derives" +version = "2.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47618bf0fac06bb670c036e48404c26a865e6a71af4114dfd97dfe89936e404e" +dependencies = [ + "diesel_table_macro_syntax", + "dsl_auto_type", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "diesel_migrations" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "745fd255645f0f1135f9ec55c7b00e0882192af9683ab4731e4bba3da82b8f9c" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" +dependencies = [ + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -1239,6 +1408,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -1251,6 +1429,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + [[package]] name = "dptree" version = "0.5.1" @@ -1261,6 +1445,20 @@ dependencies = [ "futures", ] +[[package]] +name = "dsl_auto_type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" +dependencies = [ + "darling 0.21.3", + "either", + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dtoa" version = "1.0.11" @@ -1354,12 +1552,31 @@ dependencies = [ "syn", ] +[[package]] +name = "env_filter" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +dependencies = [ + "log", +] + [[package]] name = "env_home" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" +[[package]] +name = "env_logger" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +dependencies = [ + "env_filter", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1467,6 +1684,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1513,6 +1736,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1713,6 +1942,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.32.3" @@ -1887,7 +2126,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1895,6 +2134,9 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "hashify" @@ -1950,6 +2192,24 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.36.1" @@ -2593,6 +2853,16 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -2605,6 +2875,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -2727,6 +3003,12 @@ dependencies = [ "digest", ] +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + [[package]] name = "memchr" version = "2.8.0" @@ -2755,6 +3037,27 @@ dependencies = [ "syn", ] +[[package]] +name = "migrations_internals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "migrations_macros" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", +] + [[package]] name = "mime" version = "0.3.17" @@ -2838,6 +3141,32 @@ dependencies = [ "syn", ] +[[package]] +name = "moka" +version = "0.12.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener 5.4.1", + "futures-util", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -3141,6 +3470,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "pem" version = "3.0.6" @@ -3200,6 +3539,17 @@ dependencies = [ "sha2", ] +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.13.0", +] + [[package]] name = "phf" version = "0.12.1" @@ -3362,6 +3712,18 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -3543,18 +3905,64 @@ dependencies = [ ] [[package]] -name = "prost-derive" +name = "prost-build" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "anyhow", + "heck", + "itertools 0.14.0", + "log", + "multimap", + "petgraph", + "prost", + "prost-types", + "regex", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", "itertools 0.14.0", "proc-macro2", "quote", "syn", ] +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "psm" version = "0.1.30" @@ -3565,6 +3973,12 @@ dependencies = [ "cc", ] +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quick-xml" version = "0.39.2" @@ -3596,9 +4010,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "aws-lc-rs", "bytes", @@ -3651,6 +4065,17 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + [[package]] name = "radium" version = "0.7.0" @@ -3716,6 +4141,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "rangemap" version = "1.7.1" @@ -3967,6 +4398,16 @@ dependencies = [ "libc", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + [[package]] name = "rtoolbox" version = "0.0.3" @@ -4162,6 +4603,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + [[package]] name = "schemars" version = "0.9.0" @@ -4277,6 +4727,15 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -4475,6 +4934,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -4524,6 +4989,18 @@ dependencies = [ "lock_api", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4675,6 +5152,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "take_mut" version = "0.2.2" @@ -5011,6 +5494,40 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6aa6c8b5a31e06fd3760eb5c1b8d9072e30731f0467ee3795617fe768e7449" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "http", + "httparse", + "rand 0.9.2", + "ring", + "rustls-pki-types", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow", +] + [[package]] name = "toml" version = "1.0.3+spec-1.1.0" @@ -5239,6 +5756,26 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "typed-path" version = "0.12.3" @@ -5351,6 +5888,37 @@ version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" +[[package]] +name = "ureq" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc" +dependencies = [ + "base64", + "cookie_store", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf-8", + "webpki-roots 1.0.6", +] + +[[package]] +name = "ureq-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -5399,6 +5967,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -5411,6 +5985,245 @@ version = "0.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" +[[package]] +name = "wa-rs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fecb468bdfe1e7d4c06a1bd12908c66edaca59024862cb64757ad11c3b948b1" +dependencies = [ + "anyhow", + "async-channel 2.5.0", + "async-trait", + "base64", + "bytes", + "chrono", + "dashmap", + "env_logger", + "hex", + "log", + "moka", + "prost", + "rand 0.9.2", + "rand_core 0.10.0", + "scopeguard", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "wa-rs-binary", + "wa-rs-core", + "wa-rs-proto", + "wa-rs-sqlite-storage", + "wa-rs-tokio-transport", + "wa-rs-ureq-http", +] + +[[package]] +name = "wa-rs-appstate" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3845137b3aead2d99de7c6744784bf2f5a908be9dc97a3dbd7585dc40296925c" +dependencies = [ + "anyhow", + "bytemuck", + "hex", + "hkdf", + "log", + "prost", + "serde", + "serde-big-array", + "serde_json", + "sha2", + "thiserror 2.0.18", + "wa-rs-binary", + "wa-rs-libsignal", + "wa-rs-proto", +] + +[[package]] +name = "wa-rs-binary" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b30a6e11aebb39c07392675256ead5e2570c31382bd4835d6ddc877284b6be" +dependencies = [ + "flate2", + "phf 0.13.1", + "phf_codegen", + "serde", + "serde_json", +] + +[[package]] +name = "wa-rs-core" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed13bb2aff2de43fc4dd821955f03ea48a1d31eda3c80efe6f905898e304d11f" +dependencies = [ + "aes", + "aes-gcm", + "anyhow", + "async-channel 2.5.0", + "async-trait", + "base64", + "bytes", + "chrono", + "ctr", + "flate2", + "hex", + "hkdf", + "hmac", + "log", + "md5", + "once_cell", + "pbkdf2", + "prost", + "protobuf", + "rand 0.9.2", + "rand_core 0.10.0", + "serde", + "serde-big-array", + "serde_json", + "sha2", + "thiserror 2.0.18", + "typed-builder", + "wa-rs-appstate", + "wa-rs-binary", + "wa-rs-derive", + "wa-rs-libsignal", + "wa-rs-noise", + "wa-rs-proto", +] + +[[package]] +name = "wa-rs-derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75c03f610c9bc960e653d5d6d2a4cced9013bedbe5e6e8948787bbd418e4137c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wa-rs-libsignal" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3471be8ff079ae4959fcddf2e7341281e5c6756bdc6a66454ea1a8e474d14576" +dependencies = [ + "aes", + "aes-gcm", + "arrayref", + "async-trait", + "cbc", + "chrono", + "ctr", + "curve25519-dalek", + "derive_more 2.1.1", + "displaydoc", + "ghash", + "hex", + "hkdf", + "hmac", + "itertools 0.14.0", + "log", + "prost", + "rand 0.9.2", + "serde", + "sha1", + "sha2", + "subtle", + "thiserror 2.0.18", + "uuid", + "wa-rs-proto", + "x25519-dalek", +] + +[[package]] +name = "wa-rs-noise" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3efb3891c1e22ce54646dc581e34e79377dc402ed8afb11a7671c5ef629b3ae" +dependencies = [ + "aes-gcm", + "anyhow", + "bytes", + "hkdf", + "log", + "prost", + "rand 0.9.2", + "rand_core 0.10.0", + "sha2", + "thiserror 2.0.18", + "wa-rs-binary", + "wa-rs-libsignal", + "wa-rs-proto", +] + +[[package]] +name = "wa-rs-proto" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ada50ee03752f0e66ada8cf415ed5f90d572d34039b058ce23d8b13493e510" +dependencies = [ + "prost", + "prost-build", + "serde", +] + +[[package]] +name = "wa-rs-sqlite-storage" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "006adc8ec15093946ae4c7b07d3e232c499116d469cef0da46782780df6a132c" +dependencies = [ + "async-trait", + "bincode", + "diesel", + "diesel_migrations", + "log", + "prost", + "serde_json", + "tokio", + "wa-rs-binary", + "wa-rs-core", + "wa-rs-proto", +] + +[[package]] +name = "wa-rs-tokio-transport" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc638c168949dc99cbb756a776869898d4ae654b36b90d5f7ce2d32bf92a404" +dependencies = [ + "anyhow", + "async-channel 2.5.0", + "async-trait", + "bytes", + "futures-util", + "http", + "log", + "rustls", + "tokio", + "tokio-rustls", + "tokio-websockets", + "wa-rs-core", + "webpki-roots 1.0.6", +] + +[[package]] +name = "wa-rs-ureq-http" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d0c7fff8a7bd93d0c17af8d797a3934144fa269fe47a615635f3bf04238806" +dependencies = [ + "anyhow", + "async-trait", + "tokio", + "ureq", + "wa-rs-core", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -6152,6 +6965,18 @@ dependencies = [ "tap", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "yoke" version = "0.8.1" @@ -6213,6 +7038,7 @@ dependencies = [ "once_cell", "probe-rs", "prost", + "qrcode", "quick-xml", "regex", "reqwest 0.12.28", @@ -6235,12 +7061,16 @@ dependencies = [ "tokio-serial", "tokio-test", "tokio-tungstenite", - "toml", + "toml 1.0.3+spec-1.1.0", "tower", "tower-http", "tracing", "tracing-subscriber", "uuid", + "wa-rs", + "wa-rs-sqlite-storage", + "wa-rs-tokio-transport", + "wa-rs-ureq-http", "webpki-roots 1.0.6", "zip", ] @@ -6291,6 +7121,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 37e9edba..b09c6dc1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -172,6 +172,20 @@ webpki-roots = { version = "1.0", optional = true } # Lightweight async MQTT client for IoT device communication rumqttc = { version = "0.25", optional = true } +# ============================================================================= +# WHATSAPP WEB (optional — feature-gated behind "whatsapp-web") +# ============================================================================= +# Pure Rust WhatsApp Web client (Signal Protocol, QR pairing, session persistence) +wa-rs = { version = "0.2", optional = true } +# SQLite storage backend for wa-rs session persistence +wa-rs-sqlite-storage = { version = "0.2", optional = true } +# Tokio WebSocket transport for wa-rs +wa-rs-tokio-transport = { version = "0.2", optional = true } +# HTTP client for wa-rs version/media requests +wa-rs-ureq-http = { version = "0.2", optional = true } +# QR code generation for WhatsApp Web terminal pairing +qrcode = { version = "0.14", optional = true, default-features = false } + # ============================================================================= # HARDWARE (optional — feature-gated) # ============================================================================= @@ -246,6 +260,14 @@ peripheral-esp32 = ["hardware"] probe = ["probe-rs"] # MQTT channel for IoT device communication (rumqttc async client) mqtt = ["dep:rumqttc"] +# WhatsApp Web native client (wa-rs + SQLite session storage) +whatsapp-web = [ + "dep:wa-rs", + "dep:wa-rs-sqlite-storage", + "dep:wa-rs-tokio-transport", + "dep:wa-rs-ureq-http", + "dep:qrcode", +] # Android device control tool via ADB (adds quick-xml for UI hierarchy parsing) android = [] # Linux sandbox runtimes (Linux-only, require respective binaries except landlock) diff --git a/README.md b/README.md index dc94d4bf..08b36c8b 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ Any provider's base URL can be overridden with `api_base` for proxies or self-ho | Feature | What it does | |---------|-------------| -| **9-Channel Gateway** | Telegram, Slack, Discord, WhatsApp (bridge + Cloud), Lark, Email, Webhook, Serial — unified message bus | +| **9-Channel Gateway** | Telegram, Slack, Discord, WhatsApp Web (native, `--features whatsapp-web`) + Cloud API, Lark, Email, Webhook, Serial — unified message bus | | **Persona System** | Per-chat personality switching via `/persona` command with LTM persistence | | **Plugin System** | JSON manifest plugins auto-discovered from `~/.zeptoclaw/plugins/` | | **Hooks** | `before_tool`, `after_tool`, `on_error` with Log, Block, and Notify actions | diff --git a/src/api/routes/channels.rs b/src/api/routes/channels.rs index 2081bb2b..8d80cd32 100644 --- a/src/api/routes/channels.rs +++ b/src/api/routes/channels.rs @@ -21,12 +21,20 @@ struct ChannelStatus { /// Known channel component name prefixes registered in `HealthRegistry`. /// /// Channel components are registered under their channel type name (e.g. -/// "telegram", "discord", "whatsapp"). We surface all health checks whose +/// "telegram", "discord", "whatsapp_web"). We surface all health checks whose /// name matches one of these known prefixes so the panel can display live /// channel status without hard-coding assumptions about which channels are /// enabled. const CHANNEL_NAMES: &[&str] = &[ - "telegram", "discord", "slack", "whatsapp", "webhook", "email", "serial", "lark", + "telegram", + "discord", + "slack", + "whatsapp", + "whatsapp_web", + "webhook", + "email", + "serial", + "lark", ]; pub async fn list_channels(State(state): State>) -> Json { diff --git a/src/channels/factory.rs b/src/channels/factory.rs index a3c2912f..4cfbe149 100644 --- a/src/channels/factory.rs +++ b/src/channels/factory.rs @@ -13,7 +13,6 @@ use super::email_channel::EmailChannel; use super::lark::LarkChannel; use super::plugin::{default_channel_plugins_dir, discover_channel_plugins, ChannelPluginAdapter}; use super::webhook::{WebhookChannel, WebhookChannelConfig}; -use super::WhatsAppChannel; use super::WhatsAppCloudChannel; use super::{BaseChannelConfig, ChannelManager, DiscordChannel, SlackChannel, TelegramChannel}; @@ -110,20 +109,26 @@ pub async fn register_configured_channels( } } - // WhatsApp (via bridge) - if let Some(ref whatsapp_config) = config.channels.whatsapp { - if whatsapp_config.enabled { - if whatsapp_config.bridge_url.is_empty() { - warn!("WhatsApp channel enabled but bridge_url is empty"); - } else { - manager - .register(Box::new(WhatsAppChannel::new( - whatsapp_config.clone(), - bus.clone(), - ))) - .await; - info!("Registered WhatsApp channel"); - } + // WhatsApp Web (native via wa-rs) — requires whatsapp-web feature + #[cfg(feature = "whatsapp-web")] + if let Some(ref wa_web_config) = config.channels.whatsapp_web { + if wa_web_config.enabled { + manager + .register(Box::new(super::WhatsAppWebChannel::new( + wa_web_config.clone(), + bus.clone(), + ))) + .await; + info!("Registered WhatsApp Web channel (native)"); + } + } + + #[cfg(not(feature = "whatsapp-web"))] + if let Some(ref wa_web_config) = config.channels.whatsapp_web { + if wa_web_config.enabled { + warn!( + "WhatsApp Web channel is enabled in config but this build was compiled without the whatsapp-web feature" + ); } } @@ -286,7 +291,7 @@ pub async fn register_configured_channels( mod tests { use super::*; use crate::bus::MessageBus; - use crate::config::{Config, SlackConfig, TelegramConfig, WhatsAppCloudConfig, WhatsAppConfig}; + use crate::config::{Config, SlackConfig, TelegramConfig, WhatsAppCloudConfig}; #[tokio::test] async fn test_register_configured_channels_registers_telegram() { @@ -306,25 +311,6 @@ mod tests { assert!(manager.has_channel("telegram").await); } - #[tokio::test] - async fn test_register_configured_channels_registers_whatsapp() { - let bus = Arc::new(MessageBus::new()); - let mut config = Config::default(); - config.channels.whatsapp = Some(WhatsAppConfig { - enabled: true, - bridge_url: "ws://localhost:3001".to_string(), - allow_from: Vec::new(), - bridge_managed: true, - ..Default::default() - }); - - let manager = ChannelManager::new(bus.clone(), config.clone()); - let count = register_configured_channels(&manager, bus, &config).await; - - assert_eq!(count, 1); - assert!(manager.has_channel("whatsapp").await); - } - #[tokio::test] async fn test_register_configured_channels_registers_slack() { let bus = Arc::new(MessageBus::new()); diff --git a/src/channels/manager.rs b/src/channels/manager.rs index b4ca563f..6edc0c7d 100644 --- a/src/channels/manager.rs +++ b/src/channels/manager.rs @@ -508,7 +508,14 @@ impl ChannelManager { pub async fn send(&self, channel_name: &str, msg: OutboundMessage) -> Result<()> { let channel = { let channels = self.channels.read().await; - channels.get(channel_name).cloned() + channels + .get(channel_name) + .cloned() + .or_else(|| match channel_name { + "whatsapp" => channels.get("whatsapp_web").cloned(), + "whatsapp_web" => channels.get("whatsapp").cloned(), + _ => None, + }) }; if let Some(channel) = channel { diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 5bbc2802..b2bc713e 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -124,8 +124,9 @@ pub mod slack; pub mod telegram; mod types; pub mod webhook; -pub mod whatsapp; pub mod whatsapp_cloud; +#[cfg(feature = "whatsapp-web")] +pub mod whatsapp_web; pub use discord::DiscordChannel; pub use email_channel::EmailChannel; @@ -141,5 +142,6 @@ pub use slack::SlackChannel; pub use telegram::TelegramChannel; pub use types::{BaseChannelConfig, Channel}; pub use webhook::{WebhookChannel, WebhookChannelConfig}; -pub use whatsapp::WhatsAppChannel; pub use whatsapp_cloud::WhatsAppCloudChannel; +#[cfg(feature = "whatsapp-web")] +pub use whatsapp_web::WhatsAppWebChannel; diff --git a/src/channels/whatsapp.rs b/src/channels/whatsapp.rs deleted file mode 100644 index 97c3c454..00000000 --- a/src/channels/whatsapp.rs +++ /dev/null @@ -1,1180 +0,0 @@ -//! WhatsApp channel implementation (via whatsmeow-rs bridge). -//! -//! Connects to an external whatsmeow-rs bridge binary over WebSocket. -//! The bridge handles WhatsApp protocol complexity (E2E encryption, QR pairing, -//! session persistence). ZeptoClaw just consumes/sends JSON messages. -//! -//! # Bridge Protocol (JSON over WebSocket) -//! -//! Inbound (bridge → ZeptoClaw): -//! ```json -//! {"type":"message","from":"60123456789","chat_id":"60123456789@s.whatsapp.net","content":"Hello","message_id":"wamid.xyz","timestamp":1707900000,"sender_name":"John"} -//! {"type":"connected"} -//! {"type":"disconnected","reason":"session expired"} -//! {"type":"qr_code","data":"2@base64data"} -//! ``` -//! -//! Outbound (ZeptoClaw → bridge): -//! ```json -//! {"type":"send","to":"60123456789@s.whatsapp.net","content":"Reply text","reply_to":"wamid.xyz"} -//! ``` - -use async_trait::async_trait; -use futures::{FutureExt, SinkExt, StreamExt}; -use serde::{Deserialize, Serialize}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::{mpsc, watch}; -use tokio_tungstenite::connect_async; -use tokio_tungstenite::tungstenite::Message as WsMessage; -use tracing::{debug, error, info, warn}; - -use crate::bus::{InboundMessage, MediaAttachment, MediaType, MessageBus, OutboundMessage}; -use crate::config::WhatsAppConfig; -use crate::deps::{DepKind, Dependency, HasDependencies, HealthCheck}; -use crate::error::{Result, ZeptoError}; - -use super::{BaseChannelConfig, Channel}; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -/// Maximum reconnect delay (in seconds) for exponential backoff. -const MAX_RECONNECT_DELAY_SECS: u64 = 120; -/// Base reconnect delay (in seconds). -const BASE_RECONNECT_DELAY_SECS: u64 = 2; -/// Maximum number of consecutive reconnect attempts before resetting backoff. -const MAX_RECONNECT_ATTEMPTS: u32 = 10; - -// --------------------------------------------------------------------------- -// Bridge protocol types -// --------------------------------------------------------------------------- - -/// Inbound message from the whatsmeow-rs bridge. -#[derive(Debug, Deserialize)] -struct BridgeMessage { - /// Message type: "message", "connected", "disconnected", "qr_code", etc. - #[serde(rename = "type")] - msg_type: String, - /// Sender phone number (message type only). - #[serde(default)] - from: Option, - /// WhatsApp chat JID (e.g. "60123456789@s.whatsapp.net"). - #[serde(default)] - chat_id: Option, - /// Message text content. - #[serde(default)] - content: Option, - /// WhatsApp message ID. - #[serde(default)] - message_id: Option, - /// Unix timestamp. - #[serde(default)] - timestamp: Option, - /// Sender display name. - #[serde(default)] - sender_name: Option, - /// Disconnect reason (disconnected type only). - #[serde(default)] - reason: Option, - /// QR code data (qr_code type only). - #[serde(default)] - #[allow(dead_code)] - data: Option, - /// Base64-encoded media data (image messages). - #[serde(default)] - media_base64: Option, - /// MIME type of the media (e.g. "image/jpeg"). - #[serde(default)] - media_mime_type: Option, -} - -/// Outbound message to the whatsmeow-rs bridge. -#[derive(Debug, Serialize)] -struct BridgeSendMessage { - /// Always "send". - #[serde(rename = "type")] - msg_type: String, - /// Recipient chat JID. - to: String, - /// Message text content. - content: String, - /// Optional message ID to reply to. - #[serde(skip_serializing_if = "Option::is_none")] - reply_to: Option, -} - -// --------------------------------------------------------------------------- -// WhatsAppChannel -// --------------------------------------------------------------------------- - -/// WhatsApp channel backed by the whatsmeow-rs bridge over WebSocket. -pub struct WhatsAppChannel { - config: WhatsAppConfig, - base_config: BaseChannelConfig, - bus: Arc, - running: Arc, - shutdown_tx: Option>, - outbound_tx: Option>, -} - -impl WhatsAppChannel { - /// Creates a new WhatsApp channel. - pub fn new(config: WhatsAppConfig, bus: Arc) -> Self { - let base_config = BaseChannelConfig { - name: "whatsapp".to_string(), - allowlist: config.allow_from.clone(), - deny_by_default: config.deny_by_default, - }; - - Self { - config, - base_config, - bus, - running: Arc::new(AtomicBool::new(false)), - shutdown_tx: None, - outbound_tx: None, - } - } - - /// Returns a reference to the WhatsApp configuration. - pub fn whatsapp_config(&self) -> &WhatsAppConfig { - &self.config - } - - /// Returns whether the channel is enabled in configuration. - pub fn is_enabled(&self) -> bool { - self.config.enabled - } - - // ----------------------------------------------------------------------- - // Bridge message parsing - // ----------------------------------------------------------------------- - - /// Parses a bridge "message" event into an `InboundMessage`, returning - /// `None` if it should be ignored (empty content, disallowed user, etc.). - fn parse_bridge_message( - msg: &BridgeMessage, - allowlist: &[String], - deny_by_default: bool, - ) -> Option { - let from = msg.from.as_deref().unwrap_or("").trim().to_string(); - if from.is_empty() { - return None; - } - - let chat_id = msg.chat_id.as_deref().unwrap_or("").trim().to_string(); - if chat_id.is_empty() { - return None; - } - - let content = msg.content.as_deref().unwrap_or("").trim().to_string(); - if content.is_empty() { - return None; - } - - // Allowlist check with deny_by_default support (by phone number). - let allowed = if allowlist.is_empty() { - !deny_by_default - } else { - allowlist.contains(&from) - }; - if !allowed { - info!("WhatsApp: user {} not in allowlist, ignoring message", from); - return None; - } - - let mut inbound = InboundMessage::new("whatsapp", &from, &chat_id, &content); - - if let Some(ref mid) = msg.message_id { - inbound = inbound.with_metadata("whatsapp_message_id", mid); - } - if let Some(ts) = msg.timestamp { - inbound = inbound.with_metadata("timestamp", &ts.to_string()); - } - if let Some(ref name) = msg.sender_name { - inbound = inbound.with_metadata("sender_name", name); - } - - // Decode and attach inline image data from the bridge - if let Some(ref b64_data) = msg.media_base64 { - use base64::Engine; - if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(b64_data) { - if bytes.len() <= 20 * 1024 * 1024 { - let mime = msg.media_mime_type.as_deref().unwrap_or("image/jpeg"); - if mime.starts_with("image/") { - let media = MediaAttachment::new(MediaType::Image) - .with_data(bytes) - .with_mime_type(mime); - inbound = inbound.with_media(media); - } - } - } - } - - Some(inbound) - } - - // ----------------------------------------------------------------------- - // Backoff calculation - // ----------------------------------------------------------------------- - - /// Calculates the exponential backoff delay for a given attempt number. - fn backoff_delay(attempt: u32) -> Duration { - let delay_secs = BASE_RECONNECT_DELAY_SECS - .saturating_mul(2u64.saturating_pow(attempt)) - .min(MAX_RECONNECT_DELAY_SECS); - Duration::from_secs(delay_secs) - } - - // ----------------------------------------------------------------------- - // Error redaction - // ----------------------------------------------------------------------- - - /// Produce a safe log message for a WebSocket connection error. - /// The tungstenite `Error` Debug representation may contain the full HTTP - /// request including the `Authorization: Bearer ` header, so we - /// must not log it verbatim. - fn redact_ws_error(e: &tokio_tungstenite::tungstenite::Error) -> String { - use tokio_tungstenite::tungstenite::Error as WsError; - match e { - WsError::ConnectionClosed => "connection closed".to_string(), - WsError::AlreadyClosed => "already closed".to_string(), - WsError::Io(io_err) => format!("IO error: {}", io_err.kind()), - WsError::Tls(_) => "TLS error".to_string(), - WsError::Capacity(msg) => format!("capacity: {}", msg), - WsError::Protocol(p) => format!("protocol: {}", p), - WsError::WriteBufferFull(_) => "write buffer full".to_string(), - WsError::Utf8(_) => "UTF-8 error".to_string(), - WsError::AttackAttempt => "attack attempt detected".to_string(), - WsError::Url(u) => format!("URL error: {}", u), - // Http variant may contain the full request with Authorization header. - WsError::Http(resp) => format!("HTTP status {}", resp.status()), - WsError::HttpFormat(_) => "HTTP format error".to_string(), - } - } - - // ----------------------------------------------------------------------- - // Bridge WebSocket loop - // ----------------------------------------------------------------------- - - /// Main bridge loop: connects via WebSocket, dispatches inbound messages, - /// and sends outbound messages. Reconnects with exponential backoff. - async fn run_bridge_loop( - bridge_url: String, - bridge_token: Option, - bus: Arc, - allowlist: Vec, - deny_by_default: bool, - mut shutdown_rx: watch::Receiver, - mut outbound_rx: mpsc::Receiver, - ) { - let mut reconnect_attempt: u32 = 0; - - loop { - // Check shutdown before each connection attempt. - if *shutdown_rx.borrow() { - info!("WhatsApp bridge loop shutdown requested"); - return; - } - - // --- WebSocket connect --- - let ws_stream = tokio::select! { - _ = shutdown_rx.changed() => { - info!("WhatsApp bridge loop shutdown requested"); - return; - } - result = async { - if let Some(ref token) = bridge_token { - // Build request with Authorization header. - let request = tokio_tungstenite::tungstenite::http::Request::builder() - .uri(&bridge_url) - .header("Authorization", format!("Bearer {}", token)) - .header("Host", tokio_tungstenite::tungstenite::http::Uri::try_from(bridge_url.as_str()) - .map(|u| u.host().unwrap_or("localhost").to_string()) - .unwrap_or_else(|_| "localhost".to_string())) - .header("Connection", "Upgrade") - .header("Upgrade", "websocket") - .header("Sec-WebSocket-Version", "13") - .header("Sec-WebSocket-Key", tokio_tungstenite::tungstenite::handshake::client::generate_key()) - .body(()) - .expect("valid WebSocket request"); - connect_async(request).await - } else { - connect_async(&bridge_url).await - } - } => { - match result { - Ok((stream, _)) => stream, - Err(e) => { - // Redact the error to avoid leaking the bridge token. - // tungstenite may include the full HTTP request - // (with Authorization header) in error Debug output. - warn!("WhatsApp: bridge connect failed: {}", Self::redact_ws_error(&e)); - let delay = Self::backoff_delay(reconnect_attempt); - reconnect_attempt = - (reconnect_attempt + 1).min(MAX_RECONNECT_ATTEMPTS); - tokio::select! { - _ = shutdown_rx.changed() => return, - _ = tokio::time::sleep(delay) => continue, - } - } - } - } - }; - - info!("WhatsApp bridge WebSocket connected to {}", bridge_url); - reconnect_attempt = 0; - - let (mut ws_writer, mut ws_reader) = ws_stream.split(); - - // --- Main dispatch loop --- - loop { - tokio::select! { - _ = shutdown_rx.changed() => { - info!("WhatsApp bridge loop shutdown requested"); - return; - } - - // Forward outbound messages to bridge. - outbound = outbound_rx.recv() => { - match outbound { - Some(send_msg) => { - match serde_json::to_string(&send_msg) { - Ok(json) => { - if let Err(e) = ws_writer.send(WsMessage::Text(json.into())).await { - warn!("WhatsApp: failed to send to bridge: {}", e); - break; - } - } - Err(e) => { - error!("WhatsApp: failed to serialize outbound: {}", e); - } - } - } - None => { - debug!("WhatsApp outbound channel closed"); - break; - } - } - } - - // Process incoming bridge events. - msg = ws_reader.next() => { - match msg { - Some(Ok(WsMessage::Text(raw))) => { - match serde_json::from_str::(&raw) { - Ok(bridge_msg) => { - match bridge_msg.msg_type.as_str() { - "message" => { - if let Some(inbound) = - Self::parse_bridge_message(&bridge_msg, &allowlist, deny_by_default) - { - if let Err(e) = - bus.publish_inbound(inbound).await - { - error!( - "Failed to publish WhatsApp inbound message: {}", - e - ); - } - } - } - "connected" => { - info!("WhatsApp bridge: connected to WhatsApp"); - } - "disconnected" => { - let reason = bridge_msg - .reason - .as_deref() - .unwrap_or("unknown"); - warn!( - "WhatsApp bridge: disconnected (reason: {})", - reason - ); - break; // Reconnect - } - "qr_code" => { - info!( - "WhatsApp bridge: QR code received (display on bridge terminal)" - ); - } - other => { - debug!( - "WhatsApp bridge: unknown message type '{}'", - other - ); - } - } - } - Err(e) => { - debug!("WhatsApp: failed to parse bridge message: {}", e); - } - } - } - Some(Ok(WsMessage::Ping(payload))) => { - if let Err(e) = ws_writer.send(WsMessage::Pong(payload)).await { - warn!("WhatsApp: pong send failed: {}", e); - break; - } - } - Some(Ok(WsMessage::Close(frame))) => { - info!("WhatsApp: bridge WebSocket closed: {:?}", frame); - break; - } - Some(Ok(_)) => {} - Some(Err(e)) => { - warn!("WhatsApp: bridge WebSocket error: {}", e); - break; - } - None => { - warn!("WhatsApp: bridge WebSocket stream ended"); - break; - } - } - } - } - } - - // --- Wait before reconnecting --- - let delay = Self::backoff_delay(reconnect_attempt); - reconnect_attempt = (reconnect_attempt + 1).min(MAX_RECONNECT_ATTEMPTS); - info!( - "WhatsApp: reconnecting to bridge in {} seconds", - delay.as_secs() - ); - tokio::select! { - _ = shutdown_rx.changed() => return, - _ = tokio::time::sleep(delay) => {}, - } - } - } -} - -// --------------------------------------------------------------------------- -// Channel trait implementation -// --------------------------------------------------------------------------- - -#[async_trait] -impl Channel for WhatsAppChannel { - fn name(&self) -> &str { - "whatsapp" - } - - async fn start(&mut self) -> Result<()> { - if self.running.swap(true, Ordering::SeqCst) { - info!("WhatsApp channel already running"); - return Ok(()); - } - - if !self.config.enabled { - warn!("WhatsApp channel is disabled in configuration"); - self.running.store(false, Ordering::SeqCst); - return Ok(()); - } - - let bridge_url = self.config.bridge_url.trim().to_string(); - if bridge_url.is_empty() { - self.running.store(false, Ordering::SeqCst); - return Err(ZeptoError::Config( - "WhatsApp bridge URL is empty".to_string(), - )); - } - - let (shutdown_tx, shutdown_rx) = watch::channel(false); - self.shutdown_tx = Some(shutdown_tx); - - let (outbound_tx, outbound_rx) = mpsc::channel(64); - self.outbound_tx = Some(outbound_tx); - - info!("Starting WhatsApp channel with bridge at {}", bridge_url); - let running_clone = Arc::clone(&self.running); - let bridge_token = self.config.bridge_token.clone(); - let bus = Arc::clone(&self.bus); - let allow_from = self.config.allow_from.clone(); - let deny_by_default = self.config.deny_by_default; - tokio::spawn(async move { - let task_result = std::panic::AssertUnwindSafe(async move { - Self::run_bridge_loop( - bridge_url, - bridge_token, - bus, - allow_from, - deny_by_default, - shutdown_rx, - outbound_rx, - ) - .await; - }) - .catch_unwind() - .await; - if task_result.is_err() { - error!("WhatsApp bridge task panicked"); - } - running_clone.store(false, Ordering::SeqCst); - }); - - Ok(()) - } - - async fn stop(&mut self) -> Result<()> { - if !self.running.swap(false, Ordering::SeqCst) { - info!("WhatsApp channel already stopped"); - return Ok(()); - } - - if let Some(tx) = self.shutdown_tx.take() { - let _ = tx.send(true); - } - self.outbound_tx = None; - - info!("WhatsApp channel stopped"); - Ok(()) - } - - async fn send(&self, msg: OutboundMessage) -> Result<()> { - if !self.running.load(Ordering::SeqCst) { - return Err(ZeptoError::Channel( - "WhatsApp channel not running".to_string(), - )); - } - - let tx = self.outbound_tx.as_ref().ok_or_else(|| { - ZeptoError::Channel("WhatsApp outbound channel not initialized".to_string()) - })?; - - let to = msg.chat_id.trim().to_string(); - if to.is_empty() { - return Err(ZeptoError::Channel( - "WhatsApp recipient chat ID cannot be empty".to_string(), - )); - } - - let send_msg = BridgeSendMessage { - msg_type: "send".to_string(), - to, - content: msg.content.clone(), - reply_to: msg.reply_to.clone(), - }; - - tx.send(send_msg).await.map_err(|e| { - ZeptoError::Channel(format!("Failed to queue WhatsApp outbound message: {}", e)) - })?; - - info!("WhatsApp: message queued for sending"); - Ok(()) - } - - fn is_running(&self) -> bool { - self.running.load(Ordering::SeqCst) - } - - fn is_allowed(&self, user_id: &str) -> bool { - self.base_config.is_allowed(user_id) - } -} - -impl HasDependencies for WhatsAppChannel { - fn dependencies(&self) -> Vec { - if !self.config.bridge_managed { - return vec![]; - } - - vec![Dependency { - name: "whatsmeow-bridge".to_string(), - kind: DepKind::Binary { - repo: "qhkm/whatsmeow-rs".to_string(), - asset_pattern: "whatsmeow-bridge-{os}-{arch}".to_string(), - version: String::new(), // latest - }, - health_check: HealthCheck::WebSocket { - url: self.config.bridge_url.clone(), - }, - env: std::collections::HashMap::new(), - args: vec![], - }] - } -} - -// =========================================================================== -// Tests -// =========================================================================== - -#[cfg(test)] -mod tests { - use super::*; - - fn test_bus() -> Arc { - Arc::new(MessageBus::new()) - } - - fn test_config() -> WhatsAppConfig { - WhatsAppConfig { - enabled: true, - bridge_url: "ws://localhost:3001".to_string(), - allow_from: vec!["60123456789".to_string()], - bridge_managed: true, - ..Default::default() - } - } - - // ----------------------------------------------------------------------- - // 1. Channel name - // ----------------------------------------------------------------------- - #[test] - fn test_channel_name() { - let channel = WhatsAppChannel::new(test_config(), test_bus()); - assert_eq!(channel.name(), "whatsapp"); - } - - // ----------------------------------------------------------------------- - // 2. Config initialization - // ----------------------------------------------------------------------- - #[test] - fn test_config_initialization() { - let config = WhatsAppConfig { - enabled: true, - bridge_url: "ws://bridge:3001".to_string(), - allow_from: vec!["U1".to_string(), "U2".to_string()], - bridge_managed: true, - ..Default::default() - }; - let channel = WhatsAppChannel::new(config, test_bus()); - - assert!(channel.is_enabled()); - assert_eq!(channel.whatsapp_config().bridge_url, "ws://bridge:3001"); - assert_eq!(channel.whatsapp_config().allow_from.len(), 2); - assert!(!channel.is_running()); - } - - // ----------------------------------------------------------------------- - // 3. is_allowed delegation - // ----------------------------------------------------------------------- - #[test] - fn test_is_allowed_delegation() { - let channel = WhatsAppChannel::new(test_config(), test_bus()); - - assert!(channel.is_allowed("60123456789")); - assert!(!channel.is_allowed("999999999")); - } - - #[test] - fn test_is_allowed_empty_allowlist() { - let config = WhatsAppConfig { - enabled: true, - bridge_url: "ws://localhost:3001".to_string(), - allow_from: vec![], - bridge_managed: true, - ..Default::default() - }; - let channel = WhatsAppChannel::new(config, test_bus()); - - assert!(channel.is_allowed("anyone")); - assert!(channel.is_allowed("literally_anyone")); - } - - // ----------------------------------------------------------------------- - // 4. BridgeMessage deserialization - // ----------------------------------------------------------------------- - #[test] - fn test_bridge_message_deser_message_type() { - let json = r#"{ - "type": "message", - "from": "60123456789", - "chat_id": "60123456789@s.whatsapp.net", - "content": "Hello!", - "message_id": "wamid.xyz", - "timestamp": 1707900000, - "sender_name": "John" - }"#; - let msg: BridgeMessage = serde_json::from_str(json).expect("should parse"); - - assert_eq!(msg.msg_type, "message"); - assert_eq!(msg.from.as_deref(), Some("60123456789")); - assert_eq!(msg.chat_id.as_deref(), Some("60123456789@s.whatsapp.net")); - assert_eq!(msg.content.as_deref(), Some("Hello!")); - assert_eq!(msg.message_id.as_deref(), Some("wamid.xyz")); - assert_eq!(msg.timestamp, Some(1707900000)); - assert_eq!(msg.sender_name.as_deref(), Some("John")); - } - - #[test] - fn test_bridge_message_deser_connected() { - let json = r#"{"type": "connected"}"#; - let msg: BridgeMessage = serde_json::from_str(json).expect("should parse"); - assert_eq!(msg.msg_type, "connected"); - assert!(msg.from.is_none()); - } - - #[test] - fn test_bridge_message_deser_disconnected() { - let json = r#"{"type": "disconnected", "reason": "session expired"}"#; - let msg: BridgeMessage = serde_json::from_str(json).expect("should parse"); - assert_eq!(msg.msg_type, "disconnected"); - assert_eq!(msg.reason.as_deref(), Some("session expired")); - } - - #[test] - fn test_bridge_message_deser_qr_code() { - let json = r#"{"type": "qr_code", "data": "2@base64data"}"#; - let msg: BridgeMessage = serde_json::from_str(json).expect("should parse"); - assert_eq!(msg.msg_type, "qr_code"); - assert_eq!(msg.data.as_deref(), Some("2@base64data")); - } - - #[test] - fn test_bridge_message_deser_unknown_type() { - let json = r#"{"type": "future_event", "extra": true}"#; - let msg: BridgeMessage = serde_json::from_str(json).expect("should parse"); - assert_eq!(msg.msg_type, "future_event"); - } - - // ----------------------------------------------------------------------- - // 5. parse_bridge_message - // ----------------------------------------------------------------------- - #[test] - fn test_parse_bridge_message_valid() { - let msg = BridgeMessage { - msg_type: "message".to_string(), - from: Some("60123456789".to_string()), - chat_id: Some("60123456789@s.whatsapp.net".to_string()), - content: Some("Hello!".to_string()), - message_id: Some("wamid.xyz".to_string()), - timestamp: Some(1707900000), - sender_name: Some("John".to_string()), - reason: None, - data: None, - media_base64: None, - media_mime_type: None, - }; - - let inbound = WhatsAppChannel::parse_bridge_message(&msg, &[], false); - assert!(inbound.is_some()); - let inbound = inbound.unwrap(); - assert_eq!(inbound.channel, "whatsapp"); - assert_eq!(inbound.sender_id, "60123456789"); - assert_eq!(inbound.chat_id, "60123456789@s.whatsapp.net"); - assert_eq!(inbound.content, "Hello!"); - assert_eq!( - inbound.metadata.get("whatsapp_message_id"), - Some(&"wamid.xyz".to_string()) - ); - assert_eq!( - inbound.metadata.get("timestamp"), - Some(&"1707900000".to_string()) - ); - assert_eq!( - inbound.metadata.get("sender_name"), - Some(&"John".to_string()) - ); - } - - #[test] - fn test_parse_bridge_message_allowlist_allowed() { - let msg = BridgeMessage { - msg_type: "message".to_string(), - from: Some("60123456789".to_string()), - chat_id: Some("60123456789@s.whatsapp.net".to_string()), - content: Some("test".to_string()), - message_id: None, - timestamp: None, - sender_name: None, - reason: None, - data: None, - media_base64: None, - media_mime_type: None, - }; - - let result = - WhatsAppChannel::parse_bridge_message(&msg, &["60123456789".to_string()], false); - assert!(result.is_some()); - } - - #[test] - fn test_parse_bridge_message_allowlist_denied() { - let msg = BridgeMessage { - msg_type: "message".to_string(), - from: Some("60123456789".to_string()), - chat_id: Some("60123456789@s.whatsapp.net".to_string()), - content: Some("test".to_string()), - message_id: None, - timestamp: None, - sender_name: None, - reason: None, - data: None, - media_base64: None, - media_mime_type: None, - }; - - let result = - WhatsAppChannel::parse_bridge_message(&msg, &["60999999999".to_string()], false); - assert!(result.is_none()); - } - - #[test] - fn test_parse_bridge_message_empty_content() { - let msg = BridgeMessage { - msg_type: "message".to_string(), - from: Some("60123456789".to_string()), - chat_id: Some("60123456789@s.whatsapp.net".to_string()), - content: Some(" ".to_string()), - message_id: None, - timestamp: None, - sender_name: None, - reason: None, - data: None, - media_base64: None, - media_mime_type: None, - }; - - let result = WhatsAppChannel::parse_bridge_message(&msg, &[], false); - assert!(result.is_none()); - } - - #[test] - fn test_parse_bridge_message_missing_from() { - let msg = BridgeMessage { - msg_type: "message".to_string(), - from: None, - chat_id: Some("60123456789@s.whatsapp.net".to_string()), - content: Some("Hello".to_string()), - message_id: None, - timestamp: None, - sender_name: None, - reason: None, - data: None, - media_base64: None, - media_mime_type: None, - }; - - let result = WhatsAppChannel::parse_bridge_message(&msg, &[], false); - assert!(result.is_none()); - } - - #[test] - fn test_parse_bridge_message_missing_chat_id() { - let msg = BridgeMessage { - msg_type: "message".to_string(), - from: Some("60123456789".to_string()), - chat_id: None, - content: Some("Hello".to_string()), - message_id: None, - timestamp: None, - sender_name: None, - reason: None, - data: None, - media_base64: None, - media_mime_type: None, - }; - - let result = WhatsAppChannel::parse_bridge_message(&msg, &[], false); - assert!(result.is_none()); - } - - #[test] - fn test_parse_bridge_message_content_trimmed() { - let msg = BridgeMessage { - msg_type: "message".to_string(), - from: Some("60123456789".to_string()), - chat_id: Some("60123456789@s.whatsapp.net".to_string()), - content: Some(" padded message ".to_string()), - message_id: None, - timestamp: None, - sender_name: None, - reason: None, - data: None, - media_base64: None, - media_mime_type: None, - }; - - let inbound = WhatsAppChannel::parse_bridge_message(&msg, &[], false).unwrap(); - assert_eq!(inbound.content, "padded message"); - } - - #[test] - fn test_parse_bridge_message_no_optional_metadata() { - let msg = BridgeMessage { - msg_type: "message".to_string(), - from: Some("60123456789".to_string()), - chat_id: Some("60123456789@s.whatsapp.net".to_string()), - content: Some("Hello".to_string()), - message_id: None, - timestamp: None, - sender_name: None, - reason: None, - data: None, - media_base64: None, - media_mime_type: None, - }; - - let inbound = WhatsAppChannel::parse_bridge_message(&msg, &[], false).unwrap(); - assert!(!inbound.metadata.contains_key("whatsapp_message_id")); - assert!(!inbound.metadata.contains_key("timestamp")); - assert!(!inbound.metadata.contains_key("sender_name")); - } - - // ----------------------------------------------------------------------- - // 6. BridgeSendMessage serialization - // ----------------------------------------------------------------------- - #[test] - fn test_bridge_send_message_with_reply() { - let msg = BridgeSendMessage { - msg_type: "send".to_string(), - to: "60123456789@s.whatsapp.net".to_string(), - content: "Reply text".to_string(), - reply_to: Some("wamid.xyz".to_string()), - }; - let json = serde_json::to_value(&msg).expect("should serialize"); - - assert_eq!(json["type"], "send"); - assert_eq!(json["to"], "60123456789@s.whatsapp.net"); - assert_eq!(json["content"], "Reply text"); - assert_eq!(json["reply_to"], "wamid.xyz"); - } - - #[test] - fn test_bridge_send_message_without_reply() { - let msg = BridgeSendMessage { - msg_type: "send".to_string(), - to: "60123456789@s.whatsapp.net".to_string(), - content: "Hello!".to_string(), - reply_to: None, - }; - let json = serde_json::to_value(&msg).expect("should serialize"); - - assert_eq!(json["type"], "send"); - assert_eq!(json["to"], "60123456789@s.whatsapp.net"); - assert_eq!(json["content"], "Hello!"); - assert!(json.get("reply_to").is_none()); // skip_serializing_if - } - - #[test] - fn test_bridge_send_message_roundtrip() { - let msg = BridgeSendMessage { - msg_type: "send".to_string(), - to: "60123456789@s.whatsapp.net".to_string(), - content: "Test message".to_string(), - reply_to: Some("wamid.abc".to_string()), - }; - let json_str = serde_json::to_string(&msg).expect("should serialize"); - assert!(json_str.contains(r#""type":"send""#)); - assert!(json_str.contains(r#""reply_to":"wamid.abc""#)); - } - - // ----------------------------------------------------------------------- - // 7. Running state management - // ----------------------------------------------------------------------- - #[tokio::test] - async fn test_running_state_default() { - let channel = WhatsAppChannel::new(test_config(), test_bus()); - assert!(!channel.is_running()); - } - - #[tokio::test] - async fn test_start_disabled_config() { - let config = WhatsAppConfig { - enabled: false, - bridge_url: "ws://localhost:3001".to_string(), - allow_from: vec![], - bridge_managed: true, - ..Default::default() - }; - let mut channel = WhatsAppChannel::new(config, test_bus()); - - let result = channel.start().await; - assert!(result.is_ok()); - assert!(!channel.is_running()); - } - - #[tokio::test] - async fn test_start_empty_bridge_url() { - let config = WhatsAppConfig { - enabled: true, - bridge_url: String::new(), - allow_from: vec![], - bridge_managed: true, - ..Default::default() - }; - let mut channel = WhatsAppChannel::new(config, test_bus()); - - let result = channel.start().await; - assert!(result.is_err()); - assert!(!channel.is_running()); - } - - #[tokio::test] - async fn test_stop_not_running() { - let mut channel = WhatsAppChannel::new(test_config(), test_bus()); - let result = channel.stop().await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_send_not_running() { - let channel = WhatsAppChannel::new(test_config(), test_bus()); - let msg = OutboundMessage::new("whatsapp", "60123456789@s.whatsapp.net", "Hello"); - let result = channel.send(msg).await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_send_empty_chat_id() { - // Start the channel so it's "running" - let config = WhatsAppConfig { - enabled: true, - bridge_url: "ws://localhost:3001".to_string(), - allow_from: vec![], - bridge_managed: true, - ..Default::default() - }; - let mut channel = WhatsAppChannel::new(config, test_bus()); - // Manually set running + outbound channel (avoids actual WebSocket connect) - channel.running.store(true, Ordering::SeqCst); - let (tx, _rx) = mpsc::channel(64); - channel.outbound_tx = Some(tx); - - let msg = OutboundMessage::new("whatsapp", " ", "Hello"); - let result = channel.send(msg).await; - assert!(result.is_err()); - } - - // ----------------------------------------------------------------------- - // 8. Backoff delay calculations - // ----------------------------------------------------------------------- - #[test] - fn test_backoff_delay_increases_exponentially() { - let d0 = WhatsAppChannel::backoff_delay(0); - let d1 = WhatsAppChannel::backoff_delay(1); - let d2 = WhatsAppChannel::backoff_delay(2); - let d3 = WhatsAppChannel::backoff_delay(3); - - assert_eq!(d0, Duration::from_secs(2)); // 2 * 2^0 = 2 - assert_eq!(d1, Duration::from_secs(4)); // 2 * 2^1 = 4 - assert_eq!(d2, Duration::from_secs(8)); // 2 * 2^2 = 8 - assert_eq!(d3, Duration::from_secs(16)); // 2 * 2^3 = 16 - } - - #[test] - fn test_backoff_delay_caps_at_max() { - let d_high = WhatsAppChannel::backoff_delay(20); - assert_eq!(d_high, Duration::from_secs(MAX_RECONNECT_DELAY_SECS)); - } - - #[test] - fn test_backoff_delay_does_not_overflow() { - let d = WhatsAppChannel::backoff_delay(u32::MAX); - assert_eq!(d, Duration::from_secs(MAX_RECONNECT_DELAY_SECS)); - } - - // ----------------------------------------------------------------------- - // 9. WhatsAppConfig serde defaults - // ----------------------------------------------------------------------- - #[test] - fn test_whatsapp_config_deserialize_defaults() { - let json = r#"{}"#; - let config: WhatsAppConfig = serde_json::from_str(json).expect("should parse"); - - assert!(!config.enabled); - assert_eq!(config.bridge_url, "ws://localhost:3001"); - assert!(config.allow_from.is_empty()); - } - - #[test] - fn test_whatsapp_config_deserialize_full() { - let json = r#"{ - "enabled": true, - "bridge_url": "ws://remote:9000", - "allow_from": ["601", "602", "603"] - }"#; - let config: WhatsAppConfig = serde_json::from_str(json).expect("should parse"); - - assert!(config.enabled); - assert_eq!(config.bridge_url, "ws://remote:9000"); - assert_eq!(config.allow_from, vec!["601", "602", "603"]); - } - - #[test] - fn test_whatsapp_config_default_trait() { - let config = WhatsAppConfig::default(); - assert!(!config.enabled); - assert_eq!(config.bridge_url, "ws://localhost:3001"); - assert!(config.allow_from.is_empty()); - assert!(config.bridge_managed); - } - - // ----------------------------------------------------------------------- - // 10. bridge_managed config - // ----------------------------------------------------------------------- - #[test] - fn test_whatsapp_config_bridge_managed_default() { - let json = r#"{}"#; - let config: WhatsAppConfig = serde_json::from_str(json).expect("should parse"); - assert!(config.bridge_managed); - } - - #[test] - fn test_whatsapp_config_bridge_managed_false() { - let json = r#"{"bridge_managed": false}"#; - let config: WhatsAppConfig = serde_json::from_str(json).expect("should parse"); - assert!(!config.bridge_managed); - } - - // ----------------------------------------------------------------------- - // 11. HasDependencies - // ----------------------------------------------------------------------- - #[test] - fn test_has_dependencies_managed() { - let mut config = test_config(); - config.bridge_managed = true; - let channel = WhatsAppChannel::new(config, test_bus()); - let deps = channel.dependencies(); - assert_eq!(deps.len(), 1); - assert_eq!(deps[0].name, "whatsmeow-bridge"); - } - - #[test] - fn test_has_dependencies_unmanaged() { - let mut config = test_config(); - config.bridge_managed = false; - let channel = WhatsAppChannel::new(config, test_bus()); - let deps = channel.dependencies(); - assert!(deps.is_empty()); - } - - // ----------------------------------------------------------------------- - // 12. Bridge token auth - // ----------------------------------------------------------------------- - #[test] - fn test_bridge_token_default_none() { - let config = WhatsAppConfig::default(); - assert!(config.bridge_token.is_none()); - } - - #[test] - fn test_bridge_token_deserialize_with_token() { - let json = r#"{"bridge_token": "secret-tok-123"}"#; - let config: WhatsAppConfig = serde_json::from_str(json).expect("should parse"); - assert_eq!(config.bridge_token.as_deref(), Some("secret-tok-123")); - } - - #[test] - fn test_bridge_token_serde_roundtrip() { - let config = WhatsAppConfig { - bridge_token: Some("my-token".to_string()), - ..Default::default() - }; - let json = serde_json::to_string(&config).expect("should serialize"); - let parsed: WhatsAppConfig = serde_json::from_str(&json).expect("should parse"); - assert_eq!(parsed.bridge_token.as_deref(), Some("my-token")); - } - - #[test] - fn test_bridge_token_env_override() { - // Env override is tested in config module; here just verify the field is accessible. - let mut config = WhatsAppConfig::default(); - config.bridge_token = Some("env-token".to_string()); - assert_eq!(config.bridge_token.as_deref(), Some("env-token")); - } -} diff --git a/src/channels/whatsapp_web.rs b/src/channels/whatsapp_web.rs new file mode 100644 index 00000000..ec5eef3b --- /dev/null +++ b/src/channels/whatsapp_web.rs @@ -0,0 +1,593 @@ +//! WhatsApp Web native channel via wa-rs. +//! +//! Pairs via QR code like WhatsApp Desktop and persists session state to a +//! local SQLite database. + +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::Mutex; +use tokio::task::JoinHandle; +use tracing::{error, info, warn}; +use wa_rs::bot::Bot; +use wa_rs::proto_helpers::MessageExt; +use wa_rs::store::SqliteStore; +use wa_rs::types::events::Event; +use wa_rs::wa_rs_proto::whatsapp as wa; +use wa_rs::{Client, Jid}; +use wa_rs_tokio_transport::TokioWebSocketTransportFactory; +use wa_rs_ureq_http::UreqHttpClient; + +use qrcode::QrCode; + +use crate::bus::{InboundMessage, MessageBus, OutboundMessage}; +use crate::channels::types::{BaseChannelConfig, Channel}; +use crate::config::WhatsAppWebConfig; +use crate::error::{Result, ZeptoError}; + +/// Render a QR code string as unicode block characters in the terminal. +/// +/// Uses 2x1 half-block characters for compact display: +/// - `█` (U+2588) = both pixels dark +/// - `▀` (U+2580) = top dark, bottom light +/// - `▄` (U+2584) = top light, bottom dark +/// - ` ` (space) = both pixels light +fn render_qr_terminal(data: &str) -> Option { + let code = QrCode::new(data.as_bytes()).ok()?; + let width = code.width(); + let colors: Vec = code + .into_colors() + .into_iter() + .map(|c| c == qrcode::Color::Dark) + .collect(); + + // Add 1-module quiet zone on each side + let padded_width = width + 2; + let padded_height = width + 2; + + let pixel = |row: usize, col: usize| -> bool { + if row == 0 || row > width || col == 0 || col > width { + false // quiet zone + } else { + colors[(row - 1) * width + (col - 1)] + } + }; + + let mut output = String::new(); + let mut y = 0; + while y < padded_height { + for x in 0..padded_width { + let top = pixel(y, x); + let bottom = if y + 1 < padded_height { + pixel(y + 1, x) + } else { + false + }; + output.push(match (top, bottom) { + (true, true) => '\u{2588}', + (true, false) => '\u{2580}', + (false, true) => '\u{2584}', + (false, false) => ' ', + }); + } + output.push('\n'); + y += 2; + } + Some(output) +} + +fn normalize_phone(phone: &str) -> String { + phone + .trim() + .split('@') + .next() + .unwrap_or_default() + .chars() + .filter(|c| c.is_ascii_digit()) + .collect() +} + +fn expand_auth_dir(path: &str) -> String { + if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest).to_string_lossy().to_string(); + } + } + path.to_string() +} + +fn sqlite_store_path(path: &str) -> PathBuf { + let expanded = PathBuf::from(expand_auth_dir(path)); + if expanded.extension().is_some() { + expanded + } else { + expanded.join("session.sqlite3") + } +} + +/// Resolve a sender to a phone number for allowlist matching. +/// +/// WhatsApp Web may deliver messages with a LID-based sender JID +/// (e.g. `78971563720736@lid`) instead of a phone number JID +/// (`60123456789@s.whatsapp.net`). The `sender_alt` field on +/// `MessageSource` contains the phone number JID when the primary +/// sender is a LID. This function checks `sender_alt` first, +/// falling back to the primary sender JID. +fn resolve_sender_phone(sender: &Jid, sender_alt: &Option) -> String { + // Prefer sender_alt (contains phone JID when sender is LID) + if let Some(ref alt) = sender_alt { + if alt.is_pn() { + return normalize_phone(&alt.to_string()); + } + } + // If sender itself is a phone JID, use it directly + if sender.is_pn() { + return normalize_phone(&sender.to_string()); + } + // Fallback: use sender as-is (LID user part) + normalize_phone(&sender.to_string()) +} + +fn parse_chat_jid(chat_id: &str) -> Result { + let jid = if chat_id.contains('@') { + chat_id.trim().to_string() + } else { + format!("{}@s.whatsapp.net", normalize_phone(chat_id)) + }; + + Jid::from_str(&jid) + .map_err(|e| ZeptoError::Channel(format!("WhatsApp Web: invalid recipient '{jid}': {e}"))) +} + +fn build_outbound_message(msg: &OutboundMessage) -> wa::Message { + if let Some(reply_to) = msg.reply_to.as_deref() { + wa::Message { + extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + text: Some(msg.content.clone()), + context_info: Some(Box::new(wa::ContextInfo { + stanza_id: Some(reply_to.to_string()), + ..Default::default() + })), + ..Default::default() + })), + ..Default::default() + } + } else { + wa::Message { + conversation: Some(msg.content.clone()), + ..Default::default() + } + } +} + +struct RuntimeState { + client: Arc, + task: JoinHandle<()>, +} + +/// WhatsApp Web channel using the native wa-rs protocol. +pub struct WhatsAppWebChannel { + config: WhatsAppWebConfig, + base_config: BaseChannelConfig, + bus: Arc, + running: Arc, + runtime: Arc>>, +} + +impl WhatsAppWebChannel { + pub fn new(config: WhatsAppWebConfig, bus: Arc) -> Self { + let normalized_allowlist: Vec = config + .allow_from + .iter() + .map(|p| normalize_phone(p)) + .collect(); + + let base_config = BaseChannelConfig { + name: "whatsapp_web".to_string(), + allowlist: normalized_allowlist, + deny_by_default: config.deny_by_default, + }; + + Self { + config, + base_config, + bus, + running: Arc::new(AtomicBool::new(false)), + runtime: Arc::new(Mutex::new(None)), + } + } +} + +#[async_trait] +impl Channel for WhatsAppWebChannel { + fn name(&self) -> &str { + &self.base_config.name + } + + async fn start(&mut self) -> Result<()> { + { + let runtime = self.runtime.lock().await; + if runtime.is_some() && self.running.load(Ordering::SeqCst) { + info!("WhatsApp Web channel already running"); + return Ok(()); + } + } + + let store_path = sqlite_store_path(&self.config.auth_dir); + if let Some(parent) = store_path.parent() { + tokio::fs::create_dir_all(parent).await.map_err(|e| { + ZeptoError::Channel(format!( + "WhatsApp Web: failed to create auth directory {}: {}", + parent.display(), + e + )) + })?; + } + + let store = Arc::new( + SqliteStore::new(store_path.to_str().ok_or_else(|| { + ZeptoError::Channel(format!( + "WhatsApp Web: invalid auth path {}", + store_path.display() + )) + })?) + .await + .map_err(|e| { + ZeptoError::Channel(format!( + "WhatsApp Web: failed to initialize SQLite store {}: {}", + store_path.display(), + e + )) + })?, + ); + + let bus = self.bus.clone(); + let base_config = self.base_config.clone(); + let running = self.running.clone(); + + let mut bot = Bot::builder() + .with_backend(store) + .with_transport_factory(TokioWebSocketTransportFactory::new()) + .with_http_client(UreqHttpClient::new()) + .on_event(move |event, client| { + let bus = bus.clone(); + let base_config = base_config.clone(); + let running = running.clone(); + async move { + match event { + Event::Connected(_) => { + running.store(true, Ordering::SeqCst); + info!("WhatsApp Web connected"); + } + Event::PairingQrCode { code, timeout } => { + eprintln!(); + eprintln!("╔══════════════════════════════════════════╗"); + eprintln!("║ Scan this QR code with WhatsApp ║"); + eprintln!("║ Phone → Settings → Linked Devices ║"); + eprintln!( + "║ Valid for {}s ║", + timeout.as_secs() + ); + eprintln!("╚══════════════════════════════════════════╝"); + eprintln!(); + match render_qr_terminal(&code) { + Some(qr) => eprint!("{}", qr), + None => { + eprintln!("Failed to render QR code. Please try again."); + } + } + eprintln!(); + } + Event::PairingCode { code, timeout } => { + eprintln!( + "WhatsApp Web pair code (valid for {}s): {}", + timeout.as_secs(), + code + ); + } + Event::LoggedOut(reason) => { + running.store(false, Ordering::SeqCst); + warn!("WhatsApp Web logged out: {:?}", reason.reason); + } + Event::Disconnected(_) => { + // Don't set running = false here — wa-rs will + // reconnect automatically. Only mark dead on + // LoggedOut or when the bot task actually exits. + warn!("WhatsApp Web disconnected (will reconnect)"); + } + Event::Message(message, info) => { + if info.source.is_from_me { + return; + } + + let sender_jid = info.source.sender.to_string(); + let sender_id = + resolve_sender_phone(&info.source.sender, &info.source.sender_alt); + if !base_config.is_allowed(&sender_id) { + info!( + "WhatsApp Web: sender {} (jid: {}) not in allowlist, ignoring", + sender_id, sender_jid + ); + return; + } + + let content = message + .text_content() + .or_else(|| message.get_caption()) + .map(str::trim) + .unwrap_or_default() + .to_string(); + + if content.is_empty() { + return; + } + + let chat_id = info.source.chat.to_string(); + let mut inbound = + InboundMessage::new("whatsapp_web", &sender_id, &chat_id, &content) + .with_metadata("whatsapp_message_id", &info.id) + .with_metadata("sender_jid", &sender_jid) + .with_metadata("chat_jid", &chat_id); + + if !info.push_name.is_empty() { + inbound = inbound.with_metadata("sender_name", &info.push_name); + } + if info.source.is_group { + inbound = inbound.with_metadata("is_group", "true"); + } + + if let Err(e) = bus.publish_inbound(inbound).await { + error!("WhatsApp Web: failed to publish inbound message: {}", e); + } + } + Event::PairError(err) => { + warn!("WhatsApp Web pairing failed: {}", err.error); + } + _ => { + let _ = client; + } + } + } + }) + .build() + .await + .map_err(|e| ZeptoError::Channel(format!("WhatsApp Web: bot build failed: {e}")))?; + + let client = bot.client(); + let run_handle = bot + .run() + .await + .map_err(|e| ZeptoError::Channel(format!("WhatsApp Web: bot run failed: {e}")))?; + + self.running.store(true, Ordering::SeqCst); + let running = self.running.clone(); + let task = tokio::spawn(async move { + if let Err(e) = run_handle.await { + error!("WhatsApp Web task failed: {}", e); + } + running.store(false, Ordering::SeqCst); + }); + + let mut runtime = self.runtime.lock().await; + *runtime = Some(RuntimeState { client, task }); + + info!( + auth_db = %store_path.display(), + "WhatsApp Web channel started" + ); + Ok(()) + } + + async fn stop(&mut self) -> Result<()> { + let state = self.runtime.lock().await.take(); + let Some(state) = state else { + self.running.store(false, Ordering::SeqCst); + return Ok(()); + }; + + state.client.disconnect().await; + + match tokio::time::timeout(std::time::Duration::from_secs(10), state.task).await { + Ok(Ok(())) => {} + Ok(Err(e)) => error!("WhatsApp Web task join failed: {}", e), + Err(_) => warn!("WhatsApp Web task did not stop within 10 seconds"), + } + + self.running.store(false, Ordering::SeqCst); + info!("WhatsApp Web channel stopped"); + Ok(()) + } + + async fn send(&self, msg: OutboundMessage) -> Result<()> { + if msg.content.trim().is_empty() { + return Err(ZeptoError::Channel( + "WhatsApp Web: outbound content cannot be empty".to_string(), + )); + } + + let client = { + let runtime = self.runtime.lock().await; + runtime + .as_ref() + .map(|state| state.client.clone()) + .ok_or_else(|| { + ZeptoError::Channel("WhatsApp Web: channel not started".to_string()) + })? + }; + + let jid = parse_chat_jid(&msg.chat_id)?; + let wa_message = build_outbound_message(&msg); + client + .send_message(jid, wa_message) + .await + .map_err(|e| ZeptoError::Channel(format!("WhatsApp Web: send failed: {e}")))?; + + Ok(()) + } + + fn is_running(&self) -> bool { + self.running.load(Ordering::SeqCst) + } + + fn is_allowed(&self, user_id: &str) -> bool { + self.base_config.is_allowed(&normalize_phone(user_id)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bus::MessageBus; + + fn make_channel(config: WhatsAppWebConfig) -> WhatsAppWebChannel { + WhatsAppWebChannel::new(config, Arc::new(MessageBus::new())) + } + + #[test] + fn test_default_config() { + let cfg = WhatsAppWebConfig::default(); + assert!(!cfg.enabled); + assert_eq!(cfg.auth_dir, "~/.zeptoclaw/state/whatsapp_web"); + assert!(cfg.allow_from.is_empty()); + assert!(!cfg.deny_by_default); + } + + #[test] + fn test_normalize_phone_with_plus() { + assert_eq!(normalize_phone("+60123456789"), "60123456789"); + } + + #[test] + fn test_normalize_phone_jid() { + assert_eq!(normalize_phone("60123456789@s.whatsapp.net"), "60123456789"); + } + + #[test] + fn test_expand_auth_dir_tilde() { + let expanded = expand_auth_dir("~/.zeptoclaw/state/whatsapp_web"); + if dirs::home_dir().is_some() { + assert!(!expanded.starts_with('~')); + } + } + + #[test] + fn test_sqlite_store_path_from_directory() { + let path = sqlite_store_path("/tmp/wa-state"); + assert_eq!(path, std::path::Path::new("/tmp/wa-state/session.sqlite3")); + } + + #[test] + fn test_sqlite_store_path_from_file() { + let path = sqlite_store_path("/tmp/wa-state.sqlite"); + assert_eq!(path, std::path::Path::new("/tmp/wa-state.sqlite")); + } + + #[test] + fn test_is_allowed_normalized_match() { + let ch = make_channel(WhatsAppWebConfig { + allow_from: vec!["+60123456789".to_string()], + ..Default::default() + }); + assert!(ch.is_allowed("60123456789@s.whatsapp.net")); + } + + #[test] + fn test_is_allowed_deny_by_default() { + let ch = make_channel(WhatsAppWebConfig { + allow_from: vec![], + deny_by_default: true, + ..Default::default() + }); + assert!(!ch.is_allowed("60999999999")); + } + + #[test] + fn test_parse_chat_jid_from_phone() { + let jid = parse_chat_jid("+60123456789").unwrap(); + assert_eq!(jid.to_string(), "60123456789@s.whatsapp.net"); + } + + #[test] + fn test_parse_chat_jid_from_jid() { + let jid = parse_chat_jid("60123456789@s.whatsapp.net").unwrap(); + assert_eq!(jid.to_string(), "60123456789@s.whatsapp.net"); + } + + #[test] + fn test_build_outbound_message_plain() { + let message = build_outbound_message(&OutboundMessage::new( + "whatsapp_web", + "60123456789", + "Hello", + )); + assert_eq!(message.conversation.as_deref(), Some("Hello")); + } + + #[test] + fn test_build_outbound_message_reply() { + let message = build_outbound_message( + &OutboundMessage::new("whatsapp_web", "60123456789", "Hello").with_reply("abc123"), + ); + let reply = message.extended_text_message.expect("reply message"); + assert_eq!(reply.text.as_deref(), Some("Hello")); + assert_eq!( + reply.context_info.and_then(|ctx| ctx.stanza_id).as_deref(), + Some("abc123") + ); + } + + #[test] + fn test_channel_name() { + let ch = make_channel(WhatsAppWebConfig::default()); + assert_eq!(ch.name(), "whatsapp_web"); + } + + #[test] + fn test_channel_not_running_initially() { + let ch = make_channel(WhatsAppWebConfig::default()); + assert!(!ch.is_running()); + } + + #[tokio::test] + async fn test_send_errors_when_not_started() { + let ch = make_channel(WhatsAppWebConfig::default()); + let msg = OutboundMessage::new("whatsapp_web", "60123456789", "Hello"); + let result = ch.send(msg).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("not started")); + } + + #[test] + fn test_resolve_sender_phone_from_pn_jid() { + let sender = Jid::from_str("60123456789@s.whatsapp.net").unwrap(); + let result = resolve_sender_phone(&sender, &None); + assert_eq!(result, "60123456789"); + } + + #[test] + fn test_resolve_sender_phone_from_lid_with_alt() { + let sender = Jid::from_str("78971563720736@lid").unwrap(); + let alt = Some(Jid::from_str("60123456789@s.whatsapp.net").unwrap()); + let result = resolve_sender_phone(&sender, &alt); + assert_eq!(result, "60123456789"); + } + + #[test] + fn test_resolve_sender_phone_from_lid_without_alt() { + let sender = Jid::from_str("78971563720736@lid").unwrap(); + let result = resolve_sender_phone(&sender, &None); + assert_eq!(result, "78971563720736"); + } + + #[test] + fn test_resolve_sender_phone_lid_alt_is_also_lid() { + let sender = Jid::from_str("111111@lid").unwrap(); + let alt = Some(Jid::from_str("222222@lid").unwrap()); + let result = resolve_sender_phone(&sender, &alt); + // Neither is a PN, falls back to sender user part + assert_eq!(result, "111111"); + } +} diff --git a/src/cli/channel.rs b/src/cli/channel.rs index 7d4e47a3..f66fcf89 100644 --- a/src/cli/channel.rs +++ b/src/cli/channel.rs @@ -1,16 +1,26 @@ //! CLI channel management commands (zeptoclaw channel list|setup|test). use std::io::{self, Write}; -use std::time::Duration; use anyhow::{Context, Result}; -use tokio_tungstenite::connect_async; use zeptoclaw::config::Config; -use super::common::read_line; +use super::common::{read_line, read_secret}; use super::ChannelAction; +fn canonical_channel_name(channel_name: &str) -> &str { + match channel_name { + "whatsapp" | "whatsapp_web" => "whatsapp_web", + "whatsapp_cloud" | "whatsapp-cloud" => "whatsapp_cloud", + _ => channel_name, + } +} + +fn whatsapp_web_available() -> bool { + cfg!(feature = "whatsapp-web") +} + /// Dispatch channel subcommands. pub(crate) async fn cmd_channel(action: ChannelAction) -> Result<()> { match action { @@ -42,7 +52,7 @@ async fn cmd_channel_list() -> Result<()> { ), _ => ("disabled", "-".to_string()), }; - println!(" {:<12} {:<10} {}", "telegram", tg_status, tg_detail); + println!(" {:<15} {:<10} {}", "telegram", tg_status, tg_detail); // Discord let (dc_status, dc_detail) = match config.channels.discord { @@ -56,7 +66,7 @@ async fn cmd_channel_list() -> Result<()> { ), _ => ("disabled", "-".to_string()), }; - println!(" {:<12} {:<10} {}", "discord", dc_status, dc_detail); + println!(" {:<15} {:<10} {}", "discord", dc_status, dc_detail); // Slack let (sl_status, sl_detail) = match config.channels.slack { @@ -70,14 +80,34 @@ async fn cmd_channel_list() -> Result<()> { ), _ => ("disabled", "-".to_string()), }; - println!(" {:<12} {:<10} {}", "slack", sl_status, sl_detail); + println!(" {:<15} {:<10} {}", "slack", sl_status, sl_detail); - // WhatsApp - let (wa_status, wa_detail) = match config.channels.whatsapp { - Some(ref c) if c.enabled => ("enabled", format!("bridge: {}", c.bridge_url)), + // WhatsApp Web + let (wa_status, wa_detail) = match config.channels.whatsapp_web { + Some(ref c) if c.enabled && whatsapp_web_available() => { + ("enabled", format!("auth: {}", c.auth_dir)) + } + Some(ref c) if c.enabled => ( + "configured", + format!("feature not built (auth: {})", c.auth_dir), + ), + _ => ("disabled", "-".to_string()), + }; + println!(" {:<15} {:<10} {}", "whatsapp_web", wa_status, wa_detail); + + // WhatsApp Cloud + let (wc_status, wc_detail) = match config.channels.whatsapp_cloud { + Some(ref c) if c.enabled => ( + "enabled", + if c.phone_number_id.is_empty() { + "phone_number_id missing".to_string() + } else { + format!("phone: {}", c.phone_number_id) + }, + ), _ => ("disabled", "-".to_string()), }; - println!(" {:<12} {:<10} {}", "whatsapp", wa_status, wa_detail); + println!(" {:<15} {:<10} {}", "whatsapp_cloud", wc_status, wc_detail); // Webhook let (wh_status, wh_detail) = match config.channels.webhook { @@ -87,7 +117,7 @@ async fn cmd_channel_list() -> Result<()> { ), _ => ("disabled", "-".to_string()), }; - println!(" {:<12} {:<10} {}", "webhook", wh_status, wh_detail); + println!(" {:<15} {:<10} {}", "webhook", wh_status, wh_detail); Ok(()) } @@ -97,10 +127,20 @@ async fn cmd_channel_list() -> Result<()> { // --------------------------------------------------------------------------- /// Known channel names for validation. -const KNOWN_CHANNELS: &[&str] = &["telegram", "discord", "slack", "whatsapp", "webhook"]; +const KNOWN_CHANNELS: &[&str] = &[ + "telegram", + "discord", + "slack", + "whatsapp", + "whatsapp_web", + "whatsapp_cloud", + "webhook", +]; /// Interactive setup for a named channel. async fn cmd_channel_setup(channel_name: &str) -> Result<()> { + let channel_name = canonical_channel_name(channel_name); + if !KNOWN_CHANNELS.contains(&channel_name) { anyhow::bail!( "Unknown channel '{}'. Known channels: {}", @@ -112,23 +152,12 @@ async fn cmd_channel_setup(channel_name: &str) -> Result<()> { let mut config = Config::load().unwrap_or_default(); match channel_name { - "whatsapp" => setup_whatsapp(&mut config)?, - "telegram" => { - println!("Use 'zeptoclaw onboard' to configure Telegram."); - return Ok(()); - } - "discord" => { - println!("Use 'zeptoclaw onboard' to configure Discord."); - return Ok(()); - } - "slack" => { - println!("Use 'zeptoclaw onboard' to configure Slack."); - return Ok(()); - } - "webhook" => { - println!("Use 'zeptoclaw onboard' to configure Webhook."); - return Ok(()); - } + "whatsapp_web" => setup_whatsapp_web(&mut config)?, + "whatsapp_cloud" => setup_whatsapp_cloud(&mut config)?, + "telegram" => setup_telegram(&mut config)?, + "discord" => setup_discord(&mut config)?, + "slack" => setup_slack(&mut config)?, + "webhook" => setup_webhook(&mut config)?, _ => unreachable!(), } @@ -139,50 +168,237 @@ async fn cmd_channel_setup(channel_name: &str) -> Result<()> { Ok(()) } -/// Interactive WhatsApp channel setup. -fn setup_whatsapp(config: &mut Config) -> Result<()> { - println!(); - println!("WhatsApp Channel Setup (via Bridge)"); - println!("-----------------------------------"); - println!("Requires whatsmeow-rs bridge: https://github.com/qhkm/whatsmeow-rs"); +/// Interactive WhatsApp Web channel setup. +fn setup_whatsapp_web(config: &mut Config) -> Result<()> { + if !whatsapp_web_available() { + anyhow::bail!( + "WhatsApp Web support is not available in this build. Rebuild with --features whatsapp-web." + ); + } + println!(); + println!("WhatsApp Web Channel Setup"); + println!("--------------------------"); - let whatsapp_config = config + let wa_config = config .channels - .whatsapp + .whatsapp_web .get_or_insert_with(Default::default); + wa_config.enabled = true; + + print!("Phone number allowlist (comma-separated E.164, e.g. +60123456789, or Enter for all): "); + io::stdout().flush()?; + let allowlist = read_line()?; + wa_config.allow_from = allowlist + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + println!(" WhatsApp Web channel enabled."); + println!(" Run 'zeptoclaw gateway' to pair via QR code."); + println!(" On first run, scan the QR code with your phone:"); + println!(" WhatsApp → Settings → Linked Devices → Link a Device"); + Ok(()) +} - print!("Enable WhatsApp channel? [y/N]: "); +/// Interactive Telegram channel setup. +fn setup_telegram(config: &mut Config) -> Result<()> { + println!(); + println!("Telegram Bot Setup"); + println!("------------------"); + println!("To create a bot: Open Telegram, message @BotFather, send /newbot"); + println!(); + print!("Enter Telegram bot token (or press Enter to skip): "); io::stdout().flush()?; - let enabled = read_line()?.to_ascii_lowercase(); - if !matches!(enabled.as_str(), "y" | "yes") { - whatsapp_config.enabled = false; - println!(" WhatsApp channel disabled."); + + let token = read_secret()?; + if token.is_empty() { + println!(" Skipped."); return Ok(()); } - whatsapp_config.enabled = true; - print!("Bridge WebSocket URL [{}]: ", whatsapp_config.bridge_url); + let tg = config + .channels + .telegram + .get_or_insert_with(Default::default); + tg.token = token; + tg.enabled = true; + + print!("Allowlist user IDs/usernames (comma-separated, or Enter for all): "); + io::stdout().flush()?; + let allowlist = read_line()?; + tg.allow_from = allowlist + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + println!(" Telegram bot configured."); + println!(" Run 'zeptoclaw gateway' to start the bot."); + Ok(()) +} + +/// Interactive Discord channel setup. +fn setup_discord(config: &mut Config) -> Result<()> { + println!(); + println!("Discord Bot Setup"); + println!("-----------------"); + println!("To create a bot:"); + println!(" 1. Go to https://discord.com/developers/applications"); + println!(" 2. Create New Application → Bot → Reset Token → copy it"); + println!(" 3. Enable MESSAGE CONTENT intent under Bot → Privileged Intents"); + println!(" 4. Invite bot to your server with OAuth2 URL Generator"); + println!(); + print!("Enter Discord bot token (or press Enter to skip): "); io::stdout().flush()?; - let bridge_url = read_line()?; - if !bridge_url.is_empty() { - whatsapp_config.bridge_url = bridge_url; + + let token = read_secret()?; + if token.is_empty() { + println!(" Skipped."); + return Ok(()); } - print!("Phone number allowlist (comma-separated, or Enter for all): "); + let dc = config.channels.discord.get_or_insert_with(Default::default); + dc.token = token; + dc.enabled = true; + + print!("Allowlist user IDs (comma-separated, or Enter for all): "); io::stdout().flush()?; let allowlist = read_line()?; - if !allowlist.is_empty() { - whatsapp_config.allow_from = allowlist - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); + dc.allow_from = allowlist + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + println!(" Discord bot configured."); + println!(" Run 'zeptoclaw gateway' to start the bot."); + Ok(()) +} + +/// Interactive Slack channel setup. +fn setup_slack(config: &mut Config) -> Result<()> { + println!(); + println!("Slack Bot Setup"); + println!("---------------"); + println!("To create a bot:"); + println!(" 1. Go to https://api.slack.com/apps → Create New App"); + println!(" 2. Add Bot Token Scopes: chat:write, app_mentions:read"); + println!(" 3. Install to Workspace → copy Bot User OAuth Token (xoxb-...)"); + println!(" 4. Generate App-Level Token with connections:write scope"); + println!(); + print!("Enter Slack bot token (xoxb-..., or press Enter to skip): "); + io::stdout().flush()?; + + let bot_token = read_secret()?; + if bot_token.is_empty() { + println!(" Skipped."); + return Ok(()); + } + + print!("Enter Slack app-level token (xapp-...): "); + io::stdout().flush()?; + let app_token = read_secret()?; + + let sl = config.channels.slack.get_or_insert_with(Default::default); + sl.bot_token = bot_token; + sl.app_token = app_token; + sl.enabled = true; + + println!(" Slack bot configured."); + println!(" Run 'zeptoclaw gateway' to start the bot."); + Ok(()) +} + +/// Interactive Webhook channel setup. +fn setup_webhook(config: &mut Config) -> Result<()> { + println!(); + println!("Webhook Channel Setup"); + println!("---------------------"); + println!("Receives messages via HTTP POST to a local endpoint."); + println!(); + + let wh = config.channels.webhook.get_or_insert_with(Default::default); + + print!("Bind address [{}]: ", wh.bind_address); + io::stdout().flush()?; + let bind = read_line()?; + if !bind.is_empty() { + wh.bind_address = bind; } + print!("Port [{}]: ", wh.port); + io::stdout().flush()?; + let port_str = read_line()?; + if !port_str.is_empty() { + if let Ok(p) = port_str.parse::() { + wh.port = p; + } else { + println!(" Invalid port, keeping default {}.", wh.port); + } + } + + print!("Bearer auth token (or Enter for none): "); + io::stdout().flush()?; + let auth = read_secret()?; + if !auth.is_empty() { + wh.auth_token = Some(auth); + } + + wh.enabled = true; + println!( + " Webhook configured at {}:{}{}", + wh.bind_address, wh.port, wh.path + ); + println!(" Run 'zeptoclaw gateway' to start listening."); + Ok(()) +} + +/// Interactive WhatsApp Cloud API channel setup. +fn setup_whatsapp_cloud(config: &mut Config) -> Result<()> { + println!(); + println!("WhatsApp Cloud API Setup (Official)"); + println!("-----------------------------------"); + println!("Uses Meta's official Cloud API. Requires a Meta Business account."); + println!(" 1. Go to https://developers.facebook.com → Create App → Business"); + println!(" 2. Add WhatsApp product → API Setup"); + println!(" 3. Copy Phone Number ID and generate a permanent access token"); + println!(" 4. Set up a webhook URL (use 'zeptoclaw gateway --tunnel auto')"); + println!(); + print!("Enter Phone Number ID (or press Enter to skip): "); + io::stdout().flush()?; + + let phone_id = read_line()?; + if phone_id.is_empty() { + println!(" Skipped."); + return Ok(()); + } + + print!("Enter permanent access token: "); + io::stdout().flush()?; + let token = read_secret()?; + + print!("Choose a webhook verify token (any secret string): "); + io::stdout().flush()?; + let verify_token = read_secret()?; + + let wc = config + .channels + .whatsapp_cloud + .get_or_insert_with(Default::default); + wc.phone_number_id = phone_id; + wc.access_token = token; + wc.webhook_verify_token = verify_token; + wc.enabled = true; + + println!(" WhatsApp Cloud API configured."); println!( - " WhatsApp channel configured (bridge: {}).", - whatsapp_config.bridge_url + " Webhook endpoint: {}:{}{}", + wc.bind_address, wc.port, wc.path + ); + println!( + " Run 'zeptoclaw gateway' to start, then configure the webhook URL in Meta dashboard." ); Ok(()) } @@ -193,6 +409,8 @@ fn setup_whatsapp(config: &mut Config) -> Result<()> { /// Test connectivity for a named channel. async fn cmd_channel_test(channel_name: &str) -> Result<()> { + let channel_name = canonical_channel_name(channel_name); + if !KNOWN_CHANNELS.contains(&channel_name) { anyhow::bail!( "Unknown channel '{}'. Known channels: {}", @@ -204,7 +422,18 @@ async fn cmd_channel_test(channel_name: &str) -> Result<()> { let config = Config::load().unwrap_or_default(); match channel_name { - "whatsapp" => test_whatsapp(&config).await, + "whatsapp_web" => test_whatsapp_web(&config).await, + "whatsapp_cloud" => match config.channels.whatsapp_cloud { + Some(ref c) if c.enabled => { + println!("WhatsApp Cloud API channel is configured and enabled."); + println!(" Phone Number ID: {}", c.phone_number_id); + println!(" Webhook: {}:{}{}", c.bind_address, c.port, c.path); + Ok(()) + } + _ => { + anyhow::bail!("WhatsApp Cloud channel not configured. Run 'zeptoclaw channel setup whatsapp_cloud' first."); + } + }, "telegram" => { println!("Telegram test: not yet implemented (use BotFather /getMe)."); Ok(()) @@ -225,45 +454,33 @@ async fn cmd_channel_test(channel_name: &str) -> Result<()> { } } -/// Test WhatsApp bridge connectivity via WebSocket. -async fn test_whatsapp(config: &Config) -> Result<()> { - let bridge_url = match config.channels.whatsapp { +/// Test WhatsApp Web channel configuration. +async fn test_whatsapp_web(config: &Config) -> Result<()> { + if !whatsapp_web_available() { + anyhow::bail!( + "WhatsApp Web support is not available in this build. Rebuild with --features whatsapp-web." + ); + } + + match config.channels.whatsapp_web { Some(ref c) if c.enabled => { - if c.bridge_url.is_empty() { - anyhow::bail!("WhatsApp channel enabled but bridge_url is empty"); - } - c.bridge_url.clone() + println!("WhatsApp Web channel is configured and enabled."); + println!(" Auth dir: {}", c.auth_dir); + println!(" Allowlist: {:?}", c.allow_from); + println!(" Run 'zeptoclaw gateway' to connect and pair."); + Ok(()) } Some(_) => { anyhow::bail!( - "WhatsApp channel is not enabled. Run 'zeptoclaw channel setup whatsapp' first." + "WhatsApp Web channel is not enabled. Run 'zeptoclaw channel setup whatsapp_web' first." ); } None => { anyhow::bail!( - "WhatsApp channel not configured. Run 'zeptoclaw channel setup whatsapp' first." - ); - } - }; - - println!("Testing WhatsApp bridge connection to {}...", bridge_url); - - match tokio::time::timeout(Duration::from_secs(5), connect_async(&bridge_url)).await { - Ok(Ok((_ws_stream, _))) => { - println!("WhatsApp bridge reachable at {}", bridge_url); - } - Ok(Err(e)) => { - println!("Failed to connect to WhatsApp bridge: {}", e); - } - Err(_) => { - println!( - "Connection timed out after 5 seconds. Is the bridge running at {}?", - bridge_url + "WhatsApp Web channel not configured. Run 'zeptoclaw channel setup whatsapp_web' first." ); } } - - Ok(()) } // =========================================================================== @@ -280,6 +497,7 @@ mod tests { assert!(KNOWN_CHANNELS.contains(&"discord")); assert!(KNOWN_CHANNELS.contains(&"slack")); assert!(KNOWN_CHANNELS.contains(&"whatsapp")); + assert!(KNOWN_CHANNELS.contains(&"whatsapp_web")); assert!(KNOWN_CHANNELS.contains(&"webhook")); } @@ -315,46 +533,17 @@ mod tests { } #[tokio::test] - async fn test_channel_test_whatsapp_not_configured() { - // Default config has no WhatsApp configured + async fn test_channel_test_whatsapp_web_not_configured() { let config = Config::default(); - let result = test_whatsapp(&config).await; - assert!(result.is_err()); - let err_msg = result.unwrap_err().to_string(); - assert!(err_msg.contains("not configured")); - } - - #[tokio::test] - async fn test_channel_test_whatsapp_disabled() { - let mut config = Config::default(); - config.channels.whatsapp = Some(zeptoclaw::config::WhatsAppConfig { - enabled: false, - bridge_url: "ws://localhost:3001".to_string(), - bridge_token: None, - allow_from: vec![], - bridge_managed: true, - deny_by_default: true, - }); - let result = test_whatsapp(&config).await; - assert!(result.is_err()); - let err_msg = result.unwrap_err().to_string(); - assert!(err_msg.contains("not enabled")); - } - - #[tokio::test] - async fn test_channel_test_whatsapp_empty_url() { - let mut config = Config::default(); - config.channels.whatsapp = Some(zeptoclaw::config::WhatsAppConfig { - enabled: true, - bridge_url: String::new(), - bridge_token: None, - allow_from: vec![], - bridge_managed: true, - deny_by_default: true, - }); - let result = test_whatsapp(&config).await; + let result = test_whatsapp_web(&config).await; assert!(result.is_err()); let err_msg = result.unwrap_err().to_string(); - assert!(err_msg.contains("bridge_url is empty")); + // Without whatsapp-web feature: "not available in this build" + // With whatsapp-web feature but no config: "not configured" + assert!( + err_msg.contains("not configured") || err_msg.contains("not available"), + "unexpected error: {}", + err_msg + ); } } diff --git a/src/cli/gateway.rs b/src/cli/gateway.rs index 28897c1a..cab06a38 100644 --- a/src/cli/gateway.rs +++ b/src/cli/gateway.rs @@ -8,10 +8,9 @@ use tokio::sync::{mpsc, watch}; use tracing::{error, info, warn}; use zeptoclaw::bus::MessageBus; -use zeptoclaw::channels::{register_configured_channels, ChannelManager, WhatsAppChannel}; +use zeptoclaw::channels::{register_configured_channels, ChannelManager}; use zeptoclaw::config::watcher::ConfigWatcher; use zeptoclaw::config::{Config, ContainerAgentBackend}; -use zeptoclaw::deps::{fetcher::RealFetcher, DepManager, HasDependencies}; use zeptoclaw::health::{ health_port, start_health_server, start_health_server_legacy, start_periodic_usage_flush, HealthRegistry, UsageMetrics, @@ -271,18 +270,6 @@ pub(crate) async fn cmd_gateway( let mut channel_manager = ChannelManager::new(bus.clone(), config.clone()); channel_manager.set_health_registry(health_registry.clone()); - // Install and start channel dependencies (if any) - let deps_dir = DepManager::default_dir(); - let dep_mgr = DepManager::new(deps_dir, Arc::new(RealFetcher)); - let deps = collect_enabled_channel_deps(&config); - - if !deps.is_empty() { - info!("Installing {} channel dependencies...", deps.len()); - for dep in &deps { - install_and_start_dep(&dep_mgr, dep).await; - } - } - // Register channels via factory. let channel_count = register_configured_channels(&channel_manager, bus.clone(), &config).await; if channel_count == 0 { @@ -590,60 +577,6 @@ async fn validate_apple_available() -> Result<()> { Ok(()) } -/// Collect dependencies from all enabled channels with bridge_managed=true. -fn collect_enabled_channel_deps(config: &Config) -> Vec { - let mut deps = Vec::new(); - - // WhatsApp - if let Some(ref wa_cfg) = config.channels.whatsapp { - if wa_cfg.enabled && wa_cfg.bridge_managed { - // Create temporary channel just to query dependencies - let temp_bus = Arc::new(MessageBus::new()); - let channel = WhatsAppChannel::new(wa_cfg.clone(), temp_bus); - deps.extend(channel.dependencies()); - } - } - - // Future: Add Telegram, Discord, Slack if they declare dependencies - - deps -} - -/// Install and start a dependency with warn-on-fail (non-blocking). -/// -/// Performs three phases in order: -/// 1. Install - early return on failure -/// 2. Start - early return on failure -/// 3. Health check (10s timeout) - logs warning but does not block -/// -/// All failures are logged as warnings, allowing the gateway to continue. -async fn install_and_start_dep(mgr: &DepManager, dep: &zeptoclaw::deps::Dependency) { - // Install - match mgr.ensure_installed(dep).await { - Ok(_) => info!("✓ Installed {}", dep.name), - Err(e) => { - warn!("✗ Failed to install {}: {}", dep.name, e); - return; - } - } - - // Start - match mgr.start(dep).await { - Ok(_) => info!("✓ Started {}", dep.name), - Err(e) => { - warn!("✗ Failed to start {}: {}", dep.name, e); - return; - } - } - - // Health check (10s timeout) - let health_timeout = Duration::from_secs(10); - match mgr.wait_healthy(dep, health_timeout).await { - Ok(_) => info!("✓ {} is healthy", dep.name), - Err(e) => warn!("✗ {} health check failed: {}", dep.name, e), - } -} - /// Parse a `deliver_to` string in `"channel:chat_id"` format. /// Returns `None` if the string is missing a colon or either part is empty. fn parse_deliver_to(s: &str) -> Option<(String, String)> { @@ -680,55 +613,6 @@ fn section_changed(old: &T, new: &T) -> bool { mod tests { use super::*; - #[test] - fn test_collect_enabled_channel_deps_whatsapp_managed() { - let mut config = Config::default(); - config.channels.whatsapp = Some(zeptoclaw::config::WhatsAppConfig { - enabled: true, - bridge_managed: true, - bridge_url: "ws://localhost:3001".to_string(), - bridge_token: None, - allow_from: vec![], - deny_by_default: false, - }); - - let deps = collect_enabled_channel_deps(&config); - assert_eq!(deps.len(), 1); - assert_eq!(deps[0].name, "whatsmeow-bridge"); - } - - #[test] - fn test_collect_enabled_channel_deps_whatsapp_not_managed() { - let mut config = Config::default(); - config.channels.whatsapp = Some(zeptoclaw::config::WhatsAppConfig { - enabled: true, - bridge_managed: false, // User manages externally - bridge_url: "ws://localhost:3001".to_string(), - bridge_token: None, - allow_from: vec![], - deny_by_default: false, - }); - - let deps = collect_enabled_channel_deps(&config); - assert_eq!(deps.len(), 0); // No deps when not managed - } - - #[test] - fn test_collect_enabled_channel_deps_whatsapp_disabled() { - let mut config = Config::default(); - config.channels.whatsapp = Some(zeptoclaw::config::WhatsAppConfig { - enabled: false, - bridge_managed: true, - bridge_url: "ws://localhost:3001".to_string(), - bridge_token: None, - allow_from: vec![], - deny_by_default: false, - }); - - let deps = collect_enabled_channel_deps(&config); - assert_eq!(deps.len(), 0); // No deps when disabled - } - #[test] fn test_parse_deliver_to_valid() { assert_eq!( diff --git a/src/cli/mod.rs b/src/cli/mod.rs index df1b1517..df88f495 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -422,12 +422,12 @@ pub enum ChannelAction { List, /// Interactive setup for a channel Setup { - /// Channel name (telegram, discord, slack, whatsapp, webhook) + /// Channel name (telegram, discord, slack, whatsapp_web, webhook) channel_name: String, }, /// Test channel connectivity Test { - /// Channel name (telegram, discord, slack, whatsapp, webhook) + /// Channel name (telegram, discord, slack, whatsapp_web, webhook) channel_name: String, }, } diff --git a/src/cli/onboard.rs b/src/cli/onboard.rs index 576f1973..378ba8d8 100644 --- a/src/cli/onboard.rs +++ b/src/cli/onboard.rs @@ -263,11 +263,12 @@ pub(crate) async fn cmd_onboard(full: bool) -> Result<()> { // Configure heartbeat service. configure_heartbeat(&mut config)?; - // Configure Telegram channel + // Configure messaging channels configure_telegram(&mut config)?; - - // Configure WhatsApp channel (via bridge) configure_whatsapp_channel(&mut config)?; + configure_whatsapp_cloud(&mut config)?; + configure_discord(&mut config)?; + configure_slack(&mut config)?; // Configure runtime for shell command isolation configure_runtime(&mut config)?; @@ -302,6 +303,70 @@ pub(crate) async fn cmd_onboard(full: bool) -> Result<()> { println!(" Coding tools (grep, find) enabled."); } + // Ask about messaging channels + println!(); + println!("Messaging Channels"); + println!("=================="); + println!("Connect ZeptoClaw to messaging platforms so you can chat via phone/desktop."); + println!(" 1. Telegram (recommended — easiest setup)"); + println!(" 2. WhatsApp Web (native, QR code pairing)"); + println!(" 3. WhatsApp Cloud API (official Meta API)"); + println!(" 4. Discord"); + println!(" 5. Slack"); + println!(" 6. Skip (configure later with 'zeptoclaw channel setup ')"); + println!(); + print!("Which channels? (comma-separated, e.g. 1,2 or 6 to skip): "); + io::stdout().flush()?; + let channel_input = read_line()?; + + let mut want_telegram = false; + let mut want_whatsapp_web = false; + let mut want_whatsapp_cloud = false; + let mut want_discord = false; + let mut want_slack = false; + + for raw in channel_input.split(',') { + match raw.trim() { + "1" => want_telegram = true, + "2" => want_whatsapp_web = true, + "3" => want_whatsapp_cloud = true, + "4" => want_discord = true, + "5" => want_slack = true, + "6" | "" => {} + _ => println!(" Unknown option '{}', skipping.", raw.trim()), + } + } + + if want_telegram { + configure_telegram(&mut config)?; + } + if want_whatsapp_web { + if cfg!(feature = "whatsapp-web") { + configure_whatsapp_channel(&mut config)?; + } else { + println!(); + println!(" WhatsApp Web requires: cargo build --features whatsapp-web"); + println!(" Skipped."); + } + } + if want_whatsapp_cloud { + configure_whatsapp_cloud(&mut config)?; + } + if want_discord { + configure_discord(&mut config)?; + } + if want_slack { + configure_slack(&mut config)?; + } + if !want_telegram + && !want_whatsapp_web + && !want_whatsapp_cloud + && !want_discord + && !want_slack + { + println!(" Skipped. Run 'zeptoclaw channel setup ' anytime."); + } + // Save config config .save() @@ -362,11 +427,7 @@ fn configure_memory(config: &mut Config) -> Result<()> { println!("Choose memory backend:"); println!(" 1. Built-in substring search (recommended)"); println!(" 2. BM25 keyword scoring (requires --features memory-bm25)"); - println!(" 3. Embedding + cosine similarity (not yet implemented)"); - println!(" 4. HNSW approximate nearest neighbor (not yet implemented)"); - println!(" 5. Tantivy full-text search (not yet implemented)"); - println!(" 6. QMD (planned; currently falls back to built-in)"); - println!(" 7. Disabled"); + println!(" 3. Disabled"); println!(); print!( "Memory backend [current={}]: ", @@ -379,11 +440,7 @@ fn configure_memory(config: &mut Config) -> Result<()> { config.memory.backend = match backend_choice.trim() { "1" | "builtin" => MemoryBackend::Builtin, "2" | "bm25" => MemoryBackend::Bm25, - "3" | "embedding" => MemoryBackend::Embedding, - "4" | "hnsw" => MemoryBackend::Hnsw, - "5" | "tantivy" => MemoryBackend::Tantivy, - "6" | "qmd" => MemoryBackend::Qmd, - "7" | "none" | "disabled" => MemoryBackend::Disabled, + "3" | "none" | "disabled" => MemoryBackend::Disabled, _ => config.memory.backend.clone(), }; } @@ -794,7 +851,7 @@ fn configure_telegram(config: &mut Config) -> Result<()> { print!("Enter Telegram bot token (or press Enter to skip): "); io::stdout().flush()?; - let token = read_line()?; + let token = read_secret()?; if !token.is_empty() { let telegram_config = config @@ -812,50 +869,164 @@ fn configure_telegram(config: &mut Config) -> Result<()> { Ok(()) } -/// Configure WhatsApp channel (via whatsmeow-rs bridge). +/// Configure WhatsApp Web channel (native, via wa-rs). fn configure_whatsapp_channel(config: &mut Config) -> Result<()> { + if !cfg!(feature = "whatsapp-web") { + anyhow::bail!( + "WhatsApp Web support is not available in this build. Rebuild with --features whatsapp-web." + ); + } + println!(); - println!("WhatsApp Channel Setup (via Bridge)"); + println!("WhatsApp Web Channel Setup"); + println!("--------------------------"); + + let whatsapp_config = config + .channels + .whatsapp_web + .get_or_insert_with(Default::default); + whatsapp_config.enabled = true; + + print!("Phone number allowlist (comma-separated E.164, e.g. +60123456789, or Enter for all): "); + io::stdout().flush()?; + let allowlist = read_line()?; + whatsapp_config.allow_from = allowlist + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + if whatsapp_config.allow_from.is_empty() { + print!("Deny all senders by default (strict mode)? [y/N]: "); + io::stdout().flush()?; + let deny = read_line()?.to_ascii_lowercase(); + if matches!(deny.as_str(), "y" | "yes") { + whatsapp_config.deny_by_default = true; + println!(" Strict mode enabled — no messages will be accepted until you add allowed numbers."); + } + } + + println!(); + println!(" WhatsApp Web channel enabled."); + println!(" Run 'zeptoclaw gateway' to pair via QR code."); + println!(" On first run, scan the QR code with your phone:"); + println!(" WhatsApp → Settings → Linked Devices → Link a Device"); + Ok(()) +} + +/// Configure WhatsApp Cloud API channel (official Meta API). +fn configure_whatsapp_cloud(config: &mut Config) -> Result<()> { + println!(); + println!("WhatsApp Cloud API Setup (Official)"); println!("-----------------------------------"); - println!("Requires whatsmeow-rs bridge: https://github.com/qhkm/whatsmeow-rs"); - print!("Enable WhatsApp channel? [y/N]: "); + println!("Uses Meta's official Cloud API. Requires a Meta Business account."); + println!(" 1. Go to https://developers.facebook.com → Create App → Business"); + println!(" 2. Add WhatsApp product → API Setup"); + println!(" 3. Copy Phone Number ID and generate a permanent access token"); + println!(" 4. Set up a webhook URL (use 'zeptoclaw gateway --tunnel auto')"); + println!(); + print!("Enter Phone Number ID (or press Enter to skip): "); io::stdout().flush()?; - let enabled = read_line()?.to_ascii_lowercase(); - if !matches!(enabled.as_str(), "y" | "yes") { - println!(" Skipped WhatsApp channel configuration."); + let phone_id = read_line()?; + if phone_id.is_empty() { + println!(" Skipped WhatsApp Cloud API configuration."); return Ok(()); } - let whatsapp_config = config + print!("Enter permanent access token: "); + io::stdout().flush()?; + let token = read_secret()?; + + print!("Choose a webhook verify token (any secret string): "); + io::stdout().flush()?; + let verify_token = read_secret()?; + + let wc = config .channels - .whatsapp + .whatsapp_cloud .get_or_insert_with(Default::default); - whatsapp_config.enabled = true; + wc.phone_number_id = phone_id; + wc.access_token = token; + wc.webhook_verify_token = verify_token; + wc.enabled = true; - print!("Bridge WebSocket URL [{}]: ", whatsapp_config.bridge_url); - io::stdout().flush()?; - let bridge_url = read_line()?; - if !bridge_url.is_empty() { - whatsapp_config.bridge_url = bridge_url; - } + println!(" WhatsApp Cloud API configured."); + println!( + " Webhook endpoint: {}:{}{}", + wc.bind_address, wc.port, wc.path + ); + println!( + " Run 'zeptoclaw gateway' to start, then configure the webhook URL in Meta dashboard." + ); + Ok(()) +} - print!("Phone number allowlist (comma-separated, or Enter for all): "); +/// Configure Discord channel. +fn configure_discord(config: &mut Config) -> Result<()> { + println!(); + println!("Discord Bot Setup"); + println!("-----------------"); + println!("To create a bot:"); + println!(" 1. Go to https://discord.com/developers/applications"); + println!(" 2. Create New Application → Bot → Reset Token → copy it"); + println!(" 3. Enable MESSAGE CONTENT intent under Bot → Privileged Intents"); + println!( + " 4. Invite bot to your server with OAuth2 URL Generator (bot scope + Send Messages)" + ); + println!(); + print!("Enter Discord bot token (or press Enter to skip): "); io::stdout().flush()?; - let allowlist = read_line()?; - if !allowlist.is_empty() { - whatsapp_config.allow_from = allowlist - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); + + let token = read_secret()?; + + if !token.is_empty() { + let discord_config = config.channels.discord.get_or_insert_with(Default::default); + discord_config.token = token; + discord_config.enabled = true; + println!(" Discord bot configured."); + println!(" Run 'zeptoclaw gateway' to start the bot."); + } else { + println!(" Skipped Discord configuration."); } + Ok(()) +} + +/// Configure Slack channel. +fn configure_slack(config: &mut Config) -> Result<()> { + println!(); + println!("Slack Bot Setup"); + println!("---------------"); + println!("To create a bot:"); + println!(" 1. Go to https://api.slack.com/apps → Create New App"); + println!(" 2. Add Bot Token Scopes: chat:write, app_mentions:read"); + println!(" 3. Install to Workspace → copy Bot User OAuth Token (xoxb-...)"); println!( - " WhatsApp channel configured (bridge: {}).", - whatsapp_config.bridge_url + " 4. Under Basic Information → App-Level Tokens → generate with connections:write scope" ); - println!(" Run 'zeptoclaw gateway' to start the WhatsApp channel."); + println!(); + print!("Enter Slack bot token (xoxb-..., or press Enter to skip): "); + io::stdout().flush()?; + + let bot_token = read_secret()?; + + if bot_token.is_empty() { + println!(" Skipped Slack configuration."); + return Ok(()); + } + + print!("Enter Slack app-level token (xapp-...): "); + io::stdout().flush()?; + let app_token = read_secret()?; + + let slack_config = config.channels.slack.get_or_insert_with(Default::default); + slack_config.bot_token = bot_token; + slack_config.app_token = app_token; + slack_config.enabled = true; + println!(" Slack bot configured."); + println!(" Run 'zeptoclaw gateway' to start the bot."); + Ok(()) } diff --git a/src/config/mod.rs b/src/config/mod.rs index fa1f1111..9f3845ca 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -900,29 +900,38 @@ impl Config { } } - // WhatsApp - if let Ok(val) = std::env::var("ZEPTOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL") { + // WhatsApp Web + if let Ok(val) = std::env::var("ZEPTOCLAW_CHANNELS_WHATSAPP_WEB_AUTH_DIR") { let channel = self .channels - .whatsapp - .get_or_insert_with(WhatsAppConfig::default); - channel.bridge_url = val; + .whatsapp_web + .get_or_insert_with(WhatsAppWebConfig::default); + channel.auth_dir = val; } - if let Ok(val) = std::env::var("ZEPTOCLAW_CHANNELS_WHATSAPP_BRIDGE_TOKEN") { + if let Ok(val) = std::env::var("ZEPTOCLAW_CHANNELS_WHATSAPP_AUTH_DIR") { let channel = self .channels - .whatsapp - .get_or_insert_with(WhatsAppConfig::default); - channel.bridge_token = Some(val); + .whatsapp_web + .get_or_insert_with(WhatsAppWebConfig::default); + channel.auth_dir = val; } - if let Ok(val) = std::env::var("ZEPTOCLAW_CHANNELS_WHATSAPP_ENABLED") { - if let Ok(enabled) = val.parse() { - let channel = self - .channels - .whatsapp - .get_or_insert_with(WhatsAppConfig::default); - channel.enabled = enabled; - } + if let Ok(Ok(enabled)) = + std::env::var("ZEPTOCLAW_CHANNELS_WHATSAPP_WEB_ENABLED").map(|v| v.parse::()) + { + let channel = self + .channels + .whatsapp_web + .get_or_insert_with(WhatsAppWebConfig::default); + channel.enabled = enabled; + } + if let Ok(Ok(enabled)) = + std::env::var("ZEPTOCLAW_CHANNELS_WHATSAPP_ENABLED").map(|v| v.parse::()) + { + let channel = self + .channels + .whatsapp_web + .get_or_insert_with(WhatsAppWebConfig::default); + channel.enabled = enabled; } // Runtime: Apple Container diff --git a/src/config/types.rs b/src/config/types.rs index cfab35f7..b1ac60b5 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -745,8 +745,9 @@ pub struct ChannelsConfig { pub discord: Option, /// Slack bot configuration pub slack: Option, - /// WhatsApp bridge configuration - pub whatsapp: Option, + /// WhatsApp Web native channel configuration (requires `whatsapp-web` feature). + #[serde(alias = "whatsapp")] + pub whatsapp_web: Option, /// WhatsApp Cloud API configuration (official API, no bridge) pub whatsapp_cloud: Option, /// Feishu (Lark) configuration @@ -968,52 +969,6 @@ pub struct SlackConfig { pub deny_by_default: bool, } -/// WhatsApp channel configuration (via bridge) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WhatsAppConfig { - /// Whether the channel is enabled - #[serde(default)] - pub enabled: bool, - /// WebSocket bridge URL - #[serde(default = "default_whatsapp_bridge_url")] - pub bridge_url: String, - /// Optional Bearer token for authenticating to the bridge WebSocket. - #[serde(default)] - pub bridge_token: Option, - /// Allowlist of phone numbers (empty = allow all unless `deny_by_default` is set) - #[serde(default)] - pub allow_from: Vec, - /// When true, empty `allow_from` rejects all senders (strict mode). - #[serde(default)] - pub deny_by_default: bool, - /// Whether ZeptoClaw manages the bridge binary lifecycle. - /// When true, `channel setup` and `gateway` will auto-install and start the bridge. - /// When false, the user manages the bridge process externally. - #[serde(default = "default_bridge_managed")] - pub bridge_managed: bool, -} - -fn default_whatsapp_bridge_url() -> String { - "ws://localhost:3001".to_string() -} - -fn default_bridge_managed() -> bool { - true -} - -impl Default for WhatsAppConfig { - fn default() -> Self { - Self { - enabled: false, - bridge_url: default_whatsapp_bridge_url(), - bridge_token: None, - allow_from: Vec::new(), - deny_by_default: false, - bridge_managed: default_bridge_managed(), - } - } -} - /// WhatsApp Cloud API channel configuration (official Meta API). /// /// Uses Meta's webhook system for inbound messages and the Cloud API @@ -1077,6 +1032,44 @@ impl Default for WhatsAppCloudConfig { } } +/// WhatsApp Web native channel configuration (personal WhatsApp via QR pairing). +/// +/// Uses wa-rs for direct WhatsApp Web protocol support. No Meta Business +/// account required — pairs via QR code like WhatsApp Desktop. +/// Requires: `--features whatsapp-web` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WhatsAppWebConfig { + /// Whether the channel is enabled. + #[serde(default)] + pub enabled: bool, + /// Directory for session persistence (SQLite database). + /// Default: ~/.zeptoclaw/state/whatsapp_web + #[serde(default = "default_whatsapp_web_auth_dir")] + pub auth_dir: String, + /// Allowlist of phone numbers in E.164 format (e.g., "+60123456789"). + /// Empty = allow all unless `deny_by_default` is set. + #[serde(default)] + pub allow_from: Vec, + /// When true, empty `allow_from` rejects all senders (strict mode). + #[serde(default)] + pub deny_by_default: bool, +} + +fn default_whatsapp_web_auth_dir() -> String { + "~/.zeptoclaw/state/whatsapp_web".to_string() +} + +impl Default for WhatsAppWebConfig { + fn default() -> Self { + Self { + enabled: false, + auth_dir: default_whatsapp_web_auth_dir(), + allow_from: Vec::new(), + deny_by_default: false, + } + } +} + /// Feishu (Lark) channel configuration #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct FeishuConfig { @@ -2534,6 +2527,22 @@ mod tests { assert_eq!(wac.phone_number_id, "999"); } + #[test] + fn test_channels_config_whatsapp_legacy_alias_deserializes_to_whatsapp_web() { + let json = r#"{ + "channels": { + "whatsapp": { + "enabled": true, + "auth_dir": "/tmp/wa-legacy" + } + } + }"#; + let config: Config = serde_json::from_str(json).unwrap(); + let wa = config.channels.whatsapp_web.unwrap(); + assert!(wa.enabled); + assert_eq!(wa.auth_dir, "/tmp/wa-legacy"); + } + #[test] fn test_memory_backend_bm25_deserialize() { let json = r#"{"memory": {"backend": "bm25"}}"#; diff --git a/src/lib.rs b/src/lib.rs index 4d3bf5b4..62d1b644 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,9 +44,11 @@ pub mod utils; pub use agent::{AgentLoop, ContextBuilder, SwarmScratchpad, ZeptoAgent, ZeptoAgentBuilder}; pub use bus::{InboundMessage, MediaAttachment, MediaType, MessageBus, OutboundMessage}; +#[cfg(feature = "whatsapp-web")] +pub use channels::WhatsAppWebChannel; pub use channels::{ BaseChannelConfig, Channel, ChannelManager, ChannelPluginAdapter, SlackChannel, - TelegramChannel, WhatsAppChannel, WhatsAppCloudChannel, + TelegramChannel, WhatsAppCloudChannel, }; pub use config::Config; pub use cron::{CronJob, CronPayload, CronSchedule, CronService, OnMiss}; diff --git a/src/tools/message.rs b/src/tools/message.rs index 9900bf43..7c701c25 100644 --- a/src/tools/message.rs +++ b/src/tools/message.rs @@ -23,6 +23,7 @@ const ALLOWED_CHANNELS: &[&str] = &[ "discord", "webhook", "whatsapp", + "whatsapp_web", "whatsapp_cloud", ]; @@ -74,7 +75,7 @@ impl Tool for MessageTool { }, "channel": { "type": "string", - "description": "Destination channel name (telegram, discord, slack, whatsapp, webhook). Omit when replying — the originating channel is used automatically." + "description": "Destination channel name (telegram, discord, slack, whatsapp_web, webhook). Omit when replying — the originating channel is used automatically." }, "chat_id": { "type": "string", @@ -124,6 +125,11 @@ impl Tool for MessageTool { .map(str::to_string) .or_else(|| ctx.channel.clone()) .ok_or_else(|| ZeptoError::Tool("No target channel specified".to_string()))?; + let channel = if channel.eq_ignore_ascii_case("whatsapp") { + "whatsapp_web".to_string() + } else { + channel + }; let chat_id = args .get("chat_id") @@ -423,6 +429,7 @@ mod tests { "discord", "webhook", "whatsapp", + "whatsapp_web", "whatsapp_cloud", ] { let bus = Arc::new(MessageBus::new()); @@ -445,20 +452,24 @@ mod tests { #[tokio::test] async fn test_message_tool_allows_whatsapp_channels() { - for channel in &["whatsapp", "whatsapp_cloud"] { + for (requested, published) in &[ + ("whatsapp", "whatsapp_web"), + ("whatsapp_web", "whatsapp_web"), + ("whatsapp_cloud", "whatsapp_cloud"), + ] { let bus = Arc::new(MessageBus::new()); let tool = MessageTool::new(bus.clone()); let result = tool .execute( - json!({"content": "Hi from WhatsApp", "channel": channel, "chat_id": "123"}), + json!({"content": "Hi from WhatsApp", "channel": requested, "chat_id": "123"}), &ToolContext::new(), ) .await; - assert!(result.is_ok(), "Channel '{}' should be allowed", channel); + assert!(result.is_ok(), "Channel '{}' should be allowed", requested); let outbound = bus.consume_outbound().await.expect("outbound message"); - assert_eq!(outbound.channel, *channel); + assert_eq!(outbound.channel, *published); assert_eq!(outbound.content, "Hi from WhatsApp"); } }