Skip to content

Embed zeroclawlabs::SignalChannel; migrate persistent-context to rusqlite#86

Closed
bglusman wants to merge 8 commits into
mainfrom
channels/embed-zeroclawlabs
Closed

Embed zeroclawlabs::SignalChannel; migrate persistent-context to rusqlite#86
bglusman wants to merge 8 commits into
mainfrom
channels/embed-zeroclawlabs

Conversation

@bglusman
Copy link
Copy Markdown
Owner

Summary

Calciforge's Signal channel now embeds zeroclawlabs::SignalChannel directly instead of requiring a separately-running ZeroClaw daemon. Per the agent-agnostic principle: Calciforge depends on the zeroclawlabs Rust library, not on ZeroClaw-the-product as a runtime service. The only external dep for Signal is now signal-cli-rest-api (the canonical generic Signal automation HTTP wrapper), which Calciforge talks to directly.

To unblock this, persistent_context.rs was migrated from sqlx to rusqlitesqlx-sqlite 0.8.6 and rusqlite 0.37 (transitive via zeroclawlabs's WhatsApp Web storage) declare incompatible libsqlite3-sys versions 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

  • Added crates/calciforge/src/channels/signal.rs rewrite (~1133 LOC → ~280 LOC) using zeroclawlabs::SignalChannel's Channel::listen/Channel::send instead of an axum-style webhook receiver and POST {zeroclaw_endpoint}/tools/invoke reply path. All !command dispatch logic, identity resolution, adversary scanning, telemetry, and context store wiring preserved verbatim.
  • Migrated persistent_context.rs from sqlx (async pool, FromRow derives, query_as) to rusqlite (sync Connection wrapped in Mutex, queries inside tokio::task::spawn_blocking). Public API unchanged. Bonus fix for a latent compile error in unified_context.rs (PersistentContextStore::augment_message was missing under the persistent-context feature).
  • ChannelConfig schema: new Signal fields (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.md rewritten for the embedded architecture.
  • Migration validator: at startup, if a 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" with zeroclaw_endpoint / webhook_* fields, Calciforge will refuse to start until you replace it with:

[[channels]]
kind = "signal"
enabled = true
signal_cli_url = "http://127.0.0.1:8080"   # signal-cli-rest-api endpoint
signal_account = "+15555550001"            # bot's E.164 phone
allowed_numbers = ["+15555550001"]
# Optional:
# signal_group_id = "group.abc123…"
# signal_ignore_attachments = false
# signal_ignore_stories = false

You also no longer need ZeroClaw running. Point signal_cli_url directly at signal-cli-rest-api. Your Signal session (paired phone + identity) lives inside signal-cli-rest-api's state, so no re-pairing.

Tests

  • Default-features build: 519 → 505 (−14). 26 deleted tests covered code that's now upstream's responsibility (HMAC verification, phone normalization, allowlist, webhook payload parsing). 3 new tests cover the bridge (legacy-config rejection, unknown-sender drop, group-target reply routing).
  • --features persistent-context: 522 passing (also fixes a previously-broken feature build).
  • All clippy/fmt clean.

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

🤖 Generated with Claude Code

Librarian and others added 8 commits April 29, 2026 19:09
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.
Copilot AI review requested due to automatic review settings April 30, 2026 03:18
@qodo-code-review
Copy link
Copy Markdown

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

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 30, 2026

Greptile Summary

This PR embeds zeroclawlabs::SignalChannel directly into Calciforge, eliminating the ZeroClaw daemon from the Signal runtime path, and migrates persistent_context.rs from sqlx to rusqlite to resolve a libsqlite3-sys version conflict. All command dispatch, identity resolution, adversary scanning, and context-store wiring are preserved; the public PersistentContextStore API is unchanged. All remaining findings are P2.

Confidence Score: 5/5

Safe to merge; all findings are P2 style/robustness suggestions with no blocking correctness issues.

The Signal rewrite is clean and well-tested (3 targeted new tests, 26 upstream-delegated tests deleted appropriately). The rusqlite migration is correct and the public API is unchanged. The only issues found are: a stale doc comment in ChannelConfig, missing transaction wrapping in push() (data-integrity risk is low given single-connection mutex serialization), a hardcoded column index in error construction, and an intentional TempDir leak in test helpers. None block production correctness.

crates/calciforge/src/persistent_context.rs (transaction atomicity in push) and crates/calciforge/src/config.rs (stale Signal doc comment).

Important Files Changed

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
Loading

Reviews (1): Last reviewed commit: "feat(signal): embed zeroclawlabs::Signal..." | Re-trigger Greptile

Comment on lines +196 to +231
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(())
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 zeroclawlabs transport (SSE listen + JSON-RPC send) and rejects legacy ZeroClaw/webhook config fields with a migration error.
  • Migrates persistent_context.rs from sqlx to rusqlite with spawn_blocking DB operations and adds the previously-missing augment_message API under persistent-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.

Comment on lines +649 to +670
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,
))
Comment on lines +764 to +770
// !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;
}
Comment on lines +196 to +197
tokio::task::spawn_blocking(move || -> rusqlite::Result<()> {
let conn = conn.lock().unwrap();
Comment thread docs/index.md

- [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
Comment on lines +504 to +521
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(())
Copy link
Copy Markdown
Owner Author

Superseded by #87, which carries the Signal cutover stabilization plus the review-feedback fixes on a separate replacement branch. #87 is merged at 91dc1b4 after green CI/integration/security checks.

@bglusman bglusman closed this Apr 30, 2026
@bglusman bglusman deleted the channels/embed-zeroclawlabs branch May 1, 2026 17:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants