feat: integrate pacto-bot-api for Signal→Pacto messaging (!pact)#3
feat: integrate pacto-bot-api for Signal→Pacto messaging (!pact)#3RonTuretzky wants to merge 5 commits into
Conversation
…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.
There was a problem hiding this comment.
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-clientimplementing a reconnecting JSON-RPC client with integration tests against a mock Unix-socket daemon. - Add
!pactcommand support tosignal-bot, gated byPACTO__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.
| 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, | ||
| )); |
There was a problem hiding this comment.
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.
| 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, | ||
| }); |
There was a problem hiding this comment.
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.
| let params = json!({ | ||
| "bot_ids": [self.bot_id], | ||
| "event_types": [], | ||
| "capabilities": ["SendMessages"], | ||
| }); |
There was a problem hiding this comment.
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).
| // Registration ack / other id-correlated responses. | ||
| if let Some(err) = msg.get("error") { | ||
| warn!(error = %err, "Pacto daemon returned an error frame"); | ||
| continue; | ||
| } |
There was a problem hiding this comment.
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.
|
@daopunk tagging you for a review — here's the full context on this PR. What this doesIntegrates 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.
How it's built
Parity status (important)
Testing
Not yet done: a live end-to-end run against a real Commits: |
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.
Summary
pacto-clientcrate (JSON-RPC 2.0 over the pacto-bot-api Unix socket) with transparent reconnection and 6 integration tests against a mock daemon.!pact <npub> <message>command into signal-bot (disabled by default viaPACTO__ENABLED=false) so Signal users can send encrypted DMs into Pacto.pacto-bot-apiDocker service (compose profilepacto) shared with signal-bot via a Unix socket volume, plus a Phala-compatibleDockerfile.pactofor TEE deployments..env.example, and CLAUDE.md documentation for local and Phala setup.Test plan
cargo test --workspacepasses (pre-existingx402-payments::test_default_configfailure unrelated to this PR)docker compose --profile pacto configvalidates without errorspacto-bot-admin new sigstack --backend nsec, copydocker/pacto-bot-api.toml.example, setPACTO_BOT_NSEC+PACTO_ENABLED=true, start withdocker compose --profile pacto up -d, then send!pact statusand!pact <npub> hellovia Signal