Skip to content

feat: integrate pacto-bot-api for Signal→Pacto messaging (!pact)#3

Open
RonTuretzky wants to merge 5 commits into
mainfrom
RonTuretzky/integrate-covenant-pact
Open

feat: integrate pacto-bot-api for Signal→Pacto messaging (!pact)#3
RonTuretzky wants to merge 5 commits into
mainfrom
RonTuretzky/integrate-covenant-pact

Conversation

@RonTuretzky

Copy link
Copy Markdown

Summary

  • Adds a new pacto-client crate (JSON-RPC 2.0 over the pacto-bot-api Unix socket) with transparent reconnection and 6 integration tests against a mock daemon.
  • Wires a new !pact <npub> <message> command into signal-bot (disabled by default via PACTO__ENABLED=false) so Signal users can send encrypted DMs into Pacto.
  • Adds an opt-in pacto-bot-api Docker service (compose profile pacto) shared with signal-bot via a Unix socket volume, plus a Phala-compatible Dockerfile.pacto for TEE deployments.
  • Config, .env.example, and CLAUDE.md documentation for local and Phala setup.

Test plan

  • cargo test --workspace passes (pre-existing x402-payments::test_default_config failure unrelated to this PR)
  • docker compose --profile pacto config validates without errors
  • Generate a bot identity with pacto-bot-admin new sigstack --backend nsec, copy docker/pacto-bot-api.toml.example, set PACTO_BOT_NSEC + PACTO_ENABLED=true, start with docker compose --profile pacto up -d, then send !pact status and !pact <npub> hello via Signal

…saging

Add a new `pacto-client` crate speaking JSON-RPC 2.0 over the pacto-bot-api
Unix socket, a `!pact <npub> <message>` command in signal-bot, and opt-in
Docker service wiring (profile `pacto`) for both local and Phala deployments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds an optional Pacto messaging integration to the Signal bot by introducing a new pacto-client crate (JSON-RPC over a Unix socket) and wiring a new !pact command, along with Docker/Phala deployment scaffolding and documentation.

Changes:

  • Introduce crates/pacto-client implementing a reconnecting JSON-RPC client with integration tests against a mock Unix-socket daemon.
  • Add !pact command support to signal-bot, gated by PACTO__ENABLED, and plumb new Pacto config fields.
  • Add Docker Compose + Phala deployment assets (including an opt-in daemon service and a derived Dockerfile) plus docs/examples.

Reviewed changes

