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).