Skip to content

Commit b780340

Browse files
committed
Integrate embedded WhatsApp and text channels
1 parent 8c6a4e3 commit b780340

21 files changed

Lines changed: 2423 additions & 1311 deletions

Cargo.lock

Lines changed: 533 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ being treated as daily-driver infrastructure.
2323
| Per-secret destination allowlists | Working | [Outbound traffic gating](https://calciforge.org/#outbound-traffic-gating) |
2424
| Local paste UI for one-shot and bulk `.env` secret input | Working | [Secret management](https://calciforge.org/#secret-management) |
2525
| 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) |
26-
| Telegram, Matrix, WhatsApp, and Signal routing | Working | [Multi-channel chat](https://calciforge.org/#multi-channel-chat) |
26+
| Telegram, Matrix, WhatsApp, Signal, and text/iMessage routing | Working | [Multi-channel chat](https://calciforge.org/#multi-channel-chat) |
2727
| OpenAI-compatible model gateway, provider routing, model aliases, alloys, cascades, dispatchers, exec models, and local model switching | Working | [Model gateway](docs/model-gateway.md) |
2828
| Codex CLI and OpenClaw Codex subscription/OAuth integration paths | Working | [Codex integration](docs/codex-openclaw-integration.md) |
2929
| `calciforge doctor` config/state/endpoint diagnostics | Working | [Quick Start](#quick-start) |

crates/calciforge/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ axum = "0.7"
3535
# Embedded Signal channel implementation. The crate brings its own
3636
# SignalChannel that talks to signal-cli-rest-api, so we no longer host a
3737
# webhook receiver inside calciforge for Signal.
38-
zeroclawlabs = { workspace = true }
38+
zeroclawlabs = { workspace = true, features = ["whatsapp-web"] }
3939

4040
# Secret resolution helpers
4141
secrets-client = { path = "../secrets-client" }

crates/calciforge/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ COPY --from=builder /app/target/release/security-proxy /usr/local/bin/security-p
1717
# Create config directory
1818
RUN mkdir -p /root/.calciforge
1919

20-
EXPOSE 8888 18792 18793 18794 18795 18796 18797
20+
EXPOSE 8888 18792 18793 18794 18795 18796 18797 18798
2121

2222
ENTRYPOINT ["/usr/local/bin/calciforge"]
2323
CMD ["--config", "/root/.calciforge/config.toml"]

crates/calciforge/src/channels/matrix.rs

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -310,18 +310,37 @@ async fn send_matrix_message(
310310
"msgtype": "m.text",
311311
"body": body,
312312
});
313-
let resp = http
314-
.put(&url)
315-
.header("Authorization", auth_header)
316-
.json(&payload)
317-
.send()
318-
.await?;
319-
if !resp.status().is_success() {
313+
const MAX_RETRIES: u32 = 4;
314+
let mut attempt = 0u32;
315+
loop {
316+
let resp = http
317+
.put(&url)
318+
.header("Authorization", auth_header)
319+
.json(&payload)
320+
.send()
321+
.await?;
320322
let status = resp.status();
323+
if status.is_success() {
324+
return Ok(());
325+
}
326+
if status.as_u16() == 429 && attempt < MAX_RETRIES {
327+
let body_text = resp.text().await.unwrap_or_default();
328+
let retry_ms = serde_json::from_str::<serde_json::Value>(&body_text)
329+
.ok()
330+
.and_then(|value| value["retry_after_ms"].as_u64())
331+
.unwrap_or(1000);
332+
tracing::debug!(
333+
attempt,
334+
retry_ms,
335+
"Matrix send rate-limited (429); retrying"
336+
);
337+
tokio::time::sleep(tokio::time::Duration::from_millis(retry_ms + 50)).await;
338+
attempt += 1;
339+
continue;
340+
}
321341
let err = resp.text().await.unwrap_or_default();
322342
anyhow::bail!("Matrix send failed ({status}): {err}");
323343
}
324-
Ok(())
325344
}
326345

327346
async fn send_matrix_outbound_message(
Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,16 @@
11
//! Channel adapters for Calciforge.
22
//!
3-
//! Currently active: Telegram.
4-
//! Scaffolded (needs bot account): Matrix.
5-
//! Scaffolded (needs ZeroClaw WA session): WhatsApp.
6-
//! Scaffolded (needs OpenClaw Signal session): Signal.
3+
//! Active: Telegram, Matrix, WhatsApp, Signal, text/iMessage, and mock.
74
//!
8-
//! Matrix was removed in v0.4.x (Zig) due to a tight-loop bug. The Rust v2 doesn't
9-
//! have that problem — the adapter below is ready to wire up once the bot account exists.
10-
//! See MATRIX-SETUP-NEEDED.md in the repo root for what's required.
11-
//!
12-
//! WhatsApp runs as a webhook receiver sidecar to ZeroClaw's wa-rs session.
13-
//! Calciforge listens for incoming webhook POSTs (forwarded from ZeroClaw) and sends
14-
//! replies back via ZeroClaw's /tools/invoke API. The QR pairing happens in ZeroClaw;
15-
//! Calciforge only handles identity routing and agent dispatch.
16-
//!
17-
//! Signal follows the same webhook receiver pattern as WhatsApp, but uses
18-
//! OpenClaw's native Signal support. Calciforge receives webhooks from OpenClaw
19-
//! and sends replies via the /tools/invoke API.
5+
//! Matrix and Telegram use their native HTTP APIs. WhatsApp and Signal embed
6+
//! zeroclawlabs transports directly. Text/iMessage uses the zeroclawlabs Linq
7+
//! transport for outbound sends plus a Calciforge-hosted Linq webhook receiver
8+
//! for inbound iMessage/RCS/SMS events.
209
2110
pub mod matrix;
2211
pub mod mock;
2312
pub mod signal;
13+
pub mod sms;
2414
pub mod telegram;
2515
pub mod telemetry;
2616
pub mod whatsapp;

crates/calciforge/src/channels/signal.rs

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ use crate::{
4545
commands::CommandHandler,
4646
config::CalciforgeConfig,
4747
context::ContextStore,
48+
messages::OutboundMessage,
4849
router::Router,
4950
};
5051

@@ -124,6 +125,11 @@ impl<C: Channel + ?Sized + 'static> SignalChannel<C> {
124125
}
125126
}
126127

128+
async fn send_outbound(&self, recipient: &str, message: &OutboundMessage) {
129+
self.send_reply(recipient, &message.render_text_fallback())
130+
.await;
131+
}
132+
127133
/// Handle a single inbound `ChannelMessage` end-to-end.
128134
pub async fn handle_message(self: Arc<Self>, msg: ChannelMessage) {
129135
let received_at = std::time::Instant::now();
@@ -209,6 +215,7 @@ impl<C: Channel + ?Sized + 'static> SignalChannel<C> {
209215
&& !CommandHandler::is_default_command(&text)
210216
&& !CommandHandler::is_sessions_command(&text)
211217
&& !CommandHandler::is_model_command(&text)
218+
&& !CommandHandler::is_secure_command(&text)
212219
{
213220
let reply = self.command_handler.unknown_command(&text);
214221
let channel = self.clone();
@@ -280,6 +287,33 @@ impl<C: Channel + ?Sized + 'static> SignalChannel<C> {
280287
return;
281288
}
282289

290+
// !secure
291+
if CommandHandler::is_secure_command(&text) {
292+
debug!(identity = %identity.id, "Signal: handling !secure command");
293+
if CommandHandler::is_secure_set_command(&text)
294+
&& !crate::config::channel_allows_chat_secret_set(&self.config, "signal")
295+
{
296+
let reply = CommandHandler::secure_set_disabled_reply("Signal");
297+
let channel = self.clone();
298+
let target = reply_target.clone();
299+
tokio::spawn(async move {
300+
channel.send_reply(&target, &reply).await;
301+
});
302+
return;
303+
}
304+
305+
let reply = self
306+
.command_handler
307+
.handle_secure(&text, &identity.id)
308+
.await;
309+
let channel = self.clone();
310+
let target = reply_target.clone();
311+
tokio::spawn(async move {
312+
channel.send_reply(&target, &reply).await;
313+
});
314+
return;
315+
}
316+
283317
// !context clear
284318
if text.trim().eq_ignore_ascii_case("!context clear") {
285319
self.context_store.clear(&chat_key);
@@ -345,7 +379,7 @@ impl<C: Channel + ?Sized + 'static> SignalChannel<C> {
345379
let dispatch_start = std::time::Instant::now();
346380
match self
347381
.router
348-
.dispatch_with_sender_and_model(
382+
.dispatch_message_with_sender_and_model(
349383
&augmented,
350384
&agent,
351385
&self.config,
@@ -356,21 +390,21 @@ impl<C: Channel + ?Sized + 'static> SignalChannel<C> {
356390
{
357391
Ok(response) => {
358392
let latency_ms = dispatch_start.elapsed().as_millis() as u64;
393+
let final_response = response.render_text_fallback();
359394
self.command_handler.record_dispatch(latency_ms);
360395
telemetry::agent_dispatch_succeeded(
361396
"signal",
362397
&identity_id,
363398
&agent_id,
364399
latency_ms,
365-
response.len(),
400+
response.response_len(),
366401
);
367402

368-
let final_response = response;
369-
370403
debug!(
371404
identity = %identity_id,
372405
agent_id = %agent_id,
373406
response_len = %final_response.len(),
407+
attachments = response.attachments.len(),
374408
"Signal: got agent response"
375409
);
376410

@@ -383,7 +417,7 @@ impl<C: Channel + ?Sized + 'static> SignalChannel<C> {
383417
preserve_native_commands,
384418
);
385419

386-
self.send_reply(&reply_target, &final_response).await;
420+
self.send_outbound(&reply_target, &response).await;
387421
}
388422
Err(e) => {
389423
warn!(identity = %identity_id, error = %e, "Signal: agent dispatch failed");
@@ -617,26 +651,11 @@ mod tests {
617651
fn make_test_config<F: FnOnce(&mut ChannelConfig)>(mutate: F) -> Arc<CalciforgeConfig> {
618652
let mut channel = ChannelConfig {
619653
kind: "signal".to_string(),
620-
bot_token_file: None,
621654
enabled: true,
622-
homeserver: None,
623-
access_token_file: None,
624-
room_id: None,
625-
allowed_users: vec![],
626-
zeroclaw_endpoint: None,
627-
zeroclaw_auth_token: None,
628-
webhook_listen: None,
629-
webhook_path: None,
630-
webhook_secret: None,
631655
allowed_numbers: vec!["+15555550100".to_string()],
632656
signal_cli_url: Some("http://127.0.0.1:8080".to_string()),
633657
signal_account: Some("+15555550001".to_string()),
634-
signal_group_id: None,
635-
signal_ignore_attachments: false,
636-
signal_ignore_stories: false,
637-
control_port: None,
638-
scan_messages: false,
639-
allow_chat_secret_set: false,
658+
..Default::default()
640659
};
641660
mutate(&mut channel);
642661

0 commit comments

Comments
 (0)