Copilot reviewed 20 out of 22 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
README.md Documents the new !pact command in the user-facing command list.
docker/phala-compose.yaml Adds opt-in env/volume wiring for Pacto and commented daemon service for Phala.
docker/pacto-bot-api.toml.example Provides example daemon config template for deployments.
docker/Dockerfile.pacto Adds a derived Phala-compatible image build for pacto-bot-api with baked config.
docker/docker-compose.yaml Adds opt-in pacto-bot-api service (profile) and signal-bot env/volume wiring.
crates/signal-bot/src/main.rs Constructs and conditionally registers the Pacto client + PactHandler.
crates/signal-bot/src/error.rs Adds AppError::Pacto wrapping pacto_client::PactoError.
crates/signal-bot/src/config.rs Adds PactoConfig with env-driven settings and defaults.
crates/signal-bot/src/commands/pact.rs Implements the !pact command handler and basic unit tests.
crates/signal-bot/src/commands/mod.rs Exposes the new PactHandler module/export.
crates/signal-bot/Cargo.toml Adds dependency on the new pacto-client crate.
crates/pacto-client/tests/client_test.rs Adds async integration tests using a mock daemon over a Unix socket.
crates/pacto-client/src/types.rs Defines deserializable types for handler.register and agent.version.
crates/pacto-client/src/lib.rs Exposes the Pacto client public API and modules.
crates/pacto-client/src/error.rs Adds PactoError covering socket/I/O/timeout/RPC/protocol cases.
crates/pacto-client/src/client.rs Implements JSON-RPC framing, registration, retrying reconnection, and send/version calls.
crates/pacto-client/Cargo.toml New crate manifest with deps and tempfile dev-dependency.
CLAUDE.md Adds architectural/setup documentation for Pacto messaging and Phala deployment.
Cargo.toml Adds crates/pacto-client to the workspace members.
Cargo.lock Records the new pacto-client package and dependency edges.
.gitignore Ignores per-deployment docker/pacto-bot-api.toml.
.env.example Adds Pacto env vars and defaults for local configuration.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +166 to +171
let pacto_client = if config.pacto.enabled {
let pacto = Arc::new(PactoClient::new(
&config.pacto.socket_path,
config.pacto.bot_id.clone(),
config.pacto.timeout,
));

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This compiles as-is (verified with a fresh cargo build -p signal-bot). &String satisfies Into<PathBuf> through the standard blanket impl impl<T: ?Sized + AsRef<OsStr>> From<&T> for PathBuf, and String: AsRef<OsStr>. No change needed.

Comment on lines +55 to +60
pub async fn send_dm(&self, recipient: &str, content: &str) -> Result<String, PactoError> {
let params = json!({
"bot_id": self.bot_id,
"recipient": recipient,
"content": content,
});

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

No move occurs — the serde_json::json! macro serializes its values by reference (it calls to_value(&_) internally), so self.bot_id is borrowed, not moved out of &self. Verified: cargo build/cargo test -p pacto-client both pass. No change needed.

Comment on lines +121 to +125
let params = json!({
"bot_ids": [self.bot_id],
"event_types": [],
"capabilities": ["SendMessages"],
});

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Same as above: json!({"bot_ids": [self.bot_id]}) borrows self.bot_id via the macro rather than moving it, so this compiles from &self. Confirmed by a passing build/test. No change needed.

call_with_retry previously reconnected and retried on both Io and Timeout
errors. A timeout is ambiguous — the daemon may already have published the
gift-wrap for agent.send_dm — so retrying could send the DM twice. Only a
connection drop (Io/EOF) guarantees the request never completed and is safe
to retry; on timeout, tear down the connection and surface the error instead.

Adds a test asserting a timeout returns PactoError::Timeout and does not
resend (the mock daemon confirms it never receives a second frame).
Adds an inbound Pacto DM agent so Pacto users get the same DM experience
Signal users get (AI chat with tools, !verify/!clear/!models/!help/!privacy/
!list-langs, AI-driven translation), not just outbound !pact.

- pacto-client: new full-duplex PactoAgent that registers for dm_received
  (ReadMessages+SendMessages), streams inbound DMs, replies via
  handler.response{reply} (daemon auto-addresses the sender), and
  auto-reconnects. Existing single-flight PactoClient unchanged.
- signal-bot: ProgressSink trait extracts the one Signal-coupled progress
  ping from ChatHandler (impl for Signal reply + Pacto send_dm), so the same
  chat/command handlers drive both transports via a synthetic BotMessage.
- pacto_agent module dispatches inbound DMs through the reused handlers;
  Pacto-accurate !help/!privacy. Gated by PACTO__AGENT_ENABLED (default true).
- Tests: PactoAgent register/receive/reply + reconnect; dispatch routing.
- Docs: two-way architecture and the DM-only parity ceiling (groups/voice are
  blocked upstream — the daemon delivers only dm_received, no inbound MLS group
  messages or audio attachments).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 26 out of 28 changed files in this pull request and generated 1 comment.

Comment thread crates/pacto-client/src/agent.rs Outdated
Comment on lines +193 to +197
// Registration ack / other id-correlated responses.
if let Some(err) = msg.get("error") {
warn!(error = %err, "Pacto daemon returned an error frame");
continue;
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch — fixed in the next commit. pump_events now treats a JSON-RPC error frame as a connection failure (returns false) instead of continue, so the supervisor clears the published write half (*write = None) and reconnects after the backoff rather than blocking forever on a dead registration. Added a test (registration_error_triggers_reconnect) that rejects the first handler.register with an error frame and asserts the agent reconnects, re-registers, and then delivers an event.

Previously the agent's read loop logged a JSON-RPC error frame and continued,
leaving the connection stuck (no events will arrive after a failed
registration) with the write half still published, so reply() would fail with
confusing protocol errors instead of reconnecting. Treat an error frame as a
connection failure so the supervisor clears the writer and retries after the
backoff. Adds a test rejecting the first register and asserting reconnect.
@RonTuretzky

Copy link
Copy Markdown
Author

@daopunk tagging you for a review — here's the full context on this PR.

What this does

Integrates covenant-gov/pacto-bot-api so the bot bridges Pacto in both directions, co-locating the daemon in the same TEE so the bot's Nostr key and plaintext never leave protected memory.

  • Outbound (!pact): a Signal user sends an encrypted DM into Pacto.
  • Inbound (DM agent): a Pacto user DMs the bot and gets the same DM experience a Signal user gets — AI chat (with web/weather/calc tools), !verify, !clear, !models, !help, !privacy, !list-langs, and translation via the AI.

How it's built

  • crates/pacto-client — JSON-RPC 2.0 over the daemon's Unix socket (newline-delimited frames), two connection models:
    • PactoClient — single-flight request/response for outbound !pact sends (agent.send_dm). Does not retry non-idempotent sends after a timeout (would double-send a DM).
    • PactoAgent — long-lived full-duplex connection: registers for dm_received, streams inbound DMs, replies via handler.response{reply} (the daemon auto-addresses the sender), and auto-reconnects/re-registers — including when handler.register is rejected.
  • ProgressSink trait — the only Signal-coupling in ChatHandler was the "🔧 Using…" ping; extracted behind a trait (Signal reply / Pacto send_dm) so inbound Pacto DMs run through the same chat/command handlers via a synthetic BotMessage (author npub = source, so history is per-sender).
  • Gated by PACTO__ENABLED (default off) + PACTO__AGENT_ENABLED (default on when Pacto is enabled). Docker: opt-in pacto-bot-api service (compose profile pacto) sharing the socket via a volume; Dockerfile.pacto + commented block for Phala.

Parity status (important)

  • Full DM parity for Pacto users.
  • Groups and voice are not reachable from this repo — the daemon only delivers dm_received events (it subscribes to kind:1059 DMs only; parse_event_type accepts only dm_received/mls_welcome_received). It exposes neither inbound MLS group messages nor audio attachments to handlers. This is an upstream limitation, not this integration. I filed covenant-gov/pacto-bot-api#56 specifying the group_message_received handler API; our agent already dispatches daemon events through the full pipeline, so wiring group translation is small once the daemon delivers them.

Testing

  • cargo test -p pacto-client: 10 tests (register/send/reconnect/timeout-no-resend + agent receive/reply/reconnect/registration-error).
  • pacto_agent dispatch routing tests; full workspace green except the pre-existing x402-payments::test_default_config (fails on main too).
  • New code is clippy-clean; docker compose --profile pacto config validates.
  • Copilot reviewed twice; one real bug it caught (stuck connection on a rejected registration) is fixed in dc4ebc1; its other comments were false "won't compile" positives (the code builds), refuted in-thread.

Not yet done: a live end-to-end run against a real pacto-bot-api daemon (verified against the protocol + a mock daemon so far). Happy to run that before merge if you'd like.

Commits: !pact outbound → timeout hardening → two-way parity → reconnect-on-rejected-register fix.

@RonTuretzky RonTuretzky requested review from daopunk July 8, 2026 19:06
Mirror of !pact for the fourth direction (Pacto user -> Signal user). A Pacto
user DMs the bot "!signal <+number> <message>" and it's relayed to the Signal
recipient, prefixed with the sender's npub so they can reply via !pact (no
stateful session mapping needed).

Because this can relay to arbitrary Signal numbers (an open relay if unguarded),
it is off by default and allowlist-gated:
- PACTO__SIGNAL_RELAY_ENABLED (default false)
- PACTO__SIGNAL_RELAY_ALLOWLIST (empty = deny all, "*" = any)
- requires SIGNAL__PHONE_NUMBER (the bot's own number to send from)

The !signal handler lives only in the Pacto inbound dispatch and is added to the
handler set + help only when enabled. Adds SignalConfig.phone_number, unit tests
for E.164 validation, allowlist gating, and the no-send rejection paths.
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