diff --git a/AGENTS.md b/AGENTS.md index 717a360a..de19a938 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,110 @@ bash scripts/install-git-hooks.sh # one-time Mixed: older crates on `2021`, newer on `2024`. Known and tracked. Don't bump in a PR that isn't explicitly about edition migration. +## Documentation standard (gold standard — applies to all crates) + +**The rule:** every user-facing config section, public API, and user-facing feature must +have documentation with examples that are verified by the test suite. Docs that aren't +tested go stale silently; tested docs can't. + +Channels (`docs/channels/`) were the first area to reach this standard and serve as the +reference implementation. All other config sections and crates follow the same pattern. + +### What needs docs + +- **Every `pub struct` in `config.rs`** — all fields documented; a TOML example that + parses against the live schema and is tested +- **Every user-facing feature** — setup guide in `docs/` covering prerequisites, + config, identity wiring (where applicable), and a verify step +- **Every public API in library crates** — at least one doctest per public function / + type showing the happy path; complex types get a full usage example + +### Two testing mechanisms — use the right one for the crate type + +**Library crates** (`secrets-client`, `adversary-detector`, `clashd`, `mcp-server`, +`paste-server`, `security-proxy`): use native Rust **doctests** in `///` comments. +`cargo test --doc -p ` compiles and runs them. If an API changes and the +example is wrong, the build fails. + +```rust +/// Fetches a secret value by name. +/// +/// ```no_run +/// # use secrets_client::FnoxClient; +/// # #[tokio::main] async fn main() -> anyhow::Result<()> { +/// let client = FnoxClient::new(); +/// let value = client.get("MY_API_KEY").await?; +/// # Ok(()) +/// # } +/// ``` +pub async fn get(&self, name: &str) -> Result { ... } +``` + +**Binary crates** (`calciforge`, `host-agent`): use **`include_str!` + unit tests** +in the relevant source file's `#[cfg(test)]` block. The test loads the markdown doc, +extracts fenced TOML blocks, and parses them against the live config schema. + +```rust +#[test] +fn test_agent_docs_toml_blocks_valid() { + let doc = include_str!("../../../docs/agents.md"); + for block in doc_blocks_with(doc, "[[agents]]") { + parse_as_config(&block); // panics with file + block on failure + } +} +``` + +The channel tests in `config.rs` (`test_channel_docs_*_toml_blocks_valid`) are the +canonical example — copy that pattern for each new config section. + +### Doc file locations + +| What | Location | Tested by | +|---|---|---| +| Channel setup guides | `docs/channels/.md` | `config.rs` unit tests | +| Agent config | `docs/agents.md` | `config.rs` unit tests | +| Routing / identity | `docs/routing.md` | `config.rs` unit tests | +| Model gateway (alloys, cascades, dispatchers, exec models) | `docs/model-gateway.md` | `config.rs` unit tests | +| Security / proxy config | `docs/security-gateway.md` | `config.rs` unit tests | +| Library crate APIs | `///` doc comments in source | `cargo test --doc -p ` | +| host-agent setup | `docs/host-agent.md` | `host-agent` unit tests | + +### Structure for setup guides in `docs/` + +Every setup guide covering a user-facing feature must have these sections, in order: + +1. **Architecture** — ASCII diagram showing data flow end to end +2. **Prerequisites** — external accounts, tokens, running services, dependencies +3. **Config** — TOML block(s) with every required field shown; optional fields in a table +4. **Identity / routing** (where applicable) — how to wire a user to the feature +5. **Verify** — commands to confirm it's working (health check, log output, smoke test) + +### Rules for every agent and contributor + +**When adding a config field to any `pub struct`:** +1. Add a `///` doc comment to the field explaining what it does and its default +2. Update the TOML example in the relevant `docs/` file +3. Run `cargo test -p calciforge` — the doc-block tests will fail if the example is now + invalid, telling you exactly which file to fix +4. Commit the code change and the doc update together — never in separate PRs + +**When adding a new config section:** +1. Create `docs/
.md` with all five structure sections above +2. Add `test_
_docs_toml_blocks_valid` in the relevant `#[cfg(test)]` block +3. Link from `docs/index.md` + +**When adding a public function to a library crate:** +1. Write at least one `///` doctest showing the happy path +2. Run `cargo test --doc -p ` to confirm it compiles and passes +3. For functions with meaningful error paths, add a second doctest for the failure case + +**When renaming any field or function:** +- `cargo test` will name every broken doc example +- Fix markdown files and doctests in the same commit as the rename + +Do not add a new channel, config section, or public library API without corresponding +tested documentation. A PR that adds functionality without docs should not be merged. + ## When working on a specific area, also read - `crates/host-agent/AGENTS.md` — host-agent security model (Unix-permissions enforcement, fail-closed, mTLS CN→Unix user mapping). diff --git a/crates/calciforge/src/config.rs b/crates/calciforge/src/config.rs index c84524ae..9764b032 100644 --- a/crates/calciforge/src/config.rs +++ b/crates/calciforge/src/config.rs @@ -1542,4 +1542,191 @@ weight = 20 "error should name the missing field, got: {msg}" ); } + + // ── Channel config example tests ───────────────────────────────────────── + // + // Each test below does two things: + // + // 1. Parses the inline TOML example that appears in the corresponding + // docs/channels/*.md setup guide, verifying the example is + // syntactically valid and matches the live ChannelConfig schema. + // + // 2. Loads the markdown file via include_str! and scans every fenced + // ```toml block, wrapping each in a minimal CalciforgeConfig envelope + // and parsing it. If any code block in the doc drifts out of sync + // with the schema, cargo test catches it here. + // + // When you add or rename a ChannelConfig field, run `cargo test -p calciforge` + // and fix any failures in these tests before updating the docs. + + fn extract_toml_blocks(markdown: &str) -> Vec { + let mut blocks = Vec::new(); + let mut in_block = false; + let mut current = String::new(); + for line in markdown.lines() { + if line.trim() == "```toml" { + in_block = true; + current.clear(); + } else if line.trim() == "```" && in_block { + in_block = false; + blocks.push(current.clone()); + } else if in_block { + current.push_str(line); + current.push('\n'); + } + } + blocks + } + + fn channel_blocks_from_doc(markdown: &str) -> Vec { + extract_toml_blocks(markdown) + .into_iter() + .filter(|b| b.contains("[[channels]]")) + .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| { + panic!("channel config block failed to parse:\n{block}\nerror: {e}") + }) + } + + #[test] + fn test_channel_config_telegram_inline() { + let raw = r#" +[calciforge] +version = 2 + +[[channels]] +kind = "telegram" +enabled = true +bot_token_file = "~/.calciforge/secrets/telegram-token" +"#; + let cfg: CalciforgeConfig = toml::from_str(raw).expect("telegram channel config"); + assert_eq!(cfg.channels[0].kind, "telegram"); + assert!(cfg.channels[0].enabled); + assert_eq!( + cfg.channels[0].bot_token_file.as_deref(), + Some("~/.calciforge/secrets/telegram-token") + ); + } + + #[test] + fn test_channel_config_matrix_inline() { + let raw = r#" +[calciforge] +version = 2 + +[[channels]] +kind = "matrix" +enabled = true +homeserver = "https://matrix.example.com" +access_token_file = "~/.calciforge/secrets/matrix-token" +room_id = "!abc123def456:example.com" +allowed_users = ["@operator:example.com"] +"#; + let cfg: CalciforgeConfig = toml::from_str(raw).expect("matrix channel config"); + assert_eq!(cfg.channels[0].kind, "matrix"); + assert_eq!( + cfg.channels[0].homeserver.as_deref(), + Some("https://matrix.example.com") + ); + assert_eq!(cfg.channels[0].allowed_users, ["@operator:example.com"]); + } + + #[test] + fn test_channel_config_signal_inline() { + let raw = r#" +[calciforge] +version = 2 + +[[channels]] +kind = "signal" +enabled = true +zeroclaw_endpoint = "http://127.0.0.1:18789" +zeroclaw_auth_token = "REPLACE_WITH_AUTH_TOKEN" +webhook_listen = "0.0.0.0:18796" +webhook_path = "/webhooks/signal" +allowed_numbers = ["+15555550001"] +"#; + let cfg: CalciforgeConfig = toml::from_str(raw).expect("signal channel config"); + assert_eq!(cfg.channels[0].kind, "signal"); + assert_eq!( + cfg.channels[0].webhook_listen.as_deref(), + Some("0.0.0.0:18796") + ); + assert_eq!(cfg.channels[0].allowed_numbers, ["+15555550001"]); + } + + #[test] + fn test_channel_config_whatsapp_inline() { + let raw = r#" +[calciforge] +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" +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") + ); + } + + #[test] + fn test_channel_docs_telegram_toml_blocks_valid() { + let doc = include_str!("../../../docs/channels/telegram.md"); + for block in channel_blocks_from_doc(doc) { + let cfg = parse_channel_block(&block); + assert_eq!( + cfg.channels[0].kind, "telegram", + "unexpected kind in block:\n{block}" + ); + } + } + + #[test] + fn test_channel_docs_matrix_toml_blocks_valid() { + let doc = include_str!("../../../docs/channels/matrix.md"); + for block in channel_blocks_from_doc(doc) { + let cfg = parse_channel_block(&block); + assert_eq!( + cfg.channels[0].kind, "matrix", + "unexpected kind in block:\n{block}" + ); + } + } + + #[test] + fn test_channel_docs_signal_toml_blocks_valid() { + let doc = include_str!("../../../docs/channels/signal.md"); + for block in channel_blocks_from_doc(doc) { + let cfg = parse_channel_block(&block); + assert_eq!( + cfg.channels[0].kind, "signal", + "unexpected kind in block:\n{block}" + ); + } + } + + #[test] + fn test_channel_docs_whatsapp_toml_blocks_valid() { + let doc = include_str!("../../../docs/channels/whatsapp.md"); + for block in channel_blocks_from_doc(doc) { + let cfg = parse_channel_block(&block); + assert_eq!( + cfg.channels[0].kind, "whatsapp", + "unexpected kind in block:\n{block}" + ); + } + } } diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md new file mode 100644 index 00000000..1d28a3b0 --- /dev/null +++ b/docs/channels/matrix.md @@ -0,0 +1,116 @@ +--- +layout: default +title: Matrix Channel Setup +--- + +# Matrix Channel + +Calciforge connects to Matrix via the [Client-Server API v3](https://spec.matrix.org/v1.9/client-server-api/) +using **HTTP long-polling** (`/sync`). No webhook endpoint or open firewall port required. + +> **No end-to-end encryption.** The Matrix channel sends and receives plaintext `m.text` +> events only. E2EE is not supported due to compile-time dependency conflicts in the +> current workspace. Do not use this channel in rooms where E2EE is required. + +## Architecture + +``` +Matrix user ──→ homeserver ──→ Calciforge (/sync long-poll) + │ + identity resolution + (allowed_users check) + agent dispatch + │ +Matrix user ←── homeserver ←── Calciforge (PUT /send/m.room.message) +``` + +## Prerequisites + +1. **Register a Matrix account** for the bot on your homeserver (or matrix.org for testing). + The account does not need to be a human account — a plain `@calciforge-bot:example.com` + works fine. +2. **Generate an access token** for that account: + +```bash +curl -s -X POST 'https://matrix.example.com/_matrix/client/v3/login' \ + -H 'Content-Type: application/json' \ + -d '{ + "type": "m.login.password", + "user": "@calciforge-bot:example.com", + "password": "botpassword" + }' | grep access_token +``` + + Copy the `access_token` value from the response. + +3. **Find the room ID** for the room you want the bot to listen in: + - In most clients: room settings → Advanced → Internal room ID + - Format: `!abc123def456:example.com` + - The bot will auto-accept room invites from users listed in `allowed_users` + +## Step 1: Save the access token + +```bash +install -m 600 /dev/null ~/.calciforge/secrets/matrix-token +printf '%s' 'syt_YOUR_ACCESS_TOKEN_HERE' > ~/.calciforge/secrets/matrix-token +``` + +## Step 2: Channel config + +Add to `~/.calciforge/config.toml`: + +```toml +[[channels]] +kind = "matrix" +enabled = true +homeserver = "https://matrix.example.com" +access_token_file = "~/.calciforge/secrets/matrix-token" +room_id = "!abc123def456:example.com" +allowed_users = ["@operator:example.com"] +``` + +| Field | Required | Description | +|---|---|---| +| `homeserver` | yes | Full URL of the Matrix homeserver | +| `access_token_file` | yes | Path to file containing the bot's access token | +| `room_id` | yes | Internal room ID (starts with `!`) | +| `allowed_users` | yes | Matrix user IDs permitted to send commands; empty list allows all room members (not recommended) | +| `scan_messages` | no (`false`) | Enable inbound adversarial content scanning | +| `allow_chat_secret_set` | no (`false`) | Allow `!secure set` via Matrix (not recommended) | + +## Step 3: Identity config + +The alias `id` is the full Matrix user ID including homeserver: + +```toml +[[identities]] +id = "operator" +display_name = "Alice" +role = "admin" +aliases = [ + { channel = "matrix", id = "@alice:example.com" }, +] + +[[routing]] +identity = "operator" +default_agent = "librarian" +allowed_agents = ["librarian"] +``` + +Messages from Matrix users not in `allowed_users` are ignored before identity resolution. +Messages from `allowed_users` members with no matching identity alias are also dropped. + +## Step 4: Invite the bot + +Invite `@calciforge-bot:example.com` to the room. Calciforge will auto-accept the invite +if the inviting user's Matrix ID is in `allowed_users`. + +## Step 5: Verify + +```bash +calciforge doctor # validates config +calciforge # start; send a message in the room +``` + +The bot responds to commands (`!help`, `!ping`, `!agents`, etc.) and routes other messages +to the default agent for the sender's identity. diff --git a/docs/channels/signal.md b/docs/channels/signal.md new file mode 100644 index 00000000..107bf3ec --- /dev/null +++ b/docs/channels/signal.md @@ -0,0 +1,122 @@ +--- +layout: default +title: Signal Channel Setup +--- + +# Signal Channel + +Calciforge receives Signal messages via a **webhook** posted by a running +[ZeroClaw](https://github.com/zeroclaw-labs/zeroclaw) or OpenClaw instance that owns +the Signal session. Replies are sent back through the same gateway. + +## Architecture + +``` +Signal user ──→ ZeroClaw (Signal session host) ──→ POST /webhooks/signal ──→ Calciforge + │ + identity resolution │ + agent dispatch │ + ↓ +Signal user ←── ZeroClaw (Signal session host) ←── POST /tools/invoke ←── Calciforge reply +``` + +## Prerequisites + +- A running ZeroClaw or OpenClaw instance with an active Signal session and its auth token + +## Step 1: Channel config + +Add to `~/.calciforge/config.toml`: + +```toml +[[channels]] +kind = "signal" +enabled = true + +# ZeroClaw / OpenClaw gateway that owns the Signal 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 Signal messages here. +# Must be reachable from wherever ZeroClaw is running. +webhook_listen = "0.0.0.0:18796" +webhook_path = "/webhooks/signal" + +# 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. +allowed_numbers = ["+15555550001"] +``` + +| Field | Required | Default | Description | +|---|---|---|---| +| `zeroclaw_endpoint` | yes | — | URL of the ZeroClaw/OpenClaw gateway | +| `zeroclaw_auth_token` | yes | — | Bearer token for the gateway | +| `webhook_listen` | no | `0.0.0.0:18796` | Address Calciforge listens on for incoming Signal webhooks | +| `webhook_path` | no | `/webhooks/signal` | URL path for incoming webhooks | +| `webhook_secret` | no | — | HMAC-SHA256 secret; when set, Calciforge rejects unsigned requests | +| `allowed_numbers` | yes | `[]` | E.164 phone numbers allowed to interact | +| `scan_messages` | no | `false` | Enable inbound adversarial content scanning | + +## Step 2: ZeroClaw forwarding config + +In ZeroClaw's config, point Signal message delivery at Calciforge's webhook: + +```toml +[channels_config.signal] +webhook_forward_url = "http://127.0.0.1:18796/webhooks/signal" +# webhook_forward_secret = "change-me-to-a-random-secret" # must match Calciforge's webhook_secret +allowed_numbers = ["+15555550001"] +``` + +## 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" +aliases = [ + { channel = "signal", 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 (adds `+` prefix +if absent, strips spaces and dashes). + +## 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 18796 on the Calciforge host from the ZeroClaw host: + +```bash +ufw allow from to any port 18796 +``` + +## Step 5: Verify + +```bash +calciforge doctor # validates config +calciforge # start; send a Signal message from an allowed number +``` + +Check logs for `identity resolved` on a known number, or `no identity for signal/` +on an unknown one. Run a health check against the webhook listener: + +```bash +curl http://localhost:18796/health +``` diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md new file mode 100644 index 00000000..80b025c1 --- /dev/null +++ b/docs/channels/telegram.md @@ -0,0 +1,91 @@ +--- +layout: default +title: Telegram Channel Setup +--- + +# Telegram Channel + +Calciforge connects to Telegram via the [Telegram Bot API](https://core.telegram.org/bots/api) +using **long-polling** — no public webhook endpoint or open firewall port required. + +## Architecture + +``` +Telegram user ──→ Telegram Bot API ──→ Calciforge (long-poll) + │ + identity resolution + agent dispatch + │ +Telegram user ←── Telegram Bot API ←── Calciforge reply +``` + +## Prerequisites + +1. **Create a bot** via [@BotFather](https://t.me/BotFather): send `/newbot`, follow the + prompts, copy the token it returns (format: `1234567890:ABCDEFghijklmnopqrstuvwxyz01234567`) +2. **Find your Telegram user ID** (numeric, not your username): + - Send any message to your new bot, then run `calciforge` — the user ID appears in logs + on the first unrecognised message + - Or send a message to [@userinfobot](https://t.me/userinfobot) — it replies with your ID + +## Step 1: Save the bot token + +Write the raw token string (no extra whitespace) to a file readable only by the Calciforge +process: + +```bash +install -m 600 /dev/null ~/.calciforge/secrets/telegram-token +printf '%s' '1234567890:ABCDEFghijklmnopqrstuvwxyz01234567' \ + > ~/.calciforge/secrets/telegram-token +``` + +## Step 2: Channel config + +Add to `~/.calciforge/config.toml`: + +```toml +[[channels]] +kind = "telegram" +enabled = true +bot_token_file = "~/.calciforge/secrets/telegram-token" +``` + +Optional fields: + +| Field | Default | Description | +|---|---|---| +| `scan_messages` | `false` | Enable inbound adversarial content scanning via the security proxy | +| `allow_chat_secret_set` | `false` | Allow `!secure set NAME=value` via Telegram chat (not recommended — the value appears in chat history and provider logs) | + +## Step 3: Identity config + +Each user you want to allow needs an `[[identities]]` entry. The alias `id` is the +**numeric Telegram user ID** — not a username, not a phone number: + +```toml +[[identities]] +id = "operator" +display_name = "Alice" +role = "admin" +aliases = [ + { channel = "telegram", id = "7000000001" }, +] + +[[routing]] +identity = "operator" +default_agent = "librarian" +allowed_agents = ["librarian"] +``` + +Messages from Telegram user IDs not listed in any identity's aliases are silently dropped. + +## Step 4: Verify + +```bash +calciforge doctor # validates config before starting +calciforge # start; send /start to your bot in Telegram +``` + +On first message from a known identity, you'll see `identity resolved` in the logs and the +bot will route to the default agent. On an unknown user ID, you'll see +`no identity for telegram/` — use that output to find the ID if needed. diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md new file mode 100644 index 00000000..6336d7de --- /dev/null +++ b/docs/channels/whatsapp.md @@ -0,0 +1,176 @@ +--- +layout: default +title: WhatsApp Channel Setup +--- + +# WhatsApp Channel + +Calciforge receives WhatsApp messages via a **webhook** posted by a running +[ZeroClaw](https://github.com/zeroclaw-labs/zeroclaw) or OpenClaw instance that owns +the WhatsApp Web session. Replies are sent back through the same gateway. + +## Architecture + +``` +WA user ──→ ZeroClaw (WhatsApp Web session host) ──→ POST /webhooks/whatsapp ──→ Calciforge + │ + identity resolution │ + agent dispatch │ + ↓ +WA user ←── ZeroClaw (WhatsApp Web session host) ←── POST /tools/invoke ←── Calciforge reply +``` + +## Prerequisites + +- A running ZeroClaw or OpenClaw instance with an active WhatsApp Web session and its auth token + +## Step 1: Channel config + +Add to `~/.calciforge/config.toml`: + +```toml +[[channels]] +kind = "whatsapp" +enabled = true + +# ZeroClaw / OpenClaw 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. +allowed_numbers = ["+15555550001"] +``` + +| Field | Required | Default | Description | +|---|---|---|---| +| `zeroclaw_endpoint` | yes | — | URL of the ZeroClaw/OpenClaw 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 | + +## Step 2: ZeroClaw forwarding config + +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"] +``` + +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" +aliases = [ + { 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: + +```bash +ufw allow from to any port 18795 +``` + +## Step 5: Verify + +```bash +calciforge doctor # validates config +calciforge # start; send a WhatsApp message from an allowed number +``` + +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" + }] + } + }] + }] + }' +``` + +A `200 ok` response means the webhook is reachable. The message will be dropped (unknown +identity) unless `15555550001` is in an identity alias. + +## Webhook payload format + +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" + } +} +``` + +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. diff --git a/docs/index.md b/docs/index.md index 0fb2dfdc..6ca055aa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -536,6 +536,14 @@ calciforge-secrets ref BRAVE_API_KEY Today: Telegram, Matrix, WhatsApp, Signal. Optional voice forwarding on channels that support it. +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) — webhook via ZeroClaw/OpenClaw gateway +- [WhatsApp](channels/whatsapp) — webhook via ZeroClaw/OpenClaw gateway + ```toml # /etc/calciforge/config.toml — channel configuration [[channels]]