From 3e480f328eddaf464b58a52f767d7ebc4ac40fdc Mon Sep 17 00:00:00 2001 From: Brian Glusman Date: Thu, 30 Apr 2026 07:43:25 -0400 Subject: [PATCH 1/5] Integrate embedded WhatsApp and text channels --- BACKLOG.md | 3 + Cargo.lock | 542 +++++- README.md | 12 +- crates/calciforge/Cargo.toml | 2 +- crates/calciforge/Dockerfile | 2 +- crates/calciforge/examples/config.toml | 4 +- .../calciforge/src/adapters/artifact_cli.rs | 52 +- crates/calciforge/src/adapters/cli.rs | 2 +- crates/calciforge/src/channels/matrix.rs | 35 +- crates/calciforge/src/channels/mod.rs | 22 +- crates/calciforge/src/channels/signal.rs | 61 +- crates/calciforge/src/channels/sms.rs | 732 ++++++++ crates/calciforge/src/channels/telemetry.rs | 2 +- crates/calciforge/src/channels/whatsapp.rs | 1500 +++++------------ crates/calciforge/src/config.rs | 172 +- crates/calciforge/src/main.rs | 35 +- docs/README.md | 1 + docs/agent-adapters.md | 6 +- docs/agents.md | 325 ++++ docs/channels/sms.md | 71 + docs/channels/whatsapp.md | 200 +-- docs/index.md | 64 +- docs/staging-test-matrix.md | 3 + scripts/install-calciforge.sh | 26 +- scripts/install-security-stack.sh | 13 +- scripts/install.sh | 6 +- scripts/manual-docker-test.sh | 4 + 27 files changed, 2528 insertions(+), 1369 deletions(-) create mode 100644 crates/calciforge/src/channels/sms.rs create mode 100644 docs/agents.md create mode 100644 docs/channels/sms.md diff --git a/BACKLOG.md b/BACKLOG.md index 55a40102..5d5d8209 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -76,6 +76,9 @@ - [ ] Rust client SDK for host-agent - [ ] Python bindings - [ ] CLI admin tool +- [ ] Explore a local web channel for desktop/LAN testing that uses the same + identity, routing, message-envelope, artifact, and proxy policy paths as + Telegram, Matrix, and text channels - [ ] Architecture decision records (ADRs) ### Observability diff --git a/Cargo.lock b/Cargo.lock index 1e778aa5..c12e0466 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1870,7 +1870,7 @@ checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" dependencies = [ "parse-zoneinfo", "phf 0.11.3", - "phf_codegen", + "phf_codegen 0.11.3", ] [[package]] @@ -2171,6 +2171,17 @@ dependencies = [ "unicode-segmentation", ] +[[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-factory" version = "0.3.3" @@ -2180,6 +2191,24 @@ dependencies = [ "futures", ] +[[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" @@ -2250,6 +2279,15 @@ dependencies = [ "winnow 0.6.24", ] +[[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" @@ -3147,6 +3185,25 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "env_filter", + "log", +] + [[package]] name = "envmnt" version = "0.10.4" @@ -3428,6 +3485,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide 0.8.9", + "zlib-rs", ] [[package]] @@ -4086,7 +4144,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "886aa8ec755382a1fdf4651f6e6ec01f2f3bf49f2cb0f068b9a74cafd574a715" dependencies = [ "prost 0.13.5", - "prost-types", + "prost-types 0.13.5", "tonic 0.12.3", ] @@ -4118,7 +4176,7 @@ dependencies = [ "google-cloud-gax 0.19.2", "google-cloud-googleapis", "google-cloud-token", - "prost-types", + "prost-types 0.13.5", "serde", "serde_json", "thiserror 1.0.69", @@ -5569,7 +5627,7 @@ dependencies = [ "is-terminal", "itertools 0.10.5", "lalrpop-util", - "petgraph", + "petgraph 0.6.5", "regex", "regex-syntax 0.6.29", "string_cache", @@ -6048,6 +6106,12 @@ dependencies = [ "tracing-subscriber", ] +[[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" @@ -6192,6 +6256,26 @@ dependencies = [ "tokio", ] +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +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 = "moxcms" version = "0.8.1" @@ -6202,6 +6286,12 @@ dependencies = [ "pxfm", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "nanohtml2text" version = "0.2.1" @@ -6921,6 +7011,17 @@ dependencies = [ "indexmap 2.13.0", ] +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset 0.5.7", + "hashbrown 0.15.5", + "indexmap 2.13.0", +] + [[package]] name = "phf" version = "0.11.3" @@ -6940,16 +7041,35 @@ dependencies = [ "phf_shared 0.12.1", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", +] + [[package]] name = "phf_codegen" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "phf_generator", + "phf_generator 0.11.3", "phf_shared 0.11.3", ] +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf_generator" version = "0.11.3" @@ -6960,13 +7080,23 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + [[package]] name = "phf_macros" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "phf_generator", + "phf_generator 0.11.3", "phf_shared 0.11.3", "proc-macro2", "quote", @@ -6991,6 +7121,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.11" @@ -7295,6 +7434,23 @@ dependencies = [ "prost-derive 0.14.3", ] +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck 0.4.1", + "itertools 0.10.5", + "log", + "multimap", + "petgraph 0.8.3", + "prost 0.14.3", + "prost-types 0.14.3", + "regex", + "tempfile", +] + [[package]] name = "prost-derive" version = "0.13.5" @@ -7330,12 +7486,50 @@ dependencies = [ "prost 0.13.5", ] +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost 0.14.3", +] + +[[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 = "pxfm" version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -8940,6 +9134,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_bytes" version = "0.11.19" @@ -9295,6 +9498,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 = "similar" version = "2.7.0" @@ -9523,7 +9732,7 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" dependencies = [ - "phf_generator", + "phf_generator 0.11.3", "phf_shared 0.11.3", "proc-macro2", "quote", @@ -9726,6 +9935,12 @@ dependencies = [ "syn 2.0.117", ] +[[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" @@ -9902,7 +10117,7 @@ dependencies = [ "fnv", "nom 7.1.3", "phf 0.11.3", - "phf_codegen", + "phf_codegen 0.11.3", ] [[package]] @@ -10277,6 +10492,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "http 1.4.0", + "httparse", + "rand 0.10.1", + "ring", + "rustls-pki-types", + "simdutf8", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", +] + [[package]] name = "toml" version = "0.5.11" @@ -10677,6 +10913,26 @@ dependencies = [ "rustc-hash 2.1.1", ] +[[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 2.0.117", +] + [[package]] name = "typed-path" version = "0.12.3" @@ -10892,6 +11148,37 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "cookie_store", + "log", + "percent-encoding", + "rustls 0.23.37", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots 1.0.6", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http 1.4.0", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -10941,6 +11228,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -11049,6 +11342,223 @@ dependencies = [ "utf8parse", ] +[[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 0.22.1", + "bytes", + "chrono", + "dashmap", + "env_logger", + "hex", + "log", + "moka", + "prost 0.14.3", + "rand 0.9.2", + "rand_core 0.10.1", + "scopeguard", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "wa-rs-binary", + "wa-rs-core", + "wa-rs-proto", +] + +[[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 0.12.4", + "log", + "prost 0.14.3", + "serde", + "serde-big-array", + "serde_json", + "sha2 0.10.9", + "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 0.13.1", + "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 0.8.4", + "aes-gcm 0.10.3", + "anyhow", + "async-channel 2.5.0", + "async-trait", + "base64 0.22.1", + "bytes", + "chrono", + "ctr 0.9.2", + "flate2", + "hex", + "hkdf 0.12.4", + "hmac 0.12.1", + "log", + "md5", + "once_cell", + "pbkdf2", + "prost 0.14.3", + "protobuf", + "rand 0.9.2", + "rand_core 0.10.1", + "serde", + "serde-big-array", + "serde_json", + "sha2 0.10.9", + "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 2.0.117", +] + +[[package]] +name = "wa-rs-libsignal" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3471be8ff079ae4959fcddf2e7341281e5c6756bdc6a66454ea1a8e474d14576" +dependencies = [ + "aes 0.8.4", + "aes-gcm 0.10.3", + "arrayref", + "async-trait", + "cbc 0.1.2", + "chrono", + "ctr 0.9.2", + "curve25519-dalek", + "derive_more 2.1.1", + "displaydoc", + "ghash 0.5.1", + "hex", + "hkdf 0.12.4", + "hmac 0.12.1", + "itertools 0.14.0", + "log", + "prost 0.14.3", + "rand 0.9.2", + "serde", + "sha1", + "sha2 0.10.9", + "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 0.10.3", + "anyhow", + "bytes", + "hkdf 0.12.4", + "log", + "prost 0.14.3", + "rand 0.9.2", + "rand_core 0.10.1", + "sha2 0.10.9", + "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 0.14.3", + "prost-build", + "serde", +] + +[[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 1.4.0", + "log", + "rustls 0.23.37", + "tokio", + "tokio-rustls 0.26.4", + "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 = "wait-timeout" version = "0.2.1" @@ -11234,7 +11744,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" dependencies = [ "phf 0.11.3", - "phf_codegen", + "phf_codegen 0.11.3", "string_cache", "string_cache_codegen", ] @@ -12193,6 +12703,7 @@ dependencies = [ "portable-atomic", "prometheus", "prost 0.14.3", + "qrcode", "rand 0.10.1", "ratatui", "regex", @@ -12206,6 +12717,7 @@ dependencies = [ "rustls-pki-types", "schemars 1.2.1", "serde", + "serde-big-array", "serde_json", "sha2 0.10.9", "shellexpand", @@ -12225,6 +12737,12 @@ dependencies = [ "tracing-subscriber", "urlencoding", "uuid", + "wa-rs", + "wa-rs-binary", + "wa-rs-core", + "wa-rs-proto", + "wa-rs-tokio-transport", + "wa-rs-ureq-http", "webpki-roots 1.0.6", "which", "zeroclaw-macros", @@ -12339,6 +12857,12 @@ dependencies = [ "typed-path", ] +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" diff --git a/README.md b/README.md index e94605e3..3c47a424 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ being treated as daily-driver infrastructure. | Per-secret destination allowlists | Working | [Outbound traffic gating](https://calciforge.org/#outbound-traffic-gating) | | Local paste UI for one-shot and bulk `.env` secret input | Working | [Secret management](https://calciforge.org/#secret-management) | | MCP and CLI tools for agent-facing secret-name discovery, with no value readback | Working | [Agent-facing tools](https://calciforge.org/#agent-facing-tools-mcp) | -| Telegram, Matrix, WhatsApp, and Signal routing | Working | [Multi-channel chat](https://calciforge.org/#multi-channel-chat) | +| Telegram, Matrix, WhatsApp, Signal, and text/iMessage routing | Working | [Multi-channel chat](https://calciforge.org/#multi-channel-chat) | | OpenAI-compatible model gateway, provider routing, model aliases, alloys, cascades, dispatchers, exec models, and local model switching | Working | [Model gateway](docs/model-gateway.md) | | Codex CLI and OpenClaw Codex subscription/OAuth integration paths | Working | [Codex integration](docs/codex-openclaw-integration.md) | | `calciforge doctor` config/state/endpoint diagnostics | Working | [Quick Start](#quick-start) | @@ -75,10 +75,12 @@ Channel-based secret input is intentionally being de-emphasized because chat transports can retain plaintext values. Prefer the local paste UI or direct `fnox` input for new secrets. -Route Claude Code or another HTTP-speaking agent process through the gateway. -The installer and examples bias toward setting this on managed subprocess -agents directly; for external daemons, set it on the agent process or its -service manager, not on the Calciforge daemon: +Calciforge-managed subprocess agents should get proxy environment from their +agent config or installer-generated config. Do not put proxy variables on the +Calciforge daemon itself; that can route Calciforge's own provider and +control-plane traffic through its security proxy. For externally managed agent +daemons that Calciforge does not launch, set plain HTTP proxying on the agent +process or its service manager and validate it against `security-proxy` logs: ```bash export HTTP_PROXY=http://127.0.0.1:8888 diff --git a/crates/calciforge/Cargo.toml b/crates/calciforge/Cargo.toml index 5d16424e..1410c701 100644 --- a/crates/calciforge/Cargo.toml +++ b/crates/calciforge/Cargo.toml @@ -35,7 +35,7 @@ axum = "0.7" # Embedded Signal channel implementation. The crate brings its own # SignalChannel that talks to signal-cli-rest-api, so we no longer host a # webhook receiver inside calciforge for Signal. -zeroclawlabs = { workspace = true } +zeroclawlabs = { workspace = true, features = ["whatsapp-web"] } # Secret resolution helpers secrets-client = { path = "../secrets-client" } diff --git a/crates/calciforge/Dockerfile b/crates/calciforge/Dockerfile index a5d00ce1..974ddcea 100644 --- a/crates/calciforge/Dockerfile +++ b/crates/calciforge/Dockerfile @@ -17,7 +17,7 @@ COPY --from=builder /app/target/release/security-proxy /usr/local/bin/security-p # Create config directory RUN mkdir -p /root/.calciforge -EXPOSE 8888 18792 18793 18794 18795 18796 18797 +EXPOSE 8888 18792 18793 18794 18795 18796 18797 18798 ENTRYPOINT ["/usr/local/bin/calciforge"] CMD ["--config", "/root/.calciforge/config.toml"] diff --git a/crates/calciforge/examples/config.toml b/crates/calciforge/examples/config.toml index e6f164f3..e2b31629 100644 --- a/crates/calciforge/examples/config.toml +++ b/crates/calciforge/examples/config.toml @@ -83,7 +83,7 @@ args = [ "--skip-git-repo-check", "-" ] -env = { HTTP_PROXY = "http://127.0.0.1:8888", HTTPS_PROXY = "http://127.0.0.1:8888", NO_PROXY = "localhost,127.0.0.1,::1" } +env = { HTTP_PROXY = "http://127.0.0.1:8888", NO_PROXY = "localhost,127.0.0.1,::1" } timeout_ms = 600000 [agents.registry] @@ -99,7 +99,7 @@ id = "dirac" kind = "dirac-cli" command = "dirac" args = ["--yolo", "--json"] -env = { HTTP_PROXY = "http://127.0.0.1:8888", HTTPS_PROXY = "http://127.0.0.1:8888", NO_PROXY = "localhost,127.0.0.1,::1" } +env = { HTTP_PROXY = "http://127.0.0.1:8888", NO_PROXY = "localhost,127.0.0.1,::1" } timeout_ms = 600000 [agents.registry] diff --git a/crates/calciforge/src/adapters/artifact_cli.rs b/crates/calciforge/src/adapters/artifact_cli.rs index 8209a239..0fdd8c19 100644 --- a/crates/calciforge/src/adapters/artifact_cli.rs +++ b/crates/calciforge/src/adapters/artifact_cli.rs @@ -401,12 +401,33 @@ mod tests { let mut file = std::fs::File::create(&path).expect("create script"); writeln!(file, "#!/bin/sh").expect("write shebang"); writeln!(file, "{body}").expect("write body"); + file.sync_all().expect("sync script"); let mut perms = file.metadata().expect("script metadata").permissions(); + drop(file); perms.set_mode(0o755); std::fs::set_permissions(&path, perms).expect("chmod script"); path } + fn script_adapter( + script: &Path, + args: Option>, + artifact_root: PathBuf, + max_artifact_bytes: u64, + ) -> ArtifactCliAdapter { + let mut shell_args = vec![script.display().to_string()]; + shell_args.extend(args.unwrap_or_default()); + ArtifactCliAdapter::with_artifact_root( + "/bin/sh".to_string(), + Some(shell_args), + HashMap::new(), + None, + Some(5000), + artifact_root, + max_artifact_bytes, + ) + } + #[tokio::test] async fn dispatch_captures_artifact_and_stdout() { let temp = tempfile::tempdir().expect("tempdir"); @@ -414,12 +435,9 @@ mod tests { temp.path(), "cat >/dev/null\nprintf '\\211PNG\\r\\n\\032\\n' > \"$CALCIFORGE_ARTIFACT_DIR/out.png\"\necho generated image", ); - let adapter = ArtifactCliAdapter::with_artifact_root( - script.display().to_string(), - None, - HashMap::new(), + let adapter = script_adapter( + &script, None, - Some(5000), temp.path().join("artifacts"), DEFAULT_MAX_ARTIFACT_BYTES, ); @@ -442,12 +460,9 @@ mod tests { temp.path(), "read task\nprintf '%s' \"$1\" > \"$CALCIFORGE_ARTIFACT_DIR/arg.txt\"\nprintf '%s' \"$task\"", ); - let adapter = ArtifactCliAdapter::with_artifact_root( - script.display().to_string(), + let adapter = script_adapter( + &script, Some(vec![MESSAGE_PLACEHOLDER.to_string()]), - HashMap::new(), - None, - Some(5000), temp.path().join("artifacts"), DEFAULT_MAX_ARTIFACT_BYTES, ); @@ -474,15 +489,7 @@ mod tests { temp.path(), "cat >/dev/null\nprintf 'too large' > \"$CALCIFORGE_ARTIFACT_DIR/out.txt\"\necho done", ); - let adapter = ArtifactCliAdapter::with_artifact_root( - script.display().to_string(), - None, - HashMap::new(), - None, - Some(5000), - temp.path().join("artifacts"), - 4, - ); + let adapter = script_adapter(&script, None, temp.path().join("artifacts"), 4); let err = adapter .dispatch_message_with_context(DispatchContext::message_only("make file")) @@ -501,12 +508,9 @@ mod tests { temp.path(), "cat >/dev/null\ni=0\nwhile [ \"$i\" -le 16 ]; do printf x > \"$CALCIFORGE_ARTIFACT_DIR/$i.txt\"; i=$((i + 1)); done\necho done", ); - let adapter = ArtifactCliAdapter::with_artifact_root( - script.display().to_string(), + let adapter = script_adapter( + &script, None, - HashMap::new(), - None, - Some(5000), temp.path().join("artifacts"), DEFAULT_MAX_ARTIFACT_BYTES, ); diff --git a/crates/calciforge/src/adapters/cli.rs b/crates/calciforge/src/adapters/cli.rs index 7ae7f77d..bdcd8002 100644 --- a/crates/calciforge/src/adapters/cli.rs +++ b/crates/calciforge/src/adapters/cli.rs @@ -25,7 +25,7 @@ //! command = "/usr/local/bin/ironclaw" //! args = ["run", "-m", "{message}"] //! timeout_ms = 30000 -//! env = { LLM_BACKEND = "openai_compatible", LLM_BASE_URL = "...", LLM_MODEL = "kimi-k2.5", HTTP_PROXY = "http://127.0.0.1:8888", HTTPS_PROXY = "http://127.0.0.1:8888", NO_PROXY = "localhost,127.0.0.1,::1" } +//! env = { LLM_BACKEND = "openai_compatible", LLM_BASE_URL = "...", LLM_MODEL = "kimi-k2.5", HTTP_PROXY = "http://127.0.0.1:8888", NO_PROXY = "localhost,127.0.0.1,::1" } //! ``` use std::collections::HashMap; diff --git a/crates/calciforge/src/channels/matrix.rs b/crates/calciforge/src/channels/matrix.rs index 9ed0d1b3..130e6ea6 100644 --- a/crates/calciforge/src/channels/matrix.rs +++ b/crates/calciforge/src/channels/matrix.rs @@ -310,18 +310,37 @@ async fn send_matrix_message( "msgtype": "m.text", "body": body, }); - let resp = http - .put(&url) - .header("Authorization", auth_header) - .json(&payload) - .send() - .await?; - if !resp.status().is_success() { + const MAX_RETRIES: u32 = 4; + let mut attempt = 0u32; + loop { + let resp = http + .put(&url) + .header("Authorization", auth_header) + .json(&payload) + .send() + .await?; let status = resp.status(); + if status.is_success() { + return Ok(()); + } + if status.as_u16() == 429 && attempt < MAX_RETRIES { + let body_text = resp.text().await.unwrap_or_default(); + let retry_ms = serde_json::from_str::(&body_text) + .ok() + .and_then(|value| value["retry_after_ms"].as_u64()) + .unwrap_or(1000); + tracing::debug!( + attempt, + retry_ms, + "Matrix send rate-limited (429); retrying" + ); + tokio::time::sleep(tokio::time::Duration::from_millis(retry_ms + 50)).await; + attempt += 1; + continue; + } let err = resp.text().await.unwrap_or_default(); anyhow::bail!("Matrix send failed ({status}): {err}"); } - Ok(()) } async fn send_matrix_outbound_message( diff --git a/crates/calciforge/src/channels/mod.rs b/crates/calciforge/src/channels/mod.rs index 52025b45..0bcf2bd8 100644 --- a/crates/calciforge/src/channels/mod.rs +++ b/crates/calciforge/src/channels/mod.rs @@ -1,26 +1,16 @@ //! Channel adapters for Calciforge. //! -//! Currently active: Telegram. -//! Scaffolded (needs bot account): Matrix. -//! Scaffolded (needs ZeroClaw WA session): WhatsApp. -//! Scaffolded (needs OpenClaw Signal session): Signal. +//! Active: Telegram, Matrix, WhatsApp, Signal, text/iMessage, and mock. //! -//! Matrix was removed in v0.4.x (Zig) due to a tight-loop bug. The Rust v2 doesn't -//! have that problem — the adapter below is ready to wire up once the bot account exists. -//! See MATRIX-SETUP-NEEDED.md in the repo root for what's required. -//! -//! WhatsApp runs as a webhook receiver sidecar to ZeroClaw's wa-rs session. -//! Calciforge listens for incoming webhook POSTs (forwarded from ZeroClaw) and sends -//! replies back via ZeroClaw's /tools/invoke API. The QR pairing happens in ZeroClaw; -//! Calciforge only handles identity routing and agent dispatch. -//! -//! Signal follows the same webhook receiver pattern as WhatsApp, but uses -//! OpenClaw's native Signal support. Calciforge receives webhooks from OpenClaw -//! and sends replies via the /tools/invoke API. +//! Matrix and Telegram use their native HTTP APIs. WhatsApp and Signal embed +//! zeroclawlabs transports directly. Text/iMessage uses the zeroclawlabs Linq +//! transport for outbound sends plus a Calciforge-hosted Linq webhook receiver +//! for inbound iMessage/RCS/SMS events. pub mod matrix; pub mod mock; pub mod signal; +pub mod sms; pub mod telegram; pub mod telemetry; pub mod whatsapp; diff --git a/crates/calciforge/src/channels/signal.rs b/crates/calciforge/src/channels/signal.rs index 8779522d..55220e79 100644 --- a/crates/calciforge/src/channels/signal.rs +++ b/crates/calciforge/src/channels/signal.rs @@ -45,6 +45,7 @@ use crate::{ commands::CommandHandler, config::CalciforgeConfig, context::ContextStore, + messages::OutboundMessage, router::Router, }; @@ -124,6 +125,11 @@ impl SignalChannel { } } + async fn send_outbound(&self, recipient: &str, message: &OutboundMessage) { + self.send_reply(recipient, &message.render_text_fallback()) + .await; + } + /// Handle a single inbound `ChannelMessage` end-to-end. pub async fn handle_message(self: Arc, msg: ChannelMessage) { let received_at = std::time::Instant::now(); @@ -209,6 +215,7 @@ impl SignalChannel { && !CommandHandler::is_default_command(&text) && !CommandHandler::is_sessions_command(&text) && !CommandHandler::is_model_command(&text) + && !CommandHandler::is_secure_command(&text) { let reply = self.command_handler.unknown_command(&text); let channel = self.clone(); @@ -280,6 +287,33 @@ impl SignalChannel { return; } + // !secure + if CommandHandler::is_secure_command(&text) { + debug!(identity = %identity.id, "Signal: handling !secure command"); + if CommandHandler::is_secure_set_command(&text) + && !crate::config::channel_allows_chat_secret_set(&self.config, "signal") + { + let reply = CommandHandler::secure_set_disabled_reply("Signal"); + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, &reply).await; + }); + return; + } + + let reply = self + .command_handler + .handle_secure(&text, &identity.id) + .await; + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, &reply).await; + }); + return; + } + // !context clear if text.trim().eq_ignore_ascii_case("!context clear") { self.context_store.clear(&chat_key); @@ -345,7 +379,7 @@ impl SignalChannel { let dispatch_start = std::time::Instant::now(); match self .router - .dispatch_with_sender_and_model( + .dispatch_message_with_sender_and_model( &augmented, &agent, &self.config, @@ -356,21 +390,21 @@ impl SignalChannel { { Ok(response) => { let latency_ms = dispatch_start.elapsed().as_millis() as u64; + let final_response = response.render_text_fallback(); self.command_handler.record_dispatch(latency_ms); telemetry::agent_dispatch_succeeded( "signal", &identity_id, &agent_id, latency_ms, - response.len(), + response.response_len(), ); - let final_response = response; - debug!( identity = %identity_id, agent_id = %agent_id, response_len = %final_response.len(), + attachments = response.attachments.len(), "Signal: got agent response" ); @@ -383,7 +417,7 @@ impl SignalChannel { preserve_native_commands, ); - self.send_reply(&reply_target, &final_response).await; + self.send_outbound(&reply_target, &response).await; } Err(e) => { warn!(identity = %identity_id, error = %e, "Signal: agent dispatch failed"); @@ -617,26 +651,11 @@ mod tests { fn make_test_config(mutate: F) -> Arc { let mut channel = ChannelConfig { kind: "signal".to_string(), - bot_token_file: None, enabled: true, - homeserver: None, - access_token_file: None, - room_id: None, - allowed_users: vec![], - zeroclaw_endpoint: None, - zeroclaw_auth_token: None, - webhook_listen: None, - webhook_path: None, - webhook_secret: None, allowed_numbers: vec!["+15555550100".to_string()], signal_cli_url: Some("http://127.0.0.1:8080".to_string()), signal_account: Some("+15555550001".to_string()), - signal_group_id: None, - signal_ignore_attachments: false, - signal_ignore_stories: false, - control_port: None, - scan_messages: false, - allow_chat_secret_set: false, + ..Default::default() }; mutate(&mut channel); diff --git a/crates/calciforge/src/channels/sms.rs b/crates/calciforge/src/channels/sms.rs new file mode 100644 index 00000000..768f0768 --- /dev/null +++ b/crates/calciforge/src/channels/sms.rs @@ -0,0 +1,732 @@ +//! Text/iMessage channel adapter for Calciforge. +//! +//! Calciforge exposes `kind = "sms"` and uses `zeroclawlabs::LinqChannel` +//! underneath. Linq is webhook based for inbound iMessage/RCS/SMS events, so +//! this module hosts a small webhook receiver, lets the zeroclawlabs parser +//! normalize incoming payloads, then sends replies through the same `Channel` +//! interface used by other embedded transports. + +use crate::sync::Arc; +use anyhow::{Context, Result}; +use axum::{ + body::Bytes, + extract::State, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + routing::{get, post}, + Json, Router as AxumRouter, +}; +use serde_json::json; +use tracing::{debug, info, warn}; +use zeroclaw::channels::traits::{Channel, ChannelMessage, SendMessage}; +use zeroclaw::channels::LinqChannel as ZclLinqChannel; + +use crate::{ + auth::{find_agent, resolve_channel_sender}, + commands::CommandHandler, + config::{expand_tilde, CalciforgeConfig}, + context::ContextStore, + messages::OutboundMessage, + router::Router, +}; + +use super::telemetry; + +use adversary_detector::middleware::ChannelScanner; +use adversary_detector::verdict::ScanContext; + +pub struct SmsChannel { + config: Arc, + router: Arc, + command_handler: Arc, + context_store: ContextStore, + channel_scanner: Arc, + transport: Arc, +} + +impl SmsChannel { + pub fn new( + config: Arc, + router: Arc, + command_handler: Arc, + context_store: ContextStore, + channel_scanner: Arc, + transport: Arc, + ) -> Self { + Self { + config, + router, + command_handler, + context_store, + channel_scanner, + transport, + } + } + + fn scan_enabled(&self) -> bool { + self.config + .channels + .iter() + .find(|c| c.kind == "sms") + .map(|c| c.scan_messages) + .unwrap_or(false) + } + + async fn send_reply(&self, recipient: &str, body: &str) { + let start = std::time::Instant::now(); + let response_len = body.len(); + match self + .transport + .send(&SendMessage::new(body, recipient)) + .await + { + Ok(()) => { + telemetry::reply_sent( + "sms", + recipient, + "reply", + response_len, + start.elapsed().as_millis() as u64, + ); + } + Err(e) => { + warn!(recipient = %recipient, error = %e, "Text/iMessage: failed to send reply"); + } + } + } + + async fn send_outbound(&self, recipient: &str, message: &OutboundMessage) { + self.send_reply(recipient, &message.render_text_fallback()) + .await; + } + + pub async fn handle_message(self: Arc, msg: ChannelMessage) { + let received_at = std::time::Instant::now(); + let delivery_lag_ms = telemetry::delivery_lag_ms_from_unix_seconds(msg.timestamp); + + let from = msg.sender.clone(); + let reply_target = if msg.reply_target.is_empty() { + msg.sender.clone() + } else { + msg.reply_target.clone() + }; + let text = msg.content.clone(); + + let identity = match resolve_channel_sender("sms", &from, &self.config) { + Some(id) => id, + None => { + warn!(from = %from, "Text/iMessage: unknown sender - dropping"); + return; + } + }; + + telemetry::authorized_message("sms", &identity.id, &from, text.len(), delivery_lag_ms); + + let chat_key = format!("sms-{}", identity.id); + + if self.scan_enabled() { + let verdict = self + .channel_scanner + .scan_text(&text, ScanContext::UserMessage) + .await; + match &verdict { + adversary_detector::verdict::ScanVerdict::Unsafe { reason } => { + warn!( + identity = %identity.id, + reason = %reason, + "Text/iMessage: inbound message BLOCKED by adversary scan" + ); + let channel = self.clone(); + let target = reply_target.clone(); + let reason_owned = reason.clone(); + tokio::spawn(async move { + channel + .send_reply( + &target, + &format!("Message blocked by security scanner: {reason_owned}"), + ) + .await; + }); + return; + } + adversary_detector::verdict::ScanVerdict::Review { reason } => { + warn!( + identity = %identity.id, + reason = %reason, + "Text/iMessage: inbound message flagged REVIEW - passing with caution" + ); + } + adversary_detector::verdict::ScanVerdict::Clean => { + debug!(identity = %identity.id, "Text/iMessage: inbound scan clean"); + } + } + } + + if let Some(reply) = self.command_handler.handle(&text) { + debug!(identity = %identity.id, cmd = %text.trim(), "Text/iMessage: handled pre-auth command"); + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, &reply).await; + }); + return; + } + + if CommandHandler::is_command(&text) + && !CommandHandler::is_status_command(&text) + && !CommandHandler::is_switch_command(&text) + && !CommandHandler::is_default_command(&text) + && !CommandHandler::is_sessions_command(&text) + && !CommandHandler::is_model_command(&text) + && !CommandHandler::is_secure_command(&text) + { + let reply = self.command_handler.unknown_command(&text); + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, &reply).await; + }); + return; + } + + if CommandHandler::is_status_command(&text) { + let reply = self + .command_handler + .cmd_status_for_identity(&identity.id) + .await; + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, &reply).await; + }); + return; + } + + if CommandHandler::is_switch_command(&text) { + let reply = self.command_handler.handle_switch(&text, &identity.id); + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, &reply).await; + }); + return; + } + + if CommandHandler::is_model_command(&text) { + let reply = self.command_handler.handle_model(&text, &identity.id); + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, &reply).await; + }); + return; + } + + if CommandHandler::is_sessions_command(&text) { + let reply = self + .command_handler + .handle_sessions(&text, &identity.id) + .await; + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, &reply).await; + }); + return; + } + + if CommandHandler::is_default_command(&text) { + let reply = self.command_handler.handle_default(&identity.id); + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, &reply).await; + }); + return; + } + + if CommandHandler::is_secure_command(&text) { + debug!(identity = %identity.id, "Text/iMessage: handling !secure command"); + if CommandHandler::is_secure_set_command(&text) + && !crate::config::channel_allows_chat_secret_set(&self.config, "sms") + { + let reply = CommandHandler::secure_set_disabled_reply("SMS"); + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, &reply).await; + }); + return; + } + + let reply = self + .command_handler + .handle_secure(&text, &identity.id) + .await; + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, &reply).await; + }); + return; + } + + if text.trim().eq_ignore_ascii_case("!context clear") { + self.context_store.clear(&chat_key); + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel + .send_reply(&target, "Conversation context cleared.") + .await; + }); + return; + } + + let agent_id = match self.command_handler.active_agent_for(&identity.id) { + Some(id) => id, + None => { + warn!(identity = %identity.id, "Text/iMessage: no routing rule for identity - dropping"); + return; + } + }; + + let agent = match find_agent(&agent_id, &self.config) { + Some(a) => a.clone(), + None => { + warn!(agent_id = %agent_id, "Text/iMessage: agent not in config"); + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, "Agent not configured.").await; + }); + return; + } + }; + + let sender_label = self + .config + .identities + .iter() + .find(|i| i.id == identity.id) + .and_then(|i| i.display_name.as_deref()) + .unwrap_or(&identity.id) + .to_string(); + + let identity_id = identity.id.clone(); + let model_override = self.command_handler.active_model_for_identity(&identity_id); + let preserve_native_commands = crate::adapters::agent_supports_native_commands(&agent); + + tokio::spawn(async move { + let queue_wait_ms = received_at.elapsed().as_millis() as u64; + telemetry::agent_dispatch_started("sms", &identity_id, &agent_id, queue_wait_ms); + + let augmented = self.context_store.augment_message_with_options( + &chat_key, + &agent_id, + &text, + preserve_native_commands, + ); + + let dispatch_start = std::time::Instant::now(); + match self + .router + .dispatch_message_with_sender_and_model( + &augmented, + &agent, + &self.config, + Some(&identity_id), + model_override.as_deref(), + ) + .await + { + Ok(response) => { + let latency_ms = dispatch_start.elapsed().as_millis() as u64; + let final_response = response.render_text_fallback(); + self.command_handler.record_dispatch(latency_ms); + telemetry::agent_dispatch_succeeded( + "sms", + &identity_id, + &agent_id, + latency_ms, + response.response_len(), + ); + + debug!( + identity = %identity_id, + agent_id = %agent_id, + response_len = %final_response.len(), + attachments = response.attachments.len(), + "Text/iMessage: got agent response" + ); + + self.context_store.push_with_options( + &chat_key, + &sender_label, + &text, + &agent_id, + &final_response, + preserve_native_commands, + ); + + self.send_outbound(&reply_target, &response).await; + } + Err(e) => { + warn!(identity = %identity_id, error = %e, "Text/iMessage: agent dispatch failed"); + self.send_reply(&reply_target, &format!("Agent error: {e}")) + .await; + } + } + }); + } +} + +#[derive(Clone)] +struct WebhookState { + bridge: Arc>, + transport: Arc, + signing_secret: Option, +} + +async fn health_handler() -> impl IntoResponse { + Json(json!({ "status": "ok", "channel": "sms" })) +} + +async fn webhook_handler( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> impl IntoResponse { + if let Some(secret) = state.signing_secret.as_deref() { + let timestamp = match headers + .get("x-webhook-timestamp") + .and_then(|value| value.to_str().ok()) + { + Some(value) => value, + None => return (StatusCode::UNAUTHORIZED, "missing webhook timestamp"), + }; + let signature = match headers + .get("x-webhook-signature") + .and_then(|value| value.to_str().ok()) + { + Some(value) => value, + None => return (StatusCode::UNAUTHORIZED, "missing webhook signature"), + }; + let body_text = match std::str::from_utf8(&body) { + Ok(value) => value, + Err(_) => return (StatusCode::BAD_REQUEST, "body must be utf-8 json"), + }; + if !zeroclaw::channels::linq::verify_linq_signature(secret, body_text, timestamp, signature) + { + return (StatusCode::UNAUTHORIZED, "invalid webhook signature"); + } + } + + let payload: serde_json::Value = match serde_json::from_slice(&body) { + Ok(value) => value, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid json"), + }; + + let messages = state.transport.parse_webhook_payload(&payload); + for msg in messages { + let bridge = state.bridge.clone(); + tokio::spawn(async move { + bridge.handle_message(msg).await; + }); + } + + (StatusCode::OK, "ok") +} + +fn read_secret_file(path: &str, label: &str) -> Result { + Ok(std::fs::read_to_string(expand_tilde(path)) + .with_context(|| format!("Text/iMessage: failed to read {label} '{path}'"))? + .trim() + .to_string()) +} + +fn resolve_optional_secret( + inline: &Option, + file: &Option, + label: &str, +) -> Result> { + if let Some(path) = file { + return Ok(Some(read_secret_file(path, label)?)); + } + Ok(inline.clone().map(|value| value.trim().to_string())) +} + +pub async fn run( + config: Arc, + router: Arc, + command_handler: Arc, + context_store: ContextStore, + channel_scanner: Arc, +) -> Result<()> { + let sms_cfg = config + .channels + .iter() + .find(|c| c.kind == "sms" && c.enabled) + .context("no enabled sms channel found in config")?; + + let api_token = resolve_optional_secret( + &sms_cfg.sms_linq_api_token, + &sms_cfg.sms_linq_api_token_file, + "sms_linq_api_token_file", + )? + .filter(|value| !value.is_empty()) + .context("sms_linq_api_token_file or sms_linq_api_token is required for kind = \"sms\"")?; + let signing_secret = resolve_optional_secret( + &sms_cfg.sms_linq_signing_secret, + &sms_cfg.sms_linq_signing_secret_file, + "sms_linq_signing_secret_file", + )? + .filter(|value| !value.is_empty()); + if signing_secret.is_none() { + warn!( + "Text/iMessage webhook signature verification is disabled; \ + configure sms_linq_signing_secret_file for public webhook endpoints" + ); + } + let from_phone = sms_cfg + .sms_from_phone + .as_deref() + .context("sms_from_phone is required for kind = \"sms\"")? + .to_string(); + let listen_addr = sms_cfg + .sms_webhook_listen + .clone() + .unwrap_or_else(|| "0.0.0.0:18798".to_string()); + let webhook_path = sms_cfg + .sms_webhook_path + .clone() + .unwrap_or_else(|| "/webhooks/sms".to_string()); + let allowed = sms_cfg.allowed_numbers.clone(); + + info!( + listen = %listen_addr, + path = %webhook_path, + from_phone = %from_phone, + signed = signing_secret.is_some(), + "Text/iMessage channel starting (Linq webhook receiver)" + ); + + let transport = Arc::new(ZclLinqChannel::new(api_token, from_phone, allowed)); + let bridge = Arc::new(SmsChannel::::new( + config, + router, + command_handler, + context_store, + channel_scanner, + transport.clone(), + )); + + let state = WebhookState { + bridge, + transport, + signing_secret, + }; + let app = AxumRouter::new() + .route("/health", get(health_handler)) + .route(&webhook_path, post(webhook_handler)) + .with_state(state); + + let listener = tokio::net::TcpListener::bind(&listen_addr) + .await + .with_context(|| format!("binding SMS webhook listener on {listen_addr}"))?; + + axum::serve(listener, app) + .await + .context("Text/iMessage webhook listener exited") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ + AgentConfig, CalciforgeConfig, CalciforgeHeader, ChannelAlias, ChannelConfig, Identity, + RoutingRule, + }; + use async_trait::async_trait; + use std::sync::Mutex as StdMutex; + use tokio::sync::mpsc; + use tokio::sync::Notify; + + struct MockChannel { + sent: StdMutex>, + sent_notify: Notify, + } + + impl MockChannel { + fn new() -> Self { + Self { + sent: StdMutex::new(Vec::new()), + sent_notify: Notify::new(), + } + } + + fn drain(&self) -> Vec { + std::mem::take(&mut *self.sent.lock().unwrap()) + } + + async fn wait_for_sent_len(&self, expected: usize) { + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + let notified = self.sent_notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if self.sent.lock().unwrap().len() >= expected { + return; + } + notified.await; + } + }) + .await + .expect("timed out waiting for SMS mock send"); + } + } + + #[async_trait] + impl Channel for MockChannel { + fn name(&self) -> &str { + "mock-sms" + } + + async fn send(&self, message: &SendMessage) -> anyhow::Result<()> { + self.sent.lock().unwrap().push(message.clone()); + self.sent_notify.notify_waiters(); + Ok(()) + } + + async fn listen(&self, _tx: mpsc::Sender) -> anyhow::Result<()> { + Err(anyhow::anyhow!("SMS tests drive handle_message directly")) + } + } + + fn make_test_config() -> Arc { + Arc::new(CalciforgeConfig { + calciforge: CalciforgeHeader { version: 2 }, + identities: vec![Identity { + id: "alice".to_string(), + display_name: Some("Alice".to_string()), + aliases: vec![ChannelAlias { + channel: "sms".to_string(), + id: "+15555550100".to_string(), + }], + role: Some("owner".to_string()), + }], + agents: vec![AgentConfig { + id: "librarian".to_string(), + kind: "openclaw-channel".to_string(), + endpoint: "http://127.0.0.1:18789".to_string(), + ..Default::default() + }], + routing: vec![RoutingRule { + identity: "alice".to_string(), + default_agent: "librarian".to_string(), + allowed_agents: vec![], + }], + channels: vec![ChannelConfig { + kind: "sms".to_string(), + enabled: true, + allowed_numbers: vec!["+15555550100".to_string()], + sms_linq_api_token: Some("test-token".to_string()), + sms_from_phone: Some("+15555550001".to_string()), + ..Default::default() + }], + permissions: None, + memory: None, + context: Default::default(), + model_shortcuts: vec![], + alloys: vec![], + cascades: vec![], + dispatchers: vec![], + exec_models: vec![], + security: None, + proxy: None, + local_models: None, + }) + } + + fn make_scanner() -> Arc { + let security_config = adversary_detector::profiles::SecurityConfig::balanced(); + let scanner = + adversary_detector::scanner::AdversaryScanner::new(security_config.scanner.clone()); + let audit_logger = adversary_detector::audit::AuditLogger::new("test-sms"); + Arc::new(ChannelScanner::new(scanner, audit_logger, security_config)) + } + + struct TestBridge { + bridge: Arc>, + _state_dir: tempfile::TempDir, + } + + fn dummy_bridge_with(config: Arc, transport: Arc) -> TestBridge { + let router = Arc::new(Router::new()); + let tmp = tempfile::tempdir().expect("tempdir for sms test state isolation"); + let command_handler = Arc::new(CommandHandler::with_state_dir( + config.clone(), + tmp.path().to_path_buf(), + )); + TestBridge { + bridge: Arc::new(SmsChannel::::new( + config, + router, + command_handler, + ContextStore::new(20, 5), + make_scanner(), + transport, + )), + _state_dir: tmp, + } + } + + #[tokio::test] + async fn test_handle_message_unknown_sender_drops() { + let transport = Arc::new(MockChannel::new()); + let bridge = dummy_bridge_with(make_test_config(), transport.clone()); + + bridge + .bridge + .handle_message(ChannelMessage { + id: "1".into(), + sender: "+19990001111".into(), + reply_target: "+19990001111".into(), + content: "!ping".into(), + channel: "linq".into(), + timestamp: 0, + thread_ts: None, + interruption_scope_id: None, + attachments: vec![], + }) + .await; + + assert!(transport.drain().is_empty()); + } + + #[tokio::test] + async fn test_handle_message_replies_to_chat_id_target() { + let transport = Arc::new(MockChannel::new()); + let bridge = dummy_bridge_with(make_test_config(), transport.clone()); + + bridge + .bridge + .handle_message(ChannelMessage { + id: "1".into(), + sender: "+15555550100".into(), + reply_target: "chat_123".into(), + content: "!ping".into(), + channel: "linq".into(), + timestamp: 0, + thread_ts: None, + interruption_scope_id: None, + attachments: vec![], + }) + .await; + + transport.wait_for_sent_len(1).await; + let sent = transport.drain(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].recipient, "chat_123"); + } +} diff --git a/crates/calciforge/src/channels/telemetry.rs b/crates/calciforge/src/channels/telemetry.rs index 11ad245b..9a9e590e 100644 --- a/crates/calciforge/src/channels/telemetry.rs +++ b/crates/calciforge/src/channels/telemetry.rs @@ -1,7 +1,7 @@ //! Shared channel telemetry helpers. //! //! Keep channel adapters on the same event schema so latency regressions can -//! be compared across Telegram, WhatsApp, Signal, and Matrix without scraping +//! be compared across Telegram, WhatsApp, Signal, SMS, and Matrix without scraping //! channel-specific log text. use std::fmt::Display; diff --git a/crates/calciforge/src/channels/whatsapp.rs b/crates/calciforge/src/channels/whatsapp.rs index 42c113b5..694cc22e 100644 --- a/crates/calciforge/src/channels/whatsapp.rs +++ b/crates/calciforge/src/channels/whatsapp.rs @@ -1,79 +1,30 @@ //! WhatsApp channel adapter for Calciforge. //! -//! ## Architecture -//! -//! This channel is a **webhook receiver + identity router + reply sender**. -//! -//! The actual WhatsApp protocol handling (QR pairing, WA Web encryption, send/receive -//! at the WA wire level) lives in ZeroClaw's `whatsapp-web` feature. Calciforge -//! acts as a routing sidecar: +//! Calciforge embeds [`zeroclaw::channels::WhatsAppWebChannel`] directly. The +//! embedded channel owns the WhatsApp Web session, surfaces inbound messages via +//! a `tokio::mpsc` `Receiver`, and sends replies through the same +//! `Channel::send` interface. //! //! ```text -//! WA user → ZeroClaw (wa-rs session) → POST /webhooks/whatsapp → Calciforge -//! │ -//! identity resolution │ -//! agent dispatch │ -//! ↓ -//! WA user ← ZeroClaw (wa-rs session) ← POST /tools/invoke ← Calciforge reply +//! WhatsApp user <-> zeroclawlabs::WhatsAppWebChannel <-> Calciforge dispatch //! ``` //! -//! ## Webhook payload format -//! -//! Incoming messages are expected in the WhatsApp Cloud API webhook format -//! (also used by ZeroClaw's outbound forwarding): -//! -//! ```json -//! { -//! "object": "whatsapp_business_account", -//! "entry": [{ -//! "changes": [{ -//! "value": { -//! "messages": [{ -//! "from": "15555550001", -//! "type": "text", -//! "text": { "body": "Hello!" }, -//! "timestamp": "1699999999" -//! }] -//! } -//! }] -//! }] -//! } -//! ``` -//! -//! The `from` field is a phone number with or without the leading `+`. -//! Calciforge normalises it to E.164 format (`+15555550001`) before identity lookup. -//! -//! ## Config -//! -//! ```toml -//! [[channels]] -//! kind = "whatsapp" -//! enabled = true -//! # OpenClaw gateway endpoint — the running OpenClaw instance that owns the WA session -//! # and can forward messages via its /tools/invoke HTTP API. -//! zeroclaw_endpoint = "http://127.0.0.1:18789" -//! # Bearer token for the OpenClaw gateway -//! zeroclaw_auth_token = "REPLACE_WITH_AUTH_TOKEN" -//! # Webhook path Calciforge registers (on the Calciforge gateway HTTP server — see main.rs TODO) -//! webhook_path = "/webhooks/whatsapp" -//! # HMAC secret for webhook signature verification (optional but recommended) -//! webhook_secret = "your-shared-secret" -//! # Webhook HTTP listen address (host:port) -//! webhook_listen = "0.0.0.0:18795" -//! # Allowed E.164 phone numbers. Must match identity aliases with channel = "whatsapp". -//! allowed_numbers = ["+15555550001"] -//! ``` +//! There is no webhook receiver in Calciforge for WhatsApp anymore. Legacy +//! ZeroClaw/OpenClaw webhook fields are rejected at startup for `kind = +//! "whatsapp"`. use crate::sync::Arc; -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; +use anyhow::{anyhow, Context, Result}; use tracing::{debug, info, warn}; +use zeroclaw::channels::traits::{Channel, ChannelMessage, SendMessage}; +use zeroclaw::channels::WhatsAppWebChannel as ZclWhatsAppWebChannel; use crate::{ auth::{find_agent, resolve_channel_sender}, commands::CommandHandler, - config::CalciforgeConfig, + config::{expand_tilde, CalciforgeConfig, ChannelConfig}, context::ContextStore, + messages::OutboundMessage, router::Router, }; @@ -82,116 +33,37 @@ use super::telemetry; use adversary_detector::middleware::ChannelScanner; use adversary_detector::verdict::ScanContext; -// --------------------------------------------------------------------------- -// Incoming webhook payload types -// --------------------------------------------------------------------------- - -/// Top-level WhatsApp Cloud API webhook body. -#[derive(Debug, Deserialize)] -struct WaWebhookPayload { - entry: Option>, -} - -#[derive(Debug, Deserialize)] -struct WaEntry { - changes: Option>, -} - -#[derive(Debug, Deserialize)] -struct WaChange { - value: Option, -} - -#[derive(Debug, Deserialize)] -struct WaChangeValue { - messages: Option>, -} - -#[derive(Debug, Deserialize)] -struct WaMessage { - from: String, - #[serde(rename = "type")] - kind: Option, - text: Option, - timestamp: Option, -} - -#[derive(Debug, Deserialize)] -struct WaTextBody { - body: String, -} - -/// A parsed, normalised inbound WhatsApp message. -#[derive(Debug, Clone)] -pub struct InboundWaMessage { - /// Sender phone number in E.164 format (e.g. `"+15555550001"`). - pub from: String, - /// Message text content. - pub text: String, - /// Unix timestamp (best-effort). - pub _timestamp: u64, -} - -// --------------------------------------------------------------------------- -// Outbound reply request (for the ZeroClaw /tools/invoke send API) -// --------------------------------------------------------------------------- - -#[derive(Debug, Serialize)] -struct ToolInvokeRequest { - tool: &'static str, - args: ToolInvokeArgs, -} - -#[derive(Debug, Serialize)] -struct ToolInvokeArgs { - action: &'static str, - channel: &'static str, - target: String, - message: String, -} - -// --------------------------------------------------------------------------- -// WhatsApp channel -// --------------------------------------------------------------------------- - -/// WhatsApp channel adapter. -/// -/// Runs an HTTP server listening for incoming webhook POSTs from ZeroClaw (or any -/// conforming WA webhook source), resolves sender identity, dispatches to the -/// configured agent, and sends the reply back via the ZeroClaw `/tools/invoke` API. -pub struct WhatsAppChannel { +/// Calciforge-side bridge that owns a `zeroclawlabs::WhatsAppWebChannel`, +/// drains its inbound stream, and dispatches messages through the standard +/// router / command / context pipeline. +pub struct WhatsAppChannel { config: Arc, router: Arc, command_handler: Arc, context_store: ContextStore, channel_scanner: Arc, - http_client: reqwest::Client, + transport: Arc, } -impl WhatsAppChannel { +impl WhatsAppChannel { pub fn new( config: Arc, router: Arc, command_handler: Arc, context_store: ContextStore, channel_scanner: Arc, + transport: Arc, ) -> Self { - let http_client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(300)) - .build() - .expect("failed to build HTTP client"); - Self { config, router, command_handler, context_store, channel_scanner, - http_client, + transport, } } - /// Check if message scanning is enabled for this channel. fn scan_enabled(&self) -> bool { self.config .channels @@ -201,165 +73,58 @@ impl WhatsAppChannel { .unwrap_or(false) } - /// Parse an incoming webhook payload and return all valid inbound messages. - /// - /// Filters out: - /// - Non-text messages (images, audio, etc.) - /// - Messages from numbers not in the `allowed_numbers` list - /// - Messages with empty body - pub fn parse_webhook_payload( - &self, - raw: &serde_json::Value, - allowed_numbers: &[String], - ) -> Vec { - let mut messages = Vec::new(); - - let payload: WaWebhookPayload = match serde_json::from_value(raw.clone()) { - Ok(p) => p, - Err(e) => { - warn!("WhatsApp: failed to deserialise webhook payload: {e}"); - return messages; + async fn send_reply(&self, recipient: &str, body: &str) { + let start = std::time::Instant::now(); + let response_len = body.len(); + match self + .transport + .send(&SendMessage::new(body, recipient)) + .await + { + Ok(()) => { + telemetry::reply_sent( + "whatsapp", + recipient, + "reply", + response_len, + start.elapsed().as_millis() as u64, + ); } - }; - - let Some(entries) = payload.entry else { - return messages; - }; - - for entry in entries { - let Some(changes) = entry.changes else { - continue; - }; - for change in changes { - let Some(value) = change.value else { continue }; - let Some(msgs) = value.messages else { continue }; - - for msg in msgs { - // Text-only for now; skip media, reactions, etc. - let kind = msg.kind.as_deref().unwrap_or("unknown"); - if kind != "text" { - debug!("WhatsApp: skipping non-text message type '{kind}'"); - continue; - } - - let text_body = match msg.text { - Some(t) if !t.body.is_empty() => t.body, - _ => { - debug!("WhatsApp: skipping empty text message"); - continue; - } - }; - - // Normalise phone number to E.164 - let from = normalise_phone(&msg.from); - - // Allowlist check - if !is_number_allowed(&from, allowed_numbers) { - warn!( - from = %from, - "WhatsApp: dropping message from number not in allowed_numbers" - ); - continue; - } - - let timestamp = extract_timestamp(&msg.timestamp); - - messages.push(InboundWaMessage { - from, - text: text_body, - _timestamp: timestamp, - }); - } + Err(e) => { + warn!(recipient = %recipient, error = %e, "WhatsApp: failed to send reply"); } } - - messages } - /// Send a reply to a WhatsApp user via the ZeroClaw OpenClaw gateway `/tools/invoke` API. - /// - /// ZeroClaw must be running with a live WA Web session for this to succeed. - /// Returns Ok(()) if the HTTP call was accepted (2xx), Err otherwise. - pub async fn send_reply( - &self, - zeroclaw_endpoint: &str, - zeroclaw_auth_token: Option<&str>, - to: &str, - text: &str, - ) -> Result<()> { - let url = format!("{zeroclaw_endpoint}/tools/invoke"); - - let body = ToolInvokeRequest { - tool: "message", - args: ToolInvokeArgs { - action: "send", - channel: "whatsapp", - target: to.to_string(), - message: text.to_string(), - }, - }; - - let mut req = self.http_client.post(&url).json(&body); - - if let Some(token) = zeroclaw_auth_token { - req = req.bearer_auth(token); - } - - let start = std::time::Instant::now(); - let response_len = text.len(); - let resp = req - .send() - .await - .with_context(|| format!("WhatsApp: HTTP error sending reply via {url}"))?; - - let status = resp.status(); - if !status.is_success() { - let body_text = resp.text().await.unwrap_or_default(); - anyhow::bail!("WhatsApp: ZeroClaw replied {status} for send to {to}: {body_text}"); - } - - telemetry::reply_sent( - "whatsapp", - to, - "reply", - response_len, - start.elapsed().as_millis() as u64, - ); - Ok(()) + async fn send_outbound(&self, recipient: &str, message: &OutboundMessage) { + self.send_reply(recipient, &message.render_text_fallback()) + .await; } - /// Handle a single inbound WhatsApp message end-to-end. - /// - /// Performs identity lookup, command dispatch, agent routing, and reply. - pub async fn handle_message( - self: Arc, - msg: InboundWaMessage, - zeroclaw_endpoint: String, - zeroclaw_auth_token: Option, - ) { + pub async fn handle_message(self: Arc, msg: ChannelMessage) { let received_at = std::time::Instant::now(); - let delivery_lag_ms = telemetry::delivery_lag_ms_from_unix_seconds(msg._timestamp); + let delivery_lag_ms = telemetry::delivery_lag_ms_from_unix_seconds(msg.timestamp); - // Clone owned strings up front so they can be moved into spawned tasks. - let from: String = msg.from.clone(); - let text: String = msg.text.clone(); + let from = msg.sender.clone(); + let reply_target = if msg.reply_target.is_empty() { + msg.sender.clone() + } else { + msg.reply_target.clone() + }; + let text = msg.content.clone(); - // Auth boundary: resolve sender to identity let identity = match resolve_channel_sender("whatsapp", &from, &self.config) { Some(id) => id, None => { - warn!(from = %from, "WhatsApp: unknown sender — dropping"); + warn!(from = %from, "WhatsApp: unknown sender - dropping"); return; } }; telemetry::authorized_message("whatsapp", &identity.id, &from, text.len(), delivery_lag_ms); - // Context key: scoped per identity (no chat_id for WA, phone is the key) let chat_key = format!("whatsapp-{}", identity.id); - // ── Adversary inbound scan ──────────────────────────────────────────── - if self.scan_enabled() { let verdict = self .channel_scanner @@ -373,28 +138,24 @@ impl WhatsAppChannel { "WhatsApp: inbound message BLOCKED by adversary scan" ); let channel = self.clone(); - let from_owned = from.clone(); + let target = reply_target.clone(); let reason_owned = reason.clone(); tokio::spawn(async move { - let reply = - format!("🚫 Message blocked by security scanner: {reason_owned}"); - if let Err(e) = channel + channel .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from_owned, - &reply, + &target, + &format!("Message blocked by security scanner: {reason_owned}"), ) - .await - { - warn!(from = %from_owned, error = %e, "WhatsApp: failed to send block notice"); - } + .await; }); return; } adversary_detector::verdict::ScanVerdict::Review { reason } => { - warn!(identity = %identity.id, reason = %reason, "WhatsApp: inbound message flagged REVIEW — passing with caution"); - // Pass through but logged + warn!( + identity = %identity.id, + reason = %reason, + "WhatsApp: inbound message flagged REVIEW - passing with caution" + ); } adversary_detector::verdict::ScanVerdict::Clean => { debug!(identity = %identity.id, "WhatsApp: inbound scan clean"); @@ -402,38 +163,16 @@ impl WhatsAppChannel { } } - // ── Command fast-path ────────────────────────────────────────────── - - // Pre-auth commands (!ping, !help, !agents, !metrics) if let Some(reply) = self.command_handler.handle(&text) { debug!(identity = %identity.id, cmd = %text.trim(), "WhatsApp: handled pre-auth command"); - telemetry::command_reply_ready( - "whatsapp", - &identity.id, - "command", - received_at.elapsed().as_millis() as u64, - 0, - reply.len(), - ); let channel = self.clone(); - let from_owned = from.clone(); + let target = reply_target.clone(); tokio::spawn(async move { - if let Err(e) = channel - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from_owned, - &reply, - ) - .await - { - warn!(from = %from_owned, error = %e, "WhatsApp: failed to send command reply"); - } + channel.send_reply(&target, &reply).await; }); return; } - // Unknown !command handling if CommandHandler::is_command(&text) && !CommandHandler::is_status_command(&text) && !CommandHandler::is_switch_command(&text) @@ -443,290 +182,112 @@ impl WhatsAppChannel { && !CommandHandler::is_secure_command(&text) { let reply = self.command_handler.unknown_command(&text); - telemetry::command_reply_ready( - "whatsapp", - &identity.id, - "unknown_command", - received_at.elapsed().as_millis() as u64, - 0, - reply.len(), - ); let channel = self.clone(); - let from_owned = from.clone(); + let target = reply_target.clone(); tokio::spawn(async move { - if let Err(e) = channel - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from_owned, - &reply, - ) - .await - { - warn!(from = %from_owned, error = %e, "failed to send unknown-command reply"); - } + channel.send_reply(&target, &reply).await; }); return; } - // !status — post-auth command if CommandHandler::is_status_command(&text) { - let command_start = std::time::Instant::now(); let reply = self .command_handler .cmd_status_for_identity(&identity.id) .await; - telemetry::command_reply_ready( - "whatsapp", - &identity.id, - "status", - received_at.elapsed().as_millis() as u64, - command_start.elapsed().as_millis() as u64, - reply.len(), - ); let channel = self.clone(); - let from_owned = from.clone(); + let target = reply_target.clone(); tokio::spawn(async move { - if let Err(e) = channel - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from_owned, - &reply, - ) - .await - { - warn!(from = %from_owned, error = %e, "WhatsApp: failed to send status reply"); - } + channel.send_reply(&target, &reply).await; }); return; } - // !switch — post-auth command if CommandHandler::is_switch_command(&text) { - let command_start = std::time::Instant::now(); let reply = self.command_handler.handle_switch(&text, &identity.id); - telemetry::command_reply_ready( - "whatsapp", - &identity.id, - "switch", - received_at.elapsed().as_millis() as u64, - command_start.elapsed().as_millis() as u64, - reply.len(), - ); let channel = self.clone(); - let from_owned = from.clone(); + let target = reply_target.clone(); tokio::spawn(async move { - if let Err(e) = channel - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from_owned, - &reply, - ) - .await - { - warn!(from = %from_owned, error = %e, "WhatsApp: failed to send switch reply"); - } + channel.send_reply(&target, &reply).await; }); return; } - // !model — post-auth command for alloy selection if CommandHandler::is_model_command(&text) { - let command_start = std::time::Instant::now(); let reply = self.command_handler.handle_model(&text, &identity.id); - telemetry::command_reply_ready( - "whatsapp", - &identity.id, - "model", - received_at.elapsed().as_millis() as u64, - command_start.elapsed().as_millis() as u64, - reply.len(), - ); let channel = self.clone(); - let from_owned = from.clone(); + let target = reply_target.clone(); tokio::spawn(async move { - if let Err(e) = channel - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from_owned, - &reply, - ) - .await - { - warn!(from = %from_owned, error = %e, "WhatsApp: failed to send model reply"); - } + channel.send_reply(&target, &reply).await; }); return; } - // !sessions — post-auth command if CommandHandler::is_sessions_command(&text) { - let command_start = std::time::Instant::now(); let reply = self .command_handler .handle_sessions(&text, &identity.id) .await; - telemetry::command_reply_ready( - "whatsapp", - &identity.id, - "sessions", - received_at.elapsed().as_millis() as u64, - command_start.elapsed().as_millis() as u64, - reply.len(), - ); let channel = self.clone(); - let from_owned = from.clone(); + let target = reply_target.clone(); tokio::spawn(async move { - if let Err(e) = channel - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from_owned, - &reply, - ) - .await - { - warn!(from = %from_owned, error = %e, "WhatsApp: failed to send sessions reply"); - } + channel.send_reply(&target, &reply).await; }); return; } - // !default — post-auth command if CommandHandler::is_default_command(&text) { - let command_start = std::time::Instant::now(); let reply = self.command_handler.handle_default(&identity.id); - telemetry::command_reply_ready( - "whatsapp", - &identity.id, - "default", - received_at.elapsed().as_millis() as u64, - command_start.elapsed().as_millis() as u64, - reply.len(), - ); let channel = self.clone(); - let from_owned = from.clone(); + let target = reply_target.clone(); tokio::spawn(async move { - if let Err(e) = channel - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from_owned, - &reply, - ) - .await - { - warn!(from = %from_owned, error = %e, "WhatsApp: failed to send default reply"); - } + channel.send_reply(&target, &reply).await; }); return; } - // !secure — store/list secrets without surfacing the value to - // any agent. Same redaction discipline as Telegram/Matrix: - // never log the body (which contains the value for `set`). if CommandHandler::is_secure_command(&text) { - debug!(from = %from, "WhatsApp: handling !secure command"); + debug!(identity = %identity.id, "WhatsApp: handling !secure command"); if CommandHandler::is_secure_set_command(&text) && !crate::config::channel_allows_chat_secret_set(&self.config, "whatsapp") { let reply = CommandHandler::secure_set_disabled_reply("WhatsApp"); - telemetry::command_reply_ready( - "whatsapp", - &identity.id, - "secure_disabled", - received_at.elapsed().as_millis() as u64, - 0, - reply.len(), - ); let channel = self.clone(); - let from_owned = from.clone(); - let zeroclaw_endpoint_owned = zeroclaw_endpoint.clone(); - let zeroclaw_auth_token_owned = zeroclaw_auth_token.clone(); + let target = reply_target.clone(); tokio::spawn(async move { - if let Err(e) = channel - .send_reply( - &zeroclaw_endpoint_owned, - zeroclaw_auth_token_owned.as_deref(), - &from_owned, - &reply, - ) - .await - { - warn!(from = %from_owned, error = %e, "WhatsApp: failed to send !secure disabled reply"); - } + channel.send_reply(&target, &reply).await; }); return; } - let command_start = std::time::Instant::now(); + let reply = self .command_handler .handle_secure(&text, &identity.id) .await; - telemetry::command_reply_ready( - "whatsapp", - &identity.id, - "secure", - received_at.elapsed().as_millis() as u64, - command_start.elapsed().as_millis() as u64, - reply.len(), - ); let channel = self.clone(); - let from_owned = from.clone(); + let target = reply_target.clone(); tokio::spawn(async move { - if let Err(e) = channel - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from_owned, - &reply, - ) - .await - { - warn!(from = %from_owned, error = %e, "WhatsApp: failed to send !secure reply"); - } + channel.send_reply(&target, &reply).await; }); return; } - // !context clear if text.trim().eq_ignore_ascii_case("!context clear") { self.context_store.clear(&chat_key); - telemetry::command_reply_ready( - "whatsapp", - &identity.id, - "context_clear", - received_at.elapsed().as_millis() as u64, - 0, - "🧹 Conversation context cleared.".len(), - ); let channel = self.clone(); - let from_owned = from.clone(); + let target = reply_target.clone(); tokio::spawn(async move { - if let Err(e) = channel - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from_owned, - "🧹 Conversation context cleared.", - ) - .await - { - warn!(from = %from_owned, error = %e, "WhatsApp: failed to send context-clear reply"); - } + channel + .send_reply(&target, "Conversation context cleared.") + .await; }); return; } - // ── Agent dispatch ───────────────────────────────────────────────── - let agent_id = match self.command_handler.active_agent_for(&identity.id) { Some(id) => id, None => { - warn!(identity = %identity.id, "WhatsApp: no routing rule for identity — dropping"); + warn!(identity = %identity.id, "WhatsApp: no routing rule for identity - dropping"); return; } }; @@ -736,22 +297,14 @@ impl WhatsAppChannel { None => { warn!(agent_id = %agent_id, "WhatsApp: agent not in config"); let channel = self.clone(); - let from_owned = from.clone(); + let target = reply_target.clone(); tokio::spawn(async move { - let _ = channel - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from_owned, - "⚠️ Agent not configured.", - ) - .await; + channel.send_reply(&target, "Agent not configured.").await; }); return; } }; - // Sender label for context preambles let sender_label = self .config .identities @@ -765,7 +318,6 @@ impl WhatsAppChannel { let model_override = self.command_handler.active_model_for_identity(&identity_id); let preserve_native_commands = crate::adapters::agent_supports_native_commands(&agent); - // Spawn agent dispatch — handler returns immediately tokio::spawn(async move { let queue_wait_ms = received_at.elapsed().as_millis() as u64; telemetry::agent_dispatch_started("whatsapp", &identity_id, &agent_id, queue_wait_ms); @@ -780,7 +332,7 @@ impl WhatsAppChannel { let dispatch_start = std::time::Instant::now(); match self .router - .dispatch_with_sender_and_model( + .dispatch_message_with_sender_and_model( &augmented, &agent, &self.config, @@ -791,26 +343,24 @@ impl WhatsAppChannel { { Ok(response) => { let latency_ms = dispatch_start.elapsed().as_millis() as u64; + let final_response = response.render_text_fallback(); self.command_handler.record_dispatch(latency_ms); telemetry::agent_dispatch_succeeded( "whatsapp", &identity_id, &agent_id, latency_ms, - response.len(), + response.response_len(), ); - // Outbound scanning dropped — see docs/roadmap/outbound-sensitive-data-detection.md - let final_response = response; - debug!( identity = %identity_id, agent_id = %agent_id, response_len = %final_response.len(), + attachments = response.attachments.len(), "WhatsApp: got agent response" ); - // Record exchange in context buffer self.context_store.push_with_options( &chat_key, &sender_label, @@ -820,31 +370,11 @@ impl WhatsAppChannel { preserve_native_commands, ); - if let Err(e) = self - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from, - &final_response, - ) - .await - { - warn!( - identity = %identity_id, - error = %e, - "WhatsApp: failed to send agent reply" - ); - } + self.send_outbound(&reply_target, &response).await; } Err(e) => { warn!(identity = %identity_id, error = %e, "WhatsApp: agent dispatch failed"); - let _ = self - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from, - &format!("⚠️ Agent error: {e}"), - ) + self.send_reply(&reply_target, &format!("Agent error: {e}")) .await; } } @@ -852,17 +382,32 @@ impl WhatsAppChannel { } } -// --------------------------------------------------------------------------- -// Webhook HTTP server -// --------------------------------------------------------------------------- - -/// Run the WhatsApp webhook HTTP listener. -/// -/// Starts an HTTP server on `listen_addr` that accepts POST requests on -/// `webhook_path`. For each valid inbound message, spawns a handler task that -/// routes through Calciforge's identity/agent system and sends the reply via ZeroClaw. -/// -/// This function runs until the server errors or is cancelled. +const MIGRATION_TOML: &str = r#" +[[channels]] +kind = "whatsapp" +enabled = true +whatsapp_session_path = "~/.calciforge/whatsapp/session.db" +allowed_numbers = ["+15555550001"] +# Optional pairing-code login: +# whatsapp_pair_phone = "15555550001" +"#; + +fn migration_error(field: &str) -> anyhow::Error { + anyhow::anyhow!( + "WhatsApp channel: legacy webhook field `{field}` is no longer supported. \ + Calciforge now embeds zeroclawlabs::WhatsAppWebChannel and owns the \ + WhatsApp Web session directly. Update your config to the new schema:\n{MIGRATION_TOML}" + ) +} + +fn resolved_session_path(config: &ChannelConfig) -> Result { + let configured = config + .whatsapp_session_path + .as_deref() + .context("whatsapp_session_path is required for kind = \"whatsapp\"")?; + Ok(expand_tilde(configured).display().to_string()) +} + pub async fn run( config: Arc, router: Arc, @@ -870,322 +415,208 @@ pub async fn run( context_store: ContextStore, channel_scanner: Arc, ) -> Result<()> { - use std::net::SocketAddr; - use tokio::io::AsyncReadExt; - - // Find WhatsApp channel config - let wa_channel_cfg = config + let whatsapp_cfg = config .channels .iter() .find(|c| c.kind == "whatsapp" && c.enabled) .context("no enabled whatsapp channel found in config")?; - let listen_addr: SocketAddr = wa_channel_cfg - .webhook_listen - .as_deref() - .unwrap_or("0.0.0.0:18795") - .parse() - .context("invalid whatsapp webhook_listen address")?; - - let zeroclaw_endpoint = wa_channel_cfg - .zeroclaw_endpoint - .as_deref() - .unwrap_or("http://127.0.0.1:18789") - .to_string(); + if whatsapp_cfg.zeroclaw_endpoint.is_some() { + return Err(migration_error("zeroclaw_endpoint")); + } + if whatsapp_cfg.zeroclaw_auth_token.is_some() { + return Err(migration_error("zeroclaw_auth_token")); + } + if whatsapp_cfg.webhook_listen.is_some() { + return Err(migration_error("webhook_listen")); + } + if whatsapp_cfg.webhook_path.is_some() { + return Err(migration_error("webhook_path")); + } + if whatsapp_cfg.webhook_secret.is_some() { + return Err(migration_error("webhook_secret")); + } - let zeroclaw_auth_token = wa_channel_cfg.zeroclaw_auth_token.clone(); - let webhook_path = wa_channel_cfg - .webhook_path - .as_deref() - .unwrap_or("/webhooks/whatsapp") - .to_string(); - let webhook_secret = wa_channel_cfg.webhook_secret.clone(); - let allowed_numbers = wa_channel_cfg.allowed_numbers.clone(); + let session_path = resolved_session_path(whatsapp_cfg)?; + let pair_phone = whatsapp_cfg.whatsapp_pair_phone.clone(); + let pair_code = whatsapp_cfg.whatsapp_pair_code.clone(); + let allowed = whatsapp_cfg.allowed_numbers.clone(); + let mention_only = whatsapp_cfg.whatsapp_mention_only; + let mode = whatsapp_cfg.whatsapp_mode.clone(); + let dm_policy = whatsapp_cfg.whatsapp_dm_policy.clone(); + let group_policy = whatsapp_cfg.whatsapp_group_policy.clone(); + let self_chat_mode = whatsapp_cfg.whatsapp_self_chat_mode; + let dm_mention_patterns = whatsapp_cfg.whatsapp_dm_mention_patterns.clone(); + let group_mention_patterns = whatsapp_cfg.whatsapp_group_mention_patterns.clone(); info!( - listen = %listen_addr, - path = %webhook_path, - zeroclaw = %zeroclaw_endpoint, - "WhatsApp webhook channel starting" + session_path = %session_path, + pair_phone = ?pair_phone, + mode = ?mode, + mention_only, + "WhatsApp channel starting (embedded zeroclawlabs::WhatsAppWebChannel)" + ); + + let transport = Arc::new( + ZclWhatsAppWebChannel::new( + session_path, + pair_phone, + pair_code, + allowed, + mention_only, + mode, + dm_policy, + group_policy, + self_chat_mode, + ) + .with_dm_mention_patterns(dm_mention_patterns) + .with_group_mention_patterns(group_mention_patterns), ); - let channel = Arc::new(WhatsAppChannel::new( + let bridge = Arc::new(WhatsAppChannel::::new( config, router, command_handler, context_store, channel_scanner, + transport.clone(), )); - let listener = tokio::net::TcpListener::bind(listen_addr) - .await - .with_context(|| format!("binding WhatsApp webhook listener on {listen_addr}"))?; - - info!(addr = %listen_addr, "WhatsApp webhook listener ready"); + run_transport_loop(bridge, transport).await +} - loop { - let (mut stream, peer_addr) = match listener.accept().await { - Ok(conn) => conn, - Err(e) => { - warn!(error = %e, "WhatsApp: accept error"); - continue; - } - }; +async fn run_transport_loop(bridge: Arc>, transport: Arc) -> Result<()> +where + C: Channel + ?Sized + 'static, +{ + let (tx, mut rx) = tokio::sync::mpsc::channel::(64); - let channel = channel.clone(); - let zeroclaw_endpoint = zeroclaw_endpoint.clone(); - let zeroclaw_auth_token = zeroclaw_auth_token.clone(); - let webhook_path = webhook_path.clone(); - let webhook_secret = webhook_secret.clone(); - let allowed_numbers = allowed_numbers.clone(); + let listener_transport = Arc::clone(&transport); + let listener_handle = tokio::spawn(async move { listener_transport.listen(tx).await }); + while let Some(msg) = rx.recv().await { + let bridge = bridge.clone(); tokio::spawn(async move { - // Read the raw HTTP request (max 256 KB) - let mut buf = vec![0u8; 262_144]; - let n = match stream.read(&mut buf).await { - Ok(n) => n, - Err(e) => { - warn!(peer = %peer_addr, error = %e, "WhatsApp: read error"); - return; - } - }; + bridge.handle_message(msg).await; + }); + } - let raw = match std::str::from_utf8(&buf[..n]) { - Ok(s) => s, - Err(_) => { - let _ = - send_http_response(&mut stream, 400, "Bad Request", "Invalid UTF-8").await; - return; - } - }; + match listener_handle.await { + Ok(Ok(())) => Err(anyhow!("WhatsApp listener exited unexpectedly")), + Ok(Err(e)) => Err(e).context("WhatsApp listener exited with error"), + Err(e) => Err(anyhow!("WhatsApp listener task failed: {e}")), + } +} - // Parse method and path from first line - let first_line = raw.lines().next().unwrap_or(""); - let mut parts = first_line.splitn(3, ' '); - let method = parts.next().unwrap_or("").to_uppercase(); - let path = parts.next().unwrap_or(""); +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ + AgentConfig, CalciforgeConfig, CalciforgeHeader, ChannelAlias, ChannelConfig, Identity, + RoutingRule, + }; + use async_trait::async_trait; + use std::sync::Mutex as StdMutex; + use tokio::sync::mpsc; + use tokio::sync::Notify; + + struct MockChannel { + sent: StdMutex>, + sent_notify: Notify, + listen_error: StdMutex>, + } - // Health check - if method == "GET" && (path == "/health" || path == "/healthz") { - let _ = send_http_response(&mut stream, 200, "OK", r#"{"status":"ok"}"#).await; - return; + impl MockChannel { + fn new() -> Self { + Self { + sent: StdMutex::new(Vec::new()), + sent_notify: Notify::new(), + listen_error: StdMutex::new(None), } + } - // Only POST to the configured webhook path - if method != "POST" || path != webhook_path { - let _ = - send_http_response(&mut stream, 404, "Not Found", r#"{"error":"not found"}"#) - .await; - return; + fn with_listen_error(error: &str) -> Self { + Self { + sent: StdMutex::new(Vec::new()), + sent_notify: Notify::new(), + listen_error: StdMutex::new(Some(error.to_string())), } + } - // Extract body (everything after the blank line that separates headers from body) - let body = if let Some(idx) = raw.find("\r\n\r\n") { - &raw[idx + 4..] - } else if let Some(idx) = raw.find("\n\n") { - &raw[idx + 2..] - } else { - "" - }; - - // HMAC verification (optional — only when webhook_secret is set) - if let Some(ref secret) = webhook_secret { - // Extract X-Hub-Signature-256 header - let sig_header = raw - .lines() - .find(|l| l.to_lowercase().starts_with("x-hub-signature-256:")) - .and_then(|l| l.split_once(':').map(|x| x.1)) - .map(|s| s.trim()) - .unwrap_or(""); - - if !verify_hmac_sha256(secret, body.as_bytes(), sig_header) { - warn!(peer = %peer_addr, "WhatsApp: webhook HMAC verification failed"); - let _ = send_http_response( - &mut stream, - 401, - "Unauthorized", - r#"{"error":"invalid signature"}"#, - ) - .await; - return; - } - } + fn drain(&self) -> Vec { + std::mem::take(&mut *self.sent.lock().unwrap()) + } - // Parse JSON - let payload: serde_json::Value = match serde_json::from_str(body) { - Ok(v) => v, - Err(e) => { - warn!(peer = %peer_addr, error = %e, "WhatsApp: invalid JSON body"); - let _ = send_http_response( - &mut stream, - 400, - "Bad Request", - r#"{"error":"invalid json"}"#, - ) - .await; - return; + async fn wait_for_sent_len(&self, expected: usize) { + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + let notified = self.sent_notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if self.sent.lock().unwrap().len() >= expected { + return; + } + notified.await; } - }; - - // Acknowledge immediately (Meta/ZeroClaw expects fast 200) - let _ = send_http_response(&mut stream, 200, "OK", r#"{"status":"ok"}"#).await; - - // Parse and dispatch messages - let messages = channel.parse_webhook_payload(&payload, &allowed_numbers); - if messages.is_empty() { - debug!(peer = %peer_addr, "WhatsApp: webhook acknowledged (no actionable messages)"); - return; - } - - for msg in messages { - let ch = channel.clone(); - let ep = zeroclaw_endpoint.clone(); - let tok = zeroclaw_auth_token.clone(); - tokio::spawn(async move { - ch.handle_message(msg, ep, tok).await; - }); - } - }); + }) + .await + .expect("timed out waiting for WhatsApp mock send"); + } } -} -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/// Normalise a phone number to E.164 format (`+15555550001`). -/// Strips spaces/dashes; adds `+` prefix if missing. -fn normalise_phone(raw: &str) -> String { - let digits_only: String = raw.chars().filter(|c| c.is_ascii_digit()).collect(); - format!("+{digits_only}") -} + #[async_trait] + impl Channel for MockChannel { + fn name(&self) -> &str { + "mock-whatsapp" + } -/// Check whether a normalised E.164 phone number is in the allowlist. -/// A `"*"` entry allows all numbers. -fn is_number_allowed(phone: &str, allowed: &[String]) -> bool { - allowed.iter().any(|n| n == "*" || n == phone) -} + async fn send(&self, message: &SendMessage) -> anyhow::Result<()> { + self.sent.lock().unwrap().push(message.clone()); + self.sent_notify.notify_waiters(); + Ok(()) + } -/// Extract a Unix timestamp from the webhook message's `timestamp` field. -/// Handles both string and integer JSON values. -fn extract_timestamp(ts: &Option) -> u64 { - match ts { - Some(serde_json::Value::Number(n)) => n.as_u64().unwrap_or_default(), - Some(serde_json::Value::String(s)) => s.parse().unwrap_or_default(), - _ => std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), + async fn listen(&self, _tx: mpsc::Sender) -> anyhow::Result<()> { + if let Some(error) = self.listen_error.lock().unwrap().take() { + return Err(anyhow::anyhow!(error)); + } + Ok(()) + } } -} - -/// Verify a WhatsApp `X-Hub-Signature-256` header using HMAC-SHA256. -/// -/// The header format is `sha256=`. Returns `true` if the signature matches. -fn verify_hmac_sha256(secret: &str, body: &[u8], sig_header: &str) -> bool { - let expected_hex = match sig_header.strip_prefix("sha256=") { - Some(h) => h, - None => return false, - }; - let sig_bytes = match hex::decode(expected_hex) { - Ok(bytes) => bytes, - Err(_) => return false, - }; - - use hmac::{Hmac, Mac}; - use sha2::Sha256; - type HmacSha256 = Hmac; - let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) { - Ok(mac) => mac, - Err(_) => return false, - }; - - mac.update(body); - mac.verify_slice(&sig_bytes).is_ok() -} - -/// Write a minimal HTTP/1.1 response to the stream. -async fn send_http_response( - stream: &mut tokio::net::TcpStream, - status: u16, - reason: &str, - body: &str, -) -> std::io::Result<()> { - use tokio::io::AsyncWriteExt; - let response = format!( - "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ); - stream.write_all(response.as_bytes()).await?; - stream.flush().await -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::{ - AgentConfig, CalciforgeConfig, CalciforgeHeader, ChannelAlias, ChannelConfig, Identity, - RoutingRule, - }; + fn make_test_config(mutate: F) -> Arc { + let mut channel = ChannelConfig { + kind: "whatsapp".to_string(), + enabled: true, + allowed_numbers: vec!["+15555550100".to_string()], + whatsapp_session_path: Some("test-session.db".to_string()), + ..Default::default() + }; + mutate(&mut channel); - fn make_test_config() -> Arc { Arc::new(CalciforgeConfig { calciforge: CalciforgeHeader { version: 2 }, identities: vec![Identity { - id: "brian".to_string(), - display_name: Some("Brian".to_string()), - aliases: vec![ - ChannelAlias { - channel: "telegram".to_string(), - id: "7000000001".to_string(), - }, - ChannelAlias { - channel: "whatsapp".to_string(), - id: "+15555550001".to_string(), - }, - ], + id: "alice".to_string(), + display_name: Some("Alice".to_string()), + aliases: vec![ChannelAlias { + channel: "whatsapp".to_string(), + id: "+15555550100".to_string(), + }], role: Some("owner".to_string()), }], agents: vec![AgentConfig { id: "librarian".to_string(), kind: "openclaw-channel".to_string(), - endpoint: "http://10.0.0.20:18789".to_string(), - timeout_ms: Some(120000), - model: None, - auth_token: Some("REPLACE_WITH_AUTH_TOKEN".to_string()), - api_key: None, - api_key_file: None, - openclaw_agent_id: None, - allow_model_override: None, - reply_port: None, - reply_auth_token: None, - command: None, - args: None, - env: None, - registry: None, - aliases: vec![], + endpoint: "http://127.0.0.1:18789".to_string(), + ..Default::default() }], routing: vec![RoutingRule { - identity: "brian".to_string(), + identity: "alice".to_string(), default_agent: "librarian".to_string(), allowed_agents: vec![], }], - channels: vec![ChannelConfig { - kind: "whatsapp".to_string(), - enabled: true, - zeroclaw_endpoint: Some("http://127.0.0.1:18789".to_string()), - zeroclaw_auth_token: Some("REPLACE_WITH_AUTH_TOKEN".to_string()), - webhook_path: Some("/webhooks/whatsapp".to_string()), - webhook_listen: Some("0.0.0.0:18795".to_string()), - webhook_secret: None, - allowed_numbers: vec!["+15555550001".to_string()], - ..Default::default() - }], + channels: vec![channel], permissions: None, memory: None, context: Default::default(), @@ -1200,259 +631,216 @@ mod tests { }) } - fn make_channel(config: Arc) -> Arc { + fn make_scanner() -> Arc { + let security_config = adversary_detector::profiles::SecurityConfig::balanced(); + let scanner = + adversary_detector::scanner::AdversaryScanner::new(security_config.scanner.clone()); + let audit_logger = adversary_detector::audit::AuditLogger::new("test-whatsapp"); + Arc::new(ChannelScanner::new(scanner, audit_logger, security_config)) + } + + struct TestBridge { + bridge: Arc>, + _state_dir: tempfile::TempDir, + } + + fn dummy_bridge_with(config: Arc, transport: Arc) -> TestBridge { let router = Arc::new(Router::new()); - // Use a per-test temp state dir to avoid cross-test state pollution. let tmp = tempfile::tempdir().expect("tempdir for whatsapp test state isolation"); let command_handler = Arc::new(CommandHandler::with_state_dir( config.clone(), tmp.path().to_path_buf(), )); let context_store = ContextStore::new(20, 5); - let security_config = adversary_detector::profiles::SecurityConfig::balanced(); - let scanner = - adversary_detector::scanner::AdversaryScanner::new(security_config.scanner.clone()); - let audit_logger = adversary_detector::audit::AuditLogger::new("test-wa"); - let channel_scanner = Arc::new(adversary_detector::middleware::ChannelScanner::new( - scanner, - audit_logger, - security_config, + TestBridge { + bridge: Arc::new(WhatsAppChannel::::new( + config, + router, + command_handler, + context_store, + make_scanner(), + transport, + )), + _state_dir: tmp, + } + } + + #[tokio::test] + async fn test_run_errors_on_old_config_fields() { + let config = make_test_config(|c| { + c.zeroclaw_endpoint = Some("http://127.0.0.1:18789".to_string()); + }); + + let router = Arc::new(Router::new()); + let tmp = tempfile::tempdir().expect("tempdir for whatsapp test state isolation"); + let command_handler = Arc::new(CommandHandler::with_state_dir( + config.clone(), + tmp.path().to_path_buf(), )); - Arc::new(WhatsAppChannel::new( + let context_store = ContextStore::new(20, 5); + let channel_scanner = make_scanner(); + + let err = run( config, router, command_handler, context_store, channel_scanner, - )) - } - - // --- Payload parsing tests --- - - #[test] - fn test_parse_valid_text_message() { - let config = make_test_config(); - let channel = make_channel(config); - let allowed = vec!["+15555550001".to_string()]; - - let payload = serde_json::json!({ - "object": "whatsapp_business_account", - "entry": [{ - "changes": [{ - "value": { - "messages": [{ - "from": "15555550001", - "type": "text", - "text": { "body": "Hello Calciforge!" }, - "timestamp": "1699999999" - }] - } - }] - }] - }); - - let msgs = channel.parse_webhook_payload(&payload, &allowed); - assert_eq!(msgs.len(), 1); - assert_eq!(msgs[0].from, "+15555550001"); - assert_eq!(msgs[0].text, "Hello Calciforge!"); - assert_eq!(msgs[0]._timestamp, 1_699_999_999); - } - - #[test] - fn test_parse_empty_payload() { - let config = make_test_config(); - let channel = make_channel(config); - let payload = serde_json::json!({}); - let msgs = channel.parse_webhook_payload(&payload, &[]); - assert!(msgs.is_empty()); - } - - #[test] - fn test_parse_skips_non_text_message() { - let config = make_test_config(); - let channel = make_channel(config); - let allowed = vec!["+15555550001".to_string()]; - - let payload = serde_json::json!({ - "entry": [{ - "changes": [{ - "value": { - "messages": [{ - "from": "15555550001", - "type": "image", - "timestamp": "1699999999" - }] - } - }] - }] - }); - - let msgs = channel.parse_webhook_payload(&payload, &allowed); - assert!(msgs.is_empty(), "non-text messages must be skipped"); - } - - #[test] - fn test_parse_drops_unauthorized_number() { - let config = make_test_config(); - let channel = make_channel(config); - let allowed = vec!["+15555550001".to_string()]; - - let payload = serde_json::json!({ - "entry": [{ - "changes": [{ - "value": { - "messages": [{ - "from": "9999999999", - "type": "text", - "text": { "body": "Spam" }, - "timestamp": "1699999999" - }] - } - }] - }] - }); + ) + .await + .expect_err("legacy zeroclaw_endpoint must be rejected"); - let msgs = channel.parse_webhook_payload(&payload, &allowed); - assert!(msgs.is_empty(), "unauthorised numbers must be dropped"); + let rendered = format!("{err}"); + assert!(rendered.contains("zeroclaw_endpoint")); + assert!(rendered.contains("whatsapp_session_path")); } #[test] - fn test_parse_wildcard_allowlist() { - let config = make_test_config(); - let channel = make_channel(config); - let allowed = vec!["*".to_string()]; - - let payload = serde_json::json!({ - "entry": [{ - "changes": [{ - "value": { - "messages": [{ - "from": "9999999999", - "type": "text", - "text": { "body": "Anyone can message with wildcard" }, - "timestamp": "1699999999" - }] - } - }] - }] - }); - - let msgs = channel.parse_webhook_payload(&payload, &allowed); - assert_eq!(msgs.len(), 1); - } + fn test_session_path_expands_tilde() { + let mut config = ChannelConfig { + whatsapp_session_path: Some("~/.calciforge/whatsapp/session.db".to_string()), + ..Default::default() + }; - // --- Phone normalisation tests --- + let session_path = resolved_session_path(&config) + .expect("configured WhatsApp session path should resolve"); - #[test] - fn test_normalise_phone_with_plus() { - assert_eq!(normalise_phone("+15555550001"), "+15555550001"); - } - - #[test] - fn test_normalise_phone_without_plus() { - assert_eq!(normalise_phone("15555550001"), "+15555550001"); - } + assert!( + !session_path.starts_with("~/"), + "WhatsApp session path should not keep a literal tilde: {session_path}" + ); + assert!( + session_path.ends_with(".calciforge/whatsapp/session.db"), + "WhatsApp session path should preserve the configured suffix: {session_path}" + ); - #[test] - fn test_normalise_phone_strips_spaces() { - assert_eq!(normalise_phone("1 555 555 0100"), "+15555550100"); + config.whatsapp_session_path = Some("/var/lib/calciforge/wa.db".to_string()); + assert_eq!( + resolved_session_path(&config).expect("absolute session path should resolve"), + "/var/lib/calciforge/wa.db" + ); } - // --- Identity resolution tests --- + #[tokio::test] + async fn test_transport_loop_propagates_listener_error() { + let config = make_test_config(|_| {}); + let transport = Arc::new(MockChannel::with_listen_error("listen failed")); + let bridge = dummy_bridge_with(config, Arc::clone(&transport)); - #[test] - fn test_whatsapp_identity_resolves() { - let config = make_test_config(); - let result = resolve_channel_sender("whatsapp", "+15555550001", &config); - assert!(result.is_some()); - assert_eq!(result.unwrap().id, "brian"); - } + let err = run_transport_loop(bridge.bridge, transport) + .await + .expect_err("listener errors must surface from WhatsApp run loop"); - #[test] - fn test_whatsapp_unknown_sender_drops() { - let config = make_test_config(); - let result = resolve_channel_sender("whatsapp", "+19998887777", &config); - assert!(result.is_none(), "unknown WA sender must return None"); + let rendered = format!("{err:#}"); + assert!(rendered.contains("listen failed")); } - // --- Allowlist helper tests --- + #[tokio::test] + async fn test_transport_loop_errors_on_clean_listener_exit() { + let config = make_test_config(|_| {}); + let transport = Arc::new(MockChannel::new()); + let bridge = dummy_bridge_with(config, Arc::clone(&transport)); - #[test] - fn test_is_number_allowed_exact() { - assert!(is_number_allowed( - "+15555550001", - &["+15555550001".to_string()] - )); - assert!(!is_number_allowed( - "+19998887777", - &["+15555550001".to_string()] - )); - } + let err = run_transport_loop(bridge.bridge, transport) + .await + .expect_err("clean listener exits are unexpected in production"); - #[test] - fn test_is_number_allowed_wildcard() { - assert!(is_number_allowed("+19998887777", &["*".to_string()])); + let rendered = format!("{err:#}"); + assert!(rendered.contains("exited unexpectedly")); } - #[test] - fn test_is_number_allowed_empty_list() { - assert!(!is_number_allowed("+15555550001", &[])); - } + #[tokio::test] + async fn test_handle_message_unknown_sender_drops() { + let config = make_test_config(|_| {}); + let transport = Arc::new(MockChannel::new()); + let bridge = dummy_bridge_with(config, transport.clone()); + + let msg = ChannelMessage { + id: "1".into(), + sender: "+19990001111".into(), + reply_target: "+19990001111".into(), + content: "!ping".into(), + channel: "whatsapp".into(), + timestamp: 0, + thread_ts: None, + interruption_scope_id: None, + attachments: vec![], + }; - // --- Timestamp extraction tests --- + bridge.bridge.handle_message(msg).await; - #[test] - fn test_extract_timestamp_from_string() { - let ts = Some(serde_json::Value::String("1699999999".to_string())); - assert_eq!(extract_timestamp(&ts), 1_699_999_999); + assert!(transport.drain().is_empty()); } - #[test] - fn test_extract_timestamp_from_number() { - let ts = Some(serde_json::json!(1699999999u64)); - assert_eq!(extract_timestamp(&ts), 1_699_999_999); - } + #[tokio::test] + async fn test_handle_message_replies_to_group_target() { + let config = make_test_config(|_| {}); + let transport = Arc::new(MockChannel::new()); + let bridge = dummy_bridge_with(config, transport.clone()); + + let msg = ChannelMessage { + id: "1".into(), + sender: "+15555550100".into(), + reply_target: "12345@g.us".into(), + content: "!ping".into(), + channel: "whatsapp".into(), + timestamp: 0, + thread_ts: None, + interruption_scope_id: None, + attachments: vec![], + }; - #[test] - fn test_extract_timestamp_fallback_on_none() { - let now_before = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - let ts = extract_timestamp(&None); - assert!(ts >= now_before, "fallback timestamp should be recent"); - } + bridge.bridge.handle_message(msg).await; - #[test] - fn test_verify_hmac_sha256_valid() { - use hmac::{Hmac, Mac}; - use sha2::Sha256; - - type HmacSha256 = Hmac; - let secret = "webhook-secret"; - let body = br#"{"entry":[{"changes":[]}]}"#; - let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap(); - mac.update(body); - let signature = format!("sha256={}", hex::encode(mac.finalize().into_bytes())); - - assert!(verify_hmac_sha256(secret, body, &signature)); - } + transport.wait_for_sent_len(1).await; - #[test] - fn test_verify_hmac_sha256_rejects_missing_prefix() { - assert!(!verify_hmac_sha256("secret", b"body", "abcd")); + let sent = transport.drain(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].recipient, "12345@g.us"); } - #[test] - fn test_verify_hmac_sha256_rejects_tampered_body() { - use hmac::{Hmac, Mac}; - use sha2::Sha256; - - type HmacSha256 = Hmac; - let mut mac = HmacSha256::new_from_slice(b"secret").unwrap(); - mac.update(b"original"); - let signature = format!("sha256={}", hex::encode(mac.finalize().into_bytes())); + #[tokio::test] + async fn test_handle_message_renders_artifact_fallback() { + let mut config = (*make_test_config(|_| {})).clone(); + config.agents = vec![AgentConfig { + id: "librarian".to_string(), + kind: "artifact-cli".to_string(), + command: Some("/bin/sh".to_string()), + args: Some(vec![ + "-c".to_string(), + "cat >/dev/null; printf 'image-bytes' > \"$CALCIFORGE_ARTIFACT_DIR/result.png\"; printf 'done\\n'" + .to_string(), + ]), + ..Default::default() + }]; + let config = Arc::new(config); + let transport = Arc::new(MockChannel::new()); + let bridge = dummy_bridge_with(config, transport.clone()); + + let msg = ChannelMessage { + id: "1".into(), + sender: "+15555550100".into(), + reply_target: "+15555550100".into(), + content: "make an image".into(), + channel: "whatsapp".into(), + timestamp: 0, + thread_ts: None, + interruption_scope_id: None, + attachments: vec![], + }; - assert!(!verify_hmac_sha256("secret", b"tampered", &signature)); + bridge.bridge.handle_message(msg).await; + transport.wait_for_sent_len(1).await; + + let sent = transport.drain(); + assert_eq!(sent.len(), 1); + assert!(sent[0].content.contains("done")); + assert!(sent[0].content.contains("Attachments:")); + assert!(sent[0].content.contains("result.png")); + assert!( + !sent[0].content.contains("/tmp/calciforge-artifacts"), + "fallback must not leak local artifact paths: {}", + sent[0].content + ); } } diff --git a/crates/calciforge/src/config.rs b/crates/calciforge/src/config.rs index 64759f3f..34a77868 100644 --- a/crates/calciforge/src/config.rs +++ b/crates/calciforge/src/config.rs @@ -307,11 +307,13 @@ pub struct RoutingRule { /// A channel entry (`[[channels]]`). /// -/// Supports `kind = "telegram"`, `kind = "matrix"`, `kind = "whatsapp"`, and `kind = "signal"`. +/// Supports `kind = "telegram"`, `kind = "matrix"`, `kind = "whatsapp"`, `kind = "signal"`, +/// and `kind = "sms"` for Linq-backed iMessage/RCS/SMS. /// For Telegram: set `bot_token_file`. /// For Matrix: set `homeserver`, `access_token_file`, `room_id`, and optionally `allowed_users`. -/// For WhatsApp: set `zeroclaw_endpoint`, `zeroclaw_auth_token`, `webhook_listen`, and `allowed_numbers`. -/// For Signal: set `zeroclaw_endpoint`, `zeroclaw_auth_token`, `webhook_listen`, and `allowed_numbers` (same fields as WhatsApp). +/// For WhatsApp: set `whatsapp_session_path` and `allowed_numbers`. +/// For Signal: set `signal_cli_url`, `signal_account`, and `allowed_numbers`. +/// For text/iMessage: set `sms_linq_api_token_file`, `sms_from_phone`, and `allowed_numbers`. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct ChannelConfig { pub kind: String, @@ -336,34 +338,92 @@ pub struct ChannelConfig { #[serde(default)] pub allowed_users: Vec, - // --- WhatsApp/Signal-specific fields (shared) --- - /// ZeroClaw / OpenClaw gateway endpoint that owns the WA Web or Signal session. - /// Calciforge will POST reply messages to `{zeroclaw_endpoint}/tools/invoke`. - /// Example: `"http://127.0.0.1:18789"` (local OpenClaw) or - /// `"http://10.0.0.10:18789"` (remote Lucien/ZeroClaw instance). + // --- Legacy webhook fields (rejected by embedded Signal/WhatsApp channels) --- + /// Legacy ZeroClaw / OpenClaw gateway endpoint. Embedded Signal and + /// WhatsApp reject this field with a migration error. pub zeroclaw_endpoint: Option, - /// Bearer token for the ZeroClaw / OpenClaw gateway. + /// Legacy bearer token for the ZeroClaw / OpenClaw gateway. pub zeroclaw_auth_token: Option, - /// HTTP address to listen on for incoming webhook POSTs from ZeroClaw. - /// Defaults to `"0.0.0.0:18795"`. + /// Legacy webhook listen address. pub webhook_listen: Option, - /// URL path Calciforge registers for incoming WhatsApp webhooks. - /// Defaults to `"/webhooks/whatsapp"`. + /// Legacy webhook path. pub webhook_path: Option, - /// Optional HMAC-SHA256 secret for `X-Hub-Signature-256` webhook verification. - /// Leave unset to skip signature checking (not recommended for production). + /// Legacy webhook HMAC secret. pub webhook_secret: Option, /// Allowed sender phone numbers in E.164 format, e.g. `["+15555550001"]`. /// Use `"*"` to allow any number (not recommended). - /// Must correspond to identity aliases with `channel = "whatsapp"` or `channel = "signal"`. + /// Must correspond to identity aliases with `channel = "whatsapp"`, `channel = "signal"`, + /// or `channel = "sms"`. #[serde(default)] pub allowed_numbers: Vec, + // --- WhatsApp-specific fields (embedded `zeroclawlabs::WhatsAppWebChannel`) --- + /// Path to the WhatsApp Web SQLite session database. + pub whatsapp_session_path: Option, + + /// Optional phone number for pairing-code linking, without punctuation. + pub whatsapp_pair_phone: Option, + + /// Optional custom pair code; leave unset for auto-generated pairing. + pub whatsapp_pair_code: Option, + + /// When true, only respond to WhatsApp group messages that mention the bot. + #[serde(default)] + pub whatsapp_mention_only: bool, + + /// WhatsApp Web operating mode. + #[serde(default)] + pub whatsapp_mode: zeroclaw::config::WhatsAppWebMode, + + /// Direct-message policy when `whatsapp_mode = "personal"`. + #[serde(default)] + pub whatsapp_dm_policy: zeroclaw::config::WhatsAppChatPolicy, + + /// Group-chat policy when `whatsapp_mode = "personal"`. + #[serde(default)] + pub whatsapp_group_policy: zeroclaw::config::WhatsAppChatPolicy, + + /// When true, always respond in self-chat mode when `whatsapp_mode = "personal"`. + #[serde(default)] + pub whatsapp_self_chat_mode: bool, + + /// Optional case-insensitive regexes that count as bot mentions in DMs. + #[serde(default)] + pub whatsapp_dm_mention_patterns: Vec, + + /// Optional case-insensitive regexes that count as bot mentions in groups. + #[serde(default)] + pub whatsapp_group_mention_patterns: Vec, + + // --- Text/iMessage-specific fields (embedded `zeroclawlabs::LinqChannel`) --- + /// Linq Partner API token. Prefer `sms_linq_api_token_file`. + pub sms_linq_api_token: Option, + + /// Path to a file containing the Linq Partner API token. + pub sms_linq_api_token_file: Option, + + /// E.164 phone number Linq should send from. + pub sms_from_phone: Option, + + /// HTTP address to listen on for Linq inbound webhooks. + /// Defaults to `"0.0.0.0:18798"`. + pub sms_webhook_listen: Option, + + /// URL path for Linq inbound webhooks. + /// Defaults to `"/webhooks/sms"`. + pub sms_webhook_path: Option, + + /// Linq webhook signing secret. Prefer `sms_linq_signing_secret_file`. + pub sms_linq_signing_secret: Option, + + /// Path to a file containing the Linq webhook signing secret. + pub sms_linq_signing_secret_file: Option, + // --- Signal-specific fields (embedded `zeroclawlabs::SignalChannel`) --- /// HTTP URL of `signal-cli-rest-api` (or compatible signal-cli daemon /// HTTP front-end). Example: `"http://127.0.0.1:8080"`. @@ -1611,6 +1671,13 @@ weight = 20 .collect() } + fn full_config_blocks_from_doc(markdown: &str) -> Vec { + extract_toml_blocks(markdown) + .into_iter() + .filter(|b| b.contains("[calciforge]")) + .collect() + } + fn parse_channel_block(block: &str) -> CalciforgeConfig { let wrapped = format!("[calciforge]\nversion = 2\n\n{block}"); toml::from_str(&wrapped).unwrap_or_else(|e| { @@ -1702,17 +1769,46 @@ version = 2 [[channels]] kind = "whatsapp" enabled = true -zeroclaw_endpoint = "http://127.0.0.1:18789" -zeroclaw_auth_token = "REPLACE_WITH_AUTH_TOKEN" -webhook_listen = "0.0.0.0:18795" -webhook_path = "/webhooks/whatsapp" +whatsapp_session_path = "~/.calciforge/whatsapp/session.db" +whatsapp_pair_phone = "15555550001" allowed_numbers = ["+15555550001"] "#; let cfg: CalciforgeConfig = toml::from_str(raw).expect("whatsapp channel config"); assert_eq!(cfg.channels[0].kind, "whatsapp"); assert_eq!( - cfg.channels[0].webhook_listen.as_deref(), - Some("0.0.0.0:18795") + cfg.channels[0].whatsapp_session_path.as_deref(), + Some("~/.calciforge/whatsapp/session.db") + ); + assert_eq!( + cfg.channels[0].whatsapp_pair_phone.as_deref(), + Some("15555550001") + ); + } + + #[test] + fn test_channel_config_sms_inline() { + let raw = r#" +[calciforge] +version = 2 + +[[channels]] +kind = "sms" +enabled = true +sms_linq_api_token_file = "~/.calciforge/secrets/linq-token" +sms_from_phone = "+15555550001" +sms_webhook_listen = "0.0.0.0:18798" +sms_webhook_path = "/webhooks/sms" +allowed_numbers = ["+15555550100"] +"#; + let cfg: CalciforgeConfig = toml::from_str(raw).expect("sms channel config"); + assert_eq!(cfg.channels[0].kind, "sms"); + assert_eq!( + cfg.channels[0].sms_linq_api_token_file.as_deref(), + Some("~/.calciforge/secrets/linq-token") + ); + assert_eq!( + cfg.channels[0].sms_from_phone.as_deref(), + Some("+15555550001") ); } @@ -1783,4 +1879,36 @@ allowed_numbers = ["+15555550001"] ); } } + + #[test] + fn test_channel_docs_sms_toml_blocks_valid() { + let doc = include_str!("../../../docs/channels/sms.md"); + let blocks = channel_blocks_from_doc(doc); + assert!( + !blocks.is_empty(), + "no [[channels]] TOML blocks found in sms.md" + ); + for block in blocks { + let cfg = parse_channel_block(&block); + assert_eq!( + cfg.channels[0].kind, "sms", + "unexpected kind in block:\n{block}" + ); + } + } + + #[test] + fn test_agents_doc_full_config_toml_blocks_valid() { + let doc = include_str!("../../../docs/agents.md"); + let blocks = full_config_blocks_from_doc(doc); + assert!( + !blocks.is_empty(), + "no full config TOML blocks found in agents.md" + ); + for block in blocks { + toml::from_str::(&block).unwrap_or_else(|e| { + panic!("agents.md full config block failed to parse:\n{block}\nerror: {e}") + }); + } + } } diff --git a/crates/calciforge/src/main.rs b/crates/calciforge/src/main.rs index 62f6870a..4cccf59d 100644 --- a/crates/calciforge/src/main.rs +++ b/crates/calciforge/src/main.rs @@ -371,12 +371,20 @@ async fn main() -> Result<()> { .iter() .any(|c| c.kind == "signal" && c.enabled); + let has_sms = config.channels.iter().any(|c| c.kind == "sms" && c.enabled); + let has_mock = config .channels .iter() .any(|c| c.kind == "mock" && c.enabled); - if !args.proxy_only && !has_telegram && !has_matrix && !has_whatsapp && !has_signal && !has_mock + if !args.proxy_only + && !has_telegram + && !has_matrix + && !has_whatsapp + && !has_signal + && !has_sms + && !has_mock { error!("no enabled channels found in config — nothing to do"); std::process::exit(1); @@ -418,7 +426,7 @@ async fn main() -> Result<()> { let whatsapp_fut = async { if !args.proxy_only && has_whatsapp { - info!("starting WhatsApp channel (webhook receiver)"); + info!("starting WhatsApp channel (embedded WhatsApp Web client)"); channels::whatsapp::run( config.clone(), router.clone(), @@ -435,7 +443,7 @@ async fn main() -> Result<()> { let signal_fut = async { if !args.proxy_only && has_signal { - info!("starting Signal channel (webhook receiver)"); + info!("starting Signal channel (embedded signal-cli-rest-api bridge)"); channels::signal::run( config.clone(), router.clone(), @@ -450,6 +458,23 @@ async fn main() -> Result<()> { } }; + let sms_fut = async { + if !args.proxy_only && has_sms { + info!("starting Text/iMessage channel (Linq webhook receiver)"); + channels::sms::run( + config.clone(), + router.clone(), + command_handler.clone(), + context_store.clone(), + channel_scanner.clone(), + ) + .await + .context("Text/iMessage channel error") + } else { + Ok(()) + } + }; + let mock_fut = async { if !args.proxy_only && has_mock { info!("starting Mock channel"); @@ -510,11 +535,12 @@ async fn main() -> Result<()> { } }; - let (tg_result, mx_result, wa_result, sig_result, mock_result, proxy_result) = tokio::join!( + let (tg_result, mx_result, wa_result, sig_result, sms_result, mock_result, proxy_result) = tokio::join!( telegram_fut, matrix_fut, whatsapp_fut, signal_fut, + sms_fut, mock_fut, proxy_fut ); @@ -523,6 +549,7 @@ async fn main() -> Result<()> { mx_result?; wa_result?; sig_result?; + sms_result?; mock_result?; Ok(()) diff --git a/docs/README.md b/docs/README.md index e9dc1a67..7f78a263 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,7 @@ that should be reasonably stable: - `index.md` — GitHub Pages feature tour - `agent-adapters.md` — agent adapter selection and evaluation notes +- `agents.md` — agent backends, identities, and routing rules - `agent-adapters.md` also covers secured recipes, artifact-producing CLI integrations, and the early orchestrator support model for async work systems. diff --git a/docs/agent-adapters.md b/docs/agent-adapters.md index 6f5530fc..58673204 100644 --- a/docs/agent-adapters.md +++ b/docs/agent-adapters.md @@ -95,7 +95,7 @@ args = [ "{message}", ] timeout_ms = 120000 -env = { HTTP_PROXY = "http://127.0.0.1:8888", HTTPS_PROXY = "http://127.0.0.1:8888", NO_PROXY = "localhost,127.0.0.1,::1" } +env = { HTTP_PROXY = "http://127.0.0.1:8888", NO_PROXY = "localhost,127.0.0.1,::1" } ``` The command above must read the actual task from stdin. `{message}` is a safe @@ -113,7 +113,7 @@ args = [ "{artifact_dir}/image.png", ] timeout_ms = 180000 -env = { HTTP_PROXY = "http://127.0.0.1:8888", HTTPS_PROXY = "http://127.0.0.1:8888", NO_PROXY = "localhost,127.0.0.1,::1" } +env = { HTTP_PROXY = "http://127.0.0.1:8888", NO_PROXY = "localhost,127.0.0.1,::1" } ``` Treat this npcsh command as a recipe to verify against the installed npcsh @@ -127,6 +127,8 @@ Before promoting a recipe, run: ```bash scripts/agent-recipe-smoke.sh +# or include it in the local Docker deploy smoke: +CALCIFORGE_AGENT_RECIPE_SMOKE=1 scripts/manual-docker-test.sh ``` The smoke script installs npcsh, OmO/oh-my-opencode, and Gas Town in disposable diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 00000000..b1c0410a --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,325 @@ +--- +layout: default +title: Agents, Identities, and Routing +--- + +# Agents, Identities, and Routing + +This page covers the three configuration sections that together control who +can talk to Calciforge and which AI backend handles their messages: + +- `[[agents]]` — AI backends Calciforge dispatches to +- `[[identities]]` — users and their per-channel aliases +- `[[routing]]` — maps identities to agents + +## Architecture + +``` +Channel message arrives + │ + ▼ + Identity lookup [[identities]] — alias (channel + id) → identity + │ + ▼ + Routing rule [[routing]] — identity → default_agent + allowed_agents + │ + ▼ + Agent dispatch [[agents]] — build adapter, send message, return reply + │ + ▼ + Reply sent back to user +``` + +--- + +## Agents (`[[agents]]`) + +Each `[[agents]]` entry defines one AI backend. The `kind` field selects the +adapter. All other fields are adapter-specific. + +### Common fields + +| Field | Required | Default | Description | +|---|---|---|---| +| `id` | yes | — | Unique name used in routing and `!switch` commands | +| `kind` | yes | — | Adapter type (see below) | +| `timeout_ms` | no | adapter default | Per-request timeout in milliseconds | +| `model` | no | — | Model name forwarded to the backend | +| `api_key` | no | — | Bearer token for the backend; overrides `CALCIFORGE_AGENT_TOKEN` | +| `api_key_file` | no | — | Path to file containing the API key (preferred over inline `api_key`) | +| `auth_token` | no | — | Legacy alias for `api_key` (openclaw-channel) | +| `aliases` | no | `[]` | Additional names matched by `!switch` | +| `allow_model_override` | no | adapter default | Whether `!model` overrides from identities are forwarded | +| `registry` | no | — | Optional metadata shown in `!agents` output (see below) | + +### `kind = "openclaw-channel"` + +HTTP adapter that talks to an OpenClaw or Calciforge gateway running the +channel plugin. The gateway maintains the agent session; Calciforge acts as +the routing and security layer in front of it. + +Required: `endpoint`. Recommended: `api_key` or `api_key_file`. + +```toml +[[agents]] +id = "librarian" +kind = "openclaw-channel" +endpoint = "http://127.0.0.1:18789" +api_key_file = "~/.calciforge/secrets/librarian-token" +timeout_ms = 120000 +aliases = ["lib", "main"] +registry = { display_name = "Librarian", specialties = ["general", "homelab-ops"] } +``` + +`openclaw_agent_id` (optional) sets the lane id sent to the gateway; defaults +to this agent's `id`. + +`reply_port` (optional, default 18797) is the local port Calciforge listens on +for async `/hooks/reply` callbacks when the gateway pushes replies +asynchronously instead of returning them synchronously. + +`reply_auth_token` (optional) — bearer token required on incoming +`/hooks/reply` callbacks. + +### `kind = "openai-compat"` + +Generic OpenAI-compatible HTTP endpoint (Ollama, LM Studio, Anthropic, +Together, any endpoint that accepts `/v1/chat/completions`). + +Required: `endpoint`. Recommended: `model`. + +```toml +[[agents]] +id = "local-llm" +kind = "openai-compat" +endpoint = "http://127.0.0.1:11434" +model = "llama3.2" +timeout_ms = 180000 +allow_model_override = true +``` + +Without `model`, Calciforge will not forward a model name to the backend +unless `allow_model_override = true` and the identity sets `!model`. + +### `kind = "zeroclaw"` + +Direct ZeroClaw agent endpoint (legacy; use `openclaw-channel` for new +deployments). + +Required: `endpoint`, `api_key`. + +```toml +[[agents]] +id = "zeroclaw" +kind = "zeroclaw" +endpoint = "http://127.0.0.1:18792" +api_key_file = "~/.calciforge/secrets/zeroclaw-token" +timeout_ms = 90000 +``` + +### `kind = "cli"` + +Spawns a subprocess for each message. The command receives the message via +the argument template: `{message}` in `args` is replaced at dispatch time. + +Required: `command`. + +```toml +[[agents]] +id = "ironclaw" +kind = "cli" +command = "/usr/local/bin/ironclaw" +args = ["run", "-m", "{message}"] +timeout_ms = 60000 +env = { "LLM_BACKEND" = "openai_compatible", "LLM_MODEL" = "kimi-k2.5" } +``` + +`env` (optional) — extra environment variables passed to the subprocess. + +**Security note:** `{message}` in `args` places user content in the process +argv, which is visible in `ps` output and `/proc//cmdline` on multi-user +systems. If the message may contain secret values, use a CLI that reads from +stdin instead and pass the message via stdin rather than argv. + +### `kind = "acp"` + +Persistent-session adapter for ACP-compliant agents (e.g. `claude --acp`, +`opencode acp`). Unlike `cli`, the process stays alive between messages so +session context is preserved. + +Required: `command` (the binary to invoke). + +```toml +[[agents]] +id = "claude-code" +kind = "acp" +command = "claude" +args = ["--acp"] +model = "claude-sonnet-4-5" +timeout_ms = 300000 +aliases = ["cc", "claude"] +registry = { display_name = "Claude Code", specialties = ["coding", "refactoring"] } +``` + +### `kind = "acpx"` + +Like `acp`, but delegates ACP protocol handling to the `acpx` binary, which +supports additional protocol versions. The `command` field holds the agent +name (not a path); `acpx` resolves it. + +Required: `command` (agent name passed to acpx). + +```toml +[[agents]] +id = "opencode" +kind = "acpx" +command = "opencode" +timeout_ms = 300000 +``` + +### `kind = "codex-cli"` and `kind = "dirac-cli"` + +Subprocess adapters for OpenAI Codex CLI and Dirac CLI respectively. +`command` is optional and defaults to the standard binary name. Both support +`model`, `args`, `env`, and `timeout_ms`. + +```toml +[[agents]] +id = "codex" +kind = "codex-cli" +model = "codex-mini-latest" +timeout_ms = 120000 +``` + +### Registry metadata + +The optional `registry` table is not used at dispatch time — it populates the +`!agents` command output so users can discover available agents. + +```toml +[[agents]] +id = "librarian" +kind = "openclaw-channel" +endpoint = "http://127.0.0.1:18789" +api_key_file = "~/.calciforge/secrets/librarian-token" +timeout_ms = 120000 + +[agents.registry] +display_name = "Librarian" +description = "General-purpose assistant for homelab and daily tasks" +specialties = ["general", "homelab-ops", "research"] +access = ["admin", "user"] +primary_channels = ["telegram", "matrix"] +``` + +--- + +## Identities (`[[identities]]`) + +An identity is a named user. The `aliases` list maps channel-specific IDs +(phone numbers, Telegram user IDs, Matrix handles) to the identity name. +Routing rules reference the identity `id`. + +| Field | Required | Default | Description | +|---|---|---|---| +| `id` | yes | — | Unique identity name used in routing rules | +| `display_name` | no | — | Human-readable name for logs and `!who` output | +| `role` | no | — | Arbitrary role string (e.g. `"admin"`, `"user"`) | +| `aliases` | no | `[]` | Per-channel IDs: `{ channel = "...", id = "..." }` | + +Alias `id` format by channel: + +| Channel | Alias `id` format | Example | +|---|---|---| +| `telegram` | numeric user ID | `"7000000001"` | +| `matrix` | Matrix user ID | `"@alice:matrix.org"` | +| `whatsapp` | E.164 phone number | `"+15555550001"` | +| `signal` | E.164 phone number | `"+15555550001"` | +| `sms` | E.164 phone number | `"+15555550001"` | + +```toml +[[identities]] +id = "operator" +display_name = "Alice" +role = "admin" +aliases = [ + { channel = "telegram", id = "7000000001" }, + { channel = "matrix", id = "@alice:matrix.org" }, + { channel = "whatsapp", id = "+15555550001" }, + { channel = "signal", id = "+15555550001" }, +] +``` + +--- + +## Routing (`[[routing]]`) + +Each routing rule maps one identity to a default agent and an optional +allowlist of agents they may switch to. + +| Field | Required | Default | Description | +|---|---|---|---| +| `identity` | yes | — | Must match an `id` in `[[identities]]` | +| `default_agent` | yes | — | Agent dispatched when no `!switch` is active | +| `allowed_agents` | no | `[]` | Agents the identity may `!switch` to; empty = no restriction (any configured agent, regardless of role) | + +```toml +[[routing]] +identity = "operator" +default_agent = "librarian" +allowed_agents = ["librarian", "claude-code", "local-llm"] + +[[routing]] +identity = "readonly-user" +default_agent = "librarian" +allowed_agents = ["librarian"] +``` + +When `allowed_agents` is empty, the identity can switch to any configured +agent — there is no role-based check. Set it explicitly for every identity +that should not have unrestricted agent access. + +--- + +## Full example + +Minimal working config combining agents, identities, and routing: + +```toml +[calciforge] +version = 2 + +[[identities]] +id = "operator" +display_name = "Alice" +role = "admin" +aliases = [{ channel = "telegram", id = "7000000001" }] + +[[agents]] +id = "librarian" +kind = "openclaw-channel" +endpoint = "http://127.0.0.1:18789" +api_key_file = "~/.calciforge/secrets/librarian-token" +timeout_ms = 120000 + +[[routing]] +identity = "operator" +default_agent = "librarian" +allowed_agents = ["librarian"] + +[[channels]] +kind = "telegram" +enabled = true +bot_token_file = "~/.calciforge/secrets/telegram-token" +``` + +## Verify + +```bash +calciforge doctor # checks agent reachability and identity/routing consistency +calciforge # start; send a message from a configured alias +``` + +`calciforge doctor` warns on common misconfigurations: missing `api_key` on +`openclaw-channel` agents, `openai-compat` without `model`, identities with +no routing rule, and routing rules that reference undefined agents. diff --git a/docs/channels/sms.md b/docs/channels/sms.md new file mode 100644 index 00000000..6ac9c876 --- /dev/null +++ b/docs/channels/sms.md @@ -0,0 +1,71 @@ +--- +layout: default +title: Text/iMessage Channel Setup +--- + +# Text/iMessage Channel + +Calciforge exposes text/iMessage routing as `kind = "sms"`. Under the hood it uses +the `zeroclawlabs::LinqChannel` transport, which can send and receive +iMessage, RCS, and SMS through the Linq Partner API. + +Inbound messages arrive as Linq webhooks. Outbound replies go through the Linq +API, but still pass through Calciforge identity resolution, routing, security +scan settings, and artifact fallback rendering. + +```text +phone user -> Linq webhook -> Calciforge -> agent +phone user <- Linq API <- Calciforge <- agent +``` + +## Configure + +```toml +[[channels]] +kind = "sms" +enabled = true +sms_linq_api_token_file = "~/.calciforge/secrets/linq-token" +sms_from_phone = "+15555550001" +sms_webhook_listen = "0.0.0.0:18798" +sms_webhook_path = "/webhooks/sms" +allowed_numbers = ["+15555550100"] + +# Recommended for public webhooks. +# sms_linq_signing_secret_file = "~/.calciforge/secrets/linq-webhook-secret" + +# Optional security scan for inbound messages. +# scan_messages = true +``` + +```toml +[[identities]] +id = "operator" +display_name = "Operator" +role = "owner" +aliases = [ + { channel = "sms", id = "+15555550100" }, +] +``` + +## Linq Webhook + +Point the Linq Partner webhook at: + +```text +https://YOUR-HOST.example.com/webhooks/sms +``` + +If `sms_linq_signing_secret_file` or `sms_linq_signing_secret` is configured, +Calciforge verifies `X-Webhook-Timestamp` and `X-Webhook-Signature` before +parsing the payload. + +## Verify + +```bash +calciforge doctor +calciforge +``` + +Send `!ping` from an allowed phone number. Calciforge replies to the Linq +conversation id when the webhook includes one, otherwise it replies directly to +the sender phone number. diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index 906dd46d..d067ef8e 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -5,191 +5,83 @@ title: WhatsApp Channel Setup # WhatsApp Channel -Calciforge's WhatsApp channel is a **webhook receiver**. It accepts incoming -messages from any compatible WhatsApp gateway and sends replies back through the -gateway's outbound API. Calciforge does not own the WhatsApp Web session itself. - -The contract is the wire format, not a specific product: - -- **Inbound:** the gateway POSTs to `/webhooks/whatsapp` in the WhatsApp Cloud API - webhook payload format (see [Webhook payload format](#webhook-payload-format) below). -- **Outbound:** Calciforge POSTs replies to `{gateway}/tools/invoke` with the body - shape documented under [Reply API](#reply-api). - -Any gateway that implements those two endpoints will work. The known-working -implementation is [ZeroClaw](https://github.com/zeroclaw-labs/zeroclaw)'s -`whatsapp-web` feature, which is what this guide configures. If you're running -a different gateway, point Calciforge at it and adjust the auth token accordingly. - -> **Future work:** embedding the WhatsApp protocol directly into Calciforge (so -> no external gateway is required) is tracked in the project backlog. It would -> use `zeroclawlabs::WhatsAppWebChannel` (which wraps `whatsmeow-rs`) as a Rust -> library — distinct from running ZeroClaw as a separate daemon. - -## Architecture +Calciforge embeds the `zeroclawlabs::WhatsAppWebChannel` transport directly. +The WhatsApp Web session now lives inside the Calciforge process, so there is +no ZeroClaw/OpenClaw webhook sidecar for this channel. +```text +WhatsApp user <-> WhatsApp Web session <-> Calciforge <-> agent ``` -WA user ──→ WhatsApp gateway (e.g. ZeroClaw) ──→ POST /webhooks/whatsapp ──→ Calciforge - │ - identity resolution │ - agent dispatch │ - ↓ -WA user ←── WhatsApp gateway (e.g. ZeroClaw) ←── POST /tools/invoke ←── Calciforge reply -``` - -## Prerequisites -- A WhatsApp gateway that implements the wire protocol described above and has - an active WhatsApp Web session. ZeroClaw with `whatsapp-web` enabled is the - reference implementation; any compatible alternative is fine. +## Requirements -## Step 1: Channel config +- A WhatsApp account or linked device that can pair with WhatsApp Web. +- Persistent storage for the session database. +- Identity aliases that match incoming phone numbers in E.164 format. -Add to `~/.calciforge/config.toml`: +## Configure ```toml [[channels]] kind = "whatsapp" enabled = true - -# ZeroClaw gateway that owns the WhatsApp Web session. -# Calciforge sends replies by POSTing to {zeroclaw_endpoint}/tools/invoke. -# Use 127.0.0.1 if co-located; use the host IP if running on a separate machine. -zeroclaw_endpoint = "http://127.0.0.1:18789" -zeroclaw_auth_token = "REPLACE_WITH_AUTH_TOKEN" - -# Calciforge's webhook listener — ZeroClaw will POST incoming WA messages here. -# Must be reachable from wherever ZeroClaw is running. -webhook_listen = "0.0.0.0:18795" -webhook_path = "/webhooks/whatsapp" - -# Optional HMAC-SHA256 secret for X-Hub-Signature-256 header verification. -# Set the same value in ZeroClaw as its webhook_forward_secret. -# webhook_secret = "change-me-to-a-random-secret" - -# Allowed sender phone numbers in E.164 format. -# Must match identity aliases below. +whatsapp_session_path = "~/.calciforge/whatsapp/session.db" allowed_numbers = ["+15555550001"] -``` -| Field | Required | Default | Description | -|---|---|---|---| -| `zeroclaw_endpoint` | yes | — | URL of the ZeroClaw gateway | -| `zeroclaw_auth_token` | yes | — | Bearer token for the gateway | -| `webhook_listen` | no | `0.0.0.0:18795` | Address Calciforge listens on for incoming WhatsApp webhooks | -| `webhook_path` | no | `/webhooks/whatsapp` | URL path for incoming webhooks | -| `webhook_secret` | no | — | HMAC-SHA256 secret; when set, Calciforge rejects requests with invalid or missing `X-Hub-Signature-256` headers | -| `allowed_numbers` | yes | `[]` | E.164 phone numbers allowed to interact | -| `scan_messages` | no | `false` | Enable inbound adversarial content scanning | +# Optional pairing-code login. Use digits only. +# whatsapp_pair_phone = "15555550001" -## Step 2: ZeroClaw forwarding config +# Optional personal-mode controls. +# whatsapp_mode = "personal" +# whatsapp_dm_policy = "allowlist" +# whatsapp_group_policy = "allowlist" +# whatsapp_mention_only = false +# whatsapp_self_chat_mode = false +# whatsapp_group_mention_patterns = ["@Calciforge", "calciforge"] -In ZeroClaw's config, point WhatsApp message delivery at Calciforge's webhook. Also -configure the QR-linked session path: - -```toml -[channels_config.whatsapp] -session_path = "~/.zeroclaw/whatsapp-session.db" -webhook_forward_url = "http://127.0.0.1:18795/webhooks/whatsapp" -# webhook_forward_secret = "change-me-to-a-random-secret" # must match Calciforge's webhook_secret -allowed_numbers = ["+15555550001"] +# Optional security scan for inbound messages. +# scan_messages = true ``` -Start ZeroClaw — it prints a QR code. Scan from WhatsApp on your phone to pair the session. -After pairing, the session persists to the SQLite DB and survives restarts. - -## Step 3: Identity config - -The alias `id` is the E.164 phone number. The leading `+` is required: - ```toml [[identities]] id = "operator" -display_name = "Alice" -role = "admin" +display_name = "Operator" +role = "owner" aliases = [ - { channel = "whatsapp", id = "+15555550001" }, + { channel = "whatsapp", id = "+15555550001" }, ] - -[[routing]] -identity = "operator" -default_agent = "librarian" -allowed_agents = ["librarian"] ``` -Phone numbers from `allowed_numbers` that don't match any identity alias are silently -dropped. Calciforge normalises the `from` field to E.164 before lookup. - -## Step 4: Firewall - -If ZeroClaw and Calciforge are on the same host, no changes needed — both use localhost. - -If they're on separate hosts, open port 18795 on the Calciforge host from the ZeroClaw host: +## Pair -```bash -ufw allow from to any port 18795 -``` +Start Calciforge with the channel enabled. On first run, the embedded transport +will create or open the configured session database and initiate WhatsApp Web +pairing. Keep `whatsapp_session_path` on durable storage so restarts reuse the +same linked session. -## Step 5: Verify +## Verify ```bash -calciforge doctor # validates config -calciforge # start; send a WhatsApp message from an allowed number +calciforge doctor +calciforge ``` -Health check the webhook listener and test with a synthetic payload: - -```bash -curl http://localhost:18795/health - -curl -X POST http://localhost:18795/webhooks/whatsapp \ - -H "Content-Type: application/json" \ - -d '{ - "object": "whatsapp_business_account", - "entry": [{ - "changes": [{ - "value": { - "messages": [{ - "from": "15555550001", - "type": "text", - "text": { "body": "test" }, - "timestamp": "1699999999" - }] - } - }] - }] - }' -``` +Send `!ping` from an allowed WhatsApp number. Replies go to the transport +`reply_target`, so direct chats and group chats reply to the original +conversation. -A `200 ok` response means the webhook is reachable. The message will be dropped (unknown -identity) unless `15555550001` is in an identity alias. +## Migration -## Webhook payload format +The legacy webhook fields are rejected for `kind = "whatsapp"`: -Calciforge accepts the standard WhatsApp Cloud API format. The `from` field may omit the -leading `+` — Calciforge normalises to E.164 before identity lookup. - -## Reply API - -Calciforge sends replies by POSTing to `{zeroclaw_endpoint}/tools/invoke`: - -```json -{ - "tool": "message", - "args": { - "action": "send", - "channel": "whatsapp", - "target": "+15555550001", - "message": "Agent reply text here" - } -} +```toml +zeroclaw_endpoint = "http://127.0.0.1:18789" +zeroclaw_auth_token = "..." +webhook_listen = "0.0.0.0:18795" +webhook_path = "/webhooks/whatsapp" +webhook_secret = "..." ``` -ZeroClaw must have a live WhatsApp Web session for the reply to reach the user. - -## HMAC verification - -When `webhook_secret` is set, Calciforge verifies the `X-Hub-Signature-256` header on -every incoming request using HMAC-SHA256. Requests with a missing or invalid signature -are rejected with HTTP 401. Set the same secret in ZeroClaw as `webhook_forward_secret` -to keep the two sides in sync. +Move the session into Calciforge with `whatsapp_session_path` and remove the +sidecar webhook settings. diff --git a/docs/index.md b/docs/index.md index 057f59b2..5e9093f1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -178,8 +178,8 @@ raw API keys or trusting the agent's own restraint.

