Embed zeroclawlabs::SignalChannel; migrate persistent-context to rusqlite#86
Embed zeroclawlabs::SignalChannel; migrate persistent-context to rusqlite#86bglusman wants to merge 8 commits into
Conversation
Adds per-channel setup docs (Telegram, Matrix, Signal, WhatsApp) to docs/channels/ as part of the public docs site (calciforge.org/channels/). Each guide covers: architecture diagram, prerequisites, config TOML, identity/routing wiring, and a verify step. The TOML config blocks in each doc are compile-tested: config.rs grows extract_toml_blocks() + test_channel_docs_<channel>_toml_blocks_valid tests that load each markdown via include_str!, extract every fenced [[channels]] block, and parse it against the live CalciforgeConfig schema. If a field is renamed and the doc isn't updated, cargo test fails. Also adds test_channel_config_<channel>_inline unit tests as schema-correct reference examples for each channel kind. AGENTS.md documents the standard and process: every channel needs a docs/channels/ entry, tests in config.rs, and both must be updated together when the schema changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- assert!(!in_block) at end of extract_toml_blocks catches unclosed ```toml fences instead of silently dropping the partial block - assert!(!blocks.is_empty()) before each test_channel_docs_* loop so the test fails visibly if the [[channels]] marker is ever accidentally removed from a doc file Addresses PR #82 review feedback. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- matrix.md: correct allowed_users description — empty list errors at
startup; "* " is the wildcard for all room members
- telegram.md: fix log message grep hint to match actual tracing output
("unknown Telegram sender — dropping silently sender_id=<id>")
- config.rs: update comment to say [[channels]]-filtered blocks (not
"every fenced block"); change Vec<String> assertions to explicit
.to_string() form for clarity
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Calciforge is meant to be agent-agnostic; the prior phrasing made it sound
like ZeroClaw (or OpenClaw) was a required component of the system. The
actual contract is a webhook wire format — incoming POST to /webhooks/<channel>
and outbound POST to {gateway}/tools/invoke. Any gateway implementing those
endpoints works.
Channel docs now lead with the wire-format contract and call out ZeroClaw
as the known-working reference implementation. Future-work notes describe
embedded paths via zeroclawlabs (the Rust library) — distinct from running
ZeroClaw or OpenClaw as products.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds Signal-specific fields to ChannelConfig (signal_cli_url,
signal_account, signal_group_id, signal_ignore_attachments,
signal_ignore_stories) so configs can describe the new architecture, and
rewrites docs/channels/signal.md to document it. The legacy
zeroclaw_endpoint / webhook_* fields are retained on ChannelConfig
because whatsapp.rs still uses them.
Workspace zeroclawlabs is bumped from 0.6.8 to 0.6.9 with the
"whatsapp-web" feature, in preparation for embedding
zeroclawlabs::SignalChannel in signal.rs.
Inline TOML test (test_channel_config_signal_inline) and the
docs-driven test_channel_docs_signal_toml_blocks_valid both validate
the new shape. signal.rs is unchanged in this commit and still uses the
old webhook fields; the rewrite is blocked (see BLOCKER below) and
will land in a follow-up once resolved.
BLOCKER: actually wiring signal.rs to zeroclawlabs::SignalChannel
requires adding `zeroclawlabs = { workspace = true }` to
crates/calciforge/Cargo.toml. Doing so produces an unresolvable
libsqlite3-sys link conflict:
sqlx-sqlite v0.8.6 pulls libsqlite3-sys ^0.30.1
rusqlite v0.37 (transitive via zeroclawlabs)
pulls libsqlite3-sys ^0.35.0
Both crates declare `links = "sqlite3"` so cargo refuses (only one
package may claim that link). The conflict happens during lockfile
resolution even though sqlx is gated behind the optional
`persistent-context` feature. Resolving this needs one of:
1. Upgrading sqlx to a release that uses libsqlite3-sys ~0.35
(none exists yet on the 0.8.x line; 0.9.0-alpha.1 would need
evaluation), or
2. Dropping `persistent-context` / sqlx from calciforge entirely
(used by unified_context.rs — non-trivial), or
3. Patching zeroclawlabs to drop or feature-gate its rusqlite dep.
Per the spec instruction "If you hit a wall, commit the partial
progress and report what's blocking", landing the additive config /
docs / workspace-bump pieces here and pausing on the signal.rs
rewrite.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…clawlabs sqlite-link The `persistent-context` feature previously pulled sqlx-sqlite, which pins libsqlite3-sys 0.30.x. Embedding zeroclawlabs (with whatsapp-web → rusqlite 0.37) brought in libsqlite3-sys 0.35.x, which Cargo refuses to coexist under the same `links = "sqlite3"` even when sqlx is feature-gated off. Switch the persistent context store to rusqlite 0.37 with the `bundled` feature so the workspace links a single sqlite3 implementation regardless of which optional features are enabled. Public API of `PersistentContextStore` is unchanged. Internally: - `SqlitePool` → `Arc<Mutex<rusqlite::Connection>>`, with all DB calls inside `tokio::task::spawn_blocking` so the async surface is preserved and the mutex never crosses an `.await`. - `sqlx::FromRow` derives → explicit `try_from_row` helper. - `sqlx::query`/`query_scalar`/`query_as` → `Connection::execute`, `query_row`, `query_map` with the `params!` macro. - Schema bootstrap uses `execute_batch` (multi-statement native). - Tests now create the SQLite file inside a `tempdir()` rather than reusing a `NamedTempFile` handle (rusqlite's stricter open path detection rejected the latter on tmpfs). Also fixes a latent compile error in the `persistent-context` build: `UnifiedContextStore` called `PersistentContextStore::augment_message` which did not exist; added it as a thin wrapper over `build_preamble`.
Calciforge no longer hosts an HTTP webhook receiver for Signal. Instead
it embeds `zeroclaw::channels::SignalChannel` (from `zeroclawlabs`) and
talks directly to a `signal-cli-rest-api` daemon. This removes the
ZeroClaw runtime daemon as a dependency on the inbound path for Signal
and unifies the Signal wire protocol implementation with the rest of
the workspace.
Operators must update their Signal channel configuration. The new
schema:
[[channels]]
kind = "signal"
enabled = true
signal_cli_url = "http://127.0.0.1:8080"
signal_account = "+15555550001"
allowed_numbers = ["+15555550001"]
# Optional:
# signal_group_id = "group.abc123…"
# signal_ignore_attachments = false
# signal_ignore_stories = false
The legacy webhook fields — `zeroclaw_endpoint`, `zeroclaw_auth_token`,
`webhook_listen`, `webhook_path`, `webhook_secret` — are now rejected at
startup with a migration error pointing at the new schema. The fields
remain on `ChannelConfig` because the WhatsApp channel still uses them.
Implementation notes:
- The bridge struct is generic over `Channel` so tests can inject a
mock transport without a live signal-cli daemon.
- All the existing dispatch logic — pre-auth commands, !ping/!status/
!switch/!model/!sessions/!default/!secure/!context-clear, the
unknown-command path, adversary scanning, identity resolution via
`resolve_channel_sender("signal", …)`, telemetry, context push and
augment — is preserved verbatim. Replies now go through
`Channel::send(SendMessage::new(text, reply_target))`, where
`reply_target` is sourced from the inbound `ChannelMessage` so group
messages reply to the group rather than the raw sender.
- `run()` validates config, constructs the embedded SignalChannel,
spawns its `listen` task, and dispatches each inbound `ChannelMessage`
on a fresh task.
Tests: removed the 16 webhook/parsing/HMAC unit tests (all covered code
that no longer exists in this crate; the equivalents live in
`zeroclaw::channels::signal`). Added 3 bridge-level tests that exercise
the new contract: legacy-config rejection, unknown-sender drop, and
group reply targeting via a `MockChannel` test double.
ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one. |
|
| Filename | Overview |
|---|---|
| crates/calciforge/src/channels/signal.rs | Full rewrite from ~1133 LOC webhook receiver to ~280 LOC embedded zeroclawlabs::SignalChannel bridge; migration validator, 3 new tests, generic Channel transport for testability. One minor: TempDir leak in test helpers. |
| crates/calciforge/src/persistent_context.rs | Migrated from sqlx to rusqlite with Mutex + spawn_blocking; public API unchanged. Two minor issues: statements in push() lack a transaction wrapper, and a hardcoded column index in error construction. |
| crates/calciforge/src/config.rs | New Signal-specific fields added to ChannelConfig; legacy fields retained for WhatsApp. Struct-level doc comment for Signal still references the old zeroclaw_endpoint fields and needs updating. |
| AGENTS.md | New documentation standard for channels section added with compile-tested TOML block requirements and workflow instructions. |
| docs/channels/signal.md | Fully rewritten to reflect the embedded architecture; accurate config examples, architecture ASCII diagram, migration notes, and no ZeroClaw daemon references. |
Sequence Diagram
sequenceDiagram
participant U as Signal User
participant SC as signal-cli-rest-api
participant ZCL as zeroclawlabs::SignalChannel
participant CF as Calciforge SignalChannel bridge
participant CMD as CommandHandler
participant R as Router / Agent
U->>SC: sends message (SSE stream)
SC-->>ZCL: inbound ChannelMessage via SSE
ZCL-->>CF: mpsc::Sender<ChannelMessage>
CF->>CF: resolve_channel_sender (identity check)
alt unknown sender
CF-->>CF: drop silently
else known sender
CF->>CMD: handle / dispatch command
alt pre-auth command (!ping, !help)
CMD-->>CF: reply string
CF->>ZCL: Channel::send(reply)
else agent message
CF->>R: dispatch_with_sender_and_model
R-->>CF: agent response
CF->>ZCL: Channel::send(response)
end
end
ZCL->>SC: POST JSON-RPC send
SC->>U: delivers reply
Reviews (1): Last reviewed commit: "feat(signal): embed zeroclawlabs::Signal..." | Re-trigger Greptile
| tokio::task::spawn_blocking(move || -> rusqlite::Result<()> { | ||
| let conn = conn.lock().unwrap(); | ||
|
|
||
| let next_seq: i64 = conn.query_row( | ||
| "SELECT COALESCE(MAX(seq), -1) + 1 FROM exchanges WHERE chat_id = ?", | ||
| params![chat_id], | ||
| |row| row.get(0), | ||
| )?; | ||
|
|
||
| conn.execute( | ||
| "INSERT INTO exchanges (chat_id, seq, sender_label, prompt, agent_id, response) \ | ||
| VALUES (?, ?, ?, ?, ?, ?)", | ||
| params![chat_id, next_seq, sender_label, prompt, agent_id, response], | ||
| )?; | ||
|
|
||
| conn.execute( | ||
| "INSERT OR REPLACE INTO watermarks (chat_id, agent_id, last_seen_seq) \ | ||
| VALUES (?, ?, ?)", | ||
| params![chat_id, agent_id, next_seq], | ||
| )?; | ||
|
|
||
| if buffer_size > 0 { | ||
| conn.execute( | ||
| "DELETE FROM exchanges \ | ||
| WHERE chat_id = ? AND seq < ( \ | ||
| SELECT seq FROM exchanges \ | ||
| WHERE chat_id = ? \ | ||
| ORDER BY seq DESC \ | ||
| LIMIT 1 OFFSET ? \ | ||
| )", | ||
| params![chat_id, chat_id, buffer_size as i64 - 1], | ||
| )?; | ||
| } | ||
|
|
||
| Ok(()) | ||
| }) |
There was a problem hiding this comment.
Missing transaction wrapper in
push()
The three DML statements (SELECT MAX for seq, INSERT into exchanges, INSERT OR REPLACE into watermarks, DELETE for ring-buffer eviction) execute as separate auto-committed operations. If an error occurs after the INSERT but before the DELETE, the buffer can silently exceed buffer_size. Wrapping all three in an explicit BEGIN IMMEDIATE … COMMIT would make the push atomic and prevent the buffer from growing unbounded on partial failure.
There was a problem hiding this comment.
Pull request overview
Embeds Signal support directly in Calciforge via zeroclawlabs::SignalChannel (talking to signal-cli-rest-api), and migrates the optional persistent context store from sqlx to rusqlite to avoid libsqlite3-sys linkage/version collisions introduced by the new embedded-channel dependency.
Changes:
- Rewrites the Signal channel to use the embedded
zeroclawlabstransport (SSE listen + JSON-RPC send) and rejects legacy ZeroClaw/webhook config fields with a migration error. - Migrates
persistent_context.rsfromsqlxtorusqlitewithspawn_blockingDB operations and adds the previously-missingaugment_messageAPI underpersistent-context. - Updates/introduces channel setup docs and adds compile-tested TOML doc examples + inline config example tests.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/index.md | Adds links to per-channel setup guides; Signal bullet needs updated architecture wording. |
| docs/channels/telegram.md | New Telegram long-poll setup guide with schema-tested TOML example. |
| docs/channels/matrix.md | New Matrix long-poll setup guide with E2EE caveat and schema-tested TOML example. |
| docs/channels/signal.md | New embedded Signal architecture/setup guide targeting signal-cli-rest-api. |
| docs/channels/whatsapp.md | New WhatsApp webhook gateway setup guide and schema-tested TOML example. |
| crates/calciforge/src/channels/signal.rs | Replaces webhook-based Signal channel with embedded zeroclawlabs listener + bridge; adds migration validation and tests. |
| crates/calciforge/src/persistent_context.rs | Reimplements persistent context store using rusqlite behind spawn_blocking, preserving public API. |
| crates/calciforge/src/config.rs | Extends ChannelConfig with Signal fields and adds doc/inline TOML parsing tests. |
| crates/calciforge/Cargo.toml | Switches persistent-context feature from sqlx to rusqlite; adds zeroclawlabs dependency. |
| Cargo.toml | Bumps/configures zeroclawlabs workspace dependency (now with whatsapp-web feature enabled). |
| Cargo.lock | Updates lockfile for new dependency set and removed sqlx usage. |
| AGENTS.md | Documents the channel-doc standard and the compile-tested TOML workflow. |
| fn dummy_bridge_with( | ||
| config: Arc<CalciforgeConfig>, | ||
| transport: Arc<MockChannel>, | ||
| ) -> Arc<SignalChannel<MockChannel>> { | ||
| let router = Arc::new(Router::new()); | ||
| let tmp = tempfile::tempdir().expect("tempdir for signal test state isolation"); | ||
| let command_handler = Arc::new(CommandHandler::with_state_dir( | ||
| config.clone(), | ||
| tmp.path().to_path_buf(), | ||
| )); | ||
| // Leak tmp so the dir survives for the lifetime of the test (state | ||
| // dir paths are cached inside CommandHandler). | ||
| std::mem::forget(tmp); | ||
| let context_store = ContextStore::new(20, 5); | ||
| Arc::new(SignalChannel::<MockChannel>::new( | ||
| config, | ||
| router, | ||
| command_handler, | ||
| context_store, | ||
| make_scanner(), | ||
| transport, | ||
| )) |
| // !ping is dispatched on a spawned task; give it a beat to land. | ||
| for _ in 0..20 { | ||
| if !transport.sent.lock().unwrap().is_empty() { | ||
| break; | ||
| } | ||
| tokio::time::sleep(std::time::Duration::from_millis(10)).await; | ||
| } |
| tokio::task::spawn_blocking(move || -> rusqlite::Result<()> { | ||
| let conn = conn.lock().unwrap(); |
|
|
||
| - [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 |
| let listener_transport = transport.clone(); | ||
| let listener_handle = tokio::spawn(async move { | ||
| if let Err(e) = listener_transport.listen(tx).await { | ||
| warn!(error = %e, "Signal: listener exited with error"); | ||
| } | ||
| }); | ||
|
|
||
| // Drain inbound messages. | ||
| 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, "Signal: read error"); | ||
| return; | ||
| } | ||
| }; | ||
|
|
||
| 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; | ||
| } | ||
| }; | ||
|
|
||
| // 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(""); | ||
|
|
||
| // Health check | ||
| if method == "GET" && (path == "/health" || path == "/healthz") { | ||
| let _ = send_http_response(&mut stream, 200, "OK", r#"{"status":"ok"}"#).await; | ||
| return; | ||
| } | ||
|
|
||
| // 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; | ||
| } | ||
|
|
||
| // 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 { | ||
| "" | ||
| }; | ||
|
|
||
| // Optional HMAC verification | ||
| if let Some(ref secret) = webhook_secret { | ||
| let signature = 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()); | ||
|
|
||
| if let Some(sig) = signature { | ||
| if !verify_hmac_sha256(secret, body, sig) { | ||
| warn!(peer = %peer_addr, "Signal: HMAC verification failed"); | ||
| let _ = send_http_response( | ||
| &mut stream, | ||
| 401, | ||
| "Unauthorized", | ||
| r#"{"error":"invalid signature"}"#, | ||
| ) | ||
| .await; | ||
| return; | ||
| } | ||
| } else { | ||
| warn!(peer = %peer_addr, "Signal: missing HMAC signature"); | ||
| let _ = send_http_response( | ||
| &mut stream, | ||
| 401, | ||
| "Unauthorized", | ||
| r#"{"error":"missing signature"}"#, | ||
| ) | ||
| .await; | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| // Parse JSON body | ||
| let json: serde_json::Value = match serde_json::from_str(body) { | ||
| Ok(v) => v, | ||
| Err(e) => { | ||
| warn!(peer = %peer_addr, error = %e, "Signal: JSON parse error"); | ||
| let _ = send_http_response( | ||
| &mut stream, | ||
| 400, | ||
| "Bad Request", | ||
| r#"{"error":"invalid json"}"#, | ||
| ) | ||
| .await; | ||
| return; | ||
| } | ||
| }; | ||
|
|
||
| // Parse and handle messages | ||
| let messages = channel.parse_webhook_payload(&json, &allowed_numbers); | ||
|
|
||
| if messages.is_empty() { | ||
| // Return 200 even if no messages (could be a valid webhook with no actionable content) | ||
| let _ = send_http_response(&mut stream, 200, "OK", r#"{"received":true}"#).await; | ||
| return; | ||
| } | ||
|
|
||
| // Acknowledge receipt immediately | ||
| if let Err(e) = send_http_response(&mut stream, 200, "OK", r#"{"received":true}"#).await | ||
| { | ||
| warn!(peer = %peer_addr, error = %e, "Signal: failed to send ack"); | ||
| } | ||
|
|
||
| // Handle each message asynchronously | ||
| for msg in messages { | ||
| let ch = channel.clone(); | ||
| let zeroclaw_ep = zeroclaw_endpoint.clone(); | ||
| let zeroclaw_token = zeroclaw_auth_token.clone(); | ||
| tokio::spawn(async move { | ||
| ch.handle_message(msg, zeroclaw_ep, zeroclaw_token).await; | ||
| }); | ||
| } | ||
| bridge.handle_message(msg).await; | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Helper functions | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /// Normalise a phone number to E.164 format (with leading +). | ||
| fn normalise_phone(num: &str) -> String { | ||
| let trimmed = num.trim(); | ||
| let cleaned = trimmed.replace([' ', '-'], ""); | ||
| if cleaned.starts_with('+') { | ||
| cleaned | ||
| } else { | ||
| format!("+{cleaned}") | ||
| } | ||
| } | ||
|
|
||
| /// Check if a phone number is in the allowed list. | ||
| /// Supports wildcard "*" to allow any number (not recommended for production). | ||
| fn is_number_allowed(id: &str, allowed: &[String]) -> bool { | ||
| if allowed.is_empty() { | ||
| return false; | ||
| } | ||
| allowed.iter().any(|a| a == "*" || a == id) | ||
| } | ||
|
|
||
| /// Verify HMAC-SHA256 signature. | ||
| fn verify_hmac_sha256(secret: &str, body: &str, signature: &str) -> bool { | ||
| use hmac::{Hmac, Mac}; | ||
| use sha2::Sha256; | ||
|
|
||
| type HmacSha256 = Hmac<Sha256>; | ||
|
|
||
| let sig_bytes = match hex::decode(signature.strip_prefix("sha256=").unwrap_or(signature)) { | ||
| Ok(b) => b, | ||
| Err(_) => return false, | ||
| }; | ||
|
|
||
| let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) { | ||
| Ok(m) => m, | ||
| Err(_) => return false, | ||
| }; | ||
|
|
||
| mac.update(body.as_bytes()); | ||
| mac.verify_slice(&sig_bytes).is_ok() | ||
| } | ||
|
|
||
| /// Send a simple HTTP response. | ||
| async fn send_http_response( | ||
| stream: &mut tokio::net::TcpStream, | ||
| status: u16, | ||
| status_text: &str, | ||
| body: &str, | ||
| ) -> std::io::Result<()> { | ||
| let response = format!( | ||
| "HTTP/1.1 {status} {status_text}\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 | ||
| // Listener returned (channel closed); join it for cleanup. | ||
| let _ = listener_handle.await; | ||
| Ok(()) |
Summary
Calciforge's Signal channel now embeds
zeroclawlabs::SignalChanneldirectly instead of requiring a separately-running ZeroClaw daemon. Per the agent-agnostic principle: Calciforge depends on thezeroclawlabsRust library, not on ZeroClaw-the-product as a runtime service. The only external dep for Signal is nowsignal-cli-rest-api(the canonical generic Signal automation HTTP wrapper), which Calciforge talks to directly.To unblock this,
persistent_context.rswas migrated fromsqlxtorusqlite—sqlx-sqlite 0.8.6andrusqlite 0.37(transitive via zeroclawlabs's WhatsApp Web storage) declare incompatiblelibsqlite3-sysversions and Cargo refuses to resolve them in the same lockfile regardless of feature activation. Migrating Calciforge to rusqlite unifies on one sqlite library and reduces total deps.What changes
crates/calciforge/src/channels/signal.rsrewrite (~1133 LOC → ~280 LOC) usingzeroclawlabs::SignalChannel'sChannel::listen/Channel::sendinstead of an axum-style webhook receiver andPOST {zeroclaw_endpoint}/tools/invokereply path. All!commanddispatch logic, identity resolution, adversary scanning, telemetry, and context store wiring preserved verbatim.persistent_context.rsfromsqlx(async pool,FromRowderives,query_as) torusqlite(syncConnectionwrapped inMutex, queries insidetokio::task::spawn_blocking). Public API unchanged. Bonus fix for a latent compile error inunified_context.rs(PersistentContextStore::augment_messagewas missing under thepersistent-contextfeature).signal_cli_url,signal_account,signal_group_id,signal_ignore_attachments,signal_ignore_stories). Legacy fields (zeroclaw_endpoint,webhook_*) retained — WhatsApp still uses them.docs/channels/signal.mdrewritten for the embedded architecture.kind = "signal"channel still uses any of the legacy ZeroClaw-daemon fields, Calciforge bails with a clear error and the new-shape TOML.Migration (for anyone running Signal)
If your config has
kind = "signal"withzeroclaw_endpoint/webhook_*fields, Calciforge will refuse to start until you replace it with:You also no longer need ZeroClaw running. Point
signal_cli_urldirectly atsignal-cli-rest-api. Your Signal session (paired phone + identity) lives insidesignal-cli-rest-api's state, so no re-pairing.Tests
--features persistent-context: 522 passing (also fixes a previously-broken feature build).What's next
WhatsApp embedding (
WhatsAppWebChannel+ Cloud API direct webhook from Meta) is a follow-up PR. Now unblocked since the sqlite-links collision is resolved.Notes
docs/channels/signal.md. Should merge after docs: channel setup guides with compile-tested TOML examples #82 to avoid conflicts. Sequenced.zeroclaw::channels::traits::Channeletc. (the crate's[lib].nameiszeroclaweven though its package name iszeroclawlabs).🤖 Generated with Claude Code