diff --git a/.agents/docs/DEVELOPMENT.md b/.agents/docs/DEVELOPMENT.md index c2dce50..59f1537 100644 --- a/.agents/docs/DEVELOPMENT.md +++ b/.agents/docs/DEVELOPMENT.md @@ -4,11 +4,11 @@ Interoperable Signal products (see [issue #10](https://github.com/BreadchainCoop/sigstack-bot/issues/10)): -1. **Voice transcription** — own CVM (`BOT__ROLE=transcription`) -2. **In-chat group translation** — translation CVM (see [`docs/in-chat-translation.md`](../../docs/in-chat-translation.md)) -3. **Language Threads** — translation CVM, multilingual main + N sidecars (see [`docs/language-threads.md`](../../docs/language-threads.md)) +1. **Voice transcription** — same process (`BOT__ROLE=translation`; NEAR AI Whisper Large V3, not an in-CVM sidecar) +2. **In-chat group translation** — see [`docs/in-chat-translation.md`](../../docs/in-chat-translation.md) +3. **Language Threads** — multilingual main + N sidecars (see [`docs/language-threads.md`](../../docs/language-threads.md)) -Architecture overview: [`docs/two-cvm-architecture.md`](../../docs/two-cvm-architecture.md). +Architecture: [`docs/two-cvm-architecture.md`](../../docs/two-cvm-architecture.md) (one Phala CVM, one Signal number). Why STT is remote: [`docs/solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md`](../../docs/solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md). Fork legacy removed: general AI chat, tool use (`crates/tools`), x402 payments, in-memory conversation store. @@ -20,18 +20,15 @@ Fork legacy removed: general AI chat, tool use (`crates/tools`), x402 payments, 2. **Attestation**: Remote parties verify code via TDX quotes (`!verify`) 3. **Isolation**: Hypervisor/host cannot read TEE memory -### Signal CLI must run in each product TEE +### Signal CLI must run in the product TEE -Signal E2E encryption terminates at Signal CLI. Each product CVM runs its own `signal-api` + `signal-bot` so plaintext only exists in that TEE. +Signal E2E encryption terminates at Signal CLI. Plaintext only exists in this TEE. Voice bytes leave the TEE only as metadata-stripped audio to NEAR AI Whisper. -### Two CVMs, Signal as bus +### One CVM, one phone -- Transcription CVM: Whisper + transcription bot (phone A) — **worker** (voice only; no hub) -- Translation CVM: translation bot (phone B) + NEAR AI — **hub** (menus, translation products, transcription pairing) -- No cross-CVM Docker network. Integration = both bots in the same Signal group. -- Whisper HTTP (`http://whisper-api:9000`) is **intra**-transcription-stack only. - -Full hierarchy table: [`docs/two-cvm-architecture.md`](../../docs/two-cvm-architecture.md#bot-hierarchy). +- Same Phala CVM: one `signal-api` + one `signal-bot` (phone B) +- No `whisper-api` sidecar. The bot posts audio to NEAR AI Whisper Large V3 (GPU TEE) and text to NEAR AI chat. +- Per-message `tokio::spawn` so STT HTTP waits never stall other handlers. After STT, in-chat / Language Threads fan out in-process (this number does not receive its own posts). ### What attestation proves / does not prove @@ -39,86 +36,100 @@ Full hierarchy table: [`docs/two-cvm-architecture.md`](../../docs/two-cvm-archit |----------|-------------| | Code in Intel TDX | TDX quote | | Exact compose | Compose hash | -| Whisper / bot images | Digests pinned in compose | +| Bot / proxy images | Digests pinned in compose | -Does **not** prove Signal CLI image integrity beyond pinning, or hide network metadata (timing, sizes, phone numbers). +Does **not** prove Signal CLI image integrity beyond pinning, hide network metadata (timing, sizes, phone numbers), or attest **NEAR AI Whisper weights** (`!verify` is this CVM only). ## `BOT__ROLE` | Role | Handlers | Requires | |------|----------|----------| -| `transcription` | Voice, `!transcribe*`, `!transcription` menu, `!help-transcription`, `!verify` (worker — no hub `!help` / `!info` / `!privacy`) | Whisper sidecar | -| `translation` | Hub (`!help`, `!info`, `!privacy`, product menus), Language Threads, in-chat, quote `!translate`, `!transcription` pairing, `!verify` | `NEAR_AI__API_KEY` | +| `translation` | Hub (`!help`, `!info`, `!privacy`, product menus), Language Threads, in-chat, voice / `!transcribe*`, `!transcription` menu, quote `!translate`, `!verify` | `NEAR_AI__API_KEY` + `WHISPER__ENABLED=true` | +| `transcription` | **Retired** — process fail-fasts | — | -Fail-fast if role is missing/invalid or required deps are missing. +Fail-fast if role is missing/invalid or required deps are missing. Do not add a third role. Do not drop `BOT__ROLE`. ## Project structure ``` crates/ - signal-bot/ # Binary (role-selected handlers) + signal-bot/ # Binary (unified handlers) signal-bot-core/ # CommandHandler + AppResult signal-bot-transcription/ # Voice / !transcribe* product crate - whisper-client/ - near-ai-client/ + whisper-client/ # OpenAI-compatible STT client (NEAR Whisper) + near-ai-client/ # NEAR AI chat + audio transcriptions signal-client/ dstack-client/ signal-registration-proxy/ # Ops registration helper docker/ - compose.transcription.yaml - compose.translation.yaml - phala.transcription.yaml - phala.translation.yaml - Dockerfile / Dockerfile.whisper / Dockerfile.proxy + compose.translation.yaml # local one-number stack + compose.transcription.yaml # retired stub + phala.translation.yaml # prod one-CVM suite + phala.transcription.yaml # deprecated stub — do not deploy + Dockerfile / Dockerfile.proxy docs/ two-cvm-architecture.md voice-transcription.md language-threads.md ``` -## Local dual Compose +`Dockerfile.whisper` is unused on the live path. + +## Local Compose ```bash -cp docker/transcription.env.example docker/transcription.env cp docker/translation.env.example docker/translation.env -# Different SIGNAL_PHONE values; PEER_PHONE on translation = transcription phone; -# NEAR_AI_API_KEY in translation.env +# SIGNAL_PHONE; NEAR_AI_API_KEY (chat + Whisper STT) -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env up -d docker compose -f docker/compose.translation.yaml --env-file docker/translation.env up -d ``` -Networks: `sigstack-transcription-internal`, `sigstack-translation-internal`. +Network: `sigstack-translation-internal`. ## Phala deploy -Build `linux/amd64` images, then deploy **two** CVMs @ ~4 GB (`tdx.medium`): +Build `linux/amd64` images (bot + registration proxy only), then **in-place** upgrade the surviving CVM: ```bash docker buildx build --platform linux/amd64 -t YOUR/signal-bot-tee:latest -f docker/Dockerfile --push . -docker buildx build --platform linux/amd64 -t YOUR/signal-whisper-api:latest -f docker/Dockerfile.whisper --push . docker buildx build --platform linux/amd64 -t YOUR/signal-registration-proxy:latest -f docker/Dockerfile.proxy --push . -phala deploy … -c docker/phala.transcription.yaml -e docker/phala.transcription.env --wait -t tdx.medium -phala deploy … -c docker/phala.translation.yaml -e docker/phala.translation.env --wait -t tdx.medium +phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353 \ + -c docker/phala.translation.yaml -e docker/phala.translation.env --wait ``` -Env templates: `docker/phala.transcription.env.example`, `docker/phala.translation.env.example`. +Do **not** `phala deploy -n` against the live CVM. Do **not** deploy `docker/phala.transcription.yaml`. Env template: `docker/phala.translation.env.example`. + +Encrypted secrets: `SIGNAL_PHONE` (phone B), `NEAR_AI_API_KEY`. + +Health: Signal CLI `GET /v1/health` on `signal-api`. Attestation: `!verify ` (this CVM’s compose, not remote Whisper; one reply). + +Do not re-register phone A. Proxy **:8081** only. -Encrypted secrets: phone numbers per CVM; `PEER_PHONE` for pairing; `NEAR_AI_API_KEY` on translation only. +### CVM storage — do not wipe -Health (transcription): Whisper `GET /health` on `:9000`, Signal CLI `GET /v1/health` on `:8080`. Attestation: `!verify `. +**In-place upgrades only** once the phone is registered. TEE RAM wipe is expected; **disk volumes are the product identity.** + +| Must keep | Volume | Breakage if lost | +|-----------|--------|------------------| +| Signal session (phone B) | `signal-config-translation` | Bot gone from groups until re-register | +| User prefs (`!translate-me-on`, Language Threads) | `group-prefs-translation` → `/data/group_prefs.enc` | Users must re-enable; suite looks broken | + +Use `phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353`. Do **not** `phala cvms delete` this CVM, create a replacement, rename those volumes, or `down -v` for an image bump. [`scripts/deploy_phala.sh`](../../scripts/deploy_phala.sh) defaults to that `--cvm-id`. + +After upgrade, logs should show `Loaded group preferences for N groups` (not `starting fresh` / `TEE deployment may have changed`), and `signal-api` should still list its account. + +Canonical table: [`docs/two-cvm-architecture.md` — CVM storage](../../docs/two-cvm-architecture.md#cvm-storage-keep-intact). Agent rule: [`AGENTS.md` — CVM storage](../../AGENTS.md#cvm-storage-do-not-wipe). ## Configuration | Variable | Notes | |----------|-------| -| `BOT__ROLE` | `transcription` \| `translation` | +| `BOT__ROLE` | Live value `translation` (`transcription` fail-fasts) | | `SIGNAL__SERVICE_URL` | Default `http://signal-api:8080` | -| `SIGNAL__PHONE_NUMBER` | Ops phone for this CVM | -| `SIGNAL__PEER_PHONE` | Peer product bot. Translation: invites transcription. Transcription: must be translation phone for auto-join | -| `NEAR_AI__*` | Translation role | -| `WHISPER__*` | Transcription role | +| `SIGNAL__PHONE_NUMBER` | Ops phone for this process | +| `NEAR_AI__*` | Chat + API key for remote Whisper | +| `WHISPER__*` | Required — `SERVICE_URL` is NEAR `/v1`, not `whisper-api:9000` | | `TRANSLATE_ALL__*` | In-chat translation | | `GROUP_PREFERENCES__*` | Encrypted group prefs volume | | `DSTACK__SOCKET_PATH` | `/var/run/dstack.sock` in Phala | @@ -130,9 +141,11 @@ cargo test cargo build --release ``` +Before finishing Rust work: `npm run ci` / `pnpm run ci` (never bare `pnpm ci`). + ## Registration proxy -Still useful as an **ops** helper on the translation stack (port 8081) to register phone B. Register phone A against the transcription stack’s `signal-api` via `docker compose exec` / curl. Multi-tenant “create your personal AI bot” web UX is out of scope; Stripe client site is issue #10 follow-up. +Ops helper on the one CVM: **:8081** (phone B). Multi-tenant “create your personal AI bot” web UX is out of scope; Stripe client site is issue #10 follow-up. ## Website diff --git a/.cursor/rules/compound-engineering.mdc b/.cursor/rules/compound-engineering.mdc index 35289d9..46e0dc4 100644 --- a/.cursor/rules/compound-engineering.mdc +++ b/.cursor/rules/compound-engineering.mdc @@ -24,7 +24,9 @@ Product docs stay as siblings under `docs/` (architecture, voice, translation). ## Product constraints -- Required env: `BOT__ROLE=transcription|translation` +- Required env: `BOT__ROLE=translation` (`transcription` is retired and fail-fasts) - Do not reintroduce tools, x402, or general chat paths +- Do not reintroduce a local Whisper sidecar (`whisper-api`); STT is NEAR AI Whisper Large V3 (GPU TEE). Do not put Whisper on a larger CPU TEE as the scale path. See [docs/solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md](docs/solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md) - Image digests stay pinned in compose for attestation +- **Keep live CVM storage:** never wipe or replace Phala volumes / the registered Signal phone for a routine TEE upgrade. User prefs (`group-prefs-translation`) and Signal CLI session (`signal-config-translation`) must stay on the existing CVM (`phala deploy --cvm-id`). See [docs/two-cvm-architecture.md](docs/two-cvm-architecture.md#cvm-storage-keep-intact) and [AGENTS.md](AGENTS.md#cvm-storage-do-not-wipe) - Before finishing Rust work run `npm run ci` / `pnpm run ci` (never bare `pnpm ci`) diff --git a/.cursor/rules/phala-cvm-storage.mdc b/.cursor/rules/phala-cvm-storage.mdc new file mode 100644 index 0000000..eac03fb --- /dev/null +++ b/.cursor/rules/phala-cvm-storage.mdc @@ -0,0 +1,18 @@ +--- +description: Keep live Phala CVM volumes (Signal phone + user prefs) on TEE upgrades +globs: docker/phala*.yaml,docker/compose.*.yaml,scripts/deploy_phala.sh,scripts/register_phala_phones.sh +alwaysApply: false +--- + +# CVM storage — do not wipe + +Routine TEE / image upgrades must **keep** each live CVM’s named volumes. TEE RAM wipe is expected; disk is the product identity. + +- **Signal phone:** `signal-config-translation` on `signal-api` (phone B). Losing it unlinks the bot from every group until re-register (which takes over the number). +- **User prefs:** `group-prefs-translation` → `/data/group_prefs.enc` (`!translate-me-on`, `!translate-all-on`, Language Threads). Losing it forces every user to opt in again. + +**Do:** `phala deploy --cvm-id `. Keep volume names unchanged. + +**Do not:** create a replacement CVM, `phala cvms delete`, rename volumes, or `down -v` for an image bump. [`scripts/deploy_phala.sh`](scripts/deploy_phala.sh) is first-create (`-n`), not a safe upgrade of a registered CVM. + +Canonical: [docs/two-cvm-architecture.md](docs/two-cvm-architecture.md#cvm-storage-keep-intact). Agent entry: [AGENTS.md](AGENTS.md#cvm-storage-do-not-wipe). diff --git a/.env.example b/.env.example index 79ddce0..7c23c06 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ -# Required: which product this process runs -# transcription — voice → text (Whisper); no NEAR AI required -# translation — in-chat + Language Threads (NEAR AI required) +# Required: which product this process runs. +# translation — unified bot (hub + voice + in-chat + Language Threads) +# transcription — retired; process fail-fasts (use translation) BOT__ROLE=translation # Signal Configuration @@ -8,7 +8,7 @@ SIGNAL__SERVICE_URL=http://signal-api:8080 SIGNAL__PHONE_NUMBER=+1234567890 SIGNAL__POLL_INTERVAL=1s -# NEAR AI Configuration (required when BOT__ROLE=translation) +# NEAR AI Configuration (required: chat + remote Whisper STT) NEAR_AI__API_KEY=your-api-key-here NEAR_AI__BASE_URL=https://cloud-api.near.ai/v1 NEAR_AI__MODEL=deepseek-ai/DeepSeek-V4-Flash @@ -22,10 +22,11 @@ BOT__GITHUB_REPO=https://github.com/BreadchainCoop/sigstack-bot # Dstack Configuration (TEE) DSTACK__SOCKET_PATH=/var/run/dstack.sock -# Whisper (required when BOT__ROLE=transcription; disable on translation stack) -WHISPER__ENABLED=false -WHISPER__SERVICE_URL=http://whisper-api:9000 -WHISPER__MODEL=small +# Whisper / STT (required on the unified translation bot) +# SERVICE_URL is NEAR AI /v1 — not a local whisper-api sidecar. +WHISPER__ENABLED=true +WHISPER__SERVICE_URL=https://cloud-api.near.ai/v1 +WHISPER__MODEL=openai/whisper-large-v3 WHISPER__TIMEOUT=120s # Group auto-translate (!translate-on) — translation role diff --git a/.gitignore b/.gitignore index aac0b8c..490ce02 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,4 @@ credentials.json # Compound Engineering machine-local config (keep *.example.yaml tracked) .compound-engineering/*.local.yaml .compound-engineering/config.local.yaml +docker/ops/ diff --git a/AGENTS.md b/AGENTS.md index 3461ee6..1b5c175 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Short entrypoint for coding agents. Humans: see [README.md](README.md). ## Product -TEE-hosted Signal bots for **voice transcription** and **group translation** (not a general AI chat assistant). Two phone numbers / two bots in a Signal group; Signal is the bus — no Docker network between CVMs. Whisper stays on the transcription stack only. +TEE-hosted Signal bot for **voice transcription** and **group translation** (not a general AI chat assistant). **One** phone number / one bot process in a Signal group on **one** Phala CVM. STT is **NEAR AI Whisper Large V3** (GPU TEE) — do not reintroduce an in-CVM Whisper sidecar. See [`docs/solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md`](docs/solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md). ## Compound Engineering @@ -50,11 +50,9 @@ npm run ci # all GitHub Actions gates (fmt + clippy + coverage + c pnpm run ci # same as above if you use pnpm (NOT `pnpm ci` — that only installs) npm run prepush # alias of npm run ci (also run by husky pre-push) -cp docker/transcription.env.example docker/transcription.env cp docker/translation.env.example docker/translation.env -# Two different SIGNAL_PHONE values; NEAR_AI_API_KEY in translation.env +# SIGNAL_PHONE + NEAR_AI_API_KEY (chat + Whisper STT) -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env up -d docker compose -f docker/compose.translation.yaml --env-file docker/translation.env up -d ``` @@ -62,20 +60,32 @@ docker compose -f docker/compose.translation.yaml --env-file docker/translation. | Doc | Why | |-----|-----| -| [`.agents/docs/DEVELOPMENT.md`](.agents/docs/DEVELOPMENT.md) | TEE trust model, `BOT__ROLE`, Phala dual-CVM ops | -| [`docs/two-cvm-architecture.md`](docs/two-cvm-architecture.md) | Architecture diagram and compose/Phala split | -| [`docs/voice-transcription.md`](docs/voice-transcription.md) | Voice transcription product + pairing | +| [`.agents/docs/DEVELOPMENT.md`](.agents/docs/DEVELOPMENT.md) | TEE trust model, `BOT__ROLE`, Phala one-CVM ops, **CVM volume / Signal identity** | +| [`docs/two-cvm-architecture.md`](docs/two-cvm-architecture.md) | One CVM / one phone, **CVM storage (keep intact)** | +| [`docs/voice-transcription.md`](docs/voice-transcription.md) | Voice transcription product (NEAR Whisper; in-process fan-out) | +| [`docs/solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md`](docs/solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md) | Why STT is remote; never re-home Whisper in a CPU TEE | | [`docs/in-chat-translation.md`](docs/in-chat-translation.md) | In-chat (group) bilingual auto/manual translate | | [`docs/language-threads.md`](docs/language-threads.md) | Language Threads (multilingual main + N sidecars) | | [`docs/solutions/`](docs/solutions/) | Compounded learnings from prior work | | [`docs/plans/`](docs/plans/) | CE implementation plans | | [`.agents/skills/`](.agents/skills/) | Domain skills (Rust, Docker, Stripe) | -| [`.cursor/rules/`](.cursor/rules/) | Cursor project rules (commits, compound loop) | +| [`.cursor/rules/`](.cursor/rules/) | Cursor project rules (commits, compound loop, **CVM storage**) | + +## CVM storage (do not wipe) + +**Never destroy live Phala volumes or replace the registered translation CVM for a routine upgrade.** Prod is **one** CVM (`0e82fa77-8b15-4dbd-89c4-9045ab911353`). Keep: + +1. **Registered Signal phone** (`signal-config-translation` = phone B) — losing the volume unlinks the bot until ops re-registers (and takes over the number). +2. **Encrypted user prefs** (`group-prefs-translation` → `/data/group_prefs.enc`) — `!translate-me-on`, `!translate-all-on`, Language Threads bridges. Losing this forces every user to turn features back on. + +Upgrade with `phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353`. Do not create a new CVM, rename those volumes, or `down -v`. Do not recreate a transcription CVM or re-register phone A. TEE RAM wipe on reboot is expected; disk volumes are the identity. Details: [docs/two-cvm-architecture.md — CVM storage](docs/two-cvm-architecture.md#cvm-storage-keep-intact). ## Rules of thumb -- Required env: `BOT__ROLE=transcription|translation` +- Required env: `BOT__ROLE=translation` (`transcription` is retired and fail-fasts) - Do not reintroduce tools, x402, or general chat paths +- Do not reintroduce a local Whisper sidecar; STT is NEAR AI Whisper Large V3 - Image digests stay pinned in compose for attestation +- **Keep CVM volumes and Signal registrations** — in-place Phala upgrades only; see [CVM storage](#cvm-storage-do-not-wipe) - Commits must pass [commitlint](https://github.com/conventional-changelog/commitlint) (`type: subject`); subject all lowercase, no trailing period, dashes not snake_case — see [`.cursor/rules/commit-messages.mdc`](.cursor/rules/commit-messages.mdc). Run `npm install` or `pnpm install` so husky `commit-msg` / `pre-push` hooks are active - **CI style gates are not optional.** GitHub Actions (`test.yml` + `commitlint.yml`) fails on fmt, Clippy `-D warnings`, llvm-cov ≥90% lines, and conventional commits. Before finishing Rust work run `npm run ci` / `pnpm run ci` (never bare `pnpm ci`). Husky `pre-push` runs that script; `commit-msg` runs commitlint on each commit. Cursor auto-fmts `.rs` edits and re-prompts on stop if fmt/clippy would fail CI. diff --git a/README.md b/README.md index 6ff5204..ad19712 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Bread Bot -TEE-hosted Signal bots for **voice transcription** and **group translation**, designed as an interoperable product suite (see [issue #10](https://github.com/BreadchainCoop/sigstack-bot/issues/10)). +TEE-hosted Signal bot for **voice transcription** and **group translation**, designed as an interoperable product suite (see [issue #10](https://github.com/BreadchainCoop/sigstack-bot/issues/10)). Not a general AI chat assistant. Conversation history, tool-calling, and x402 credits have been removed from this fork. @@ -8,38 +8,37 @@ Not a general AI chat assistant. Conversation history, tool-calling, and x402 cr | Product | Bot role | Where it runs | |---------|----------|---------------| -| Voice transcription | `BOT__ROLE=transcription` | Own CVM / Compose stack (with Whisper) | -| In-chat (group) translation | `BOT__ROLE=translation` | Shared translation CVM | -| Language Threads | `BOT__ROLE=translation` | Shared translation CVM | +| Voice transcription | `BOT__ROLE=translation` | Same Phala CVM / same bot (NEAR AI Whisper; no local sidecar) | +| In-chat (group) translation | `BOT__ROLE=translation` | Same CVM (NEAR AI chat) | +| Language Threads | `BOT__ROLE=translation` | Same CVM | -Pair products by adding **both bots** (two phone numbers) to the same Signal group. Signal is the bus — there is no Docker network between CVMs. - -**Bot hierarchy:** the **translation** bot is the Bread Bot **hub** (`!help`, `!info`, `!privacy`, translation products, transcription pairing). The **transcription** bot is a **specialized worker** (voice → text via `!transcription` / `!transcribe*` only). See [docs/two-cvm-architecture.md](docs/two-cvm-architecture.md#bot-hierarchy). +Add **one** bot to a Signal group. `BOT__ROLE=transcription` is retired. Details: [docs/two-cvm-architecture.md](docs/two-cvm-architecture.md) · [docs/voice-transcription.md](docs/voice-transcription.md) · [docs/in-chat-translation.md](docs/in-chat-translation.md) · [docs/language-threads.md](docs/language-threads.md) -## Translation commands - -| Product | Command | Where | Effect | -|---------|---------|-------|--------| -| Hub | `!help` / `!info` | Translation bot only | Bread Bot hub menus | -| Hub | `!privacy` | Translation bot only | Privacy, TEE, and `!verify` (dual quotes in paired groups) | -| Hub | `!translation-threads` | Translation bot | Language Threads menu | -| Hub | `!translation-in-chat` | Translation bot | In-chat translation menu | -| Hub | `!help-threads` | Translation bot | How Language Threads works | -| Hub | `!help-in-chat` | Translation bot | How in-chat translation works | -| Hub | `!help-transcription` | Translation bot | How voice transcription works (guide; worker runs on transcription bot) | -| Transcription | `!transcription` | Transcription bot | Voice product menu | -| Language Threads | `!translate-me-thread ` | Main only | Create/join sidecar | -| Language Threads | `!leave` | Sidecar only | Leave this Language Thread | -| Language Threads | `!commands` | Sidecar only | Compact Language Thread command list | -| Language Threads | `!enable-in-chat` | Main | Tear down Language Threads (switch path to in-chat) | -| In-chat group-wide | `!translate-all-on ` | Group | Auto-translate all messages | -| In-chat group-wide | `!translate-all-off` | Group | Disable group-wide auto | -| In-chat personal | `!translate-me-on ` | Group (not sidecar) | Auto-translate this user’s messages only | -| In-chat personal | `!translate-me-off` | Group (not sidecar) | Clear this user’s personal auto | -| In-chat | `!enable-threads` | Group | Clear all in-chat auto (switch path to Language Threads) | -| In-chat manual | `!translate ` | Group | Quote-reply translate one message | +## Commands + +| Product | Command | Effect | +|---------|---------|--------| +| Hub | `!help` / `!info` | Bread Bot hub menus | +| Hub | `!privacy` | Privacy, TEE, and `!verify` (one reply) | +| Hub | `!translation-threads` | Language Threads menu | +| Hub | `!translation-in-chat` | In-chat translation menu | +| Hub | `!help-threads` | How Language Threads works | +| Hub | `!help-in-chat` | How in-chat translation works | +| Hub | `!help-transcription` | How voice transcription works | +| Voice | `!transcription` | Voice product menu | +| Voice | `!transcribe` / `!transcribe-on` / `!transcribe-off` | Manual or auto transcription (default off) | +| Language Threads | `!translate-me-thread ` | Create/join sidecar (main only) | +| Language Threads | `!leave` | Leave this Language Thread (sidecar only) | +| Language Threads | `!commands` | Compact Language Thread command list (sidecar only) | +| Language Threads | `!enable-in-chat` | Tear down Language Threads (switch path to in-chat) | +| In-chat group-wide | `!translate-all-on ` | Auto-translate all messages | +| In-chat group-wide | `!translate-all-off` | Disable group-wide auto | +| In-chat personal | `!translate-me-on ` | Auto-translate this user’s messages only | +| In-chat personal | `!translate-me-off` | Clear this user’s personal auto | +| In-chat | `!enable-threads` | Clear all in-chat auto (switch path to Language Threads) | +| In-chat manual | `!translate ` | Quote-reply translate one message | Language Threads and in-chat auto are mutually exclusive. Details in the product docs above. @@ -47,22 +46,22 @@ Language Threads and in-chat auto are mutually exclusive. Details in the product ``` Signal group - ├── Transcription CVM (4 GB): signal-api + whisper-api + signal-bot - └── Translation CVM (4 GB): signal-api + signal-bot (+ registration proxy) + └── One Phala CVM (tdx.medium) + └── signal-api + signal-bot (phone B) + ├── audio bytes → NEAR AI Whisper Large V3 (GPU TEE) + └── text → NEAR AI chat ``` -- Signal E2E encryption terminates inside each TEE -- Whisper stays on the transcription CVM only (local Docker HTTP) -- Translation uses NEAR AI on text (including transcripts posted by the transcription bot) +- Signal E2E encryption terminates inside this TEE +- No local Whisper sidecar — see [CPU TEE Whisper does not scale](docs/solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md) +- After STT, transcripts fan out in-process to in-chat / Language Threads (this number does not receive its own posts) -## Local dual stack +## Local ```bash -cp docker/transcription.env.example docker/transcription.env cp docker/translation.env.example docker/translation.env -# Two different SIGNAL_PHONE values; NEAR_AI_API_KEY in translation.env +# Set SIGNAL_PHONE; NEAR_AI_API_KEY (chat + Whisper STT) -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env up -d docker compose -f docker/compose.translation.yaml --env-file docker/translation.env up -d ``` @@ -71,28 +70,32 @@ More thorough local setup (Signal captcha registration, verify SMS/voice codes, ## Phala ```bash -# Build linux/amd64 images, then deploy each compose to its own CVM @ tdx.medium (4 GB) -phala deploy … -c docker/phala.transcription.yaml … -phala deploy … -c docker/phala.translation.yaml … +# In-place upgrade of the surviving CVM (phone B stays; do not create a replacement) +phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353 \ + -c docker/phala.translation.yaml -e docker/phala.translation.env --wait ``` +**Do not replace this CVM or wipe its volumes** for a routine upgrade. Disk holds the **registered Signal phone** and **encrypted user prefs**. TEE RAM is cleared on reboot; Phala reattaches named volumes on in-place upgrade. Details: [docs/two-cvm-architecture.md — CVM storage](docs/two-cvm-architecture.md#cvm-storage-keep-intact). + ## Project structure ``` crates/ - signal-bot/ # Binary; role via BOT__ROLE - whisper-client/ # Whisper HTTP client - near-ai-client/ # NEAR AI (translation) + signal-bot/ # Binary; live role BOT__ROLE=translation + signal-bot-transcription/ # Voice / !transcribe* product crate + whisper-client/ # NEAR Whisper STT client + near-ai-client/ # NEAR AI (chat + audio transcriptions) signal-client/ # Signal CLI REST client dstack-client/ # TEE attestation / key derive signal-registration-proxy/ # Ops helper for Signal registration docker/ - compose.transcription.yaml - compose.translation.yaml - phala.transcription.yaml - phala.translation.yaml + compose.translation.yaml # local one-number stack + compose.transcription.yaml # retired stub + phala.translation.yaml # prod one-CVM suite + phala.transcription.yaml # deprecated stub docs/ - two-cvm-architecture.md + two-cvm-architecture.md # one CVM / one phone; CVM storage + in-chat-translation.md language-threads.md ``` @@ -116,11 +119,11 @@ Format: `type(scope): subject` — e.g. `feat: add whisper timeout`, `fix(docker | Variable | Description | |----------|-------------| -| `BOT__ROLE` | `transcription` or `translation` (required) | +| `BOT__ROLE` | Live value `translation` (required). `transcription` fail-fasts. | | `SIGNAL__SERVICE_URL` | Signal CLI REST URL (default `http://signal-api:8080`) | -| `NEAR_AI__API_KEY` | Required for translation role | -| `WHISPER__ENABLED` / `WHISPER__SERVICE_URL` | Required for transcription role | -| `TRANSLATE_ALL__ENABLED` | In-chat `!translate-all-on` / `!translate-me-on` (translation role) | +| `NEAR_AI__API_KEY` | Required (chat + remote Whisper) | +| `WHISPER__ENABLED` / `WHISPER__SERVICE_URL` | Required on the unified bot; URL is NEAR `/v1` | +| `TRANSLATE_ALL__ENABLED` | In-chat `!translate-all-on` / `!translate-me-on` | See `.env.example` and the docker `*.env.example` files. diff --git a/crates/near-ai-client/Cargo.toml b/crates/near-ai-client/Cargo.toml index e56113f..3d912d3 100644 --- a/crates/near-ai-client/Cargo.toml +++ b/crates/near-ai-client/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true [dependencies] -reqwest.workspace = true +reqwest = { workspace = true, features = ["multipart"] } serde.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/crates/near-ai-client/src/client.rs b/crates/near-ai-client/src/client.rs index bf6c690..8d4270c 100644 --- a/crates/near-ai-client/src/client.rs +++ b/crates/near-ai-client/src/client.rs @@ -92,6 +92,46 @@ impl NearAiClient { .ok_or(NearAiError::EmptyResponse) } + /// Transcribe audio via `POST /audio/transcriptions` (multipart; metadata-stripped file only). + #[instrument(skip(self, audio))] + pub async fn transcribe( + &self, + audio: &[u8], + filename: &str, + content_type: &str, + ) -> Result { + let mut part = + reqwest::multipart::Part::bytes(audio.to_vec()).file_name(filename.to_string()); + if !content_type.is_empty() { + part = part.mime_str(content_type).map_err(NearAiError::Http)?; + } + + let form = reqwest::multipart::Form::new() + .part("file", part) + .text("model", "openai/whisper-large-v3") + .text("response_format", "json"); + + let response = self + .client + .post(format!("{}/audio/transcriptions", self.base_url)) + .header( + "Authorization", + format!("Bearer {}", self.api_key.expose_secret()), + ) + .multipart(form) + .send() + .await?; + + let body = self + .handle_response::(response) + .await?; + let text = body.text.trim().to_string(); + if text.is_empty() { + return Err(NearAiError::EmptyResponse); + } + Ok(text) + } + /// Send a chat completion request with tool support. #[instrument(skip(self, messages, tools), fields(message_count = messages.len()))] pub async fn chat_with_tools( diff --git a/crates/near-ai-client/src/lib.rs b/crates/near-ai-client/src/lib.rs index bc21139..a5782a5 100644 --- a/crates/near-ai-client/src/lib.rs +++ b/crates/near-ai-client/src/lib.rs @@ -64,6 +64,53 @@ mod tests { assert_eq!(result.unwrap(), "Hello! How can I help you?"); } + #[tokio::test] + async fn test_transcribe_audio() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/audio/transcriptions")) + .and(header("Authorization", "Bearer test-api-key")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "text": " Hello from NEAR\n" + }))) + .mount(&mock_server) + .await; + + let client = create_test_client(&mock_server).await; + let text = client + .transcribe(b"fake-audio", "voice.ogg", "audio/ogg") + .await + .unwrap(); + assert_eq!(text, "Hello from NEAR"); + + let received = mock_server.received_requests().await.unwrap(); + let body = String::from_utf8_lossy(&received[0].body); + assert!(body.contains("filename=\"voice.ogg\"")); + assert!(body.contains("openai/whisper-large-v3")); + assert!(!body.contains("+1555")); + assert!(!body.contains("group_id")); + assert!(!body.contains("display_name")); + } + + #[tokio::test] + async fn test_transcribe_empty_fails() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/audio/transcriptions")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "text": " " + }))) + .mount(&mock_server) + .await; + + let client = create_test_client(&mock_server).await; + let err = client + .transcribe(b"fake-audio", "voice.m4a", "audio/aac") + .await + .unwrap_err(); + assert!(matches!(err, NearAiError::EmptyResponse)); + } + #[tokio::test] async fn test_chat_empty_response() { let mock_server = MockServer::start().await; diff --git a/crates/near-ai-client/src/types.rs b/crates/near-ai-client/src/types.rs index e48480f..4a124af 100644 --- a/crates/near-ai-client/src/types.rs +++ b/crates/near-ai-client/src/types.rs @@ -192,6 +192,14 @@ pub struct ModelsResponse { pub data: Vec, } +/// OpenAI-compatible audio transcription response. +#[derive(Debug, Clone, Deserialize)] +pub struct AudioTranscriptionResponse { + pub text: String, + #[serde(default)] + pub language: Option, +} + /// Attestation report from NEAR AI. #[derive(Debug, Clone, Deserialize)] pub struct AttestationReport { diff --git a/crates/signal-bot-transcription/Cargo.toml b/crates/signal-bot-transcription/Cargo.toml index e919ef3..2145315 100644 --- a/crates/signal-bot-transcription/Cargo.toml +++ b/crates/signal-bot-transcription/Cargo.toml @@ -11,6 +11,7 @@ whisper-client = { path = "../whisper-client" } anyhow.workspace = true async-trait.workspace = true serde_json.workspace = true +tokio = { workspace = true, features = ["rt"] } tracing.workspace = true [dev-dependencies] diff --git a/crates/signal-bot-transcription/src/fanout.rs b/crates/signal-bot-transcription/src/fanout.rs new file mode 100644 index 0000000..eb8ca75 --- /dev/null +++ b/crates/signal-bot-transcription/src/fanout.rs @@ -0,0 +1,90 @@ +//! After STT, fan transcripts into in-chat translation / Language Threads. +//! +//! One Signal number never receives its own group posts, so translation must +//! run in-process instead of waiting for a second bot to see the transcript. + +use async_trait::async_trait; +use signal_client::BotMessage; + +/// Optional hook invoked with the original voice message and spoken text. +#[async_trait] +pub trait TranscriptFanout: Send + Sync { + async fn fan_out_transcript(&self, original: &BotMessage, spoken_text: &str); +} + +/// Fire-and-forget so the transcript quote-reply is not delayed by NEAR chat. +pub fn spawn_fanout(fanout: Option, original: &BotMessage, spoken: &str) { + let Some(fanout) = fanout else { + return; + }; + if spoken.trim().is_empty() { + return; + } + let original = original.clone(); + let spoken = spoken.to_string(); + tokio::spawn(async move { + fanout.fan_out_transcript(&original, &spoken).await; + }); +} + +/// Shared handle for voice / `!transcribe` handlers. +pub type SharedTranscriptFanout = std::sync::Arc; + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + struct Rec(Mutex>); + + #[async_trait] + impl TranscriptFanout for Rec { + async fn fan_out_transcript(&self, _original: &BotMessage, spoken_text: &str) { + self.0.lock().unwrap().push(spoken_text.to_string()); + } + } + + fn msg() -> BotMessage { + BotMessage { + source: "+alice".into(), + source_number: Some("+alice".into()), + source_name: None, + text: String::new(), + timestamp: 1, + message_timestamp: 1, + is_group: true, + group_id: Some("g".into()), + group_name: None, + receiving_account: "+bot".into(), + attachments: vec![], + quote: None, + } + } + + #[test] + fn spawn_fanout_skips_none_and_empty() { + spawn_fanout(None, &msg(), "hello"); + let rec = Arc::new(Rec(Mutex::new(Vec::new()))); + let fanout: SharedTranscriptFanout = rec.clone(); + spawn_fanout(Some(fanout), &msg(), " "); + assert!(rec.0.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn spawn_fanout_invokes_hook() { + let rec = Arc::new(Rec(Mutex::new(Vec::new()))); + let fanout: SharedTranscriptFanout = rec.clone(); + spawn_fanout(Some(fanout), &msg(), "hola"); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + if !rec.0.lock().unwrap().is_empty() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("fan-out should run"); + assert_eq!(*rec.0.lock().unwrap(), vec!["hola".to_string()]); + } +} diff --git a/crates/signal-bot-transcription/src/handlers.rs b/crates/signal-bot-transcription/src/handlers.rs index 7cbac2e..12dda77 100644 --- a/crates/signal-bot-transcription/src/handlers.rs +++ b/crates/signal-bot-transcription/src/handlers.rs @@ -1,5 +1,6 @@ //! Build the transcription product handler stack (voice / !transcribe*). +use crate::fanout::SharedTranscriptFanout; use crate::manual_transcribe::ManualTranscribeHandler; use crate::prefs::SharedTranscribeGroupPrefs; use crate::transcribe::TranscribeHandler; @@ -11,13 +12,14 @@ use signal_client::SignalClient; use std::sync::Arc; use whisper_client::WhisperClient; -/// Voice + `!transcribe` + `!transcribe-on/off` handlers for the transcription role. +/// Voice + `!transcribe` + `!transcribe-on/off` handlers. pub fn build_voice_handlers( whisper: Arc, signal: Arc, reply_prefix: impl Into, max_attachment_bytes: usize, group_prefs: SharedTranscribeGroupPrefs, + fanout: Option, ) -> Vec> { let reply_prefix = reply_prefix.into(); let transcribe_store = Arc::new(TranscribeStore::new(Some(group_prefs))); @@ -32,16 +34,20 @@ pub fn build_voice_handlers( max_attachment_bytes, ) .with_transcribe_store(transcribe_store.clone()) - .with_voice_cache(voice_cache.clone()), + .with_voice_cache(voice_cache.clone()) + .with_fanout(fanout.clone()), + ), + Box::new( + ManualTranscribeHandler::new( + whisper, + signal, + reply_prefix, + max_attachment_bytes, + voice_cache, + transcribe_store.clone(), + ) + .with_fanout(fanout), ), - Box::new(ManualTranscribeHandler::new( - whisper, - signal, - reply_prefix, - max_attachment_bytes, - voice_cache, - transcribe_store.clone(), - )), Box::new(TranscribeHandler::new(transcribe_store, true)), ] } diff --git a/crates/signal-bot-transcription/src/lib.rs b/crates/signal-bot-transcription/src/lib.rs index 5c6b60e..a875845 100644 --- a/crates/signal-bot-transcription/src/lib.rs +++ b/crates/signal-bot-transcription/src/lib.rs @@ -1,5 +1,6 @@ //! Voice transcription product handlers (Whisper pipeline). +mod fanout; mod handlers; mod manual_transcribe; mod prefs; @@ -8,6 +9,7 @@ mod transcribe_store; mod voice; mod voice_attachment_cache; +pub use fanout::{SharedTranscriptFanout, TranscriptFanout}; pub use handlers::build_voice_handlers; pub use manual_transcribe::ManualTranscribeHandler; pub use prefs::{SharedTranscribeGroupPrefs, TranscribeGroupPrefs}; diff --git a/crates/signal-bot-transcription/src/manual_transcribe.rs b/crates/signal-bot-transcription/src/manual_transcribe.rs index 45240db..3c0c69a 100644 --- a/crates/signal-bot-transcription/src/manual_transcribe.rs +++ b/crates/signal-bot-transcription/src/manual_transcribe.rs @@ -1,5 +1,6 @@ //! `!transcribe` — quote-reply manual voice transcription via Whisper. +use crate::fanout::{spawn_fanout, SharedTranscriptFanout}; use crate::transcribe_store::TranscribeStore; use crate::voice::VoiceHandler; use crate::voice_attachment_cache::VoiceAttachmentCache; @@ -17,6 +18,7 @@ pub struct ManualTranscribeHandler { max_attachment_bytes: usize, voice_cache: Arc, transcribe_store: Arc, + fanout: Option, } const AUTO_ALREADY_ON_MSG: &str = "Automatic transcription is already on. Voice notes are transcribed as they arrive — no need to !transcribe."; @@ -37,9 +39,15 @@ impl ManualTranscribeHandler { max_attachment_bytes, voice_cache, transcribe_store, + fanout: None, } } + pub fn with_fanout(mut self, fanout: Option) -> Self { + self.fanout = fanout; + self + } + pub(crate) fn resolve_quoted_audio( quote: &QuotedMessage, chat_id: &str, @@ -107,19 +115,31 @@ impl ManualTranscribeHandler { &self, audio: &Attachment, bytes: &[u8], - ) -> Result { + ) -> Result<(String, String), WhisperError> { let filename = VoiceHandler::attachment_filename(audio); let transcript = self .whisper .transcribe(bytes, &filename, &audio.content_type) .await?; - Ok(VoiceHandler::format_transcript( - transcript.trimmed_text(), - &self.reply_prefix, + let spoken = transcript.trimmed_text().to_string(); + Ok(( + spoken.clone(), + VoiceHandler::format_transcript(&spoken, &self.reply_prefix), )) } } +fn speaker_msg_for_fanout(command: &BotMessage, quote: &QuotedMessage) -> BotMessage { + let mut msg = command.clone(); + if let Some(author) = quote.author_number.as_deref() { + if !author.is_empty() { + msg.source = author.to_string(); + msg.source_number = Some(author.to_string()); + } + } + msg +} + #[async_trait] impl CommandHandler for ManualTranscribeHandler { fn matches(&self, message: &BotMessage) -> bool { @@ -198,12 +218,17 @@ impl CommandHandler for ManualTranscribeHandler { } let body = match self.transcribe_audio(&audio, &bytes).await { - Ok(transcript) => { + Ok((spoken, transcript)) => { info!( source = %message.source, chars = transcript.len(), "!transcribe completed" ); + spawn_fanout( + self.fanout.clone(), + &speaker_msg_for_fanout(message, quote), + &spoken, + ); transcript } Err(e) => { @@ -254,12 +279,22 @@ mod tests { Arc::new(TranscribeStore::new(None)) } + fn test_whisper(url: &str) -> Arc { + Arc::new( + WhisperClient::new( + url, + std::time::Duration::from_secs(5), + "test-key", + "openai/whisper-large-v3", + ) + .unwrap(), + ) + } + #[test] fn matches_bare_command_only() { let handler = ManualTranscribeHandler::new( - Arc::new( - WhisperClient::new("http://localhost", std::time::Duration::from_secs(5)).unwrap(), - ), + test_whisper("http://localhost"), Arc::new(SignalClient::new("http://localhost").unwrap()), "📝 Transcript:", 5_000_000, @@ -302,10 +337,7 @@ mod tests { .await; let handler = ManualTranscribeHandler::new( - Arc::new( - WhisperClient::new("http://127.0.0.1:9", std::time::Duration::from_secs(2)) - .unwrap(), - ), + test_whisper("http://127.0.0.1:9"), Arc::new(SignalClient::new(signal_mock.uri()).unwrap()), "📝 Transcript:", 5_000_000, @@ -352,7 +384,7 @@ mod tests { .mount(&signal_mock) .await; Mock::given(method("POST")) - .and(path("/inference")) + .and(path("/audio/transcriptions")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "text": "quoted transcript", "language": "english" @@ -361,9 +393,7 @@ mod tests { .await; let handler = ManualTranscribeHandler::new( - Arc::new( - WhisperClient::new(whisper_mock.uri(), std::time::Duration::from_secs(5)).unwrap(), - ), + test_whisper(&whisper_mock.uri()), Arc::new(SignalClient::new(signal_mock.uri()).unwrap()), "📝 Transcript:", 5_000_000, @@ -412,10 +442,7 @@ mod tests { store.set_enabled("+15550002222", true, false); let handler = ManualTranscribeHandler::new( - Arc::new( - WhisperClient::new("http://127.0.0.1:9", std::time::Duration::from_secs(2)) - .unwrap(), - ), + test_whisper("http://127.0.0.1:9"), Arc::new(SignalClient::new(signal_mock.uri()).unwrap()), "📝 Transcript:", 5_000_000, diff --git a/crates/signal-bot-transcription/src/voice.rs b/crates/signal-bot-transcription/src/voice.rs index d032705..ccd999c 100644 --- a/crates/signal-bot-transcription/src/voice.rs +++ b/crates/signal-bot-transcription/src/voice.rs @@ -1,5 +1,6 @@ //! Implicit voice note handler — transcribe via Whisper and quote-reply. +use crate::fanout::SharedTranscriptFanout; use crate::transcribe_store::TranscribeStore; use crate::voice_attachment_cache::VoiceAttachmentCache; use async_trait::async_trait; @@ -19,6 +20,7 @@ pub struct VoiceHandler { max_attachment_bytes: usize, transcribe_store: Option>, voice_cache: Option>, + fanout: Option, } impl VoiceHandler { @@ -35,6 +37,7 @@ impl VoiceHandler { max_attachment_bytes, transcribe_store: None, voice_cache: None, + fanout: None, } } @@ -48,16 +51,21 @@ impl VoiceHandler { self } + pub fn with_fanout(mut self, fanout: Option) -> Self { + self.fanout = fanout; + self + } + + fn spawn_fanout(&self, original: &signal_client::BotMessage, spoken: &str) { + crate::fanout::spawn_fanout(self.fanout.clone(), original, spoken); + } + pub fn format_transcript(text: &str, prefix: &str) -> String { format!("{prefix}\n{text}") } pub fn attachment_filename(audio: &Attachment) -> String { - if let Some(name) = &audio.filename { - if !name.is_empty() { - return name.clone(); - } - } + // Generic names only — do not forward Signal filenames to the STT vendor. if audio.content_type.contains("aac") || audio.content_type.contains("mp4") { "voice.m4a".into() } else if audio.content_type.contains("ogg") { @@ -150,10 +158,9 @@ impl CommandHandler for VoiceHandler { chars = transcription.text.len(), "Voice note transcribed" ); - Ok(Self::format_transcript( - transcription.trimmed_text(), - &self.reply_prefix, - )) + let spoken = transcription.trimmed_text().to_string(); + self.spawn_fanout(message, &spoken); + Ok(Self::format_transcript(&spoken, &self.reply_prefix)) } Err(e) => { warn!("Whisper transcription failed: {}", e); @@ -175,15 +182,15 @@ mod tests { } #[test] - fn attachment_filename_from_mime() { + fn attachment_filename_ignores_signal_name() { let audio = Attachment { - content_type: "audio/aac".into(), - filename: None, + content_type: "audio/ogg".into(), + filename: Some("from-alice-group.ogg".into()), id: "x".into(), size: None, upload_timestamp: None, }; - assert_eq!(VoiceHandler::attachment_filename(&audio), "voice.m4a"); + assert_eq!(VoiceHandler::attachment_filename(&audio), "voice.ogg"); } fn dm_voice(size: Option) -> BotMessage { @@ -212,7 +219,13 @@ mod tests { #[tokio::test] async fn execute_without_audio_attachment() { let whisper = Arc::new( - WhisperClient::new("http://127.0.0.1:9", std::time::Duration::from_secs(2)).unwrap(), + WhisperClient::new( + "http://127.0.0.1:9", + std::time::Duration::from_secs(2), + "test-key", + "openai/whisper-large-v3", + ) + .unwrap(), ); let signal = Arc::new(SignalClient::new("http://127.0.0.1:9").unwrap()); let handler = VoiceHandler::new(whisper, signal, DEFAULT_REPLY_PREFIX, 1024); @@ -225,7 +238,13 @@ mod tests { #[tokio::test] async fn execute_rejects_oversized_declared_size() { let whisper = Arc::new( - WhisperClient::new("http://127.0.0.1:9", std::time::Duration::from_secs(2)).unwrap(), + WhisperClient::new( + "http://127.0.0.1:9", + std::time::Duration::from_secs(2), + "test-key", + "openai/whisper-large-v3", + ) + .unwrap(), ); let signal = Arc::new(SignalClient::new("http://127.0.0.1:9").unwrap()); let handler = VoiceHandler::new(whisper, signal, DEFAULT_REPLY_PREFIX, 100); @@ -247,7 +266,7 @@ mod tests { .mount(&signal_mock) .await; Mock::given(method("POST")) - .and(path("/inference")) + .and(path("/audio/transcriptions")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "text": " Hola mundo\n", "language": "spanish" @@ -256,7 +275,13 @@ mod tests { .await; let whisper = Arc::new( - WhisperClient::new(whisper_mock.uri(), std::time::Duration::from_secs(5)).unwrap(), + WhisperClient::new( + whisper_mock.uri(), + std::time::Duration::from_secs(5), + "test-key", + "openai/whisper-large-v3", + ) + .unwrap(), ); let signal = Arc::new(SignalClient::new(signal_mock.uri()).unwrap()); let cache = VoiceAttachmentCache::with_default_capacity(); @@ -286,7 +311,13 @@ mod tests { .await; let whisper = Arc::new( - WhisperClient::new("http://127.0.0.1:9", std::time::Duration::from_secs(2)).unwrap(), + WhisperClient::new( + "http://127.0.0.1:9", + std::time::Duration::from_secs(2), + "test-key", + "openai/whisper-large-v3", + ) + .unwrap(), ); let signal = Arc::new(SignalClient::new(signal_mock.uri()).unwrap()); let handler = VoiceHandler::new(whisper, signal, DEFAULT_REPLY_PREFIX, 10_000); diff --git a/crates/signal-bot/src/commands/menu_locale.rs b/crates/signal-bot/src/commands/menu_locale.rs index 53f7a6b..5466635 100644 --- a/crates/signal-bot/src/commands/menu_locale.rs +++ b/crates/signal-bot/src/commands/menu_locale.rs @@ -61,22 +61,6 @@ pub fn translation_split_redirect() -> &'static str { TRANSLATION_SPLIT_REDIRECT } -pub fn transcription_unavailable() -> &'static str { - TRANSCRIPTION_UNAVAILABLE -} - -pub fn transcription_invited() -> String { - format!( - "Invited the transcription bot to this group.\n\n\ - {}", - help_menu(BotRole::Transcription) - ) -} - -pub fn transcription_group_only() -> &'static str { - TRANSCRIPTION_GROUP_ONLY -} - pub fn privacy_menu() -> &'static str { PRIVACY_MENU } @@ -156,7 +140,7 @@ const INFO_HUB: &str = r#"--Bread Bot-- In-chat translation — auto or quote-translate in this group !transcription - Voice transcription — pair/open the transcription bot + Voice transcription — quote !transcribe or !transcribe-on !privacy Privacy, TEE, and !verify attestation @@ -273,7 +257,7 @@ const HELP_TRANSCRIPTION_GUIDE: &str = r#"Voice transcription — how it works Use when people send voice notes and you want text in the same Signal chat. -This bot runs Whisper in its own Phala CVM/TEE. The Bread Bot translation bot is a separate CVM — use !privacy on the translation bot for suite privacy and !verify behavior. +Voice is decrypted in this TEE, then sent (audio only, no Signal metadata) to NEAR AI Whisper Large V3 in their GPU TEE. Use !privacy for suite privacy and !verify. How it works: - Auto mode (default off): send !transcribe-on so inbound voice notes become quote-reply transcripts. @@ -281,13 +265,12 @@ How it works: - Toggle with !transcribe-on / !transcribe-off Typical use: -1. Add the translation bot to the group (it auto-accepts invites), or invite via Signal -2. !transcription — hub invites the transcription bot; that bot auto-joins when PEER_PHONE is the translation number -3. Send a voice note and quote-reply !transcribe, or !transcribe-on for auto -4. With the translation bot in the group, transcripts can also be auto-translated +1. Add this bot to the group (it auto-accepts invites) +2. Quote a voice note and send !transcribe, or !transcribe-on for auto +3. With in-chat auto or Language Threads on, transcripts are translated in this same bot Commands: !transcription -Privacy / TEE: !privacy on the translation bot"#; +Privacy / TEE: !privacy"#; const TRANSLATION_SPLIT_REDIRECT: &str = r#"Translation has two menus: @@ -296,22 +279,6 @@ const TRANSLATION_SPLIT_REDIRECT: &str = r#"Translation has two menus: !help"#; -const TRANSCRIPTION_UNAVAILABLE: &str = r#"Voice transcription is currently unavailable. - -The transcription bot is not paired with this group yet. Meanwhile, try translation: - -!translation-threads -!translation-in-chat - -!help-transcription -!help"#; - -const TRANSCRIPTION_GROUP_ONLY: &str = r#"Voice transcription pairing works in a Signal group. - -Add both bots to a group, then send !transcription there. - -!help"#; - const PRIVACY_MENU: &str = r#"Privacy & TEE !verify @@ -319,15 +286,13 @@ const PRIVACY_MENU: &str = r#"Privacy & TEE example: !verify "write something unique here" -Bread Bot runs in two separate and isolated TEEs/CVMs: -- translation bot -- transcription bot +Bread Bot is one Signal number in one Phala TEE/CVM. -Translation: Signal text is processed in the translation TEE and translated via NEAR AI private inference. +Translation: Signal text is processed in this TEE and translated via NEAR AI private inference. -Transcription: Voice notes are processed in the transcription TEE and transcribed with Whisper in that CVM. +Transcription: Voice notes are decrypted in this TEE. Audio bytes (no phone, group id, or filename) are sent to NEAR AI Whisper Large V3 (GPU TEE). Transcripts come back here and are posted in Signal. -Attestation: In a group with both bots, !verify produces two replies — one TDX quote per CVM. Each quote binds your text as Translation: … or Transcription: … so you can tell which bot attested which string. Message one bot directly for a single quote. +Attestation: !verify attests this CVM's compose, not the remote Whisper weights. You get one reply from this bot. !help"#; @@ -411,10 +376,11 @@ mod tests { assert!(in_chat.contains("quote")); let transcription = help_transcription_guide(); assert!(transcription.contains("Voice transcription")); + assert!(transcription.contains("NEAR AI")); assert!(transcription.contains("Whisper")); assert!(transcription.contains("!transcribe")); assert!(transcription.contains("default off")); - assert!(transcription.contains("separate CVM")); + assert!(transcription.contains("Add this bot")); assert!(transcription.contains("!privacy")); assert!(!transcription.trim_end().ends_with("!help")); } @@ -488,12 +454,16 @@ mod tests { } #[test] - fn privacy_menu_covers_both_cvms() { + fn privacy_menu_covers_near_stt() { let m = privacy_menu(); - assert!(m.contains("two separate and isolated TEEs/CVMs")); + assert!(m.contains("one Phala TEE/CVM")); + assert!(m.contains("one Signal number")); assert!(m.contains("Translation:")); assert!(m.contains("Transcription:")); + assert!(m.contains("NEAR AI Whisper")); assert!(m.contains("!verify ")); + assert!(!m.contains("two Signal")); + assert!(!m.contains("both bots")); assert!(!m.contains("**")); } @@ -507,10 +477,11 @@ mod tests { } #[test] - fn transcription_unavailable_offers_translation() { - let m = transcription_unavailable(); - assert!(m.contains("unavailable")); - assert!(m.contains("!translation-threads")); - assert!(m.contains("!translation-in-chat")); + fn help_transcription_guide_is_one_bot() { + let g = help_transcription_guide(); + assert!(g.contains("Add this bot")); + assert!(g.contains("!transcribe")); + assert!(!g.contains("PEER_PHONE")); + assert!(!g.contains("invites the transcription")); } } diff --git a/crates/signal-bot/src/commands/mod.rs b/crates/signal-bot/src/commands/mod.rs index 4b1369d..fafd3e4 100644 --- a/crates/signal-bot/src/commands/mod.rs +++ b/crates/signal-bot/src/commands/mod.rs @@ -17,8 +17,8 @@ pub use help::{CommandsHandler, HelpHandler, InfoHandler}; pub use privacy::PrivacyHandler; pub use product_menus::{ HelpInChatHandler, HelpThreadsHandler, HelpTranscriptionHandler, InChatMenuHandler, - TranscriptionMenuHandler, TranscriptionPairingHandler, TranslationInChatMenuHandler, - TranslationMenuHandler, TranslationThreadsMenuHandler, + TranscriptionMenuHandler, TranslationInChatMenuHandler, TranslationMenuHandler, + TranslationThreadsMenuHandler, }; pub use rename::RenameHandler; pub use signal_bot_core::CommandHandler; diff --git a/crates/signal-bot/src/commands/privacy.rs b/crates/signal-bot/src/commands/privacy.rs index c76adb5..fd866a3 100644 --- a/crates/signal-bot/src/commands/privacy.rs +++ b/crates/signal-bot/src/commands/privacy.rs @@ -63,7 +63,8 @@ mod tests { assert!(!handler.matches(&dm("!privacy-translation"))); assert!(!handler.matches(&dm("!privacy-transcription"))); let out = handler.execute(&dm("!privacy")).await.unwrap(); - assert!(out.contains("two separate and isolated TEEs/CVMs")); + assert!(out.contains("one Phala TEE/CVM")); + assert!(out.contains("NEAR AI Whisper")); assert!(out.contains("!verify")); assert!(!out.contains("**")); } diff --git a/crates/signal-bot/src/commands/product_menus.rs b/crates/signal-bot/src/commands/product_menus.rs index fcabfbf..d960973 100644 --- a/crates/signal-bot/src/commands/product_menus.rs +++ b/crates/signal-bot/src/commands/product_menus.rs @@ -3,16 +3,13 @@ use crate::commands::menu_locale::{ help_in_chat_guide, help_menu, help_threads_guide, help_transcription_guide, is_exact_command, is_translation_in_chat_menu_command, is_translation_threads_menu_command, - transcription_group_only, transcription_invited, transcription_unavailable, translation_in_chat_menu, translation_split_redirect, translation_threads_menu, }; use crate::commands::CommandHandler; use crate::config::BotRole; use crate::error::AppResult; use async_trait::async_trait; -use signal_client::{BotMessage, SignalClient}; -use std::sync::Arc; -use tracing::warn; +use signal_client::BotMessage; /// Legacy `!translation` → points at the two product menus. pub struct TranslationMenuHandler; @@ -94,129 +91,7 @@ impl CommandHandler for TranslationInChatMenuHandler { } } -/// Translation role: invite the transcription peer, or stay silent when already paired. -pub struct TranscriptionPairingHandler { - signal: Arc, - peer_phone: Option, -} - -impl TranscriptionPairingHandler { - pub fn new(signal: Arc, peer_phone: Option) -> Self { - Self { - signal, - peer_phone: peer_phone.and_then(|p| { - let t = p.trim().to_string(); - if t.is_empty() { - None - } else { - Some(t) - } - }), - } - } - - async fn send(&self, message: &BotMessage, body: &str) -> AppResult<()> { - self.signal.reply(message, body).await?; - Ok(()) - } -} - -#[async_trait] -impl CommandHandler for TranscriptionPairingHandler { - fn matches(&self, message: &BotMessage) -> bool { - is_exact_command(&message.text, "!transcription") - } - - fn handles_own_reply(&self) -> bool { - true - } - - fn label(&self) -> &'static str { - "transcription_pairing" - } - - async fn execute(&self, message: &BotMessage) -> AppResult { - if !message.is_group { - self.send(message, transcription_group_only()).await?; - return Ok(String::new()); - } - - let Some(peer) = self.peer_phone.as_deref() else { - self.send(message, transcription_unavailable()).await?; - return Ok(String::new()); - }; - - let Some(group_id) = message.group_id.as_deref() else { - self.send(message, transcription_unavailable()).await?; - return Ok(String::new()); - }; - - let bot = message.receiving_account.as_str(); - let groups = match self.signal.list_groups(bot).await { - Ok(g) => g, - Err(e) => { - warn!(error = %e, "Failed to list groups for transcription pairing"); - self.send(message, "Could not look up this group. Try again shortly.") - .await?; - return Ok(String::new()); - } - }; - - let Some(group) = groups - .iter() - .find(|g| g.internal_id == group_id || g.id == group_id) - else { - self.send(message, transcription_unavailable()).await?; - return Ok(String::new()); - }; - - if group.contains_member_or_pending(peer) { - // Paired (or invite pending): stay silent so the transcription bot can answer. - return Ok(String::new()); - } - - let send_id = match self - .signal - .resolve_group_send_id_for_account(bot, group_id) - .await - { - Ok(id) => id, - Err(e) => { - warn!(error = %e, "Failed to resolve group send id for pairing"); - self.send( - message, - "Could not resolve this group for invites. Try again shortly.", - ) - .await?; - return Ok(String::new()); - } - }; - - match self - .signal - .add_members(bot, &send_id, vec![peer.to_string()]) - .await - { - Ok(()) => { - self.send(message, &transcription_invited()).await?; - } - Err(e) => { - warn!(error = %e, peer, "Failed to invite transcription bot"); - let body = format!( - "Could not add the transcription bot ({peer}): {e}\n\n\ - This bot must be a group admin to invite members. \ - Or set SIGNAL__PEER_PHONE and try again.\n\n\ - !help\n Main menu" - ); - self.send(message, &body).await?; - } - } - - Ok(String::new()) - } -} - -/// Transcription role: product menu for `!transcription`. +/// Voice command menu for `!transcription` (no pairing / invite). pub struct TranscriptionMenuHandler; impl TranscriptionMenuHandler { @@ -366,9 +241,6 @@ impl CommandHandler for HelpTranscriptionHandler { #[cfg(test)] mod tests { use super::*; - use serde_json::json; - use wiremock::matchers::{method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; fn msg(text: &str) -> BotMessage { BotMessage { @@ -421,12 +293,6 @@ mod tests { assert!(!htr.matches(&msg("!help"))); assert!(!htr.matches(&msg("!transcription"))); - let s = TranscriptionPairingHandler::new( - Arc::new(SignalClient::new("http://127.0.0.1:9").unwrap()), - None, - ); - assert!(s.matches(&msg("!transcription"))); - let m = TranscriptionMenuHandler::new(); assert!(m.matches(&msg("!transcription"))); } @@ -501,74 +367,4 @@ mod tests { assert!(transcription.contains("Whisper")); assert!(transcription.contains("!transcribe")); } - - #[tokio::test] - async fn pairing_without_peer_reports_unavailable() { - let signal_mock = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/groups/%2B15550001111")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([{ - "name": "Main", - "id": "group.send=", - "internal_id": "g-internal", - "members": ["+15550001111"] - }]))) - .mount(&signal_mock) - .await; - Mock::given(method("POST")) - .and(path("/v2/send")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) - .expect(1) - .mount(&signal_mock) - .await; - - let handler = TranscriptionPairingHandler::new( - Arc::new(SignalClient::new(signal_mock.uri()).unwrap()), - None, - ); - let out = handler.execute(&msg("!transcription")).await.unwrap(); - assert!(out.is_empty()); - } - - #[tokio::test] - async fn pairing_invites_missing_peer() { - let signal_mock = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/groups/%2B15550001111")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([{ - "name": "Main", - "id": "group.send=", - "internal_id": "g-internal", - "members": ["+15550001111"], - "pending_invites": [], - "pending_requests": [], - "admins": ["+15550001111"] - }]))) - .mount(&signal_mock) - .await; - Mock::given(method("POST")) - .and(path("/v1/groups/%2B15550001111/group.send%3D/members")) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&signal_mock) - .await; - Mock::given(method("POST")) - .and(path("/v2/send")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) - .expect(1) - .mount(&signal_mock) - .await; - - let handler = TranscriptionPairingHandler::new( - Arc::new(SignalClient::new(signal_mock.uri()).unwrap()), - Some("+15550009999".into()), - ); - let out = handler.execute(&msg("!transcription")).await.unwrap(); - assert!(out.is_empty()); - - let invited = transcription_invited(); - assert!(invited.contains("Voice Transcription")); - assert!(invited.contains("!transcribe-on")); - assert!(!invited.contains("send !transcription again")); - } } diff --git a/crates/signal-bot/src/commands/translate_all.rs b/crates/signal-bot/src/commands/translate_all.rs index 6e36160..d52b96b 100644 --- a/crates/signal-bot/src/commands/translate_all.rs +++ b/crates/signal-bot/src/commands/translate_all.rs @@ -1,5 +1,6 @@ //! In-chat auto-translate: `!translate-all-on/off`, `!translate-me-on/off`, `!enable-threads`. +use crate::bot_identity::BotIdentity; use crate::commands::translate_lang::resolve_language; use crate::commands::translate_me::TranslateMeHandler; use crate::commands::translate_service::{ @@ -84,12 +85,12 @@ fn is_bare_me_on(text: &str) -> bool { ME_ON_PREFIXES.contains(&text.trim()) } +#[derive(Clone)] pub struct TranslateAllHandler { store: Arc, near_ai: Arc, signal: Arc, - /// Transcription peer E.164 (`SIGNAL__PEER_PHONE`), when paired. - peer_phone: Option, + bot_identity: Option>, transcript_prefix: String, } @@ -99,32 +100,41 @@ impl TranslateAllHandler { near_ai: Arc, signal: Arc, ) -> Self { - Self::with_peer(store, near_ai, signal, None, DEFAULT_TRANSCRIPT_PREFIX) + Self::with_prefix(store, near_ai, signal, DEFAULT_TRANSCRIPT_PREFIX) } - pub fn with_peer( + pub fn with_prefix( store: Arc, near_ai: Arc, signal: Arc, - peer_phone: Option, transcript_prefix: impl Into, ) -> Self { Self { store, near_ai, signal, - peer_phone: peer_phone.and_then(|p| { - let t = p.trim().to_string(); - if t.is_empty() { - None - } else { - Some(t) - } - }), + bot_identity: None, transcript_prefix: transcript_prefix.into(), } } + pub fn with_bot_identity(mut self, bot_identity: Arc) -> Self { + self.bot_identity = Some(bot_identity); + self + } + + /// In-chat translate a transcript as if the original speaker posted it. + pub(crate) async fn fan_out_transcript(&self, original: &BotMessage, spoken: &str) { + if spoken.trim().is_empty() { + return; + } + let mut msg = original.clone(); + msg.text = format!("{}\n{spoken}", self.transcript_prefix); + if let Err(e) = self.handle_text_intercept(&msg).await { + warn!(error = %e, "in-chat fan-out after transcript failed"); + } + } + fn is_command(text: &str) -> bool { is_translate_on_or_off_command(text) } @@ -138,18 +148,6 @@ impl TranslateAllHandler { message.group_id.is_some() && !text.is_empty() && !text.starts_with('!') } - fn is_peer_source(&self, message: &BotMessage) -> bool { - let Some(peer) = self.peer_phone.as_deref() else { - return false; - }; - message.source == peer - || message.source_number.as_deref() == Some(peer) - || message - .source_number - .as_deref() - .is_some_and(|n| n.trim() == peer) - } - fn looks_like_transcript(&self, text: &str) -> bool { let prefix = if self.transcript_prefix.is_empty() { DEFAULT_TRANSCRIPT_PREFIX @@ -170,8 +168,7 @@ impl TranslateAllHandler { group_id: &str, message: &BotMessage, ) -> Option { - let treat_as_transcript = - self.is_peer_source(message) || self.looks_like_transcript(&message.text); + let treat_as_transcript = self.looks_like_transcript(&message.text); if treat_as_transcript { if let Some(author) = message .quote @@ -508,6 +505,13 @@ impl CommandHandler for TranslateAllHandler { if Self::is_command(&message.text) { return true; } + if self + .bot_identity + .as_ref() + .is_some_and(|id| id.is_bot_message(message)) + { + return false; + } if Self::is_text_intercept(message) { if let Some(gid) = &message.group_id { return self.resolve_mode_for_message(gid, message).is_some() @@ -693,14 +697,14 @@ mod tests { } #[test] - fn personal_matches_peer_transcript_via_quote_author() { + fn personal_matches_transcript_via_quote_author() { use signal_client::QuotedMessage; let mode = GroupTranslateMode::new( resolve_language("es").unwrap(), resolve_language("en").unwrap(), ); - let handler = TranslateAllHandler::with_peer( + let handler = TranslateAllHandler::with_prefix( GroupPreferencesStore::new_in_memory(30), Arc::new( NearAiClient::new( @@ -712,15 +716,14 @@ mod tests { .unwrap(), ), Arc::new(SignalClient::new("http://localhost").unwrap()), - Some("+15550009999".into()), DEFAULT_TRANSCRIPT_PREFIX, ); handler.store.set_member_translate("gid", "+alice", mode); let msg = BotMessage { - source: "+15550009999".into(), - source_number: Some("+15550009999".into()), - source_name: Some("Transcription".into()), + source: "+15550001111".into(), + source_number: Some("+15550001111".into()), + source_name: Some("Bread Bot".into()), text: "📝 Transcript:\nHola amigos".into(), timestamp: 0, message_timestamp: 0, @@ -750,6 +753,121 @@ mod tests { assert!(!handler.matches(&other)); } + #[test] + fn fan_out_message_uses_speaker_source() { + let mode = GroupTranslateMode::new( + resolve_language("es").unwrap(), + resolve_language("en").unwrap(), + ); + let handler = TranslateAllHandler::with_prefix( + GroupPreferencesStore::new_in_memory(30), + Arc::new( + NearAiClient::new( + "key", + "http://localhost", + "model", + std::time::Duration::from_secs(5), + ) + .unwrap(), + ), + Arc::new(SignalClient::new("http://localhost").unwrap()), + DEFAULT_TRANSCRIPT_PREFIX, + ); + handler.store.set_member_translate("gid", "+alice", mode); + + let msg = BotMessage { + source: "+alice".into(), + source_number: Some("+alice".into()), + source_name: Some("Alice".into()), + text: format!("{}\nHola amigos", DEFAULT_TRANSCRIPT_PREFIX), + timestamp: 0, + message_timestamp: 0, + is_group: true, + group_id: Some("gid".into()), + group_name: None, + receiving_account: "+15550001111".into(), + attachments: vec![], + quote: None, + }; + assert!(handler.resolve_mode_for_message("gid", &msg).is_some()); + assert!(handler.matches(&msg)); + } + + #[test] + fn bot_translation_replies_do_not_match() { + let mode = GroupTranslateMode::new( + resolve_language("es").unwrap(), + resolve_language("en").unwrap(), + ); + let identity = BotIdentity::new(); + identity.remember_phone("+15550001111"); + let handler = TranslateAllHandler::with_prefix( + GroupPreferencesStore::new_in_memory(30), + Arc::new( + NearAiClient::new( + "key", + "http://localhost", + "model", + std::time::Duration::from_secs(5), + ) + .unwrap(), + ), + Arc::new(SignalClient::new("http://localhost").unwrap()), + DEFAULT_TRANSCRIPT_PREFIX, + ) + .with_bot_identity(identity); + handler.store.set("gid".into(), mode); + + let msg = BotMessage { + source: "+15550001111".into(), + source_number: Some("+15550001111".into()), + source_name: Some("Bread Bot".into()), + text: "Hello friends".into(), + timestamp: 0, + message_timestamp: 0, + is_group: true, + group_id: Some("gid".into()), + group_name: None, + receiving_account: "+15550001111".into(), + attachments: vec![], + quote: None, + }; + assert!(!handler.matches(&msg)); + } + + #[tokio::test] + async fn fan_out_transcript_skips_empty_spoken() { + let handler = TranslateAllHandler::with_prefix( + GroupPreferencesStore::new_in_memory(30), + Arc::new( + NearAiClient::new( + "key", + "http://localhost", + "model", + std::time::Duration::from_secs(5), + ) + .unwrap(), + ), + Arc::new(SignalClient::new("http://localhost").unwrap()), + DEFAULT_TRANSCRIPT_PREFIX, + ); + let msg = BotMessage { + source: "+alice".into(), + source_number: Some("+alice".into()), + source_name: None, + text: String::new(), + timestamp: 0, + message_timestamp: 0, + is_group: true, + group_id: Some("gid".into()), + group_name: None, + receiving_account: "+bot".into(), + attachments: vec![], + quote: None, + }; + handler.fan_out_transcript(&msg, " ").await; + } + #[tokio::test] async fn execute_setup_commands_send_replies() { use serde_json::json; diff --git a/crates/signal-bot/src/commands/translate_me.rs b/crates/signal-bot/src/commands/translate_me.rs index ddcebe4..8a4cbfd 100644 --- a/crates/signal-bot/src/commands/translate_me.rs +++ b/crates/signal-bot/src/commands/translate_me.rs @@ -37,6 +37,7 @@ const ENABLE_IN_CHAT_CMDS: &[&str] = &["!enable-in-chat", "!translation-enable-i const THREADS_DISABLED_SIDECAR_MSG: &str = "Language Threads were disabled in the main group (in-chat translation is on).\n\nReturn to the main chat to continue — this thread will no longer relay messages."; const LEAVE_CMDS: &[&str] = &["!leave"]; +#[derive(Clone)] pub struct TranslateMeHandler { store: Arc, near_ai: Arc, @@ -371,6 +372,18 @@ impl TranslateMeHandler { Ok(()) } + /// Relay spoken transcript text as if the original speaker posted it. + pub(crate) async fn fan_out_transcript(&self, original: &BotMessage, spoken: &str) { + if spoken.trim().is_empty() { + return; + } + let mut msg = original.clone(); + msg.text = spoken.to_string(); + if let Err(e) = self.handle_relay(&msg).await { + warn!(error = %e, "Language Threads fan-out after transcript failed"); + } + } + /// Subscribe `user` (defaults to message.source) to a language sidecar on `main_id`. pub(crate) async fn subscribe_user_to_thread( store: &Arc, @@ -674,6 +687,9 @@ impl CommandHandler for TranslateMeHandler { if self.bot_identity.is_bot_message(message) { return false; } + if message.is_voice_note() { + return false; + } Self::is_command(&message.text) || self.is_relay_candidate(message) } @@ -1491,4 +1507,17 @@ mod tests { from_side.group_id = Some("es-internal".into()); assert!(handler.execute(&from_side).await.unwrap().is_empty()); } + + #[tokio::test] + async fn fan_out_transcript_skips_empty_spoken() { + let store = GroupPreferencesStore::new_in_memory(0); + let handler = handler_pair( + store, + "http://127.0.0.1:9".into(), + "http://127.0.0.1:9".into(), + ); + handler + .fan_out_transcript(&group_msg("+alice", ""), " ") + .await; + } } diff --git a/crates/signal-bot/src/commands/verify.rs b/crates/signal-bot/src/commands/verify.rs index 3ebaade..78ac0d7 100644 --- a/crates/signal-bot/src/commands/verify.rs +++ b/crates/signal-bot/src/commands/verify.rs @@ -31,7 +31,8 @@ impl OperatorAddresses { pub struct VerifyHandler { dstack: Arc, - role: BotRole, + /// Kept so callers still pass `BotRole`; challenge prefix is unified. + _role: BotRole, /// Optional operator addresses to display. operator_addresses: Option, } @@ -40,7 +41,7 @@ impl VerifyHandler { pub fn new(dstack: Arc, role: BotRole) -> Self { Self { dstack, - role, + _role: role, operator_addresses: None, } } @@ -53,17 +54,14 @@ impl VerifyHandler { ) -> Self { Self { dstack, - role, + _role: role, operator_addresses: Some(addresses), } } pub(crate) fn prefixed_challenge(&self, raw: Option) -> String { let user_part = raw.unwrap_or_else(|| "no-challenge-provided".into()); - match self.role { - BotRole::Translation => format!("Translation: {user_part}"), - BotRole::Transcription => format!("Transcription: {user_part}"), - } + format!("Bread Bot: {user_part}") } /// Parse the challenge nonce from the message text. @@ -314,20 +312,20 @@ mod tests { } #[test] - fn prefixed_challenge_labels_role() { + fn prefixed_challenge_uses_bot_label() { let tr = create_test_handler(BotRole::Translation); assert_eq!( tr.prefixed_challenge(Some("hello".into())), - "Translation: hello" + "Bread Bot: hello" ); assert_eq!( tr.prefixed_challenge(None), - "Translation: no-challenge-provided" + "Bread Bot: no-challenge-provided" ); let tx = create_test_handler(BotRole::Transcription); assert_eq!( tx.prefixed_challenge(Some("hello".into())), - "Transcription: hello" + "Bread Bot: hello" ); } diff --git a/crates/signal-bot/src/config.rs b/crates/signal-bot/src/config.rs index 12300ba..077ec9d 100644 --- a/crates/signal-bot/src/config.rs +++ b/crates/signal-bot/src/config.rs @@ -56,10 +56,6 @@ pub struct SignalConfig { /// This bot's Signal phone (ops/registration; identity still learned from inbound). #[serde(default)] pub phone_number: Option, - - /// Peer product bot phone (E.164). Translation uses this to invite transcription. - #[serde(default)] - pub peer_phone: Option, } #[derive(Debug, Clone, Deserialize)] @@ -111,11 +107,11 @@ pub struct WhisperConfig { #[serde(default = "default_true")] pub enabled: bool, - /// whisper-server base URL (no trailing path) + /// OpenAI-compatible STT base URL (NEAR AI `/v1`) #[serde(default = "default_whisper_service")] pub service_url: String, - /// Model name loaded in the sidecar (e.g. `small`) + /// STT model (e.g. `openai/whisper-large-v3`) #[serde(default = "default_whisper_model")] pub model: String, @@ -161,7 +157,6 @@ impl Default for SignalConfig { service_url: default_signal_service(), poll_interval: default_poll_interval(), phone_number: None, - peer_phone: None, } } } @@ -238,11 +233,11 @@ fn default_true() -> bool { } fn default_whisper_service() -> String { - "http://whisper-api:9000".into() + "https://cloud-api.near.ai/v1".into() } fn default_whisper_model() -> String { - "small".into() + "openai/whisper-large-v3".into() } fn default_whisper_timeout() -> Duration { @@ -294,11 +289,14 @@ impl Config { pub(crate) fn validate(&self) -> Result<()> { match self.bot.role { BotRole::Transcription => { - if !self.whisper.enabled { - bail!("BOT__ROLE=transcription requires WHISPER__ENABLED=true"); - } + bail!( + "BOT__ROLE=transcription is retired; use BOT__ROLE=translation (unified bot)" + ); } BotRole::Translation => { + if !self.whisper.enabled { + bail!("BOT__ROLE=translation requires WHISPER__ENABLED=true"); + } let Some(near) = &self.near_ai else { bail!("BOT__ROLE=translation requires NEAR_AI__API_KEY (and related NEAR_AI__* settings)"); }; @@ -315,10 +313,15 @@ impl Config { mod tests { use super::*; - fn transcription_config(whisper_enabled: bool) -> Config { + fn transcription_config(whisper_enabled: bool, near_key: Option<&str>) -> Config { Config { signal: SignalConfig::default(), - near_ai: None, + near_ai: near_key.map(|k| NearAiConfig { + api_key: k.into(), + base_url: default_near_ai_url(), + model: default_model(), + timeout: default_timeout(), + }), bot: BotConfig { role: BotRole::Transcription, signal_username: None, @@ -335,7 +338,7 @@ mod tests { } } - fn translation_config(api_key: Option<&str>) -> Config { + fn translation_config(api_key: Option<&str>, whisper_enabled: bool) -> Config { Config { signal: SignalConfig::default(), near_ai: api_key.map(|k| NearAiConfig { @@ -352,7 +355,7 @@ mod tests { }, dstack: DstackConfig::default(), whisper: WhisperConfig { - enabled: false, + enabled: whisper_enabled, ..WhisperConfig::default() }, translate_all: TranslateAllConfig::default(), @@ -361,20 +364,29 @@ mod tests { } #[test] - fn transcription_requires_whisper_enabled() { - assert!(transcription_config(true).validate().is_ok()); - let err = transcription_config(false).validate().unwrap_err(); - assert!(err.to_string().contains("WHISPER__ENABLED")); + fn transcription_role_is_retired() { + let err = transcription_config(true, Some("sk-test")) + .validate() + .unwrap_err(); + assert!(err.to_string().contains("retired")); + assert!(err.to_string().contains("translation")); } #[test] - fn translation_requires_near_ai_key() { - assert!(translation_config(Some("sk-test")).validate().is_ok()); + fn translation_requires_whisper_and_near_ai() { + assert!(translation_config(Some("sk-test"), true).validate().is_ok()); + + let err = translation_config(Some("sk-test"), false) + .validate() + .unwrap_err(); + assert!(err.to_string().contains("WHISPER__ENABLED")); - let err = translation_config(None).validate().unwrap_err(); + let err = translation_config(None, true).validate().unwrap_err(); assert!(err.to_string().contains("NEAR_AI__API_KEY")); - let err = translation_config(Some(" ")).validate().unwrap_err(); + let err = translation_config(Some(" "), true) + .validate() + .unwrap_err(); assert!(err.to_string().contains("non-empty")); } @@ -385,8 +397,7 @@ mod tests { "http://signal-api:8080" ); assert!(SignalConfig::default().phone_number.is_none()); - assert!(SignalConfig::default().peer_phone.is_none()); - assert_eq!(WhisperConfig::default().model, "small"); + assert_eq!(WhisperConfig::default().model, "openai/whisper-large-v3"); assert!(TranslateAllConfig::default().enabled); assert!(GroupPreferencesConfig::default().persist); } diff --git a/crates/signal-bot/src/group_invite_acceptor.rs b/crates/signal-bot/src/group_invite_acceptor.rs index 4f76fc4..7a2dab3 100644 --- a/crates/signal-bot/src/group_invite_acceptor.rs +++ b/crates/signal-bot/src/group_invite_acceptor.rs @@ -1,7 +1,6 @@ //! Auto-accept pending Signal group invites. //! -//! - **Translation:** accept any pending invite/request (MVP). -//! - **Transcription:** accept only when the translation peer is already a member/admin. +//! Unified bot: accept any pending invite/request (`AcceptAll`). use crate::config::BotRole; use signal_client::{Group, SignalClient}; @@ -15,41 +14,20 @@ pub const DEFAULT_INVITE_POLL_INTERVAL: Duration = Duration::from_secs(5); /// Policy for which pending invites to accept. #[derive(Debug, Clone, PartialEq, Eq)] pub enum InvitePolicy { - /// Accept every group where this account is pending (translation hub). + /// Accept every group where this account is pending. AcceptAll, - /// Accept only when `peer` is already a member or admin (transcription worker). - AcceptIfPeerPresent { peer: String }, } impl InvitePolicy { - /// Build policy from bot role and optional peer phone. - /// - /// Transcription without `PEER_PHONE` refuses all invites. - pub fn for_role(role: BotRole, peer_phone: Option<&str>) -> Option { - match role { - BotRole::Translation => Some(Self::AcceptAll), - BotRole::Transcription => { - let peer = peer_phone?.trim(); - if peer.is_empty() { - return None; - } - Some(Self::AcceptIfPeerPresent { - peer: peer.to_string(), - }) - } - } + /// Unified bot always auto-accepts group invites. + pub fn for_role(_role: BotRole) -> Self { + Self::AcceptAll } } /// Whether this account should `POST .../join` for `group`. -pub fn should_join(group: &Group, self_identity: &str, policy: &InvitePolicy) -> bool { - if !group.is_pending_for(self_identity) { - return false; - } - match policy { - InvitePolicy::AcceptAll => true, - InvitePolicy::AcceptIfPeerPresent { peer } => group.has_member_or_admin(peer), - } +pub fn should_join(group: &Group, self_identity: &str, _policy: &InvitePolicy) -> bool { + group.is_pending_for(self_identity) } /// One scan: list groups and join those that match policy. @@ -166,17 +144,13 @@ mod tests { #[test] fn policy_for_role() { assert_eq!( - InvitePolicy::for_role(BotRole::Translation, None), - Some(InvitePolicy::AcceptAll) + InvitePolicy::for_role(BotRole::Translation), + InvitePolicy::AcceptAll ); assert_eq!( - InvitePolicy::for_role(BotRole::Transcription, Some("+15550009999")), - Some(InvitePolicy::AcceptIfPeerPresent { - peer: "+15550009999".into() - }) + InvitePolicy::for_role(BotRole::Transcription), + InvitePolicy::AcceptAll ); - assert!(InvitePolicy::for_role(BotRole::Transcription, None).is_none()); - assert!(InvitePolicy::for_role(BotRole::Transcription, Some(" ")).is_none()); } #[test] @@ -187,22 +161,6 @@ mod tests { assert!(!should_join(&pending, "+15550001111", &policy)); } - #[test] - fn transcription_requires_peer_member() { - let policy = InvitePolicy::AcceptIfPeerPresent { - peer: "+15550003333".into(), - }; - let with_peer = sample_group( - &["+15550003333", "+15550001111"], - &["+15550002222"], - &["+15550003333"], - ); - assert!(should_join(&with_peer, "+15550002222", &policy)); - - let without_peer = sample_group(&["+15550001111"], &["+15550002222"], &["+15550001111"]); - assert!(!should_join(&without_peer, "+15550002222", &policy)); - } - #[tokio::test] async fn accept_pending_joins_matching_groups() { let server = MockServer::start().await; @@ -231,26 +189,4 @@ mod tests { let n = accept_pending_invites(&signal, "+15550002222", &InvitePolicy::AcceptAll).await; assert_eq!(n, 1); } - - #[tokio::test] - async fn transcription_skips_when_peer_absent() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/groups/%2B15550002222")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([group_json( - "group.nopeer==", - &["+15550001111"], - &["+15550002222"], - &["+15550001111"] - )]))) - .mount(&server) - .await; - - let signal = SignalClient::new(server.uri()).unwrap(); - let policy = InvitePolicy::AcceptIfPeerPresent { - peer: "+15550003333".into(), - }; - let n = accept_pending_invites(&signal, "+15550002222", &policy).await; - assert_eq!(n, 0); - } } diff --git a/crates/signal-bot/src/handlers_setup.rs b/crates/signal-bot/src/handlers_setup.rs index 1b6f540..a21ea2a 100644 --- a/crates/signal-bot/src/handlers_setup.rs +++ b/crates/signal-bot/src/handlers_setup.rs @@ -1,4 +1,4 @@ -//! Build role-specific command handler stacks. +//! Build the unified command handler stack (one Signal number). use crate::bot_identity::BotIdentity; use crate::commands::*; @@ -6,10 +6,11 @@ use crate::config::{BotRole, Config}; use crate::error::AppResult; use crate::group_preferences_store::GroupPreferencesStore; use crate::transcribe_prefs::GroupTranscribePrefs; +use crate::transcript_fanout::SuiteTranscriptFanout; use anyhow::Context; use dstack_client::DstackClient; use near_ai_client::NearAiClient; -use signal_bot_transcription::build_voice_handlers; +use signal_bot_transcription::{build_voice_handlers, SharedTranscriptFanout}; use signal_client::SignalClient; use std::path::PathBuf; use std::sync::Arc; @@ -24,63 +25,17 @@ pub async fn build_handlers( bot_identity: Arc, ) -> AppResult>> { match config.bot.role { - BotRole::Transcription => build_transcription_handlers(config, signal, dstack).await, + BotRole::Transcription => Err(anyhow::anyhow!( + "BOT__ROLE=transcription is retired; use BOT__ROLE=translation (unified bot)" + ) + .into()), BotRole::Translation => { build_translation_handlers(config, signal, dstack, bot_identity).await } } } -/// Transcription CVM: voice / !transcribe* / !transcription / help-transcription / verify. -pub async fn build_transcription_handlers( - config: &Config, - signal: Arc, - dstack: Arc, -) -> AppResult>> { - let whisper = Arc::new( - WhisperClient::new(&config.whisper.service_url, config.whisper.timeout) - .context("Failed to create Whisper client")?, - ); - - if whisper.health_check().await { - info!( - "Whisper healthy at {} (model={})", - config.whisper.service_url, config.whisper.model - ); - } else { - warn!( - "Whisper health check failed at {} — will retry on requests", - config.whisper.service_url - ); - } - - let group_prefs = GroupPreferencesStore::open( - dstack.clone(), - PathBuf::from(&config.group_preferences.storage_path), - config.group_preferences.persist, - config.translate_all.max_messages_per_minute, - ) - .await; - - // Voice only — no NEAR translate-on-voice; translation CVM handles posted text. - let mut handlers = build_voice_handlers( - whisper, - signal, - config.whisper.reply_prefix.clone(), - config.whisper.max_attachment_bytes, - Arc::new(GroupTranscribePrefs(group_prefs.clone())), - ); - handlers.push(Box::new(TranscriptionMenuHandler::new())); - handlers.push(Box::new(HelpTranscriptionHandler::new())); - handlers.push(Box::new(VerifyHandler::new(dstack, BotRole::Transcription))); - - info!( - "Transcription role: voice / !transcribe* / !transcription / help-transcription / verify (hub !help / !info / !privacy on translation bot only)" - ); - Ok(handlers) -} - -/// Translation CVM: Language Threads + in-chat + quote !translate. +/// Unified bot: Language Threads → voice → in-chat → hub menus → quote translate → verify/help. pub async fn build_translation_handlers( config: &Config, signal: Arc, @@ -108,6 +63,28 @@ pub async fn build_translation_handlers( warn!("NEAR AI health check failed - will retry on requests"); } + let whisper = Arc::new( + WhisperClient::new( + &config.whisper.service_url, + config.whisper.timeout, + &near_cfg.api_key, + &config.whisper.model, + ) + .context("Failed to create Whisper client")?, + ); + + if whisper.health_check().await { + info!( + "NEAR Whisper healthy at {} (model={})", + config.whisper.service_url, config.whisper.model + ); + } else { + warn!( + "NEAR Whisper health check failed at {} — will retry on requests", + config.whisper.service_url + ); + } + let group_prefs = GroupPreferencesStore::open( dstack.clone(), PathBuf::from(&config.group_preferences.storage_path), @@ -123,27 +100,52 @@ pub async fn build_translation_handlers( ); } - let mut handlers: Vec> = Vec::new(); - - handlers.push(Box::new(TranslateMeHandler::new( + let translate_me = TranslateMeHandler::new( group_prefs.clone(), near_ai.clone(), signal.clone(), - bot_identity, - ))); + bot_identity.clone(), + ); + + let translate_all = if config.translate_all.enabled { + Some( + TranslateAllHandler::with_prefix( + group_prefs.clone(), + near_ai.clone(), + signal.clone(), + DEFAULT_TRANSCRIPT_PREFIX, + ) + .with_bot_identity(bot_identity.clone()), + ) + } else { + None + }; + + let fanout: SharedTranscriptFanout = Arc::new(SuiteTranscriptFanout { + translate_all: translate_all.clone(), + translate_me: translate_me.clone(), + }); + + let mut handlers: Vec> = Vec::new(); + + handlers.push(Box::new(translate_me)); info!( "Language Threads enabled: !translate-me-thread / !leave / !enable-in-chat (max {}/min)", config.translate_all.max_messages_per_minute ); - if config.translate_all.enabled { - handlers.push(Box::new(TranslateAllHandler::with_peer( - group_prefs.clone(), - near_ai.clone(), - signal.clone(), - config.signal.peer_phone.clone(), - DEFAULT_TRANSCRIPT_PREFIX, - ))); + handlers.extend(build_voice_handlers( + whisper, + signal.clone(), + config.whisper.reply_prefix.clone(), + config.whisper.max_attachment_bytes, + Arc::new(GroupTranscribePrefs(group_prefs.clone())), + Some(fanout), + )); + info!("Voice transcription enabled: !transcribe* / auto voice notes"); + + if let Some(in_chat) = translate_all { + handlers.push(Box::new(in_chat)); info!( "In-chat translation enabled: !translate-all-on / !translate-me-on / !enable-threads" ); @@ -159,10 +161,7 @@ pub async fn build_translation_handlers( handlers.push(Box::new(HelpThreadsHandler::new())); handlers.push(Box::new(HelpInChatHandler::new())); handlers.push(Box::new(HelpTranscriptionHandler::new())); - handlers.push(Box::new(TranscriptionPairingHandler::new( - signal.clone(), - config.signal.peer_phone.clone(), - ))); + handlers.push(Box::new(TranscriptionMenuHandler::new())); handlers.push(Box::new(InChatMenuHandler::new( config.translate_all.enabled, ))); @@ -189,7 +188,7 @@ pub async fn build_translation_handlers( ))); handlers.push(Box::new(PrivacyHandler::new())); - info!("Translation role: hub menus + in-chat + Language Threads"); + info!("Unified bot: hub menus + voice + in-chat + Language Threads"); Ok(handlers) } @@ -210,7 +209,6 @@ mod tests { service_url: "http://127.0.0.1:9".into(), poll_interval: Duration::from_millis(50), phone_number: None, - peer_phone: None, }, near_ai: None, bot: BotConfig { @@ -238,56 +236,53 @@ mod tests { handlers.iter().map(|h| h.label()).collect() } - #[tokio::test] - async fn transcription_registers_expected_handlers() { - let whisper = MockServer::start().await; + async fn mock_near() -> MockServer { + let near = MockServer::start().await; Mock::given(method("GET")) - .and(path("/health")) + .and(path("/models")) .respond_with(ResponseTemplate::new(200)) - .mount(&whisper) + .mount(&near) .await; - - let mut config = base_config(BotRole::Transcription); - config.whisper.service_url = whisper.uri(); - - let signal = Arc::new(SignalClient::new(&config.signal.service_url).unwrap()); - let dstack = Arc::new(DstackClient::new(&config.dstack.socket_path)); - let identity = BotIdentity::new(); - - let handlers = build_handlers(&config, signal, dstack, identity) - .await - .expect("transcription handlers"); - - assert_eq!(handlers.len(), 6); - assert_eq!( - labels(&handlers), - vec![ - "voice", - "manual_transcribe", - "transcribe", - "transcription_menu", - "help_transcription", - "command", // verify - ] - ); - } - - #[tokio::test] - async fn translation_registers_expected_handlers_with_translate_all() { - let near = MockServer::start().await; Mock::given(method("POST")) .and(path("/chat/completions")) .respond_with(ResponseTemplate::new(200)) .mount(&near) .await; + near + } - let mut config = base_config(BotRole::Translation); + fn with_near(mut config: Config, near: &MockServer) -> Config { config.near_ai = Some(NearAiConfig { api_key: "test-key".into(), base_url: near.uri(), model: "test-model".into(), timeout: Duration::from_secs(5), }); + config.whisper.service_url = near.uri(); + config.whisper.enabled = true; + config + } + + #[tokio::test] + async fn transcription_role_is_rejected() { + let config = base_config(BotRole::Transcription); + let signal = Arc::new(SignalClient::new(&config.signal.service_url).unwrap()); + let dstack = Arc::new(DstackClient::new(&config.dstack.socket_path)); + let identity = BotIdentity::new(); + + let result = build_handlers(&config, signal, dstack, identity).await; + assert!(result.is_err(), "transcription role should fail"); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("retired") && err.contains("translation"), + "error should mention retired transcription: {err}" + ); + } + + #[tokio::test] + async fn translation_registers_unified_handlers_with_translate_all() { + let near = mock_near().await; + let config = with_near(base_config(BotRole::Translation), &near); let signal = Arc::new(SignalClient::new("http://127.0.0.1:9").unwrap()); let dstack = Arc::new(DstackClient::new(&config.dstack.socket_path)); @@ -297,9 +292,12 @@ mod tests { .await .expect("translation handlers"); - assert_eq!(handlers.len(), 18); + assert_eq!(handlers.len(), 21); let got = labels(&handlers); assert!(got.contains(&"translate_me")); + assert!(got.contains(&"voice")); + assert!(got.contains(&"manual_transcribe")); + assert!(got.contains(&"transcribe")); assert!(got.contains(&"translate_all")); assert!(got.contains(&"translation_menu")); assert!(got.contains(&"translation_threads_menu")); @@ -307,7 +305,8 @@ mod tests { assert!(got.contains(&"help_threads")); assert!(got.contains(&"help_in_chat")); assert!(got.contains(&"help_transcription")); - assert!(got.contains(&"transcription_pairing")); + assert!(got.contains(&"transcription_menu")); + assert!(!got.contains(&"transcription_pairing")); assert!(got.contains(&"in_chat_menu")); assert!(!got.contains(&"translate_parallel")); assert!(!got.contains(&"parallel_menu")); @@ -320,25 +319,16 @@ mod tests { assert!(got.contains(&"help")); assert!(got.contains(&"info")); assert!(got.contains(&"privacy")); + assert_eq!(got[0], "translate_me"); + assert_eq!(&got[1..4], &["voice", "manual_transcribe", "transcribe"]); + assert_eq!(got[4], "translate_all"); } #[tokio::test] async fn translation_omits_translate_all_when_disabled() { - let near = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200)) - .mount(&near) - .await; - - let mut config = base_config(BotRole::Translation); + let near = mock_near().await; + let mut config = with_near(base_config(BotRole::Translation), &near); config.translate_all.enabled = false; - config.near_ai = Some(NearAiConfig { - api_key: "test-key".into(), - base_url: near.uri(), - model: "test-model".into(), - timeout: Duration::from_secs(5), - }); let signal = Arc::new(SignalClient::new("http://127.0.0.1:9").unwrap()); let dstack = Arc::new(DstackClient::new(&config.dstack.socket_path)); @@ -348,15 +338,21 @@ mod tests { .await .expect("translation handlers"); - assert_eq!(handlers.len(), 17); - assert!(!labels(&handlers).contains(&"translate_all")); - assert!(labels(&handlers).contains(&"translate_me")); - assert!(labels(&handlers).contains(&"in_chat_menu")); - assert!(labels(&handlers).contains(&"translation_threads_menu")); - assert!(labels(&handlers).contains(&"help_threads")); - assert!(labels(&handlers).contains(&"help_in_chat")); - assert!(labels(&handlers).contains(&"help_transcription")); - assert!(labels(&handlers).contains(&"info")); + assert_eq!(handlers.len(), 20); + let got = labels(&handlers); + assert!(!got.contains(&"translate_all")); + assert!(got.contains(&"translate_me")); + assert!(got.contains(&"voice")); + assert!(got.contains(&"manual_transcribe")); + assert!(got.contains(&"transcribe")); + assert!(got.contains(&"transcription_menu")); + assert!(!got.contains(&"transcription_pairing")); + assert!(got.contains(&"in_chat_menu")); + assert!(got.contains(&"translation_threads_menu")); + assert!(got.contains(&"help_threads")); + assert!(got.contains(&"help_in_chat")); + assert!(got.contains(&"help_transcription")); + assert!(got.contains(&"info")); } #[tokio::test] diff --git a/crates/signal-bot/src/lib.rs b/crates/signal-bot/src/lib.rs index d33c580..28471be 100644 --- a/crates/signal-bot/src/lib.rs +++ b/crates/signal-bot/src/lib.rs @@ -8,3 +8,4 @@ pub mod group_preferences_store; pub mod handlers_setup; pub mod menu_language; pub mod transcribe_prefs; +pub mod transcript_fanout; diff --git a/crates/signal-bot/src/main.rs b/crates/signal-bot/src/main.rs index 4b379c1..c31f0eb 100644 --- a/crates/signal-bot/src/main.rs +++ b/crates/signal-bot/src/main.rs @@ -51,23 +51,6 @@ async fn main() -> AppResult<()> { } info!("Signal API healthy"); - if let (Some(self_phone), Some(peer_raw)) = ( - config.signal.phone_number.as_deref(), - config.signal.peer_phone.as_deref(), - ) { - let peer = peer_raw.trim(); - if !peer.is_empty() { - match signal.trust_identity(self_phone, peer).await { - Ok(()) => info!(peer, "Trusted Signal peer identity (PEER_PHONE)"), - Err(e) => warn!( - peer, - error = %e, - "Could not trust PEER_PHONE identity — peer messages may not decrypt until trusted" - ), - } - } - } - let bot_identity = BotIdentity::new(); let handlers = build_handlers( @@ -78,22 +61,16 @@ async fn main() -> AppResult<()> { ) .await?; + let handlers = Arc::new(handlers); info!("Registered {} command handlers", handlers.len()); - match InvitePolicy::for_role(config.bot.role, config.signal.peer_phone.as_deref()) { - Some(policy) => { - let signal_invites = signal.clone(); - let phone = config.signal.phone_number.clone(); - tokio::spawn(async move { - run_invite_acceptor(signal_invites, phone, policy, DEFAULT_INVITE_POLL_INTERVAL) - .await; - }); - } - None => { - warn!( - "Group invite auto-accept disabled (transcription requires SIGNAL__PEER_PHONE = translation bot)" - ); - } + { + let policy = InvitePolicy::for_role(config.bot.role); + let signal_invites = signal.clone(); + let phone = config.signal.phone_number.clone(); + tokio::spawn(async move { + run_invite_acceptor(signal_invites, phone, policy, DEFAULT_INVITE_POLL_INTERVAL).await; + }); } info!("Listening for messages..."); @@ -104,7 +81,18 @@ async fn main() -> AppResult<()> { loop { tokio::select! { Some(message) = stream.next() => { - let _ = dispatch_message(&handlers, &signal, &bot_identity, &message).await; + let handlers = handlers.clone(); + let signal = signal.clone(); + let bot_identity = bot_identity.clone(); + tokio::spawn(async move { + let _ = dispatch_message( + handlers.as_slice(), + &signal, + &bot_identity, + &message, + ) + .await; + }); } _ = signal::ctrl_c() => { info!("Shutdown signal received"); diff --git a/crates/signal-bot/src/transcript_fanout.rs b/crates/signal-bot/src/transcript_fanout.rs new file mode 100644 index 0000000..b331484 --- /dev/null +++ b/crates/signal-bot/src/transcript_fanout.rs @@ -0,0 +1,23 @@ +//! In-process fan-out of transcripts into in-chat + Language Threads. + +use crate::commands::{TranslateAllHandler, TranslateMeHandler}; +use async_trait::async_trait; +use signal_bot_transcription::TranscriptFanout; +use signal_client::BotMessage; + +pub struct SuiteTranscriptFanout { + pub translate_all: Option, + pub translate_me: TranslateMeHandler, +} + +#[async_trait] +impl TranscriptFanout for SuiteTranscriptFanout { + async fn fan_out_transcript(&self, original: &BotMessage, spoken_text: &str) { + if let Some(in_chat) = &self.translate_all { + in_chat.fan_out_transcript(original, spoken_text).await; + } + self.translate_me + .fan_out_transcript(original, spoken_text) + .await; + } +} diff --git a/crates/signal-client/src/client.rs b/crates/signal-client/src/client.rs index 9ec0a4c..2c66999 100644 --- a/crates/signal-client/src/client.rs +++ b/crates/signal-client/src/client.rs @@ -368,7 +368,7 @@ impl SignalClient { /// Trust a peer identity (`PUT /v1/identities/{number}/trust/{numberToTrust}`). /// /// Uses `trust_all_known_keys` so paired product bots can exchange group messages after - /// re-registration (safety-number change). Intended for the configured `PEER_PHONE` only. + /// re-registration (safety-number change). #[instrument(skip(self))] pub async fn trust_identity( &self, diff --git a/crates/whisper-client/src/client.rs b/crates/whisper-client/src/client.rs index 461c3ba..ab25c07 100644 --- a/crates/whisper-client/src/client.rs +++ b/crates/whisper-client/src/client.rs @@ -1,4 +1,4 @@ -//! whisper.cpp HTTP server client. +//! OpenAI-compatible audio transcription client (NEAR AI Whisper Large V3). use crate::error::WhisperError; use crate::types::{ @@ -8,40 +8,55 @@ use reqwest::Client; use std::time::Duration; use tracing::{debug, instrument, warn}; -/// Client for whisper.cpp `whisper-server` (`/health`, `/inference`). +/// Client for `POST /audio/transcriptions` (OpenAI-compatible, e.g. NEAR AI). #[derive(Clone)] pub struct WhisperClient { client: Client, base_url: String, + api_key: String, + model: String, } impl WhisperClient { - /// Create a new Whisper client. - pub fn new(base_url: impl Into, timeout: Duration) -> Result { + /// Create a client. `base_url` is the API root including `/v1` when required. + pub fn new( + base_url: impl Into, + timeout: Duration, + api_key: impl Into, + model: impl Into, + ) -> Result { let client = Client::builder().timeout(timeout).build()?; Ok(Self { client, base_url: base_url.into().trim_end_matches('/').to_string(), + api_key: api_key.into(), + model: model.into(), }) } - /// Check if the Whisper API is healthy. + fn auth_header(&self) -> String { + format!("Bearer {}", self.api_key) + } + + /// Check if the STT API is reachable (`GET /models`). pub async fn health_check(&self) -> bool { self.client - .get(format!("{}/health", self.base_url)) + .get(format!("{}/models", self.base_url)) + .header("Authorization", self.auth_header()) .send() .await .map(|r| r.status().is_success()) .unwrap_or(false) } - /// Fetch health response (includes status string when available). + /// Fetch a coarse health status from `GET /models`. #[instrument(skip(self))] pub async fn health(&self) -> Result { let response = self .client - .get(format!("{}/health", self.base_url)) + .get(format!("{}/models", self.base_url)) + .header("Authorization", self.auth_header()) .send() .await?; @@ -50,10 +65,12 @@ impl WhisperClient { return Err(WhisperError::Api(msg)); } - Ok(response.json().await?) + Ok(HealthResponse { + status: "ok".into(), + }) } - /// Transcribe audio bytes via `POST /inference` (multipart upload). + /// Transcribe audio bytes via `POST /audio/transcriptions`. #[instrument(skip(self, audio))] pub async fn transcribe( &self, @@ -61,10 +78,10 @@ impl WhisperClient { filename: &str, content_type: &str, ) -> Result { - self.inference(audio, filename, content_type, false).await + self.upload(audio, filename, content_type, false).await } - /// Translate speech to English via `POST /inference` with translate enabled. + /// Translate speech to English via `POST /audio/translations`. #[instrument(skip(self, audio))] pub async fn translate_to_english( &self, @@ -72,10 +89,10 @@ impl WhisperClient { filename: &str, content_type: &str, ) -> Result { - self.inference(audio, filename, content_type, true).await + self.upload(audio, filename, content_type, true).await } - async fn inference( + async fn upload( &self, audio: &[u8], filename: &str, @@ -88,27 +105,28 @@ impl WhisperClient { part = part.mime_str(content_type).map_err(WhisperError::Http)?; } - let mut form = reqwest::multipart::Form::new() + let form = reqwest::multipart::Form::new() .part("file", part) - .text("response_format", "verbose_json") - .text("language", "auto"); + .text("model", self.model.clone()) + .text("response_format", "json"); - if translate { - form = form.text("translate", "true"); + let path = if translate { + "audio/translations" } else { - form = form.text("translate", "false"); - } + "audio/transcriptions" + }; let response = self .client - .post(format!("{}/inference", self.base_url)) + .post(format!("{}/{path}", self.base_url)) + .header("Authorization", self.auth_header()) .multipart(form) .send() .await?; if !response.status().is_success() { let msg = response.text().await.unwrap_or_default(); - warn!("Whisper inference failed: {}", msg); + warn!("Whisper {path} failed: {msg}"); return Err(WhisperError::Api(msg)); } diff --git a/crates/whisper-client/src/lib.rs b/crates/whisper-client/src/lib.rs index 5eb38b1..ee4e402 100644 --- a/crates/whisper-client/src/lib.rs +++ b/crates/whisper-client/src/lib.rs @@ -1,4 +1,4 @@ -//! HTTP client for whisper.cpp `whisper-server` sidecar. +//! HTTP client for OpenAI-compatible audio transcription (NEAR AI Whisper). mod client; mod error; @@ -16,14 +16,20 @@ mod tests { use wiremock::{Mock, MockServer, ResponseTemplate}; async fn test_client(server: &MockServer) -> WhisperClient { - WhisperClient::new(server.uri(), Duration::from_secs(5)).unwrap() + WhisperClient::new( + server.uri(), + Duration::from_secs(5), + "test-key", + "openai/whisper-large-v3", + ) + .unwrap() } #[tokio::test] async fn test_health_check_success() { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/health")) + .and(path("/models")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "ok" }))) @@ -38,7 +44,7 @@ mod tests { async fn test_transcribe_multipart() { let server = MockServer::start().await; Mock::given(method("POST")) - .and(path("/inference")) + .and(path("/audio/transcriptions")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "text": " Hello world\n", "language": "english" @@ -54,13 +60,21 @@ mod tests { assert_eq!(result.trimmed_text(), "Hello world"); assert_eq!(result.language.as_deref(), Some("en")); + + let received = server.received_requests().await.unwrap(); + let body = String::from_utf8_lossy(&received[0].body); + assert!(body.contains("filename=\"note.m4a\"")); + assert!(body.contains("openai/whisper-large-v3")); + assert!(!body.contains("+1555")); + assert!(!body.contains("group_id")); + assert!(!body.contains("display_name")); } #[tokio::test] async fn test_transcribe_empty_fails() { let server = MockServer::start().await; Mock::given(method("POST")) - .and(path("/inference")) + .and(path("/audio/transcriptions")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "text": " \n" }))) @@ -80,7 +94,7 @@ mod tests { async fn health_endpoint_parses_status() { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/health")) + .and(path("/models")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "ok" }))) @@ -95,7 +109,7 @@ mod tests { async fn health_endpoint_http_error() { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/health")) + .and(path("/models")) .respond_with(ResponseTemplate::new(503).set_body_string("down")) .mount(&server) .await; @@ -107,10 +121,10 @@ mod tests { } #[tokio::test] - async fn translate_to_english_uses_inference() { + async fn translate_to_english_uses_translations() { let server = MockServer::start().await; Mock::given(method("POST")) - .and(path("/inference")) + .and(path("/audio/translations")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "text": " Hello from Spanish\n", "language": "spanish" diff --git a/crates/whisper-client/src/types.rs b/crates/whisper-client/src/types.rs index 74f4b22..6ef076e 100644 --- a/crates/whisper-client/src/types.rs +++ b/crates/whisper-client/src/types.rs @@ -2,13 +2,13 @@ use serde::Deserialize; -/// Response from `GET /health`. +/// Response from `GET /models` mapped to a coarse status. #[derive(Debug, Clone, Deserialize)] pub struct HealthResponse { pub status: String, } -/// JSON response from `POST /inference` with `response_format=verbose_json`. +/// JSON response from OpenAI-compatible `POST /audio/transcriptions`. #[derive(Debug, Clone, Deserialize)] pub struct InferenceResponse { pub text: String, diff --git a/deploy/dstack-app-ctqs1/phala.env.example b/deploy/dstack-app-ctqs1/phala.env.example index 3c4b1c6..1c76a17 100644 --- a/deploy/dstack-app-ctqs1/phala.env.example +++ b/deploy/dstack-app-ctqs1/phala.env.example @@ -4,7 +4,7 @@ # --- Your Docker images (build & push linux/amd64 first) --- SIGNAL_BOT_IMAGE=YOUR_DOCKERHUB/signal-bot-tee:latest SIGNAL_PROXY_IMAGE=YOUR_DOCKERHUB/signal-registration-proxy:latest -WHISPER_IMAGE=YOUR_DOCKERHUB/signal-whisper-api:latest +# No WHISPER_IMAGE — STT is NEAR AI Whisper, not an in-CVM sidecar. # --- Secrets --- NEAR_AI_API_KEY=sk-... diff --git a/docker/compose.transcription.yaml b/docker/compose.transcription.yaml index 5bb5a05..3064409 100644 --- a/docker/compose.transcription.yaml +++ b/docker/compose.transcription.yaml @@ -1,87 +1,14 @@ -# Local transcription stack — mirrors Phala transcription CVM. -# Own Docker network; does not share a network with the translation stack. -# Integration with translation is via Signal group chat only. -# -# docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env up -d +# Retired — use docker/compose.translation.yaml (one Signal number). +# BOT__ROLE=transcription fail-fasts. This stub exists so old scripts fail clearly. -name: sigstack-transcription +name: sigstack-transcription-retired services: - signal-api: - image: bbernhard/signal-cli-rest-api@sha256:2399d449123cdad56c4d859277e3b9127e1a00c4d2ab4601c239882609286cf8 - environment: - - MODE=normal - - AUTO_RECEIVE_SCHEDULE= - - LOG_LEVEL=info - volumes: - - signal-config-transcription:/home/.local/share/signal-cli - networks: - - internal - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/v1/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 30s - restart: unless-stopped - - whisper-api: - build: - context: .. - dockerfile: docker/Dockerfile.whisper - platform: linux/amd64 - environment: - - WHISPER_MODEL=${WHISPER_MODEL:-small} - volumes: - - whisper-models:/models - networks: - - internal - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9000/health"] - interval: 30s - timeout: 10s - retries: 5 - start_period: 300s - restart: unless-stopped - - signal-bot: - build: - context: .. - dockerfile: docker/Dockerfile - environment: - - BOT__ROLE=transcription - - SIGNAL__SERVICE_URL=http://signal-api:8080 - - SIGNAL__PHONE_NUMBER=${SIGNAL_PHONE} - - SIGNAL__PEER_PHONE=${PEER_PHONE:-} - - WHISPER__ENABLED=true - - WHISPER__SERVICE_URL=http://whisper-api:9000 - - WHISPER__MODEL=${WHISPER_MODEL:-small} - - WHISPER__TIMEOUT=${WHISPER_TIMEOUT:-120s} - - BOT__LOG_LEVEL=${LOG_LEVEL:-info} - - BOT__GITHUB_REPO=${BOT_GITHUB_REPO:-https://github.com/BreadchainCoop/sigstack-bot} - - DSTACK__SOCKET_PATH=/var/run/dstack.sock - - GROUP_PREFERENCES__PERSIST=${GROUP_PREFERENCES_PERSIST:-true} - - GROUP_PREFERENCES__STORAGE_PATH=/data/group_prefs.enc - volumes: - - /var/run/dstack.sock:/var/run/dstack.sock:ro - - group-prefs-transcription:/data - networks: - - internal - depends_on: - signal-api: - condition: service_healthy - whisper-api: - condition: service_healthy - restart: unless-stopped - -volumes: - signal-config-transcription: - driver: local - whisper-models: - driver: local - group-prefs-transcription: - driver: local - -networks: - internal: - name: sigstack-transcription-internal + retired: + image: alpine:3.20 + command: + [ + "sh", + "-c", + "echo 'Use docker/compose.translation.yaml (unified bot; BOT__ROLE=translation).' >&2; exit 1", + ] diff --git a/docker/compose.translation.yaml b/docker/compose.translation.yaml index 35307f8..5a4bbcb 100644 --- a/docker/compose.translation.yaml +++ b/docker/compose.translation.yaml @@ -1,6 +1,6 @@ -# Local translation stack — mirrors Phala translation CVM. -# Own Docker network; does not share a network with the transcription stack. -# Integration with transcription is via Signal group chat only. +# Local unified stack — one Signal number (phone B). +# Hub, translation, and voice in one signal-api + signal-bot. +# STT is NEAR AI Whisper Large V3 (no Whisper sidecar). # # docker compose -f docker/compose.translation.yaml --env-file docker/translation.env up -d @@ -33,12 +33,14 @@ services: - BOT__ROLE=translation - SIGNAL__SERVICE_URL=http://signal-api:8080 - SIGNAL__PHONE_NUMBER=${SIGNAL_PHONE} - - SIGNAL__PEER_PHONE=${PEER_PHONE:-} - NEAR_AI__API_KEY=${NEAR_AI_API_KEY} - NEAR_AI__BASE_URL=${NEAR_AI_BASE_URL:-https://cloud-api.near.ai/v1} - NEAR_AI__MODEL=${NEAR_AI_MODEL:-deepseek-ai/DeepSeek-V4-Flash} - NEAR_AI__TIMEOUT=${NEAR_AI_TIMEOUT:-120s} - - WHISPER__ENABLED=false + - WHISPER__ENABLED=true + - WHISPER__SERVICE_URL=${NEAR_AI_BASE_URL:-https://cloud-api.near.ai/v1} + - WHISPER__MODEL=${WHISPER_MODEL:-openai/whisper-large-v3} + - WHISPER__TIMEOUT=${WHISPER_TIMEOUT:-120s} - TRANSLATE_ALL__ENABLED=${TRANSLATE_ALL_ENABLED:-true} - TRANSLATE_ALL__MAX_MESSAGES_PER_MINUTE=${TRANSLATE_ALL_MAX_MESSAGES_PER_MINUTE:-30} - BOT__LOG_LEVEL=${LOG_LEVEL:-info} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index ae1edf7..b4e52d5 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -1,7 +1,7 @@ # DEPRECATED monolith stack. -# Use the two product stacks instead (mirrors two Phala CVMs): +# Local and prod: one Signal number via docker/compose.translation.yaml / +# docker/phala.translation.yaml. # -# docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env up -d # docker compose -f docker/compose.translation.yaml --env-file docker/translation.env up -d # # See docs/two-cvm-architecture.md diff --git a/docker/phala-compose.yaml b/docker/phala-compose.yaml index 8f83f80..79be5af 100644 --- a/docker/phala-compose.yaml +++ b/docker/phala-compose.yaml @@ -1,9 +1,8 @@ -# DEPRECATED single-CVM compose. -# Use the two Phala product stacks instead: +# DEPRECATED single-CVM compose from before the dual-CVM experiment. +# Prod is again one CVM, but the live file is: # -# docker/phala.transcription.yaml — transcription CVM (4 GB) -# docker/phala.translation.yaml — translation CVM (4 GB) +# docker/phala.translation.yaml # -# See docs/two-cvm-architecture.md +# Do not deploy this stub. See docs/two-cvm-architecture.md services: {} diff --git a/docker/phala-test.yaml b/docker/phala-test.yaml index 98e5dfe..b15f9e2 100644 --- a/docker/phala-test.yaml +++ b/docker/phala-test.yaml @@ -1,6 +1,5 @@ # Minimal Phala smoke: Signal CLI only (useful before attaching a product bot). -# Prefer product stacks for real deploys: -# docker/phala.transcription.yaml +# Prefer the product suite for real deploys: # docker/phala.translation.yaml services: diff --git a/docker/phala.transcription.env.example b/docker/phala.transcription.env.example index dbf903b..79a8d14 100644 --- a/docker/phala.transcription.env.example +++ b/docker/phala.transcription.env.example @@ -1,15 +1,5 @@ -# Phala transcription CVM env — copy to phala.transcription.env (do not commit secrets). +# DEPRECATED — transcription no longer has its own CVM. +# Use docker/phala.translation.env.example (one Signal number, phone B). # -# phala deploy … -c docker/phala.transcription.yaml -e docker/phala.transcription.env --wait -t tdx.medium - -SIGNAL_PHONE=+1XXXXXXXXXX -# Translation bot E.164 — required for auto-accepting group invites from the hub. -PEER_PHONE=+1YYYYYYYYYY - -SIGNAL_BOT_IMAGE=YOUR_DOCKERHUB/signal-bot-tee:latest -WHISPER_IMAGE=YOUR_DOCKERHUB/signal-whisper-api:latest - -WHISPER_MODEL=small -WHISPER_TIMEOUT=120s -LOG_LEVEL=info -BOT_GITHUB_REPO=https://github.com/BreadchainCoop/sigstack-bot +# See docker/phala.translation.env.example and +# docs/solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md diff --git a/docker/phala.transcription.yaml b/docker/phala.transcription.yaml index 716de87..49222c5 100644 --- a/docker/phala.transcription.yaml +++ b/docker/phala.transcription.yaml @@ -1,71 +1,12 @@ -# Phala Cloud TEE — transcription CVM (4 GB / tdx.medium) +# DEPRECATED — do not deploy. # -# Build & push (linux/amd64): -# docker buildx build --platform linux/amd64 -t YOUR_DOCKERHUB/signal-bot-tee:latest -f docker/Dockerfile --push . -# docker buildx build --platform linux/amd64 -t YOUR_DOCKERHUB/signal-whisper-api:latest -f docker/Dockerfile.whisper --push . +# Local Whisper on a dedicated transcription CVM is not a production scale path +# (see docs/solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md). +# Both Signal products now run on one CVM: # -# Deploy to a dedicated CVM (separate from translation): -# phala deploy --cvm-id YOUR_TX_CVM -c docker/phala.transcription.yaml -e docker/phala.transcription.env --wait -t tdx.medium - -services: - signal-api: - image: bbernhard/signal-cli-rest-api@sha256:2399d449123cdad56c4d859277e3b9127e1a00c4d2ab4601c239882609286cf8 - environment: - - MODE=normal - - LOG_LEVEL=info - volumes: - - signal-config-transcription:/home/.local/share/signal-cli - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/v1/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 30s - restart: unless-stopped - - whisper-api: - image: ${WHISPER_IMAGE} - platform: linux/amd64 - environment: - - WHISPER_MODEL=${WHISPER_MODEL:-small} - volumes: - - whisper-models:/models - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9000/health"] - interval: 30s - timeout: 10s - retries: 5 - start_period: 300s - restart: unless-stopped - - signal-bot: - image: ${SIGNAL_BOT_IMAGE} - environment: - - BOT__ROLE=transcription - - SIGNAL__SERVICE_URL=http://signal-api:8080 - - SIGNAL__PHONE_NUMBER=${SIGNAL_PHONE} - - SIGNAL__PEER_PHONE=${PEER_PHONE:-} - - WHISPER__ENABLED=true - - WHISPER__SERVICE_URL=http://whisper-api:9000 - - WHISPER__MODEL=${WHISPER_MODEL:-small} - - WHISPER__TIMEOUT=${WHISPER_TIMEOUT:-120s} - - BOT__LOG_LEVEL=${LOG_LEVEL:-info} - - BOT__GITHUB_REPO=${BOT_GITHUB_REPO:-https://github.com/BreadchainCoop/sigstack-bot} - - DSTACK__SOCKET_PATH=/var/run/dstack.sock - - GROUP_PREFERENCES__PERSIST=true - - GROUP_PREFERENCES__STORAGE_PATH=/data/group_prefs.enc - volumes: - - /var/run/dstack.sock:/var/run/dstack.sock:ro - - group-prefs-transcription:/data - depends_on: - - signal-api - - whisper-api - restart: unless-stopped +# docker/phala.translation.yaml +# docker/phala.translation.env +# +# Upgrade in place: phala deploy --cvm-id … -volumes: - signal-config-transcription: - driver: local - whisper-models: - driver: local - group-prefs-transcription: - driver: local +services: {} diff --git a/docker/phala.translation.env.example b/docker/phala.translation.env.example index 711567e..c2f5535 100644 --- a/docker/phala.translation.env.example +++ b/docker/phala.translation.env.example @@ -1,19 +1,23 @@ -# Phala translation CVM env — copy to phala.translation.env (do not commit secrets). +# Phala one-CVM env — copy to phala.translation.env (do not commit secrets). # -# phala deploy … -c docker/phala.translation.yaml -e docker/phala.translation.env --wait -t tdx.medium +# Upgrade live: phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353 \ +# -c docker/phala.translation.yaml -e docker/phala.translation.env --wait +# +# SIGNAL_PHONE = surviving translation number (phone B), already registered on this CVM. SIGNAL_PHONE=+1YYYYYYYYYY -# Transcription bot E.164 — used by !transcription to invite/pair into the group. -PEER_PHONE=+1XXXXXXXXXX SIGNAL_BOT_IMAGE=YOUR_DOCKERHUB/signal-bot-tee:latest -SIGNAL_REGISTRATION_PROXY_IMAGE=YOUR_DOCKERHUB/signal-registration-proxy:latest +SIGNAL_PROXY_IMAGE=YOUR_DOCKERHUB/signal-registration-proxy:latest NEAR_AI_API_KEY=sk-your-api-key-here NEAR_AI_BASE_URL=https://cloud-api.near.ai/v1 NEAR_AI_MODEL=deepseek-ai/DeepSeek-V4-Flash NEAR_AI_TIMEOUT=120s +WHISPER_MODEL=openai/whisper-large-v3 +WHISPER_TIMEOUT=120s + TRANSLATE_ALL_ENABLED=true TRANSLATE_ALL_MAX_MESSAGES_PER_MINUTE=30 LOG_LEVEL=info @@ -21,3 +25,5 @@ BOT_GITHUB_REPO=https://github.com/BreadchainCoop/sigstack-bot BOT_SIGNAL_USERNAME= RATE_LIMIT_GLOBAL_PER_MINUTE=10 RATE_LIMIT_PER_NUMBER_PER_HOUR=3 +# Optional: inject an ops SSH pubkey so `phala ssh -i …` works. +# DSTACK_AUTHORIZED_KEYS=ssh-ed25519 AAAA... diff --git a/docker/phala.translation.yaml b/docker/phala.translation.yaml index 1e39b26..24753bf 100644 --- a/docker/phala.translation.yaml +++ b/docker/phala.translation.yaml @@ -1,12 +1,20 @@ -# Phala Cloud TEE — translation CVM (4 GB / tdx.medium) -# Hosts in-chat translation + Language Threads. +# Phala Cloud TEE — one Signal number on one CVM (tdx.medium). +# +# No local Whisper sidecar. Voice posts audio to NEAR AI Whisper Large V3. +# One signal-api + one signal-bot: hub, translation, and voice. Per-message +# tokio::spawn keeps STT from stalling other handlers. # # Build & push (linux/amd64): # docker buildx build --platform linux/amd64 -t YOUR_DOCKERHUB/signal-bot-tee:latest -f docker/Dockerfile --push . # docker buildx build --platform linux/amd64 -t YOUR_DOCKERHUB/signal-registration-proxy:latest -f docker/Dockerfile.proxy --push . # -# Deploy to a dedicated CVM (separate from transcription): -# phala deploy --cvm-id YOUR_TR_CVM -c docker/phala.translation.yaml -e docker/phala.translation.env --wait -t tdx.medium +# First create: `phala deploy -n sigstack-translation …` +# Later upgrades MUST use `--cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353` (or the live app id) +# so volumes stay (Signal phone session + group prefs). Do not rename volumes below. +# See docs/two-cvm-architecture.md#cvm-storage-keep-intact +# phala deploy --cvm-id YOUR_CVM -c docker/phala.translation.yaml -e docker/phala.translation.env --wait +# +# Registration proxy :8081 (phone B). Do not re-register phone A. services: signal-api: @@ -30,12 +38,14 @@ services: - BOT__ROLE=translation - SIGNAL__SERVICE_URL=http://signal-api:8080 - SIGNAL__PHONE_NUMBER=${SIGNAL_PHONE} - - SIGNAL__PEER_PHONE=${PEER_PHONE:-} - NEAR_AI__API_KEY=${NEAR_AI_API_KEY} - NEAR_AI__BASE_URL=${NEAR_AI_BASE_URL:-https://cloud-api.near.ai/v1} - NEAR_AI__MODEL=${NEAR_AI_MODEL:-deepseek-ai/DeepSeek-V4-Flash} - NEAR_AI__TIMEOUT=${NEAR_AI_TIMEOUT:-120s} - - WHISPER__ENABLED=false + - WHISPER__ENABLED=true + - WHISPER__SERVICE_URL=${NEAR_AI_BASE_URL:-https://cloud-api.near.ai/v1} + - WHISPER__MODEL=${WHISPER_MODEL:-openai/whisper-large-v3} + - WHISPER__TIMEOUT=${WHISPER_TIMEOUT:-120s} - TRANSLATE_ALL__ENABLED=${TRANSLATE_ALL_ENABLED:-true} - TRANSLATE_ALL__MAX_MESSAGES_PER_MINUTE=${TRANSLATE_ALL_MAX_MESSAGES_PER_MINUTE:-30} - BOT__LOG_LEVEL=${LOG_LEVEL:-info} @@ -72,6 +82,7 @@ services: restart: unless-stopped volumes: + # Keep these names: renaming creates empty volumes and drops Signal identity + prefs. signal-config-translation: driver: local registry-data: diff --git a/docker/transcription.env.example b/docker/transcription.env.example index 058b657..21820e3 100644 --- a/docker/transcription.env.example +++ b/docker/transcription.env.example @@ -1,11 +1,2 @@ -# Copy to docker/transcription.env and fill in. -# Phone A — transcription bot (must differ from translation.env SIGNAL_PHONE). - -SIGNAL_PHONE=+1XXXXXXXXXX -# Translation bot phone — required so this bot auto-accepts group invites from the hub. -PEER_PHONE=+1YYYYYYYYYY -WHISPER_MODEL=small -WHISPER_TIMEOUT=120s -LOG_LEVEL=info -GROUP_PREFERENCES_PERSIST=true -BOT_GITHUB_REPO=https://github.com/BreadchainCoop/sigstack-bot +# Retired — one Signal number now. Use docker/translation.env.example +# and docker/compose.translation.yaml (BOT__ROLE=translation). diff --git a/docker/translation.env.example b/docker/translation.env.example index 55400af..5fe7f8f 100644 --- a/docker/translation.env.example +++ b/docker/translation.env.example @@ -1,13 +1,13 @@ # Copy to docker/translation.env and fill in. -# Phone B — translation bot (must differ from transcription.env SIGNAL_PHONE). +# Phone B — unified Bread Bot (hub + voice + translation). SIGNAL_PHONE=+1YYYYYYYYYY -# Transcription bot phone — used by !transcription to invite/pair into the group. -PEER_PHONE=+1XXXXXXXXXX NEAR_AI_API_KEY=sk-your-api-key-here NEAR_AI_BASE_URL=https://cloud-api.near.ai/v1 NEAR_AI_MODEL=deepseek-ai/DeepSeek-V4-Flash NEAR_AI_TIMEOUT=120s +WHISPER_MODEL=openai/whisper-large-v3 +WHISPER_TIMEOUT=120s TRANSLATE_ALL_ENABLED=true TRANSLATE_ALL_MAX_MESSAGES_PER_MINUTE=30 LOG_LEVEL=info diff --git a/docs/in-chat-translation.md b/docs/in-chat-translation.md index 7c5c894..de38c3c 100644 --- a/docs/in-chat-translation.md +++ b/docs/in-chat-translation.md @@ -1,6 +1,6 @@ # In-chat (group) translation -Status: **MVP implemented** on the translation bot (Bread Bot **hub** — manages menus, in-chat, and Language Threads; the transcription bot is a separate voice worker). +Status: **MVP implemented** on the unified Bread Bot (hub menus, in-chat, Language Threads, and voice). One **bilingual** Signal group (e.g. English + Spanish). The bot detects which side of the pair a message is on and quote-replies with the other language in the **same** main thread. @@ -59,7 +59,11 @@ Not dual-post: the original stays as the human message; the bot only quote-repli Skip when language is undetected or not in the pair. Rate-limited per group (`TRANSLATE_ALL__MAX_MESSAGES_PER_MINUTE`). -Voice notes: the **transcription** bot posts a transcript in-group; with `!translate-all-on` (or personal auto for the speaker), the **translation** bot intercepts that text like any other message — including when the transcript quote-reply still carries voice attachment metadata. The `📝 Transcript:` label is stripped before detect/translate (same as manual quote `!translate`). +Voice notes: after STT this bot quote-replies `📝 Transcript:` then fans out spoken text in-process (one number does not receive its own posts). With `!translate-all-on` (or personal auto for the speaker), intercept uses the **original speaker**. The `📝 Transcript:` label is stripped before detect/translate (same as manual quote `!translate`). Bot translation replies are not re-translated. + +## Persistence + +`!translate-all-on` and `!translate-me-on` are stored in encrypted group prefs on the translation CVM (`group-prefs-translation` → `/data/group_prefs.enc`), not in TEE RAM. An **in-place** Phala upgrade keeps that volume: users do **not** re-enable after a routine image bump. A new CVM, volume wipe, or prefs decrypt failure starts empty. Same file also holds Language Threads bridges. Canonical ops: [two-cvm-architecture.md — CVM storage](two-cvm-architecture.md#cvm-storage-keep-intact). ## Key code diff --git a/docs/language-threads.md b/docs/language-threads.md index 9a9c4b8..2c3fd01 100644 --- a/docs/language-threads.md +++ b/docs/language-threads.md @@ -2,7 +2,7 @@ Status: **implemented and verified locally**; Phala TEE redeploy paused (image `daopunk/signal-bot-tee:latest` already pushed for `linux/amd64`). -The sole **cross-group** bridging product on the translation bot (**hub**): one **multilingual main** Signal chat plus per-language **Language Thread** sidecar groups. Parallel Translation was retired — use this for N=1 or N sidecars with the same rules (no mode switch). Voice and hub menus live on other roles — see [two-cvm-architecture.md — Bot hierarchy](two-cvm-architecture.md#bot-hierarchy). +The sole **cross-group** bridging product: one **multilingual main** Signal chat plus per-language **Language Thread** sidecar groups. Parallel Translation was retired — use this for N=1 or N sidecars with the same rules (no mode switch). Voice and hub menus live on the same bot — see [two-cvm-architecture.md](two-cvm-architecture.md). ## Problem @@ -97,6 +97,8 @@ In-memory reverse index: sidecar `internal_id` → `(main_id, lang)`. Local Docker without dstack may not persist prefs across restarts; Phala with dstack does. +The same encrypted file on `group-prefs-translation` also holds in-chat `!translate-me-on` / `!translate-all-on`. An **in-place** Phala CVM upgrade reattaches that volume (and `signal-config-translation`, the hub’s registered Signal phone). Users should not have to re-subscribe. Replacing the CVM or wiping volumes drops bridges, personal auto-translate, **and** the bot’s Signal session — the suite looks broken until ops re-registers and users opt in again. See [two-cvm-architecture.md — CVM storage](two-cvm-architecture.md#cvm-storage-keep-intact). + Legacy encrypted prefs that still contain a `parallel_bridge` key are ignored on load and dropped on the next persist. ## Key code @@ -132,29 +134,27 @@ Only **signal-bot** on the translation stack needs rebuild for Language Threads 4. Bot-attributed posts are not re-relayed (no ping-pong). 5. From sidecar → `!leave` unsubscribes; from main → `!enable-in-chat` tears down the product. -Whisper / voice live on the **transcription** stack — see [voice-transcription.md](voice-transcription.md) and [two-cvm-architecture.md](two-cvm-architecture.md). +Whisper / voice run in the same bot process (NEAR AI Whisper) — after STT, spoken text fans out into Language Threads as the original speaker. See [voice-transcription.md](voice-transcription.md) and [two-cvm-architecture.md](two-cvm-architecture.md). ## Interoperability -- **Transcription** (worker CVM) composes with Language Threads or in-chat in the same Signal groups. The **translation hub** invites via `!transcription`; the worker only transcribes voice. +- **Voice** composes with Language Threads or in-chat in the same Signal groups. Transcripts fan out in-process (this number does not receive its own posts). - **In-chat** translates inside one group thread; **Language Threads** bridges a multilingual main to N monolingual sidecars. -## Phala / TEE (paused) +## Phala / TEE -- Deploy uses **Docker images** in compose, not a public git clone. -- Translation CVM target: **4 GB** (`tdx.medium`) via [`docker/phala.translation.yaml`](../docker/phala.translation.yaml) — **no Whisper** on this box. +- One CVM on Phala (`tdx.medium` = 2 vCPU / 4 GB RAM): [`docker/phala.translation.yaml`](../docker/phala.translation.yaml) — one Signal number (phone B). No Whisper sidecar; STT is NEAR AI. See [CPU TEE Whisper does not scale](solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md). +- Deploy uses **Docker images** (digest-pinned in env), not a public git clone. Upgrade in place: `phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353`. - Env template: [`docker/phala.translation.env.example`](../docker/phala.translation.env.example) (secrets; do not commit filled env). -- Fresh CVM ⇒ expect **re-register** Signal phone (volume died with old CVM). +- Registration proxy on this CVM: `:8081` phone B. ## Trust / privacy notes - Signal E2E still terminates at Signal CLI inside the TEE (same architecture as before). -- Translation plaintext goes to **NEAR AI** (their GPU TEE / cloud path as configured). +- Translation plaintext and voice audio (metadata-stripped) go to **NEAR AI** (chat + Whisper Large V3 GPU TEE). - Operator still sees metadata (timing, sizes, which numbers). - Sidecar names and bridged posts are visible to members of those Signal groups. ## Open follow-ups -- Resume Phala deploy at 4 GB; confirm memory with `phala cvms get` -- Pin image digest in compose for stronger attestation - Optional: delete empty sidecars after last member leaves diff --git a/docs/local-dev/README.md b/docs/local-dev/README.md index 7c0c1b9..dd62b19 100644 --- a/docs/local-dev/README.md +++ b/docs/local-dev/README.md @@ -1,23 +1,21 @@ # Local development -Thorough guide for running the dual Compose stacks on your machine, registering Signal phone numbers (including captcha), and tailing logs. +Thorough guide for running the one-number Compose stack on your machine, registering a Signal phone number (including captcha), and tailing logs. -Quick start (env copy + `up -d`) stays in [README.md](../README.md#local-dual-stack). Architecture context: [two-cvm-architecture.md](../two-cvm-architecture.md). TEE / role details: [`.agents/docs/DEVELOPMENT.md`](../../.agents/docs/DEVELOPMENT.md). - -Each stack has its own `signal-api`, network, and Signal CLI data volume. Follow **Transcription stack** and/or **Translation stack** end-to-end; use [Using both together](#using-both-together) when you need pairing in one Signal group. +Quick start (env copy + `up -d`) stays in [README.md](../README.md#local). Architecture context: [two-cvm-architecture.md](../two-cvm-architecture.md) (prod is one Phala CVM, one Signal number). TEE / role details: [`.agents/docs/DEVELOPMENT.md`](../../.agents/docs/DEVELOPMENT.md). **Already running and code changed?** Plain `up -d` / `restart` keep the old binary — see [Code changes (rebuild the bot)](#code-changes-rebuild-the-bot). ## Prerequisites - Docker with Compose v2 -- Two different Signal-capable phone numbers (E.164), one per stack -- `NEAR_AI_API_KEY` in `docker/translation.env` for the translation bot +- One Signal-capable phone number (E.164) +- `NEAR_AI_API_KEY` in `docker/translation.env` (chat + Whisper STT) - Optional: a local `/var/run/dstack.sock` if you care about attestation paths; local Compose mounts it read-only — registration and day-to-day bot traffic do not require a live Phala socket ## Captcha token -Signal almost always requires a captcha before SMS/voice verification. Both stacks use the same flow: +Signal almost always requires a captcha before SMS/voice verification. 1. Open [Signal registration captcha](https://signalcaptchas.org/registration/generate.html) in a browser. 2. Solve the challenge. @@ -44,11 +42,6 @@ That means: After you pull or edit bot code, rebuild and recreate the bot container (Signal registration volumes are untouched): ```bash -# Transcription stack -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ - up -d --build --force-recreate signal-bot - -# Translation stack docker compose -f docker/compose.translation.yaml --env-file docker/translation.env \ up -d --build --force-recreate signal-bot ``` @@ -56,8 +49,6 @@ docker compose -f docker/compose.translation.yaml --env-file docker/translation. Confirm the container is new (Created time should be “seconds/minutes ago”): ```bash -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ - ps signal-bot docker compose -f docker/compose.translation.yaml --env-file docker/translation.env \ ps signal-bot ``` @@ -78,156 +69,22 @@ docker compose -f docker/compose.translation.yaml --env-file docker/translation. up -d --force-recreate signal-bot ``` -(Same pattern with `compose.transcription.yaml` / `transcription.env` for the transcription bot.) - -Do **not** use `down -v` to “force a refresh” — that wipes Signal CLI state and you must re-register the phone. +Do **not** use `down -v` to “force a refresh” — that wipes Signal CLI state and you must re-register the phone. On Phala the same volumes hold the live bot identity and user prefs; upgrade in place (`--cvm-id`), never replace the CVM for a routine bump — [two-cvm-architecture.md — CVM storage](../two-cvm-architecture.md#cvm-storage-keep-intact). --- -## Transcription stack - -Compose file: `docker/compose.transcription.yaml` -Env file: `docker/transcription.env` -Role: voice transcription (`BOT__ROLE=transcription`). Includes Whisper. - -### Env + start - -```bash -cp docker/transcription.env.example docker/transcription.env -# Edit: SIGNAL_PHONE = phone A (transcription bot) -# Optional: PEER_PHONE = phone B when pairing with translation - -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env up -d -``` - -First `up -d` builds `signal-bot` if needed. After later code changes, use [Code changes (rebuild the bot)](#code-changes-rebuild-the-bot) — plain `up -d` keeps the old binary. - -Confirm network: - -```bash -docker network ls | grep sigstack-transcription -# Expect: sigstack-transcription-internal -``` - -### Health - -`signal-api` `/v1/health` returns **HTTP 204** with an empty body — no printed output and exit code 0 means healthy. Failure exits non-zero (`curl -sf`). - -```bash -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ - exec whisper-api curl -sf http://localhost:9000/health - -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ - exec signal-api curl -sf http://localhost:8080/v1/health -``` - -### Register phone A - -`signal-api` is not published on the host; call it from inside the container. The number must match `SIGNAL_PHONE` in `docker/transcription.env`. - -Replace `+1XXXXXXXXXX` with phone A. - -Check whether the number is already registered (skip captcha/register if it appears in the list): - -```bash -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ - exec signal-api curl -sS 'http://localhost:8080/v1/accounts' -# Expect JSON array, e.g. ["+1XXXXXXXXXX"]. Empty [] means not registered yet. -``` - -If not listed, generate a [captcha token](#captcha-token) and register: - -```bash -# Start registration (SMS by default) -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ - exec signal-api curl -sS -X POST \ - -H 'Content-Type: application/json' \ - -d '{"captcha":"signalcaptcha://PASTE_TOKEN_HERE","use_voice":false}' \ - 'http://localhost:8080/v1/register/+1XXXXXXXXXX' -``` - -Use `"use_voice":true` if you prefer a voice call for the code. - -When the SMS/voice code arrives: - -```bash -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ - exec signal-api curl -sS -X POST \ - -H 'Content-Type: application/json' \ - -d '{}' \ - 'http://localhost:8080/v1/register/+1XXXXXXXXXX/verify/123456' -``` - -Confirm the account is present (same accounts call as above): - -```bash -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ - exec signal-api curl -sS 'http://localhost:8080/v1/accounts' -``` - -Restart the bot so it picks up the registered session: - -```bash -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ - restart signal-bot -``` - -### Logs - -Idle bots are quiet at `info` — empty polls do not print. Incoming receive lines are mostly `debug`; successful command/handler work logs at `info`. - -```bash -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ - logs -f signal-bot -``` - -Useful variants: - -```bash -# All services on this stack -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env logs -f - -# Last 100 lines, then follow -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ - logs -f --tail=100 signal-bot - -# signal-api (registration / receive issues) -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ - logs -f signal-api -``` - -Raise verbosity: set `LOG_LEVEL=debug` in `docker/transcription.env`, then recreate the bot: - -```bash -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ - up -d signal-bot -``` - -### Stop / rebuild - -Stop the stack (keeps Signal CLI volumes): - -```bash -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env down -``` - -After code changes, rebuild/recreate — see [Code changes (rebuild the bot)](#code-changes-rebuild-the-bot). Do **not** use `down -v` unless you intend to wipe Signal CLI state (you will need to re-register the phone). - ---- - -## Translation stack +## Stack Compose file: `docker/compose.translation.yaml` Env file: `docker/translation.env` -Role: in-chat translation + Language Threads (`BOT__ROLE=translation`). No Whisper; needs `NEAR_AI_API_KEY`. +Role: unified bot (`BOT__ROLE=translation`) — hub + voice + in-chat + Language Threads. Needs `NEAR_AI_API_KEY` and Whisper enabled. ### Env + start ```bash cp docker/translation.env.example docker/translation.env -# Edit: SIGNAL_PHONE = phone B (translation bot) -# NEAR_AI_API_KEY = required -# Optional: PEER_PHONE = phone A when pairing with transcription +# Edit: SIGNAL_PHONE +# NEAR_AI_API_KEY = required (chat + Whisper STT) docker compose -f docker/compose.translation.yaml --env-file docker/translation.env up -d ``` @@ -256,7 +113,7 @@ Registration proxy (host port **8081**): curl -sf http://localhost:8081/health ``` -### Register phone B +### Register the phone The number must match `SIGNAL_PHONE` in `docker/translation.env`. @@ -290,8 +147,6 @@ curl -sS -X POST "http://localhost:8081/v1/register/+1YYYYYYYYYY/verify/123456" -d '{}' ``` -Alternatively, register against translation `signal-api` via compose `exec` (same pattern as phone A on the transcription stack). - Confirm again with `/v1/accounts` (or `v1/debug/signal-accounts`), then restart the bot so it picks up the registered session: ```bash @@ -299,6 +154,8 @@ docker compose -f docker/compose.translation.yaml --env-file docker/translation. restart signal-bot ``` +Add this one bot to a Signal group (it auto-accepts invites). Quote a voice note with `!transcribe`, or send `!transcribe-on` for auto. + ### Logs Idle bots are quiet at `info` — empty polls do not print. Incoming receive lines are mostly `debug`; successful command/handler work logs at `info`. @@ -340,32 +197,11 @@ docker compose -f docker/compose.translation.yaml --env-file docker/translation. After code changes, rebuild/recreate — see [Code changes (rebuild the bot)](#code-changes-rebuild-the-bot). Do **not** use `down -v` unless you intend to wipe Signal CLI state (you will need to re-register the phone). ---- - -## Using both together - -Confirm both networks exist: - -```bash -docker network ls | grep sigstack -# Expect: sigstack-transcription-internal and sigstack-translation-internal -``` - -There is **no** Docker network between the two CVMs/stacks — Signal is the bus. - -**Hierarchy:** treat the **translation** bot as the Bread Bot hub (menus, translation products, `!transcription` pairing). The **transcription** bot is a voice worker only — see [two-cvm-architecture.md — Bot hierarchy](../two-cvm-architecture.md#bot-hierarchy). - -After both numbers are registered: - -1. Create (or open) a Signal group that includes both bot numbers and your personal account. -2. For transcription pairing, set `PEER_PHONE` on translation to phone A and follow [voice-transcription.md](../voice-transcription.md#pairing-translation-leads) (`!transcription` as group admin). -3. Confirm peer trust: each bot must not list the other as `UNTRUSTED` (`GET /v1/identities/{phone}`). Bots auto-trust `PEER_PHONE` on startup; if you still see `Untrusted Identity` in logs, rebuild/restart both bots after pairing. - ## Related docs | Doc | Why | |-----|-----| -| [voice-transcription.md](../voice-transcription.md) | Transcription ops + pairing | +| [voice-transcription.md](../voice-transcription.md) | Voice ops + in-process transcript fan-out | | [in-chat-translation.md](../in-chat-translation.md) | In-chat translate product | | [language-threads.md](../language-threads.md) | Language Threads | -| [two-cvm-architecture.md](../two-cvm-architecture.md) | Why two stacks / no shared Docker network | +| [two-cvm-architecture.md](../two-cvm-architecture.md) | One CVM / one phone; CVM storage | diff --git a/docs/plans/2026-08-05-dual-cvm-phala-deploy.md b/docs/plans/2026-08-05-dual-cvm-phala-deploy.md new file mode 100644 index 0000000..a69aa4a --- /dev/null +++ b/docs/plans/2026-08-05-dual-cvm-phala-deploy.md @@ -0,0 +1,173 @@ +# Plan: Dual-CVM Phala deploy (2× tdx.medium) + +> **Superseded (2026-08-13):** CPU-TEE Whisper + a dedicated transcription CVM is not the production path. See [CPU TEE Whisper does not scale](../solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md). Keep this file as history. + +**Goal:** Deploy the product suite on two Phala TDX CVMs — **2 vCPU / 4 GB each** (`tdx.medium`) — matching [`docs/two-cvm-architecture.md`](../two-cvm-architecture.md). + +| CVM | Instance | Compose | Role | Live | +|-----|----------|---------|------|------| +| `sigstack-transcription` | `tdx.medium` | [`docker/phala.transcription.yaml`](../../docker/phala.transcription.yaml) | Phone A + Whisper + `BOT__ROLE=transcription` | running — app `c5df154154e8c61105fefe84d43515e69b0e2537` | +| `sigstack-translation` | `tdx.medium` | [`docker/phala.translation.yaml`](../../docker/phala.translation.yaml) | Phone B + NEAR AI + `BOT__ROLE=translation` + registration proxy | running — app `9adac7636fe255182f699940ffd1924960415507` | + +**Cost:** ~$0.232/hr combined (~$167/mo if always on). +**Auth:** Phala CLI / workspace **Bread Coop**. Images digest-pinned in gitignored `docker/phala.*.env`. + +**Registration proxies:** +- TX: `https://c5df154154e8c61105fefe84d43515e69b0e2537-8081.dstack-pha-prod9.phala.network` +- TR: `https://9adac7636fe255182f699940ffd1924960415507-8081.dstack-pha-prod9.phala.network` + +--- + +## Preflight (fix before first deploy) + +1. **Env var name mismatch (blocker for translation proxy):** + `docker/phala.translation.yaml` uses `${SIGNAL_PROXY_IMAGE}`; + `docker/phala.translation.env.example` documents `SIGNAL_REGISTRATION_PROXY_IMAGE`. + Align to one name (prefer `SIGNAL_PROXY_IMAGE` to match compose + `deploy/dstack-app-ctqs1`). + +2. **Refresh `scripts/deploy_phala.sh`:** still uses deprecated `phala cvms create` + single `phala-compose.yaml`. Either replace with two `phala deploy … -t tdx.medium` calls or document CLI-only and delete the script later. + +3. **Images:** bot / whisper / proxy currently pulled by tag (`:latest`). Signal CLI is already digest-pinned. Optional-but-recommended: push and pin digests in compose for attestation (open follow-up in language-threads.md). + +4. **Prerequisites to have on hand:** + - Two Signal-capable E.164 numbers (A = transcription, B = translation) + - Docker registry (Docker Hub or GHCR) with push access; images must be `linux/amd64` + - `NEAR_AI_API_KEY` for translation CVM + - Optional SSH pubkey if you want `--dev-os` / SSH into CVMs + +--- + +## Phase 0 — Secrets + env files (local, never commit) + +```bash +cp docker/phala.transcription.env.example docker/phala.transcription.env +cp docker/phala.translation.env.example docker/phala.translation.env +``` + +Fill: + +| File | Required | +|------|----------| +| `phala.transcription.env` | `SIGNAL_PHONE`=A, `PEER_PHONE`=B, `SIGNAL_BOT_IMAGE`, `WHISPER_IMAGE` | +| `phala.translation.env` | `SIGNAL_PHONE`=B, `PEER_PHONE`=A, `SIGNAL_BOT_IMAGE`, `SIGNAL_PROXY_IMAGE`, `NEAR_AI_API_KEY` | + +Confirm both env files are gitignored. + +--- + +## Phase 1 — Build & push images (`linux/amd64`) + +Replace `YOUR_DOCKERHUB` with the chosen org/user: + +```bash +docker buildx build --platform linux/amd64 \ + -t YOUR_DOCKERHUB/signal-bot-tee:latest \ + -f docker/Dockerfile --push . + +docker buildx build --platform linux/amd64 \ + -t YOUR_DOCKERHUB/signal-whisper-api:latest \ + -f docker/Dockerfile.whisper --push . + +docker buildx build --platform linux/amd64 \ + -t YOUR_DOCKERHUB/signal-registration-proxy:latest \ + -f docker/Dockerfile.proxy --push . +``` + +If the registry is private, set Phala pull creds (`DSTACK_DOCKER_USERNAME` / `DSTACK_DOCKER_PASSWORD` or current Phala private-registry flow). Prefer public images for the first bring-up to reduce moving parts. + +**Optional:** after push, `docker buildx imagetools inspect …` and pin `@sha256:…` in both Phala compose files. + +--- + +## Phase 2 — Create the two CVMs + +Prefer **new** CVMs (clean volumes). Do not co-locate Whisper with translation. + +```bash +# Transcription worker +phala deploy \ + -n sigstack-transcription \ + -c docker/phala.transcription.yaml \ + -e docker/phala.transcription.env \ + -t tdx.medium \ # 2 vCPU / 4 GB RAM + --disk-size 40G \ # 40 GB disk (not RAM); Phala default + --wait + +# Translation hub +phala deploy \ + -n sigstack-translation \ + -c docker/phala.translation.yaml \ + -e docker/phala.translation.env \ + -t tdx.medium \ # 2 vCPU / 4 GB RAM + --disk-size 40G \ # 40 GB disk (not RAM) + --wait +``` + +Verify: + +```bash +phala cvms list +phala cvms get --cvm-id sigstack-transcription # expect 2 vCPU / 4 GB +phala cvms get --cvm-id sigstack-translation +phala ps --cvm-id sigstack-transcription # signal-api, whisper-api, signal-bot +phala ps --cvm-id sigstack-translation # signal-api, signal-bot, signal-registration-proxy +``` + +Whisper on transcription may take several minutes for first model pull (`start_period: 300s`). + +Cleanup (optional): `phala cvms delete --cvm-id dstack-app-hqvaf` once the new pair is healthy. + +--- + +## Phase 3 — Register Signal phones on the CVMs + +**Why:** Fresh CVMs have empty Signal CLI volumes. Registration that exists on your laptop Compose stacks does **not** transfer. Both numbers must be registered into each product CVM’s `signal-api` so plaintext Signal sessions live inside that TEE. + +**Target = Phala CVMs, not local Docker.** Do not use `localhost:8080` / `localhost:8081` from the dual Compose guide for this step. + +1. Captcha: https://signalcaptchas.org/registration/generate.html — copy the full `signalcaptcha://…` token. +2. **Translation CVM (phone B):** register + verify via that CVM’s registration proxy (`:8081` through Phala gateway / `phala ssh`) or its in-CVM `signal-api` `/v1/register/...`. +3. **Transcription CVM (phone A):** register + verify against **that** CVM’s `signal-api` only (no proxy in transcription compose — `phala ssh` / container exec). +4. Confirm `/v1/accounts` on each CVM; restart that CVM’s `signal-bot` if the session was created after the bot started. + +Do **not** wipe CVM volumes after registration unless you intend to re-register. + +--- + +## Phase 4 — Smoke test in a shared Signal group + +1. Create a test group; add **both** bot numbers. +2. Hub (translation): `!help`, `!info`, `!privacy` — expect menus. +3. Pairing: from translation, `!transcription` invite path; confirm transcription auto-accepts / joins (PEER_PHONE wired both ways). +4. Voice: send a short voice note → transcription posts text; translation can act on that text if in-chat/threads enabled. +5. Attestation: `!verify ` on each bot (separate CVM quotes). +6. Logs: `phala logs --cvm-id …` if anything stalls. + +--- + +## Phase 5 — Harden (same session or immediate follow-up) + +- Pin bot/whisper/proxy digests in Phala compose; redeploy with `--wait`. +- Update `scripts/deploy_phala.sh` (or remove) so it matches dual `phala deploy -t tdx.medium`. +- Flip language-threads “Phala / TEE (paused)” section to live once verified. +- Document CVM names/IDs in an ops note (not secrets) for the Bread Coop workspace. + +--- + +## Out of scope + +- Stripe / multi-tenant “create your bot” website +- Reintroducing tools, x402, or general chat +- Cross-CVM Docker networking (Signal remains the only bus) +- Growing past `tdx.medium` unless Whisper OOMs (then resize transcription only) + +--- + +## Decision checklist (before executing) + +- [ ] Registry org/name chosen and push works for `linux/amd64` +- [ ] Phone A + Phone B ready (or known acquisition path) +- [ ] `NEAR_AI_API_KEY` available +- [ ] Fix `SIGNAL_PROXY_IMAGE` env name +- [ ] Delete vs ignore stopped `dstack-app-hqvaf` +- [ ] Go / no-go on digest pinning in the first deploy vs phase 5 diff --git a/docs/plans/2026-08-05-phala-deploy-handoff.md b/docs/plans/2026-08-05-phala-deploy-handoff.md new file mode 100644 index 0000000..a651b20 --- /dev/null +++ b/docs/plans/2026-08-05-phala-deploy-handoff.md @@ -0,0 +1,157 @@ +# Ops handoff: dual-CVM Phala deploy + +> **Superseded (2026-08-13):** the transcription CVM was deleted; both products run on the translation CVM with remote NEAR Whisper. See [CPU TEE Whisper does not scale](../solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md) and [two-cvm-architecture.md](../two-cvm-architecture.md). Keep this file as history. + +Finish Signal registration + smoke test yourself. Everything below assumes workspace **Bread Coop** and images under **`daopunk/`**. + +Related: [2026-08-05-dual-cvm-phala-deploy.md](./2026-08-05-dual-cvm-phala-deploy.md), [two-cvm-architecture.md](../two-cvm-architecture.md). + +--- + +## Done + +| Step | Status | +|------|--------| +| Fix `SIGNAL_PROXY_IMAGE` env name | Done ([`docker/phala.translation.env.example`](../../docker/phala.translation.env.example)) | +| Rewrite [`scripts/deploy_phala.sh`](../../scripts/deploy_phala.sh) for dual `tdx.medium` | Done | +| Gitignored Phala env files with phones / NEAR key / digests / ops SSH pubkey | Done (`docker/phala.transcription.env`, `docker/phala.translation.env`) | +| Build + push `linux/amd64` images | Done — `daopunk/signal-bot-tee`, `signal-whisper-api`, `signal-registration-proxy` | +| Deploy 2× `tdx.medium` (2 vCPU / 4 GB RAM, 40 GB disk) | Done | +| Digest-pin images in env + redeploy | Done | +| Registration proxy on **both** CVMs (`:8081`) | Done | +| Ops SSH via `~/.ssh/phala_sigstack` + `DSTACK_AUTHORIZED_KEYS` | Done | +| Unpause Phala section in [`docs/language-threads.md`](../language-threads.md) | Done | + +### Live CVMs + +| Name | Role | App ID | Dashboard | +|------|------|--------|-----------| +| `sigstack-transcription` | Whisper + phone A | `c5df154154e8c61105fefe84d43515e69b0e2537` | https://cloud.phala.com/dashboard/cvms/eba19afc-0c26-4409-b026-f757928d2ef8 | +| `sigstack-translation` | Hub + phone B | `9adac7636fe255182f699940ffd1924960415507` | https://cloud.phala.com/dashboard/cvms/0e82fa77-8b15-4dbd-89c4-9045ab911353 | + +**Registration proxies (Phala gateway):** + +```text +TX: https://c5df154154e8c61105fefe84d43515e69b0e2537-8081.dstack-pha-prod9.phala.network +TR: https://9adac7636fe255182f699940ffd1924960415507-8081.dstack-pha-prod9.phala.network +``` + +**SSH (ops key you created):** + +```bash +phala ssh sigstack-transcription -- -i ~/.ssh/phala_sigstack -o IdentitiesOnly=yes +phala ssh sigstack-translation -- -i ~/.ssh/phala_sigstack -o IdentitiesOnly=yes +``` + +**Quick health checks:** + +```bash +phala cvms list +phala ps --cvm-id sigstack-transcription +phala ps --cvm-id sigstack-translation + +curl -sfS https://c5df154154e8c61105fefe84d43515e69b0e2537-8081.dstack-pha-prod9.phala.network/health +curl -sfS https://9adac7636fe255182f699940ffd1924960415507-8081.dstack-pha-prod9.phala.network/health +``` + +Expect JSON `{"status":"ok",...,"signal_api_healthy":true}` and containers including `signal-api`, `signal-bot`, `signal-registration-proxy` (plus `whisper-api` on transcription). + +--- + +## Left for you + +### 1. Register both phones **on the CVMs** (not local Docker) + +Local Compose already has the numbers registered. That session does **not** live on Phala. Each CVM’s `signal-api` still has `[]` accounts until you register there. + +**Warning:** Re-registering on a CVM takes over the number from local Signal CLI. Stop local bots first if you care about a clean cutover: + +```bash +docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env stop signal-bot +docker compose -f docker/compose.translation.yaml --env-file docker/translation.env stop signal-bot +``` + +**A. Captcha** — open https://signalcaptchas.org/registration/generate.html twice. Copy the full `signalcaptcha://…` string each time (tokens expire quickly). + +**B. Register** (phones match your local env: TX `+573103479014`, TR `+573107677679`): + +```bash +TX_PROXY=https://c5df154154e8c61105fefe84d43515e69b0e2537-8081.dstack-pha-prod9.phala.network +TR_PROXY=https://9adac7636fe255182f699940ffd1924960415507-8081.dstack-pha-prod9.phala.network +PHONE_TX=+573103479014 +PHONE_TR=+573107677679 + +# Transcription (phone A) +curl -sS -X POST "$TX_PROXY/v1/register/${PHONE_TX}" \ + -H 'Content-Type: application/json' \ + -d '{"captcha":"signalcaptcha://PASTE_TX_TOKEN","use_voice":false}' + +# Translation (phone B) +curl -sS -X POST "$TR_PROXY/v1/register/${PHONE_TR}" \ + -H 'Content-Type: application/json' \ + -d '{"captcha":"signalcaptcha://PASTE_TR_TOKEN","use_voice":false}' +``` + +Helper script (same URLs/phones baked in): [`scripts/register_phala_phones.sh`](../../scripts/register_phala_phones.sh) + +```bash +CAPTCHA_TX='signalcaptcha://...' CAPTCHA_TR='signalcaptcha://...' ./scripts/register_phala_phones.sh +``` + +**C. Verify SMS codes:** + +```bash +curl -sS -X POST "$TX_PROXY/v1/register/${PHONE_TX}/verify/XXXXXX" \ + -H 'Content-Type: application/json' -d '{}' + +curl -sS -X POST "$TR_PROXY/v1/register/${PHONE_TR}/verify/YYYYYY" \ + -H 'Content-Type: application/json' -d '{}' +``` + +Or: + +```bash +SMS_TX=XXXXXX SMS_TR=YYYYYY ./scripts/register_phala_phones.sh --verify +``` + +**D. Confirm accounts, then restart bots on the CVMs:** + +```bash +curl -sS "$TX_PROXY/v1/debug/signal-accounts" +curl -sS "$TR_PROXY/v1/debug/signal-accounts" +# Expect each CLI to list its phone. + +phala ssh sigstack-transcription -- -i ~/.ssh/phala_sigstack -o IdentitiesOnly=yes \ + 'docker restart dstack-signal-bot-1' + +phala ssh sigstack-translation -- -i ~/.ssh/phala_sigstack -o IdentitiesOnly=yes \ + 'docker restart dstack-signal-bot-1' +``` + +### 2. Smoke test in Signal + +1. Create (or use) a test group; add **both** bot numbers. +2. Hub (translation): `!help`, `!info`, `!privacy`. +3. Pairing: `!transcription` from the hub; confirm the transcription bot joins / responds. +4. Short voice note → transcription posts text. +5. `!verify ` on each bot (separate CVM quotes). +6. If stuck: `phala logs --cvm-id sigstack-transcription` / `sigstack-translation`. + +### 3. Optional cleanup + +- Local dual Compose: leave stopped, or `down` (do **not** `down -v` unless you intend to wipe local Signal state). +- Old stopped CVM `dstack-app-hqvaf` was already gone from `phala cvms list` earlier; confirm with `phala cvms list`. + +--- + +## Useful paths + +| Path | Purpose | +|------|---------| +| `docker/phala.transcription.yaml` / `.env` | Transcription CVM compose + secrets | +| `docker/phala.translation.yaml` / `.env` | Translation CVM compose + secrets | +| `scripts/deploy_phala.sh` | Redeploy both CVMs | +| `scripts/register_phala_phones.sh` | Register/verify via gateways | +| `~/.ssh/phala_sigstack` | Passphrase-free ops SSH key | + +Never commit `docker/phala.*.env` (gitignored). diff --git a/docs/solutions/README.md b/docs/solutions/README.md index 72ba66d..f063dca 100644 --- a/docs/solutions/README.md +++ b/docs/solutions/README.md @@ -3,3 +3,11 @@ Institutional learnings written by `/ce-compound` (Compound Engineering). Each solved problem becomes searchable markdown so the next `/ce-brainstorm` / `/ce-plan` cycle starts smarter. Prefer compounding after non-trivial fixes and features. + +## Architecture + +- [CPU TEE Whisper does not scale](architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md) — remote NEAR Whisper Large V3; one CVM; two bot processes; never re-home Whisper in a CPU TEE. + +## Product / UX + +- [Signal mobile menus](signal-mobile-menus.md) — command list layout for mobile Signal. diff --git a/docs/solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md b/docs/solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md new file mode 100644 index 0000000..d2642b6 --- /dev/null +++ b/docs/solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md @@ -0,0 +1,62 @@ +--- +title: CPU TEE Whisper does not scale +date: 2026-08-13 +category: architecture-patterns +module: transcription +problem_type: architecture_pattern +component: documentation +severity: high +applies_when: + - "choosing where voice transcription inference runs" + - "sizing a Phala TDX CVM for Signal bots" + - "considering a local whisper-api sidecar in compose" +tags: [whisper, phala, tdx, near-ai, concurrency] +--- + +# CPU TEE Whisper does not scale + +## Context + +Dual-CVM was supposed to give Whisper room. It did not. Live `tdx.medium` (2 vCPU / 4 GB) still made ~5s Signal voice notes feel unusable, and overlapping subscribers queued on one CPU-bound `whisper.cpp`. Phala TDX SKUs couple **1 vCPU / 2 GB**, so “buy more RAM for a bigger model” also buys cores you still cannot turn into real parallel Whisper without paying 2×/4× for `large`/`xlarge`. A bigger ggml model on the same box is **slower**, not faster (accuracy vs compute). Extra Whisper workers on 2 vCPU split the same cores and make the first user worse. TDX has no GPU; CPU Whisper will not become production-grade by resizing the CVM. Meanwhile a dedicated transcription TEE billed ~$0.116/hr whether anyone was speaking. + +## Guidance + +Decrypt voice notes in the Signal TEE, strip Signal metadata, and send **audio bytes only** to **NEAR AI Whisper Large V3** (`POST /v1/audio/transcriptions`, model `openai/whisper-large-v3`) in their GPU TEE — the same vendor already used for translation text. + +Keep **one** bot process on **one** Phala CVM (`tdx.medium`). Per-message `tokio::spawn` keeps translation off the STT wait. Do **not** add `whisper-api` back to compose. Do **not** put Whisper on a larger CPU TEE as the scale path. + +Outbound STT is a multipart file plus model name. Generic filename (`voice.ogg` / `voice.m4a`). No phone, group id, Signal timestamp, or display name in form fields, headers, or filenames. + +## Why This Matters + +Local Whisper on 2 vCPU cannot run real parallel jobs. Isolation across two CVMs only stopped translation from sharing RAM with Whisper; it did not make inference fast. Remote GPU STT is the latency lever. A dedicated transcription CVM was idle cost with no product win. + +Two processes used to matter because each bot awaited `dispatch_message` inside its own poll loop, and a second Signal number let translation *see* transcripts. One number does not receive its own group sends, so transcripts fan out in-process; `tokio::spawn` per inbound message replaces the second process for latency isolation. Shared CVM contention is mild I/O (one Java CLI + one Rust bot waiting on NEAR), not a CPU Whisper queue. + +## When to Apply + +- Any change that would reintroduce an in-CVM Whisper sidecar +- CVM sizing discussions for transcription latency +- Privacy copy (`!privacy`, `!help-transcription`) and attestation (`!verify` attests **this** CVM’s compose, not remote Whisper weights) + +## Examples + +**Do** + +- `WHISPER__SERVICE_URL=https://cloud-api.near.ai/v1` with `NEAR_AI__API_KEY` +- One compose: [`docker/phala.translation.yaml`](../../../docker/phala.translation.yaml) — one `signal-api` + one `signal-bot` (`BOT__ROLE=translation`) +- In-place upgrades: `phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353` + +**Do not** + +- Add `whisper-api` / `Dockerfile.whisper` to live compose +- Deploy `docker/phala.transcription.yaml` (deprecated stub) +- “Just use `medium`/`large` ggml” or `tdx.large`/`xlarge` to fix voice latency +- Re-introduce a second Signal number / pairing, or re-home Whisper in this CVM + +## Related + +- [`docs/two-cvm-architecture.md`](../../two-cvm-architecture.md) +- [`docs/voice-transcription.md`](../../voice-transcription.md) +- [`AGENTS.md`](../../../AGENTS.md) +- Historical dual-CVM deploy: [`docs/plans/2026-08-05-dual-cvm-phala-deploy.md`](../../plans/2026-08-05-dual-cvm-phala-deploy.md) (superseded) diff --git a/docs/spikes/2026-6-23-phase0-whisper-spike.md b/docs/spikes/2026-6-23-phase0-whisper-spike.md index f92232a..d2550df 100644 --- a/docs/spikes/2026-6-23-phase0-whisper-spike.md +++ b/docs/spikes/2026-6-23-phase0-whisper-spike.md @@ -1,5 +1,7 @@ # Phase 0 Spike: Whisper + Signal Attachments +> **Superseded (2026-08-13):** in-CVM whisper.cpp is not the production STT path. See [CPU TEE Whisper does not scale](../solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md). Keep this file as history. + **Date:** 2026-06-23 **Status:** Complete (pending live voice-note JSON capture from user) **Plan:** `docs/plans/2026-6-22-whisper-integration.md` diff --git a/docs/two-cvm-architecture.md b/docs/two-cvm-architecture.md index 66e4602..82bdfd3 100644 --- a/docs/two-cvm-architecture.md +++ b/docs/two-cvm-architecture.md @@ -1,95 +1,107 @@ -# Two-CVM architecture +# One CVM, one Signal number -Product suite split across two Phala CVMs (and two local Docker Compose projects). Signal group chat is the only cross-stack bus. +Prod runs hub, translation, and voice on **one** Phala CVM as **one** Signal member (the surviving translation number, phone B). Users add **one** bot to a group. STT is remote **NEAR AI Whisper Large V3**; after a transcript, in-chat and Language Threads fan out **in-process** (Signal does not echo this bot’s own posts). -See also: [issue #10](https://github.com/BreadchainCoop/sigstack-bot/issues/10). +See also: [issue #10](https://github.com/BreadchainCoop/sigstack-bot/issues/10) and the architecture learning [CPU TEE Whisper does not scale](solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md). -## Bot hierarchy - -In a shared Signal group, users interact with **one hub** and one optional **worker**: +## Bot | Role | Phone | Duty | |------|-------|------| -| **Translation bot** (Bread Bot hub) | Phone B | Product menus (`!help`, `!info`, `!privacy`, `!translation-*`), Language Threads, in-chat translation, pairing (`!transcription` invite), `!verify` (translation CVM quote) | -| **Transcription bot** (worker) | Phone A | Voice only: `!transcription`, `!transcribe*`, `!verify` (transcription CVM quote). **No hub** — does not answer `!help` / `!info` / `!privacy` | +| **Bread Bot** (`BOT__ROLE=translation`) | Phone B | Hub menus (`!help`, `!info`, `!privacy`), Language Threads, in-chat translation, voice (`!transcription` menu, `!transcribe*`), `!verify` (one reply) | -Signal still delivers every message to both members; the transcription stack **ignores** hub text commands and non-voice work. The translation bot **leads** suite navigation and inviting the transcription peer; the transcription bot **executes** voice→text and its own product toggles. +`BOT__ROLE=transcription` is retired and fail-fasts. Do not add a third role. Do not drop `BOT__ROLE` from compose. -Hub vs worker command split: [voice-transcription.md](voice-transcription.md#commands-transcription-bot). +Voice crate [`crates/signal-bot-transcription`](../crates/signal-bot-transcription) stays; pairing / `PEER_PHONE` / a second Signal number do not. ## Diagram ```mermaid flowchart LR - subgraph group [Signal_group] - Users[Human_members] - end - subgraph txCVM [Transcription_CVM_4GB] - txApi[signal-api_phone_A] - whisper[whisper-api] - txBot[signal-bot_role_transcription] - txBot -->|"HTTP same Docker net"| whisper - txApi --- txBot - end - subgraph trCVM [Translation_CVM_4GB] - trApi[signal-api_phone_B] - trBot[signal-bot_role_translation] - trApi --- trBot - end - Users <-->|Signal_all_messages| txApi - Users <-->|Signal_all_messages| trApi - txBot -->|acts_on_voice_only_posts_text| group - trBot -->|acts_on_text_incl_transcripts| group + Users[Signal_group] --> sigApi[signal-api_phone_B] + sigApi --> bot[signal-bot] + bot -->|"audio bytes only"| NearWhisper[NEAR_AI_Whisper_large_v3] + NearWhisper -->|text| bot + bot -->|"text"| NearChat[NEAR_AI_chat] ``` ## Rules -- **Two phone numbers**, two bots in the group. **Translation = hub manager; transcription = specialized worker** (see [Bot hierarchy](#bot-hierarchy)). -- Signal delivers **all** group messages to every member bot. The transcription bot **receives** text but **ignores** it; it only **acts** on voice. The translation bot receives voice too but only **acts** on text (including transcripts posted by the transcription bot). -- No cross-CVM Docker/HTTP link. Whisper stays **inside** the transcription stack only. -- Same `signal-bot` image; role selected by `BOT__ROLE=transcription|translation`. +- **One phone number**, one bot in the group. Auto-accepts invites (`AcceptAll`). +- No local Whisper sidecar. Voice audio is decrypted in this TEE, stripped of Signal metadata, and sent to **NEAR AI Whisper Large V3** (GPU TEE). Translation text uses the same vendor. +- Per-message `tokio::spawn` so an STT wait cannot stall other handlers in the same process. +- After STT, fan out spoken text in-process so in-chat auto and Language Threads still see transcripts. +- Same `signal-bot` image; live role is `BOT__ROLE=translation` (Whisper + NEAR required). +- Do not reintroduce `whisper-api` or put Whisper on a larger CPU TEE as the scale path. -## Local dual stack (mock prod) +## Local stack ```bash -cp docker/transcription.env.example docker/transcription.env cp docker/translation.env.example docker/translation.env -# Set two different SIGNAL_PHONE values; set NEAR_AI_API_KEY in translation.env +# Set SIGNAL_PHONE; NEAR_AI_API_KEY (chat + Whisper STT) -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env up -d docker compose -f docker/compose.translation.yaml --env-file docker/translation.env up -d - -docker network ls | grep sigstack -# Expect: sigstack-transcription-internal and sigstack-translation-internal ``` -Register each number against its own stack’s `signal-api` (e.g. `docker compose … exec signal-api curl …`). Translation compose also exposes the registration proxy on host port `8081`. - -E2E still needs two registered Signal numbers in a shared test group. Dual compose mocks **isolation and resource split**, not Signal itself. +Register the number against this stack’s `signal-api` (or the registration proxy on host port `8081`). [`docker/compose.transcription.yaml`](../docker/compose.transcription.yaml) is a retired stub. ## Phala (prod) | Compose | CVM | Contents | |---------|-----|----------| -| [`docker/phala.transcription.yaml`](../docker/phala.transcription.yaml) | 4 GB (`tdx.medium`) | `signal-api` + `whisper-api` + `signal-bot` (`BOT__ROLE=transcription`) | -| [`docker/phala.translation.yaml`](../docker/phala.translation.yaml) | 4 GB (`tdx.medium`) | `signal-api` + `signal-bot` (`BOT__ROLE=translation`) + registration proxy | +| [`docker/phala.translation.yaml`](../docker/phala.translation.yaml) | 4 GB (`tdx.medium`) | `signal-api` (phone B) + `signal-bot` + proxy `:8081`. **No Whisper sidecar.** | +| [`docker/phala.transcription.yaml`](../docker/phala.transcription.yaml) | — | **Deprecated stub.** Do not deploy. | + +Live CVM: `sigstack-translation` **`0e82fa77-8b15-4dbd-89c4-9045ab911353`** (app `9adac7636fe255182f699940ffd1924960415507`). The former transcription CVM `eba19afc-0c26-4409-b026-f757928d2ef8` was deleted (idle Whisper bill). Phone A is not re-registered; groups that still list it can remove that contact. + +Upgrade **in place** only: + +```bash +phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353 \ + -c docker/phala.translation.yaml -e docker/phala.translation.env --wait +``` + +[`scripts/deploy_phala.sh`](../scripts/deploy_phala.sh) defaults to that `--cvm-id`. Do not `phala deploy -n` against the live CVM. + +### CVM storage (keep intact) + +The suite stays cohesive only if the live CVM keeps disk state. **TEE RAM is wiped** on every restart or upgrade; that is expected. User prefs and Signal identity are **not** in RAM — they live on named Docker volumes that Phala reattaches on an in-place upgrade. + +| Volume | Compose | What it holds | If wiped | +|--------|---------|---------------|----------| +| `signal-config-translation` | `signal-api` (phone B) | Bot **registered Signal phone** | Bot disappears from Signal until ops re-register | +| `group-prefs-translation` | `signal-bot` → `/data/group_prefs.enc` | Encrypted prefs: `!translate-me-on`, `!translate-all-on`, Language Threads bridges, menu language | Users must re-enable features | +| `registry-data` | registration proxy | Ops registration helper state | Re-register via proxy; does not by itself drop Signal CLI | + +Do **not** rename `signal-config-translation` / `group-prefs-translation` / `registry-data`. Do not migrate `group-prefs-transcription` (`!transcribe-on` default off is the product). Unused transcription volumes from the old two-phone compose may remain on disk; they are not attached. + +**Upgrade the live CVM in place** (`phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353` or the dashboard compose update). Do **not** create a replacement CVM or `down -v` for a routine image bump. First bring-up of a **new** CVM is the exception (empty volumes; register the phone there). + +| Event | Volumes | Users re-enable prefs? | Re-register phone? | +|-------|---------|------------------------|---------------------| +| In-place CVM upgrade (`--cvm-id`) | Kept (Phala reattaches) | No | No | +| Container / CVM restart, same compose | Kept | No | No | +| New CVM / `phala cvms delete` / volume rename | Empty | Yes | Yes | +| Prefs decrypt fail (key mismatch) | File present, unreadable | Yes (bot starts empty) | No (Signal volume is separate) | + +Prefs are encrypted with dstack `DeriveKey` (path `signal-bot/group-preferences`), bound to the CVM **app id**, so a compose/image change should still decrypt. If DeriveKey is unavailable the AppInfo fallback includes `compose_hash` — a compose change then fails decrypt. After upgrade, logs should show `Loaded group preferences for N groups`, not `starting fresh` or `TEE deployment may have changed`. Confirm Signal accounts still listed on `signal-api`. -Deploy each compose to its **own** CVM. Do not co-locate Whisper with the translation bot. +Do not change volume names in [`docker/phala.translation.yaml`](../docker/phala.translation.yaml) without a deliberate migration. -## Products on each CVM +## Products on this CVM -| Product | CVM | Doc | -|---------|-----|-----| -| Voice transcription | Transcription | [voice-transcription.md](voice-transcription.md) | -| In-chat (group) translation | Translation | [in-chat-translation.md](in-chat-translation.md) | -| Language Threads | Translation | [language-threads.md](language-threads.md) | +| Product | Process | Doc | +|---------|---------|-----| +| Voice transcription | Same `signal-bot` | [voice-transcription.md](voice-transcription.md) | +| In-chat (group) translation | Same `signal-bot` | [in-chat-translation.md](in-chat-translation.md) | +| Language Threads | Same `signal-bot` | [language-threads.md](language-threads.md) | ### Interoperability -- **Transcription** (worker CVM) composes with translation products in the same group. The **translation bot** invites via `!transcription` and posts the voice menu after invite; the **transcription bot** runs voice and answers `!transcription` when already paired. -- **In-chat** translates inside one group thread (quote-reply). -- **Language Threads** bridges a multilingual main to N monolingual sidecars (`!translate-me-on`). +- **Voice** quote-replies `📝 Transcript:` then fans out spoken text to in-chat / Language Threads as the **original speaker** (not the bot). +- **In-chat** translates inside one group thread (quote-reply). Bot translation replies are not re-translated. +- **Language Threads** bridges a multilingual main to N monolingual sidecars (`!translate-me-thread`). -## Why split +## Why one process (not two phones) -Transcription (Whisper) is latency- and memory-heavy. Keeping it on a separate CVM prevents long voice jobs from queuing behind translation traffic for users who only subscribe to translation. +Two **processes** used to keep translation off the STT wait, with two Signal numbers so the translation bot could *see* transcripts as another member’s posts. One number does not receive its own group sends, so transcripts must fan out in-process. Per-message `tokio::spawn` replaces the second process for latency isolation. Do not re-home Whisper in this CVM. diff --git a/docs/voice-transcription.md b/docs/voice-transcription.md index 6442276..e4de8ff 100644 --- a/docs/voice-transcription.md +++ b/docs/voice-transcription.md @@ -1,56 +1,42 @@ # Voice transcription -Status: **implemented** on its own Phala / Compose stack (`BOT__ROLE=transcription`). +Status: **implemented** on the unified Bread Bot (`BOT__ROLE=translation`) on the surviving Phala CVM. -Speech → text inside Signal via Whisper in the same CVM as the transcription bot. See [two-CVM architecture](two-cvm-architecture.md) and [issue #8](https://github.com/BreadchainCoop/sigstack-bot/issues/8) under umbrella [#10](https://github.com/BreadchainCoop/sigstack-bot/issues/10). +Speech → text via **NEAR AI Whisper Large V3** (GPU TEE). Audio is decrypted in this CVM, stripped of Signal metadata, and uploaded as a generic file. See [one-CVM architecture](two-cvm-architecture.md), [CPU TEE Whisper does not scale](solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md), and [issue #8](https://github.com/BreadchainCoop/sigstack-bot/issues/8) under umbrella [#10](https://github.com/BreadchainCoop/sigstack-bot/issues/10). -This stack is a **specialized worker**, not the Bread Bot hub. Users discover products and pair bots through the **translation** bot (`!help`, `!transcription` invite). This bot only handles voice transcription, its product menu on `!transcription`, and transcription-side TEE attestation. Hierarchy: [two-cvm-architecture.md — Bot hierarchy](two-cvm-architecture.md#bot-hierarchy). +Users add **one** bot to a group. `!transcription` is the **voice command menu** only (no invite, no pairing). Voice is **default off** (`!transcribe-on` / quote `!transcribe`). ## Where it runs | Stack | Contents | |-------|----------| -| Transcription CVM / Compose | `signal-api` (phone A) + `whisper-api` + `signal-bot` (`BOT__ROLE=transcription`) | -| Translation CVM | Does **not** run Whisper | +| Phala (prod) | Same CVM / same bot as translation: `signal-api` (phone B) + `signal-bot` (`BOT__ROLE=translation`, `WHISPER__ENABLED=true`). **No Whisper sidecar.** | +| Local Compose | [`docker/compose.translation.yaml`](../docker/compose.translation.yaml) — one phone + bot; STT is NEAR AI | -Whisper HTTP (`http://whisper-api:9000`) is **intra**-transcription-stack only. There is no cross-CVM Docker or HTTP link to the translation bot. +`!verify` attests **this** CVM’s compose (one reply). It does not imply Whisper weights live here. -## Signal as bus +## Signal + in-process fan-out -Both bots are members of the same Signal group (two phone numbers). +One Signal number never receives its own group posts. After a successful transcribe (auto or `!transcribe`): -1. Human sends a voice note. -2. Transcription bot downloads the attachment, calls Whisper, quote-replies with text prefixed by `📝 Transcript:` (configurable via `WHISPER__REPLY_PREFIX`). -3. Translation bot treats that text like any other group message (in-chat auto-translate, Language Threads). +1. Quote-reply `📝 Transcript:` as before. +2. If the group has in-chat auto, run the same intercept path (mode from the **original speaker** / quote author, not the bot). +3. If Language Threads is active, relay the spoken text as if the speaker posted it. -## Pairing (translation leads) +Outbound STT: generic filename (`voice.ogg` / `voice.m4a`). No phone, group id, timestamp, or display name in form fields, headers, or filenames. -1. Set `PEER_PHONE` in translation env to the transcription bot’s E.164 (`SIGNAL__PEER_PHONE`). -2. Set `PEER_PHONE` in transcription env to the **translation** bot’s E.164 (required for auto-join). -3. Translation bot must be a **group admin**. -4. In the group: `!transcription` → translation bot invites the peer if missing and posts the Voice Transcription menu. -5. Transcription bot auto-accepts the invite when the translation peer is already in the group (polls pending invites). +**Group invites:** this bot auto-accepts any pending group invite. -Without `PEER_PHONE` on translation, `!transcription` stubs as unavailable. Without `PEER_PHONE` on transcription, auto-join is disabled. - -**Peer trust:** each bot must trust the other’s Signal identity (`TRUSTED_*`, not `UNTRUSTED`). On startup the bot calls `PUT /v1/identities/{self}/trust/{PEER_PHONE}` with `trust_all_known_keys`. If the peer is `UNTRUSTED`, the translation bot will not decrypt transcription posts (so in-chat auto never sees transcripts), and group sends can fail with `Untrusted Identity`. - -When the peer is already in the group (or invite pending), the hub stays silent on `!transcription` so the transcription bot can answer with its menu. - -**Group invites:** the translation hub auto-accepts any pending group invite. The transcription worker only auto-accepts invites for groups where the translation peer is already a member/admin. - -## Commands (transcription bot) - -Worker-only — no `!help` / `!info`. Use the translation bot for the Bread Bot hub. +## Commands | Command | Effect | |---------|--------| -| `!transcription` | Product menu (compact command list for this bot) | +| `!transcription` | Voice product menu (compact command list) | | `!transcribe-on` / `!transcribe-off` | Toggle auto transcription (DM or group; **default off**) | | `!transcribe` | Quote a voice note to transcribe it (refuses with a notice if auto is already on) | -| `!help-transcription` | How voice transcription works (separate CVM/TEE from translation) | +| `!help-transcription` | How voice transcription works (NEAR Whisper GPU TEE) | -Hub `!privacy` (translation bot only) covers both CVMs. In a paired group, `!verify ` returns two quotes (`Translation: …` / `Transcription: …`). +Hub `!privacy` covers this CVM. `!verify ` returns **one** quote. Auto path: inbound voice notes are transcribed only after `!transcribe-on` (default off). @@ -59,35 +45,37 @@ Auto path: inbound voice notes are transcribed only after `!transcribe-on` (defa ### Local ```bash -cp docker/transcription.env.example docker/transcription.env -# Set SIGNAL_PHONE (phone A); PEER_PHONE = translation phone (required for auto-join) +cp docker/translation.env.example docker/translation.env +# Set SIGNAL_PHONE; NEAR_AI_API_KEY (chat + Whisper STT) -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env up -d +docker compose -f docker/compose.translation.yaml --env-file docker/translation.env up -d ``` -Register phone A against this stack’s `signal-api`. Health: +Register the number against this stack’s `signal-api` or proxy `:8081`. Health: ```bash -docker compose -f docker/compose.transcription.yaml exec whisper-api curl -sf http://localhost:9000/health -docker compose -f docker/compose.transcription.yaml exec signal-api curl -sf http://localhost:8080/v1/health +docker compose -f docker/compose.translation.yaml exec signal-api curl -sf http://localhost:8080/v1/health ``` ### Phala +In-place upgrade of the **surviving** translation CVM (do not deploy `phala.transcription.yaml`; do not re-register phone A): + ```bash -# Build & push linux/amd64 images, then: -phala deploy … -c docker/phala.transcription.yaml -e docker/phala.transcription.env --wait -t tdx.medium +phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353 \ + -c docker/phala.translation.yaml -e docker/phala.translation.env --wait ``` -Env template: [`docker/phala.transcription.env.example`](../docker/phala.transcription.env.example). Target size: **4 GB** (`tdx.medium`). Attestation: `!verify ` inside Signal. +Env template: [`docker/phala.translation.env.example`](../docker/phala.translation.env.example). SKU stays **tdx.medium** (2 vCPU / 4 GB) — remote GPU is the STT speed lever, not a larger TDX. Attestation: `!verify ` inside Signal. ## Key code | Area | Path | |------|------| | Voice / `!transcribe*` handlers | [`crates/signal-bot-transcription`](../crates/signal-bot-transcription) | +| In-process transcript fan-out | [`crates/signal-bot/src/transcript_fanout.rs`](../crates/signal-bot/src/transcript_fanout.rs) | | Shared handler trait / errors | [`crates/signal-bot-core`](../crates/signal-bot-core) | -| Whisper HTTP client | [`crates/whisper-client`](../crates/whisper-client) | -| Pairing (`!transcription` on translation) | [`crates/signal-bot/src/commands/product_menus.rs`](../crates/signal-bot/src/commands/product_menus.rs) | +| STT HTTP client | [`crates/whisper-client`](../crates/whisper-client) (NEAR `/audio/transcriptions`) | +| NEAR client (chat + transcribe) | [`crates/near-ai-client`](../crates/near-ai-client) | | Role wiring | [`crates/signal-bot/src/handlers_setup.rs`](../crates/signal-bot/src/handlers_setup.rs) | -| Compose / Phala | [`docker/compose.transcription.yaml`](../docker/compose.transcription.yaml), [`docker/phala.transcription.yaml`](../docker/phala.transcription.yaml) | +| Compose / Phala | [`docker/compose.translation.yaml`](../docker/compose.translation.yaml), [`docker/phala.translation.yaml`](../docker/phala.translation.yaml) | diff --git a/scripts/deploy_phala.sh b/scripts/deploy_phala.sh index 809453f..65aac95 100755 --- a/scripts/deploy_phala.sh +++ b/scripts/deploy_phala.sh @@ -1,26 +1,63 @@ #!/bin/bash -set -e +# Deploy the one-CVM product suite (one Signal number, no local Whisper). +# +# Live upgrades MUST use --cvm-id so volumes stay (phone B + group prefs): +# CVM_ID=0e82fa77-8b15-4dbd-89c4-9045ab911353 ./scripts/deploy_phala.sh +# +# Do NOT `phala deploy -n` against the live translation CVM — that can create a +# replacement with empty volumes (lost Signal session + user prefs). +# Do NOT deploy docker/phala.transcription.yaml (deprecated stub). +# See docs/two-cvm-architecture.md#cvm-storage-keep-intact +# +# First create (empty volumes) only when no CVM exists: +# FIRST_CREATE=1 ./scripts/deploy_phala.sh +# +# Requires: phala CLI logged in; filled docker/phala.translation.env (never commit). +# Images must already be pushed (linux/amd64). +set -euo pipefail -# Check for phala CLI -if ! command -v phala &> /dev/null; -then - echo "Error: phala CLI not found. Install with: npm install -g phala" - exit 1 +if ! command -v phala &> /dev/null; then + echo "Error: phala CLI not found. Install with: npm install -g phala" + exit 1 fi -# Configuration -APP_NAME=${APP_NAME:-"signal-bot-tee"} -COMPOSE_FILE=${COMPOSE_FILE:-"./docker/phala-compose.yaml"} +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TR_COMPOSE="${TR_COMPOSE:-$ROOT/docker/phala.translation.yaml}" +TR_ENV="${TR_ENV:-$ROOT/docker/phala.translation.env}" +TR_NAME="${TR_NAME:-sigstack-translation}" +INSTANCE_TYPE="${INSTANCE_TYPE:-tdx.medium}" +DISK_SIZE="${DISK_SIZE:-40G}" +# Surviving prod CVM (phone B). +CVM_ID="${CVM_ID:-0e82fa77-8b15-4dbd-89c4-9045ab911353}" +FIRST_CREATE="${FIRST_CREATE:-0}" -echo "Deploying $APP_NAME to Phala Cloud..." -echo "Using compose file: $COMPOSE_FILE" +if [[ ! -f "$TR_COMPOSE" ]]; then + echo "Error: missing $TR_COMPOSE" + exit 1 +fi +if [[ ! -f "$TR_ENV" ]]; then + echo "Error: missing $TR_ENV (copy docker/phala.translation.env.example)" + exit 1 +fi -phala cvms create \ - --name "$APP_NAME" \ - --compose "$COMPOSE_FILE" \ - --vcpu 2 \ - --memory 4096 \ - --disk-size 20 +if [[ "$FIRST_CREATE" == "1" ]]; then + echo "First-create $TR_NAME ($INSTANCE_TYPE, disk $DISK_SIZE) — empty volumes; register phone after." + phala deploy \ + -n "$TR_NAME" \ + -c "$TR_COMPOSE" \ + -e "$TR_ENV" \ + -t "$INSTANCE_TYPE" \ + --disk-size "$DISK_SIZE" \ + --wait +else + echo "In-place upgrade of CVM $CVM_ID ($TR_COMPOSE)..." + phala deploy \ + --cvm-id "$CVM_ID" \ + -c "$TR_COMPOSE" \ + -e "$TR_ENV" \ + --wait +fi echo "" -echo "Deployment initiated. Check status with: phala cvms list" +echo "Done. Check status with: phala cvms list" +echo "Phone B stays on this CVM (proxy :8081). Do not re-register phone A." diff --git a/scripts/register_phala_phones.sh b/scripts/register_phala_phones.sh new file mode 100755 index 0000000..fa31ab6 --- /dev/null +++ b/scripts/register_phala_phones.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# Register the surviving Signal number (phone B) via proxy :8081. +# +# Phone B should already be registered on the live CVM — skip if +# accounts already lists it. Do not re-register phone A. +# +# Usage: +# CAPTCHA_TR='signalcaptcha://...' ./scripts/register_phala_phones.sh +# Then when SMS arrives: +# SMS_TR=123456 ./scripts/register_phala_phones.sh --verify +set -euo pipefail + +APP_ID="${APP_ID:-9adac7636fe255182f699940ffd1924960415507}" +GATEWAY="${GATEWAY:-dstack-pha-prod9.phala.network}" +TR_PROXY="${TR_PROXY:-https://${APP_ID}-8081.${GATEWAY}}" +PHONE_TR="${PHONE_TR:-+573107677679}" + +MODE="${1:-register}" + +if [[ "$MODE" == "--verify" ]]; then + if [[ -z "${SMS_TR:-}" ]]; then + echo "Error: set SMS_TR" >&2 + exit 1 + fi + echo "Verifying $PHONE_TR on :8081..." + curl -sS -X POST "$TR_PROXY/v1/register/${PHONE_TR}/verify/${SMS_TR}" \ + -H 'Content-Type: application/json' -d '{}' + echo +else + if [[ -z "${CAPTCHA_TR:-}" ]]; then + echo "Error: set CAPTCHA_TR (phone B / :8081)" >&2 + exit 1 + fi + echo "Registering $PHONE_TR on :8081..." + curl -sS -X POST "$TR_PROXY/v1/register/${PHONE_TR}" \ + -H 'Content-Type: application/json' \ + -d "$(python3 -c 'import json,os; print(json.dumps({"captcha":os.environ["CAPTCHA_TR"],"use_voice":False}))')" + echo +fi + +echo "Accounts (translation :8081):" +curl -sS "$TR_PROXY/v1/debug/signal-accounts"; echo