@@ -315,7 +315,7 @@ Outbound bodies are also scanned for *exfiltration-attempt* patterns `what is your api key`). Generic high-entropy secret-shape detection (JWT-shaped strings, `sk-*` keys, etc.) was deliberately removed during the channel-integration cut and is on the -[roadmap](https://github.com/bglusman/calciforge/blob/main/docs/roadmap/outbound-sensitive-data-detection.md). +[roadmap](roadmap/outbound-sensitive-data-detection.md). The scanner pipeline is configurable. The default policy now runs through `builtin:calciforge/default-scanner.star`, so the rule set can be copied, @@ -473,10 +473,10 @@ context_window = 262144 ``` The full gateway reference is -[`docs/model-gateway.md`](https://github.com/bglusman/calciforge/blob/main/docs/model-gateway.md). +[`docs/model-gateway.md`](model-gateway.md). Named cascades, dispatchers, and token-window fit checks are captured in -[`docs/rfcs/model-gateway-primitives.md`](https://github.com/bglusman/calciforge/blob/main/docs/rfcs/model-gateway-primitives.md). +[`docs/rfcs/model-gateway-primitives.md`](rfcs/model-gateway-primitives.md). ### Subscription-backed agents and models @@ -542,7 +542,7 @@ kind = "artifact-cli" command = "/usr/local/bin/npcsh-vixynt-stdin" args = ["{artifact_dir}/image.png"] timeout_ms = 180000 -env = { HTTP_PROXY = "http://127.0.0.1:8888", HTTPS_PROXY = "http://127.0.0.1:8888", NO_PROXY = "localhost,127.0.0.1,::1" } +env = { HTTP_PROXY = "http://127.0.0.1:8888", NO_PROXY = "localhost,127.0.0.1,::1" } ``` The command above is a recipe shape, not a promise that every npcsh @@ -567,7 +567,7 @@ Today, discovery is process-scoped: it sees the fnox names available to the MCP server or CLI process. Calciforge enforces per-secret destination allowlists at substitution time, but does not yet enforce per-agent secret discovery/use ACLs. That policy layer is on the -[roadmap](https://github.com/bglusman/calciforge/blob/main/docs/roadmap/agent-secret-access-policy.md). +[roadmap](roadmap/agent-secret-access-policy.md). ```json // ~/.claude/mcp-config.json @@ -588,16 +588,21 @@ calciforge-secrets ref BRAVE_API_KEY ### Multi-channel chat -Today: Telegram, Matrix, WhatsApp, Signal. Optional voice forwarding -on channels that support it. +Today: Telegram, Matrix, WhatsApp, Signal, and text/iMessage. Voice is a separate +proxy passthrough surface today, not a settled per-chat-channel capability; richer +voice input, push-to-talk channels, and audio artifacts remain roadmap work. Per-channel setup guides (config reference + TOML examples tested against the live schema in CI): -- [Telegram](channels/telegram) — long-poll, no open port required -- [Matrix](channels/matrix) — HTTP long-poll; note: no E2EE -- [Signal](channels/signal) — embedded `zeroclawlabs::SignalChannel` via `signal-cli-rest-api` -- [WhatsApp](channels/whatsapp) — webhook via ZeroClaw/OpenClaw gateway +- [Telegram](channels/telegram.md) — long-poll, no open port required +- [Matrix](channels/matrix.md) — HTTP long-poll; note: no E2EE +- [Signal](channels/signal.md) — embedded `zeroclawlabs::SignalChannel` via `signal-cli-rest-api` +- [WhatsApp](channels/whatsapp.md) — embedded WhatsApp Web session +- [Text/iMessage](channels/sms.md) — Linq webhook receiver for iMessage/RCS/SMS + +Agent backends, identities, and routing rules are documented in the +[Agents, Identities, and Routing](agents.md) guide. ```toml # /etc/calciforge/config.toml — channel configuration @@ -618,8 +623,16 @@ allowed_users = ["@alice:example.com"] [[channels]] kind = "whatsapp" enabled = true -zeroclaw_endpoint = "http://127.0.0.1:18789" -zeroclaw_auth_token = "{% raw %}{{secret:OPENCLAW_HOOK_TOKEN}}{% endraw %}" +whatsapp_session_path = "/var/lib/calciforge/whatsapp/session.db" +allowed_numbers = ["+15555550100"] + +[[channels]] +kind = "sms" +enabled = true +sms_linq_api_token_file = "/etc/calciforge/secrets/linq-token" +sms_from_phone = "+15555550001" +sms_webhook_listen = "0.0.0.0:18798" +sms_webhook_path = "/webhooks/sms" allowed_numbers = ["+15555550100"] ``` @@ -669,13 +682,17 @@ unverified, validates configured scanner policy files and rule syntax, and can probe configured endpoints. Use `calciforge doctor --no-network` when you want a local-only check. -Route Claude Code through the gateway. The installer and examples bias -toward setting this on managed subprocess agents directly; for external -daemons, set it on the agent process or its service manager, not on the -Calciforge daemon: +Calciforge-managed subprocess agents should get proxy environment from their +agent config or installer-generated config. Do not put proxy variables in +`~/.zshrc` for the Calciforge daemon itself; that can route Calciforge's own +provider and control-plane traffic through its security proxy. + +For externally managed agent daemons that Calciforge does not launch, set +plain HTTP proxying on the agent process or its service manager and validate +it against `security-proxy` logs: ```bash -# ~/.zshrc +# External agent process environment export HTTP_PROXY=http://127.0.0.1:8888 export NO_PROXY=localhost,127.0.0.1,::1 ``` @@ -690,10 +707,9 @@ macOS and a headless Linux service host). Treat new deployments as operator-reviewed until their channel credentials, fnox store, model gateway providers, and synthetic model routes pass smoke tests. -The list of what works today and what's still in flight lives in the -[README's status table](https://github.com/bglusman/calciforge/blob/main/README.md#what-works-today). -Public roadmap ideas live in -[`docs/roadmap/`](https://github.com/bglusman/calciforge/tree/main/docs/roadmap). +The status summary above is the site-facing snapshot of what works today and +what is still in flight. Public roadmap ideas live in +the [roadmap notes](roadmap/v3-ideas.md).