From 0844b2167ddf3fa2be4e00d7af4ef64ae216dff0 Mon Sep 17 00:00:00 2001 From: daopunk Date: Wed, 5 Aug 2026 08:22:04 -0500 Subject: [PATCH 1/4] docs: deployment plan --- .../plans/2026-08-05-dual-cvm-phala-deploy.md | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 docs/plans/2026-08-05-dual-cvm-phala-deploy.md 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..dc8071b --- /dev/null +++ b/docs/plans/2026-08-05-dual-cvm-phala-deploy.md @@ -0,0 +1,165 @@ +# Plan: Dual-CVM Phala deploy (2× tdx.medium) + +**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 | +|-----|----------|---------|------| +| Transcription (worker) | `tdx.medium` | [`docker/phala.transcription.yaml`](../../docker/phala.transcription.yaml) | Phone A + Whisper + `BOT__ROLE=transcription` | +| Translation (hub) | `tdx.medium` | [`docker/phala.translation.yaml`](../../docker/phala.translation.yaml) | Phone B + NEAR AI + `BOT__ROLE=translation` + registration proxy | + +**Cost:** ~$0.232/hr combined (~$167/mo if always on). +**Auth (already true here):** Phala CLI logged in as `daopunk` / workspace **Bread Coop**. +**Existing:** one stopped CVM `dstack-app-hqvaf` — delete or ignore before creating the two new apps. + +--- + +## 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 \ + --disk-size 40G \ + --wait + +# Translation hub +phala deploy \ + -n sigstack-translation \ + -c docker/phala.translation.yaml \ + -e docker/phala.translation.env \ + -t tdx.medium \ + --disk-size 40G \ + --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 + +Fresh CVM ⇒ empty Signal CLI volumes ⇒ **re-register** both numbers. + +1. **Translation (phone B):** use registration proxy on `:8081` (same captcha flow as [docs/local-dev](../local-dev/README.md)), or `phala ssh` / exec into `signal-api` and hit `/v1/register`. +2. **Transcription (phone A):** register against that CVM’s `signal-api` (no public proxy in transcription compose — SSH/exec or temporary ops path). +3. Verify SMS/voice codes; confirm both numbers appear as Signal contacts. + +Do **not** `down -v` / wipe 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 From b35768241b2a7f5ee3855207c74d39c56af635de Mon Sep 17 00:00:00 2001 From: daopunk Date: Thu, 13 Aug 2026 23:02:38 -0500 Subject: [PATCH 2/4] docs: keep cvm volumes across phala tee upgrades --- .agents/docs/DEVELOPMENT.md | 20 ++- .cursor/rules/compound-engineering.mdc | 1 + .cursor/rules/phala-cvm-storage.mdc | 18 ++ .gitignore | 1 + AGENTS.md | 16 +- README.md | 15 +- docker/phala.transcription.env.example | 5 + docker/phala.transcription.yaml | 32 +++- docker/phala.translation.env.example | 4 +- docker/phala.translation.yaml | 6 +- docs/in-chat-translation.md | 4 + docs/language-threads.md | 16 +- docs/local-dev/README.md | 2 +- .../plans/2026-08-05-dual-cvm-phala-deploy.md | 38 +++-- docs/plans/2026-08-05-phala-deploy-handoff.md | 155 ++++++++++++++++++ docs/two-cvm-architecture.md | 25 ++- scripts/deploy_phala.sh | 62 +++++-- scripts/register_phala_phones.sh | 44 +++++ 18 files changed, 409 insertions(+), 55 deletions(-) create mode 100644 .cursor/rules/phala-cvm-storage.mdc create mode 100644 docs/plans/2026-08-05-phala-deploy-handoff.md create mode 100755 scripts/register_phala_phones.sh diff --git a/.agents/docs/DEVELOPMENT.md b/.agents/docs/DEVELOPMENT.md index c2dce50..9411905 100644 --- a/.agents/docs/DEVELOPMENT.md +++ b/.agents/docs/DEVELOPMENT.md @@ -99,8 +99,9 @@ docker buildx build --platform linux/amd64 -t YOUR/signal-bot-tee:latest -f dock 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 -n … -c docker/phala.transcription.yaml -e docker/phala.transcription.env --wait -t tdx.medium +phala deploy -n … -c docker/phala.translation.yaml -e docker/phala.translation.env --wait -t tdx.medium +# After phones are registered: upgrade with --cvm-id (not a new CVM) so volumes stay. ``` Env templates: `docker/phala.transcription.env.example`, `docker/phala.translation.env.example`. @@ -109,6 +110,21 @@ Encrypted secrets: phone numbers per CVM; `PEER_PHONE` for pairing; `NEAR_AI_API Health (transcription): Whisper `GET /health` on `:9000`, Signal CLI `GET /v1/health` on `:8080`. Attestation: `!verify `. +### CVM storage — do not wipe + +**In-place upgrades only** once phones are registered. TEE RAM wipe is expected; **disk volumes are the product identity.** + +| Must keep | Volume | Breakage if lost | +|-----------|--------|------------------| +| Bot Signal phone session | `signal-config-transcription` / `signal-config-translation` | Bot gone from groups until re-register (takes over the number) | +| User prefs (`!translate-me-on`, `!translate-all-on`, Language Threads) | `group-prefs-*` → `/data/group_prefs.enc` | Users must re-enable; suite looks broken | + +Use `phala deploy --cvm-id `. Do **not** `phala cvms delete`, create a replacement CVM, rename those volumes, or `down -v` for an image bump. [`scripts/deploy_phala.sh`](../../scripts/deploy_phala.sh) uses `-n` (first create), not a safe upgrade of a registered CVM. + +After upgrade, logs should show `Loaded group preferences for N groups` (not `starting fresh` / `TEE deployment may have changed`), and each CVM’s `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 | diff --git a/.cursor/rules/compound-engineering.mdc b/.cursor/rules/compound-engineering.mdc index 35289d9..1112caa 100644 --- a/.cursor/rules/compound-engineering.mdc +++ b/.cursor/rules/compound-engineering.mdc @@ -27,4 +27,5 @@ Product docs stay as siblings under `docs/` (architecture, voice, translation). - Required env: `BOT__ROLE=transcription|translation` - Do not reintroduce tools, x402, or general chat paths - Image digests stay pinned in compose for attestation +- **Keep live CVM storage:** never wipe or replace Phala volumes / registered Signal phones for a routine TEE upgrade. User prefs (`group-prefs-*`) and Signal CLI sessions (`signal-config-*`) 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..43eb007 --- /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-transcription` / `signal-config-translation` on `signal-api`. Losing it unlinks the bot from every group until re-register (which takes over the number). +- **User prefs:** `group-prefs-*` → `/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/.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..753a2a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,20 +62,30 @@ 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 | +| [`.agents/docs/DEVELOPMENT.md`](.agents/docs/DEVELOPMENT.md) | TEE trust model, `BOT__ROLE`, Phala dual-CVM ops, **CVM volume / Signal identity** | +| [`docs/two-cvm-architecture.md`](docs/two-cvm-architecture.md) | Architecture diagram, compose/Phala split, **CVM storage (keep intact)** | | [`docs/voice-transcription.md`](docs/voice-transcription.md) | Voice transcription product + pairing | | [`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 a registered CVM for a routine upgrade.** The product is cohesive only if each TEE keeps: + +1. **The bot’s registered Signal phone** (`signal-config-*` on `signal-api`) — losing this unlinks the bot from every group until ops re-registers (and takes over the number). +2. **Encrypted user prefs** (`group-prefs-*` → `/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 `. Do not create a new CVM, rename those volumes, or `down -v`. 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` - Do not reintroduce tools, x402, or general chat paths - 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..1c62660 100644 --- a/README.md +++ b/README.md @@ -71,11 +71,17 @@ 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 … +# First create (empty volumes — register phones on the CVM afterward) +phala deploy -n sigstack-transcription -c docker/phala.transcription.yaml … +phala deploy -n sigstack-translation -c docker/phala.translation.yaml … + +# Later image/compose bumps: upgrade the *existing* CVM so volumes stay attached +phala deploy --cvm-id sigstack-transcription -c docker/phala.transcription.yaml … +phala deploy --cvm-id sigstack-translation -c docker/phala.translation.yaml … ``` +**Do not replace a live CVM or wipe its volumes** for a routine upgrade. Each CVM’s disk holds the bot’s **registered Signal phone** and **encrypted user prefs** (`!translate-me-on`, Language Threads, and so on). TEE RAM is cleared on reboot; Phala reattaches named volumes on in-place upgrade, so users should not have to turn features back on. Details: [docs/two-cvm-architecture.md — CVM storage](docs/two-cvm-architecture.md#cvm-storage-keep-intact). + ## Project structure ``` @@ -92,7 +98,8 @@ docker/ phala.transcription.yaml phala.translation.yaml docs/ - two-cvm-architecture.md + two-cvm-architecture.md # includes CVM storage / in-place upgrade + in-chat-translation.md language-threads.md ``` diff --git a/docker/phala.transcription.env.example b/docker/phala.transcription.env.example index dbf903b..d4d1ca4 100644 --- a/docker/phala.transcription.env.example +++ b/docker/phala.transcription.env.example @@ -8,8 +8,13 @@ PEER_PHONE=+1YYYYYYYYYY SIGNAL_BOT_IMAGE=YOUR_DOCKERHUB/signal-bot-tee:latest WHISPER_IMAGE=YOUR_DOCKERHUB/signal-whisper-api:latest +SIGNAL_PROXY_IMAGE=YOUR_DOCKERHUB/signal-registration-proxy:latest WHISPER_MODEL=small WHISPER_TIMEOUT=120s LOG_LEVEL=info BOT_GITHUB_REPO=https://github.com/BreadchainCoop/sigstack-bot +RATE_LIMIT_GLOBAL_PER_MINUTE=10 +RATE_LIMIT_PER_NUMBER_PER_HOUR=3 +# Optional: inject an ops SSH pubkey so `phala ssh -i …` works without the account ssh-keys API. +# DSTACK_AUTHORIZED_KEYS=ssh-ed25519 AAAA... diff --git a/docker/phala.transcription.yaml b/docker/phala.transcription.yaml index 716de87..64d555a 100644 --- a/docker/phala.transcription.yaml +++ b/docker/phala.transcription.yaml @@ -3,9 +3,16 @@ # 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 . +# 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 translation): +# Deploy to a dedicated CVM (separate from translation). +# First create: `phala deploy -n …`. Later upgrades MUST use `--cvm-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_TX_CVM -c docker/phala.transcription.yaml -e docker/phala.transcription.env --wait -t tdx.medium +# +# Prefer pinning images by digest in the env file (attestation). Registration proxy on :8081 +# is an ops helper to register phone A against this CVM's signal-api (no SSH required). services: signal-api: @@ -62,10 +69,33 @@ services: - whisper-api restart: unless-stopped + signal-registration-proxy: + image: ${SIGNAL_PROXY_IMAGE} + environment: + - SIGNAL__API_URL=http://signal-api:8080 + - REGISTRY__PATH=/data/registry.enc + - SERVER__LISTEN_ADDR=0.0.0.0 + - SERVER__PORT=8081 + - DSTACK__SOCKET_PATH=/var/run/dstack.sock + - RATE_LIMIT__GLOBAL_PER_MINUTE=${RATE_LIMIT_GLOBAL_PER_MINUTE:-60} + - RATE_LIMIT__PER_NUMBER_PER_HOUR=${RATE_LIMIT_PER_NUMBER_PER_HOUR:-10} + - LOG__LEVEL=${LOG_LEVEL:-info} + volumes: + - /var/run/dstack.sock:/var/run/dstack.sock:ro + - registry-data-transcription:/data + ports: + - "8081:8081" + depends_on: + - signal-api + restart: unless-stopped + volumes: + # Keep these names: renaming creates empty volumes and drops Signal identity + prefs. signal-config-transcription: driver: local whisper-models: driver: local group-prefs-transcription: driver: local + registry-data-transcription: + driver: local diff --git a/docker/phala.translation.env.example b/docker/phala.translation.env.example index 711567e..1ecdd63 100644 --- a/docker/phala.translation.env.example +++ b/docker/phala.translation.env.example @@ -7,7 +7,7 @@ SIGNAL_PHONE=+1YYYYYYYYYY 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 @@ -21,3 +21,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..da680e1 100644 --- a/docker/phala.translation.yaml +++ b/docker/phala.translation.yaml @@ -5,7 +5,10 @@ # 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): +# Deploy to a dedicated CVM (separate from transcription). +# First create: `phala deploy -n …`. Later upgrades MUST use `--cvm-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_TR_CVM -c docker/phala.translation.yaml -e docker/phala.translation.env --wait -t tdx.medium services: @@ -72,6 +75,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/docs/in-chat-translation.md b/docs/in-chat-translation.md index 7c5c894..4a55a1c 100644 --- a/docs/in-chat-translation.md +++ b/docs/in-chat-translation.md @@ -61,6 +61,10 @@ Skip when language is undetected or not in the pair. Rate-limited per group (`TR 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`). +## 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 | Area | Path | diff --git a/docs/language-threads.md b/docs/language-threads.md index 9a9c4b8..1a21473 100644 --- a/docs/language-threads.md +++ b/docs/language-threads.md @@ -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 @@ -139,12 +141,14 @@ Whisper / voice live on the **transcription** stack — see [voice-transcription - **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. - **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. -- 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). +- Dual CVMs live on Phala (`tdx.medium` = 2 vCPU / 4 GB RAM each): + - **Transcription:** `sigstack-transcription` — Whisper + phone A ([`docker/phala.transcription.yaml`](../docker/phala.transcription.yaml)) + - **Translation:** `sigstack-translation` — hub + phone B, no Whisper ([`docker/phala.translation.yaml`](../docker/phala.translation.yaml)) +- Deploy uses **Docker images** (digest-pinned in env), not a public git clone. +- Env templates: [`docker/phala.transcription.env.example`](../docker/phala.transcription.env.example), [`docker/phala.translation.env.example`](../docker/phala.translation.env.example) (secrets; do not commit filled env). +- Fresh CVM ⇒ register Signal phones against each CVM’s registration proxy (`:8081` via Phala gateway). ## Trust / privacy notes @@ -155,6 +159,4 @@ Whisper / voice live on the **transcription** stack — see [voice-transcription ## 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..81ff602 100644 --- a/docs/local-dev/README.md +++ b/docs/local-dev/README.md @@ -80,7 +80,7 @@ docker compose -f docker/compose.translation.yaml --env-file docker/translation. (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). --- diff --git a/docs/plans/2026-08-05-dual-cvm-phala-deploy.md b/docs/plans/2026-08-05-dual-cvm-phala-deploy.md index dc8071b..6d5a70b 100644 --- a/docs/plans/2026-08-05-dual-cvm-phala-deploy.md +++ b/docs/plans/2026-08-05-dual-cvm-phala-deploy.md @@ -2,14 +2,17 @@ **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 | -|-----|----------|---------|------| -| Transcription (worker) | `tdx.medium` | [`docker/phala.transcription.yaml`](../../docker/phala.transcription.yaml) | Phone A + Whisper + `BOT__ROLE=transcription` | -| Translation (hub) | `tdx.medium` | [`docker/phala.translation.yaml`](../../docker/phala.translation.yaml) | Phone B + NEAR AI + `BOT__ROLE=translation` + registration proxy | +| 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 (already true here):** Phala CLI logged in as `daopunk` / workspace **Bread Coop**. -**Existing:** one stopped CVM `dstack-app-hqvaf` — delete or ignore before creating the two new apps. +**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` --- @@ -84,8 +87,8 @@ phala deploy \ -n sigstack-transcription \ -c docker/phala.transcription.yaml \ -e docker/phala.transcription.env \ - -t tdx.medium \ - --disk-size 40G \ + -t tdx.medium \ # 2 vCPU / 4 GB RAM + --disk-size 40G \ # 40 GB disk (not RAM); Phala default --wait # Translation hub @@ -93,8 +96,8 @@ phala deploy \ -n sigstack-translation \ -c docker/phala.translation.yaml \ -e docker/phala.translation.env \ - -t tdx.medium \ - --disk-size 40G \ + -t tdx.medium \ # 2 vCPU / 4 GB RAM + --disk-size 40G \ # 40 GB disk (not RAM) --wait ``` @@ -114,15 +117,18 @@ Cleanup (optional): `phala cvms delete --cvm-id dstack-app-hqvaf` once the new p --- -## Phase 3 — Register Signal phones +## 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. -Fresh CVM ⇒ empty Signal CLI volumes ⇒ **re-register** both numbers. +**Target = Phala CVMs, not local Docker.** Do not use `localhost:8080` / `localhost:8081` from the dual Compose guide for this step. -1. **Translation (phone B):** use registration proxy on `:8081` (same captcha flow as [docs/local-dev](../local-dev/README.md)), or `phala ssh` / exec into `signal-api` and hit `/v1/register`. -2. **Transcription (phone A):** register against that CVM’s `signal-api` (no public proxy in transcription compose — SSH/exec or temporary ops path). -3. Verify SMS/voice codes; confirm both numbers appear as Signal contacts. +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** `down -v` / wipe volumes after registration unless you intend to re-register. +Do **not** wipe CVM volumes after registration unless you intend to re-register. --- 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..6dcd141 --- /dev/null +++ b/docs/plans/2026-08-05-phala-deploy-handoff.md @@ -0,0 +1,155 @@ +# Ops handoff: dual-CVM Phala deploy + +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/two-cvm-architecture.md b/docs/two-cvm-architecture.md index 66e4602..bf630a6 100644 --- a/docs/two-cvm-architecture.md +++ b/docs/two-cvm-architecture.md @@ -76,6 +76,29 @@ E2E still needs two registered Signal numbers in a shared test group. Dual compo Deploy each compose to its **own** CVM. Do not co-locate Whisper with the translation bot. +### CVM storage (keep intact) + +The suite stays cohesive only if each live CVM keeps two kinds of 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-transcription` / `signal-config-translation` | `signal-api` | That CVM’s **registered Signal phone** (CLI session) | Bot disappears from Signal until ops re-register; re-registering also takes the number over from any other device | +| `group-prefs-transcription` / `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; bots look forgetful | +| `registry-data` / `registry-data-transcription` | registration proxy | Ops registration helper state | Re-register via proxy; does not by itself drop Signal CLI | + +**Upgrade live CVMs in place** (`phala deploy --cvm-id ` or the dashboard compose update). Do **not** create a replacement CVM, rename those volumes, or `down -v` for a routine image bump. First bring-up of a **new** CVM is the exception (empty volumes; register phones there). + +| Event | Volumes | Users re-enable prefs? | Re-register phones? | +|-------|---------|------------------------|---------------------| +| 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 each CVM’s `signal-api`. + +Do not change volume names in [`docker/phala.transcription.yaml`](../docker/phala.transcription.yaml) / [`docker/phala.translation.yaml`](../docker/phala.translation.yaml) without a deliberate migration. [`scripts/deploy_phala.sh`](../scripts/deploy_phala.sh) deploys by **name** (`-n`); that is for first create, not a safe in-place upgrade of an already-registered CVM. + ## Products on each CVM | Product | CVM | Doc | @@ -88,7 +111,7 @@ Deploy each compose to its **own** CVM. Do not co-locate Whisper with the transl - **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`). +- **Language Threads** bridges a multilingual main to N monolingual sidecars (`!translate-me-thread`). ## Why split diff --git a/scripts/deploy_phala.sh b/scripts/deploy_phala.sh index 809453f..12d74f4 100755 --- a/scripts/deploy_phala.sh +++ b/scripts/deploy_phala.sh @@ -1,26 +1,52 @@ #!/bin/bash -set -e +# First-create both product CVMs on Phala Cloud (2× tdx.medium = 2 vCPU / 4 GB RAM each). +# Uses `phala deploy -n` (new names). Do NOT run this against live registered CVMs — +# that would risk empty volumes (lost Signal sessions + user prefs). Upgrade existing +# CVMs with `phala deploy --cvm-id ` so signal-config-* and group-prefs-* stay. +# See docs/two-cvm-architecture.md#cvm-storage-keep-intact and AGENTS.md. +# Requires: phala CLI logged in; filled docker/phala.transcription.env + docker/phala.translation.env +# (never commit those env files). 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)" +TX_COMPOSE="${TX_COMPOSE:-$ROOT/docker/phala.transcription.yaml}" +TR_COMPOSE="${TR_COMPOSE:-$ROOT/docker/phala.translation.yaml}" +TX_ENV="${TX_ENV:-$ROOT/docker/phala.transcription.env}" +TR_ENV="${TR_ENV:-$ROOT/docker/phala.translation.env}" +TX_NAME="${TX_NAME:-sigstack-transcription}" +TR_NAME="${TR_NAME:-sigstack-translation}" +INSTANCE_TYPE="${INSTANCE_TYPE:-tdx.medium}" +DISK_SIZE="${DISK_SIZE:-40G}" + +for f in "$TX_COMPOSE" "$TR_COMPOSE" "$TX_ENV" "$TR_ENV"; do + if [[ ! -f "$f" ]]; then + echo "Error: missing $f" + exit 1 + fi +done -echo "Deploying $APP_NAME to Phala Cloud..." -echo "Using compose file: $COMPOSE_FILE" +echo "Deploying $TX_NAME ($INSTANCE_TYPE, disk $DISK_SIZE)..." +phala deploy \ + -n "$TX_NAME" \ + -c "$TX_COMPOSE" \ + -e "$TX_ENV" \ + -t "$INSTANCE_TYPE" \ + --disk-size "$DISK_SIZE" \ + --wait -phala cvms create \ - --name "$APP_NAME" \ - --compose "$COMPOSE_FILE" \ - --vcpu 2 \ - --memory 4096 \ - --disk-size 20 +echo "Deploying $TR_NAME ($INSTANCE_TYPE, disk $DISK_SIZE)..." +phala deploy \ + -n "$TR_NAME" \ + -c "$TR_COMPOSE" \ + -e "$TR_ENV" \ + -t "$INSTANCE_TYPE" \ + --disk-size "$DISK_SIZE" \ + --wait echo "" -echo "Deployment initiated. Check status with: phala cvms list" +echo "Both CVMs deployed. Check status with: phala cvms list" diff --git a/scripts/register_phala_phones.sh b/scripts/register_phala_phones.sh new file mode 100755 index 0000000..dd4fe08 --- /dev/null +++ b/scripts/register_phala_phones.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Register Signal phones onto the live Phala CVMs via each CVM's registration proxy. +# Usage: +# CAPTCHA_TX='signalcaptcha://...' CAPTCHA_TR='signalcaptcha://...' ./scripts/register_phala_phones.sh +# Then when SMS arrives: +# SMS_TX=123456 SMS_TR=654321 ./scripts/register_phala_phones.sh --verify +set -euo pipefail + +TX_PROXY="${TX_PROXY:-https://c5df154154e8c61105fefe84d43515e69b0e2537-8081.dstack-pha-prod9.phala.network}" +TR_PROXY="${TR_PROXY:-https://9adac7636fe255182f699940ffd1924960415507-8081.dstack-pha-prod9.phala.network}" +PHONE_TX="${PHONE_TX:-+573103479014}" +PHONE_TR="${PHONE_TR:-+573107677679}" + +MODE="${1:-register}" + +if [[ "$MODE" == "--verify" ]]; then + : "${SMS_TX:?set SMS_TX}" + : "${SMS_TR:?set SMS_TR}" + echo "Verifying transcription $PHONE_TX..." + curl -sS -X POST "$TX_PROXY/v1/register/${PHONE_TX}/verify/${SMS_TX}" \ + -H 'Content-Type: application/json' -d '{}' + echo + echo "Verifying translation $PHONE_TR..." + curl -sS -X POST "$TR_PROXY/v1/register/${PHONE_TR}/verify/${SMS_TR}" \ + -H 'Content-Type: application/json' -d '{}' + echo +else + : "${CAPTCHA_TX:?set CAPTCHA_TX to full signalcaptcha:// token}" + : "${CAPTCHA_TR:?set CAPTCHA_TR to full signalcaptcha:// token}" + echo "Registering transcription $PHONE_TX on CVM proxy..." + curl -sS -X POST "$TX_PROXY/v1/register/${PHONE_TX}" \ + -H 'Content-Type: application/json' \ + -d "$(python3 -c 'import json,os; print(json.dumps({"captcha":os.environ["CAPTCHA_TX"],"use_voice":False}))')" + echo + echo "Registering translation $PHONE_TR on CVM proxy..." + 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:" +curl -sS "$TX_PROXY/v1/debug/signal-accounts"; echo +curl -sS "$TR_PROXY/v1/debug/signal-accounts"; echo From 00834fae7bad714af814f2385f6f633f526682f8 Mon Sep 17 00:00:00 2001 From: daopunk Date: Thu, 13 Aug 2026 23:47:33 -0500 Subject: [PATCH 3/4] feat: replace cpu-tee whisper with near-ai large-v3 on one cvm --- .agents/docs/DEVELOPMENT.md | 85 +++++++------- .cursor/rules/compound-engineering.mdc | 1 + .env.example | 11 +- AGENTS.md | 18 +-- README.md | 46 ++++---- crates/near-ai-client/Cargo.toml | 2 +- crates/near-ai-client/src/client.rs | 40 +++++++ crates/near-ai-client/src/lib.rs | 47 ++++++++ crates/near-ai-client/src/types.rs | 8 ++ .../src/manual_transcribe.rs | 32 +++--- crates/signal-bot-transcription/src/voice.rs | 48 +++++--- crates/signal-bot/src/commands/menu_locale.rs | 22 ++-- crates/signal-bot/src/commands/privacy.rs | 3 +- crates/signal-bot/src/config.rs | 43 +++++-- crates/signal-bot/src/handlers_setup.rs | 26 ++++- crates/signal-bot/src/main.rs | 14 ++- crates/whisper-client/src/client.rs | 64 +++++++---- crates/whisper-client/src/lib.rs | 32 ++++-- crates/whisper-client/src/types.rs | 4 +- deploy/dstack-app-ctqs1/phala.env.example | 2 +- docker/compose.transcription.yaml | 36 ++---- docker/compose.translation.yaml | 5 +- docker/docker-compose.yaml | 2 +- docker/phala-compose.yaml | 9 +- docker/phala-test.yaml | 3 +- docker/phala.transcription.env.example | 23 +--- docker/phala.transcription.yaml | 105 ++---------------- docker/phala.translation.env.example | 15 ++- docker/phala.translation.yaml | 88 +++++++++++++-- docker/transcription.env.example | 8 +- docker/translation.env.example | 1 + docs/language-threads.md | 16 ++- docs/local-dev/README.md | 15 +-- .../plans/2026-08-05-dual-cvm-phala-deploy.md | 2 + docs/plans/2026-08-05-phala-deploy-handoff.md | 2 + docs/solutions/README.md | 8 ++ ...26-08-13-cpu-tee-whisper-does-not-scale.md | 62 +++++++++++ docs/spikes/2026-6-23-phase0-whisper-spike.md | 2 + docs/two-cvm-architecture.md | 99 +++++++++-------- docs/voice-transcription.md | 35 +++--- scripts/deploy_phala.sh | 79 +++++++------ scripts/register_phala_phones.sh | 79 ++++++++----- 42 files changed, 768 insertions(+), 474 deletions(-) create mode 100644 docs/solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md diff --git a/.agents/docs/DEVELOPMENT.md b/.agents/docs/DEVELOPMENT.md index 9411905..b466d0b 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** — `BOT__ROLE=transcription` (NEAR AI Whisper Large V3, not an in-CVM sidecar) +2. **In-chat group translation** — `BOT__ROLE=translation` (see [`docs/in-chat-translation.md`](../../docs/in-chat-translation.md)) +3. **Language Threads** — translation role, 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, two phones). 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,16 +20,16 @@ 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. Each bot process has its own `signal-api` so 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, two phones, Signal as bus -- 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. +- Same Phala CVM: transcription bot (phone A, **worker**) + translation bot (phone B, **hub**) +- No `whisper-api` sidecar. Transcription posts audio to NEAR AI Whisper Large V3 (GPU TEE). +- Translation sends **text** to NEAR AI chat. Roles do not HTTP to each other; integration = both bots in the same Signal group. +- Two processes so STT HTTP waits never stall translation polling. Full hierarchy table: [`docs/two-cvm-architecture.md`](../../docs/two-cvm-architecture.md#bot-hierarchy). @@ -39,18 +39,18 @@ 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 | +| `transcription` | Voice, `!transcribe*`, `!transcription` menu, `!help-transcription`, `!verify` (worker — no hub `!help` / `!info` / `!privacy`) | `WHISPER__ENABLED=true` + `NEAR_AI__API_KEY` (remote STT) | | `translation` | Hub (`!help`, `!info`, `!privacy`, product menus), Language Threads, in-chat, quote `!translate`, `!transcription` pairing, `!verify` | `NEAR_AI__API_KEY` | -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 combined role. ## Project structure @@ -59,30 +59,32 @@ crates/ signal-bot/ # Binary (role-selected 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.transcription.yaml # local phone A (no Whisper sidecar) + compose.translation.yaml # local phone B + 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 ``` +`Dockerfile.whisper` is unused on the live path. + ## Local dual 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 +# NEAR_AI_API_KEY in both env files 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 @@ -92,23 +94,23 @@ Networks: `sigstack-transcription-internal`, `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 -n … -c docker/phala.transcription.yaml -e docker/phala.transcription.env --wait -t tdx.medium -phala deploy -n … -c docker/phala.translation.yaml -e docker/phala.translation.env --wait -t tdx.medium -# After phones are registered: upgrade with --cvm-id (not a new CVM) so volumes stay. +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), `TRANSCRIPTION_PHONE` (phone A), `NEAR_AI_API_KEY` (shared). -Encrypted secrets: phone numbers per CVM; `PEER_PHONE` for pairing; `NEAR_AI_API_KEY` on translation only. +Health: Signal CLI `GET /v1/health` on each `signal-api`. Attestation: `!verify ` (this CVM’s compose, not remote Whisper). -Health (transcription): Whisper `GET /health` on `:9000`, Signal CLI `GET /v1/health` on `:8080`. Attestation: `!verify `. +After the first one-CVM merge, re-register phone A on proxy **:8082**. Phone B on **:8081** stays. ### CVM storage — do not wipe @@ -116,12 +118,13 @@ Health (transcription): Whisper `GET /health` on `:9000`, Signal CLI `GET /v1/he | Must keep | Volume | Breakage if lost | |-----------|--------|------------------| -| Bot Signal phone session | `signal-config-transcription` / `signal-config-translation` | Bot gone from groups until re-register (takes over the number) | -| User prefs (`!translate-me-on`, `!translate-all-on`, Language Threads) | `group-prefs-*` → `/data/group_prefs.enc` | Users must re-enable; suite looks broken | +| Translation Signal session (phone B) | `signal-config-translation` | Hub gone from groups until re-register | +| Transcription Signal session (phone A) | `signal-config-transcription` | Voice worker gone until re-register | +| User prefs (`!translate-me-on`, Language Threads, `!transcribe-on`) | `group-prefs-*` → `/data/group_prefs.enc` | Users must re-enable; suite looks broken | -Use `phala deploy --cvm-id `. Do **not** `phala cvms delete`, create a replacement CVM, rename those volumes, or `down -v` for an image bump. [`scripts/deploy_phala.sh`](../../scripts/deploy_phala.sh) uses `-n` (first create), not a safe upgrade of a registered CVM. +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 each CVM’s `signal-api` should still list its account. +After upgrade, logs should show `Loaded group preferences for N groups` (not `starting fresh` / `TEE deployment may have changed`), and each `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). @@ -130,11 +133,11 @@ Canonical table: [`docs/two-cvm-architecture.md` — CVM storage](../../docs/two | Variable | Notes | |----------|-------| | `BOT__ROLE` | `transcription` \| `translation` | -| `SIGNAL__SERVICE_URL` | Default `http://signal-api:8080` | -| `SIGNAL__PHONE_NUMBER` | Ops phone for this CVM | +| `SIGNAL__SERVICE_URL` | Default `http://signal-api:8080` (transcription container uses `http://signal-api-transcription:8080`) | +| `SIGNAL__PHONE_NUMBER` | Ops phone for this process | | `SIGNAL__PEER_PHONE` | Peer product bot. Translation: invites transcription. Transcription: must be translation phone for auto-join | -| `NEAR_AI__*` | Translation role | -| `WHISPER__*` | Transcription role | +| `NEAR_AI__*` | Both roles (chat on translation; API key also required for remote Whisper) | +| `WHISPER__*` | Transcription role — `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 | @@ -146,9 +149,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 (translation), **:8082** phone A (transcription). 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 1112caa..e2e371a 100644 --- a/.cursor/rules/compound-engineering.mdc +++ b/.cursor/rules/compound-engineering.mdc @@ -26,6 +26,7 @@ Product docs stay as siblings under `docs/` (architecture, voice, translation). - Required env: `BOT__ROLE=transcription|translation` - 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 / registered Signal phones for a routine TEE upgrade. User prefs (`group-prefs-*`) and Signal CLI sessions (`signal-config-*`) 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/.env.example b/.env.example index 79ddce0..493173d 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # Required: which product this process runs -# transcription — voice → text (Whisper); no NEAR AI required +# transcription — voice → text (NEAR AI Whisper); NEAR_AI__API_KEY required # translation — in-chat + Language Threads (NEAR AI required) BOT__ROLE=translation @@ -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 for both roles) 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 / STT (required when BOT__ROLE=transcription; disable on translation) +# SERVICE_URL is NEAR AI /v1 — not a local whisper-api sidecar. WHISPER__ENABLED=false -WHISPER__SERVICE_URL=http://whisper-api:9000 -WHISPER__MODEL=small +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/AGENTS.md b/AGENTS.md index 753a2a2..b783d50 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 bots for **voice transcription** and **group translation** (not a general AI chat assistant). Two phone numbers / two bot processes 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 @@ -52,7 +52,7 @@ 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 +# Two different SIGNAL_PHONE values; NEAR_AI_API_KEY in both (STT + chat) 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,9 +62,10 @@ 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, **CVM volume / Signal identity** | -| [`docs/two-cvm-architecture.md`](docs/two-cvm-architecture.md) | Architecture diagram, compose/Phala split, **CVM storage (keep intact)** | -| [`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 / two phones, **CVM storage (keep intact)** | +| [`docs/voice-transcription.md`](docs/voice-transcription.md) | Voice transcription product + pairing (NEAR Whisper) | +| [`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 | @@ -74,17 +75,18 @@ docker compose -f docker/compose.translation.yaml --env-file docker/translation. ## CVM storage (do not wipe) -**Never destroy live Phala volumes or replace a registered CVM for a routine upgrade.** The product is cohesive only if each TEE keeps: +**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. **The bot’s registered Signal phone** (`signal-config-*` on `signal-api`) — losing this unlinks the bot from every group until ops re-registers (and takes over the number). +1. **Registered Signal phones** (`signal-config-translation` = phone B; `signal-config-transcription` = phone A) — losing a volume unlinks that bot until ops re-registers (and takes over the number). 2. **Encrypted user prefs** (`group-prefs-*` → `/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 `. Do not create a new CVM, rename those volumes, or `down -v`. 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). +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 for Whisper. 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` - 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 diff --git a/README.md b/README.md index 1c62660..e92b593 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ 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=transcription` | Same Phala CVM as translation (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. +Pair products by adding **both bots** (two phone numbers) to the same Signal group. Locally, two Compose projects mock two phones. **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). @@ -47,12 +47,14 @@ 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, translation hub) + └── signal-api + signal-bot (phone A, transcription worker) + └── audio bytes → NEAR AI Whisper Large V3 (GPU TEE) ``` -- Signal E2E encryption terminates inside each TEE -- Whisper stays on the transcription CVM only (local Docker HTTP) +- 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) - Translation uses NEAR AI on text (including transcripts posted by the transcription bot) ## Local dual stack @@ -60,7 +62,7 @@ Signal group ```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 +# Two different SIGNAL_PHONE values; NEAR_AI_API_KEY in both env files 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,34 +73,30 @@ More thorough local setup (Signal captcha registration, verify SMS/voice codes, ## Phala ```bash -# First create (empty volumes — register phones on the CVM afterward) -phala deploy -n sigstack-transcription -c docker/phala.transcription.yaml … -phala deploy -n sigstack-translation -c docker/phala.translation.yaml … - -# Later image/compose bumps: upgrade the *existing* CVM so volumes stay attached -phala deploy --cvm-id sigstack-transcription -c docker/phala.transcription.yaml … -phala deploy --cvm-id sigstack-translation -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 a live CVM or wipe its volumes** for a routine upgrade. Each CVM’s disk holds the bot’s **registered Signal phone** and **encrypted user prefs** (`!translate-me-on`, Language Threads, and so on). TEE RAM is cleared on reboot; Phala reattaches named volumes on in-place upgrade, so users should not have to turn features back on. Details: [docs/two-cvm-architecture.md — CVM storage](docs/two-cvm-architecture.md#cvm-storage-keep-intact). +**Do not replace this CVM or wipe its volumes** for a routine upgrade. Disk holds **registered Signal phones** and **encrypted user prefs**. TEE RAM is cleared on reboot; Phala reattaches named volumes on in-place upgrade. After the first one-CVM merge, re-register phone A on proxy `:8082`. 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) + 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 + phala.translation.yaml # prod one-CVM suite + phala.transcription.yaml # deprecated stub docs/ - two-cvm-architecture.md # includes CVM storage / in-place upgrade + two-cvm-architecture.md # one CVM / two phones; CVM storage in-chat-translation.md language-threads.md ``` @@ -125,8 +123,8 @@ Format: `type(scope): subject` — e.g. `feat: add whisper timeout`, `fix(docker |----------|-------------| | `BOT__ROLE` | `transcription` or `translation` (required) | | `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 | +| `NEAR_AI__API_KEY` | Required for both roles (chat + remote Whisper) | +| `WHISPER__ENABLED` / `WHISPER__SERVICE_URL` | Transcription role; URL is NEAR `/v1` | | `TRANSLATE_ALL__ENABLED` | In-chat `!translate-all-on` / `!translate-me-on` (translation role) | 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/src/manual_transcribe.rs b/crates/signal-bot-transcription/src/manual_transcribe.rs index 45240db..6cfe472 100644 --- a/crates/signal-bot-transcription/src/manual_transcribe.rs +++ b/crates/signal-bot-transcription/src/manual_transcribe.rs @@ -254,12 +254,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 +312,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 +359,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 +368,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 +417,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..80a9198 100644 --- a/crates/signal-bot-transcription/src/voice.rs +++ b/crates/signal-bot-transcription/src/voice.rs @@ -53,11 +53,7 @@ impl VoiceHandler { } 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") { @@ -175,15 +171,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 +208,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 +227,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 +255,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 +264,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 +300,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..c66817d 100644 --- a/crates/signal-bot/src/commands/menu_locale.rs +++ b/crates/signal-bot/src/commands/menu_locale.rs @@ -273,7 +273,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. The translation hub is a second Signal number on the same CVM. Use !privacy on the translation bot for suite privacy and !verify. How it works: - Auto mode (default off): send !transcribe-on so inbound voice notes become quote-reply transcripts. @@ -319,15 +319,15 @@ 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 runs two Signal numbers in one Phala TEE/CVM: +- translation bot (hub) +- transcription bot (voice worker) -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. In a group with both bots you get two replies (one per Signal number). Message one bot directly for a single quote. !help"#; @@ -411,10 +411,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("same CVM")); assert!(transcription.contains("!privacy")); assert!(!transcription.trim_end().ends_with("!help")); } @@ -488,11 +489,12 @@ 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("Translation:")); assert!(m.contains("Transcription:")); + assert!(m.contains("NEAR AI Whisper")); assert!(m.contains("!verify ")); assert!(!m.contains("**")); } 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/config.rs b/crates/signal-bot/src/config.rs index 12300ba..b563cfb 100644 --- a/crates/signal-bot/src/config.rs +++ b/crates/signal-bot/src/config.rs @@ -111,11 +111,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, @@ -238,11 +238,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 { @@ -297,6 +297,14 @@ impl Config { if !self.whisper.enabled { bail!("BOT__ROLE=transcription requires WHISPER__ENABLED=true"); } + let Some(near) = &self.near_ai else { + bail!( + "BOT__ROLE=transcription requires NEAR_AI__API_KEY for remote Whisper STT" + ); + }; + if near.api_key.trim().is_empty() { + bail!("BOT__ROLE=transcription requires a non-empty NEAR_AI__API_KEY"); + } } BotRole::Translation => { let Some(near) = &self.near_ai else { @@ -315,10 +323,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, @@ -361,10 +374,20 @@ mod tests { } #[test] - fn transcription_requires_whisper_enabled() { - assert!(transcription_config(true).validate().is_ok()); - let err = transcription_config(false).validate().unwrap_err(); + fn transcription_requires_whisper_and_near_ai() { + assert!(transcription_config(true, Some("sk-test")) + .validate() + .is_ok()); + let err = transcription_config(false, Some("sk-test")) + .validate() + .unwrap_err(); assert!(err.to_string().contains("WHISPER__ENABLED")); + let err = transcription_config(true, None).validate().unwrap_err(); + assert!(err.to_string().contains("NEAR_AI__API_KEY")); + let err = transcription_config(true, Some(" ")) + .validate() + .unwrap_err(); + assert!(err.to_string().contains("non-empty")); } #[test] @@ -386,7 +409,7 @@ mod tests { ); 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/handlers_setup.rs b/crates/signal-bot/src/handlers_setup.rs index 1b6f540..4d70be3 100644 --- a/crates/signal-bot/src/handlers_setup.rs +++ b/crates/signal-bot/src/handlers_setup.rs @@ -37,19 +37,29 @@ pub async fn build_transcription_handlers( signal: Arc, dstack: Arc, ) -> AppResult>> { + let near_cfg = config + .near_ai + .as_ref() + .context("NEAR AI config missing after validation")?; + let whisper = Arc::new( - WhisperClient::new(&config.whisper.service_url, config.whisper.timeout) - .context("Failed to create Whisper client")?, + 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!( - "Whisper healthy at {} (model={})", + "NEAR Whisper healthy at {} (model={})", config.whisper.service_url, config.whisper.model ); } else { warn!( - "Whisper health check failed at {} — will retry on requests", + "NEAR Whisper health check failed at {} — will retry on requests", config.whisper.service_url ); } @@ -242,13 +252,19 @@ mod tests { async fn transcription_registers_expected_handlers() { let whisper = MockServer::start().await; Mock::given(method("GET")) - .and(path("/health")) + .and(path("/models")) .respond_with(ResponseTemplate::new(200)) .mount(&whisper) .await; let mut config = base_config(BotRole::Transcription); config.whisper.service_url = whisper.uri(); + config.near_ai = Some(NearAiConfig { + api_key: "test-key".into(), + base_url: whisper.uri(), + model: "openai/whisper-large-v3".into(), + timeout: Duration::from_secs(5), + }); let signal = Arc::new(SignalClient::new(&config.signal.service_url).unwrap()); let dstack = Arc::new(DstackClient::new(&config.dstack.socket_path)); diff --git a/crates/signal-bot/src/main.rs b/crates/signal-bot/src/main.rs index 4b379c1..f87151e 100644 --- a/crates/signal-bot/src/main.rs +++ b/crates/signal-bot/src/main.rs @@ -78,6 +78,7 @@ 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()) { @@ -104,7 +105,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/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..80b2971 100644 --- a/docker/compose.transcription.yaml +++ b/docker/compose.transcription.yaml @@ -1,6 +1,5 @@ -# 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. +# Local transcription stack — Signal worker only (no Whisper sidecar). +# STT is NEAR AI Whisper Large V3. Own Docker network; Signal is the bus to translation. # # docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env up -d @@ -25,25 +24,6 @@ services: 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: .. @@ -53,9 +33,13 @@ services: - 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=true - - WHISPER__SERVICE_URL=http://whisper-api:9000 - - WHISPER__MODEL=${WHISPER_MODEL:-small} + - 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} - BOT__LOG_LEVEL=${LOG_LEVEL:-info} - BOT__GITHUB_REPO=${BOT_GITHUB_REPO:-https://github.com/BreadchainCoop/sigstack-bot} @@ -70,15 +54,11 @@ services: 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 diff --git a/docker/compose.translation.yaml b/docker/compose.translation.yaml index 35307f8..87f404d 100644 --- a/docker/compose.translation.yaml +++ b/docker/compose.translation.yaml @@ -1,6 +1,7 @@ -# Local translation stack — mirrors Phala translation CVM. -# Own Docker network; does not share a network with the transcription stack. +# Local translation stack — phone B / hub. +# Own Docker network; does not share a network with the transcription compose. # Integration with transcription is via Signal group chat only. +# Prod co-locates both phones on one CVM (docker/phala.translation.yaml). # # docker compose -f docker/compose.translation.yaml --env-file docker/translation.env up -d diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index ae1edf7..cc67498 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -1,5 +1,5 @@ # DEPRECATED monolith stack. -# Use the two product stacks instead (mirrors two Phala CVMs): +# Local: two Compose projects (two phones). Prod: one CVM in 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 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 d4d1ca4..8d68333 100644 --- a/docker/phala.transcription.env.example +++ b/docker/phala.transcription.env.example @@ -1,20 +1,5 @@ -# Phala transcription CVM env — copy to phala.transcription.env (do not commit secrets). +# DEPRECATED — transcription no longer has its own CVM. +# Put TRANSCRIPTION_PHONE + NEAR_AI_API_KEY in docker/phala.translation.env instead. # -# 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 -SIGNAL_PROXY_IMAGE=YOUR_DOCKERHUB/signal-registration-proxy:latest - -WHISPER_MODEL=small -WHISPER_TIMEOUT=120s -LOG_LEVEL=info -BOT_GITHUB_REPO=https://github.com/BreadchainCoop/sigstack-bot -RATE_LIMIT_GLOBAL_PER_MINUTE=10 -RATE_LIMIT_PER_NUMBER_PER_HOUR=3 -# Optional: inject an ops SSH pubkey so `phala ssh -i …` works without the account ssh-keys API. -# DSTACK_AUTHORIZED_KEYS=ssh-ed25519 AAAA... +# 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 64d555a..49222c5 100644 --- a/docker/phala.transcription.yaml +++ b/docker/phala.transcription.yaml @@ -1,101 +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 . -# docker buildx build --platform linux/amd64 -t YOUR_DOCKERHUB/signal-registration-proxy:latest -f docker/Dockerfile.proxy --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). -# First create: `phala deploy -n …`. Later upgrades MUST use `--cvm-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_TX_CVM -c docker/phala.transcription.yaml -e docker/phala.transcription.env --wait -t tdx.medium +# docker/phala.translation.yaml +# docker/phala.translation.env # -# Prefer pinning images by digest in the env file (attestation). Registration proxy on :8081 -# is an ops helper to register phone A against this CVM's signal-api (no SSH required). +# Upgrade in place: phala deploy --cvm-id … -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 - - signal-registration-proxy: - image: ${SIGNAL_PROXY_IMAGE} - environment: - - SIGNAL__API_URL=http://signal-api:8080 - - REGISTRY__PATH=/data/registry.enc - - SERVER__LISTEN_ADDR=0.0.0.0 - - SERVER__PORT=8081 - - DSTACK__SOCKET_PATH=/var/run/dstack.sock - - RATE_LIMIT__GLOBAL_PER_MINUTE=${RATE_LIMIT_GLOBAL_PER_MINUTE:-60} - - RATE_LIMIT__PER_NUMBER_PER_HOUR=${RATE_LIMIT_PER_NUMBER_PER_HOUR:-10} - - LOG__LEVEL=${LOG_LEVEL:-info} - volumes: - - /var/run/dstack.sock:/var/run/dstack.sock:ro - - registry-data-transcription:/data - ports: - - "8081:8081" - depends_on: - - signal-api - restart: unless-stopped - -volumes: - # Keep these names: renaming creates empty volumes and drops Signal identity + prefs. - signal-config-transcription: - driver: local - whisper-models: - driver: local - group-prefs-transcription: - driver: local - registry-data-transcription: - driver: local +services: {} diff --git a/docker/phala.translation.env.example b/docker/phala.translation.env.example index 1ecdd63..7db775a 100644 --- a/docker/phala.translation.env.example +++ b/docker/phala.translation.env.example @@ -1,9 +1,15 @@ -# 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 = translation hub (phone B, already registered on this CVM). +# TRANSCRIPTION_PHONE = voice worker (phone A) — re-register on :8082 after first merge +# (the old transcription CVM was deleted; that session does not transfer). SIGNAL_PHONE=+1YYYYYYYYYY -# Transcription bot E.164 — used by !transcription to invite/pair into the group. +TRANSCRIPTION_PHONE=+1XXXXXXXXXX +# Legacy alias: translation bot PEER_PHONE. Prefer TRANSCRIPTION_PHONE. PEER_PHONE=+1XXXXXXXXXX SIGNAL_BOT_IMAGE=YOUR_DOCKERHUB/signal-bot-tee:latest @@ -14,6 +20,9 @@ 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/docker/phala.translation.yaml b/docker/phala.translation.yaml index da680e1..9670726 100644 --- a/docker/phala.translation.yaml +++ b/docker/phala.translation.yaml @@ -1,15 +1,19 @@ -# Phala Cloud TEE — translation CVM (4 GB / tdx.medium) -# Hosts in-chat translation + Language Threads. +# Phala Cloud TEE — both products on one CVM (tdx.medium). +# +# No local Whisper sidecar. Transcription posts audio to NEAR AI Whisper Large V3. +# Two Signal numbers, two bot processes so STT does not block translation. # # 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). -# First create: `phala deploy -n …`. Later upgrades MUST use `--cvm-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_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 sessions + 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: translation proxy :8081 (phone B), transcription proxy :8082 (phone A). services: signal-api: @@ -27,13 +31,28 @@ services: start_period: 30s restart: unless-stopped + signal-api-transcription: + 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 + signal-bot: image: ${SIGNAL_BOT_IMAGE} environment: - BOT__ROLE=translation - SIGNAL__SERVICE_URL=http://signal-api:8080 - SIGNAL__PHONE_NUMBER=${SIGNAL_PHONE} - - SIGNAL__PEER_PHONE=${PEER_PHONE:-} + - SIGNAL__PEER_PHONE=${TRANSCRIPTION_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} @@ -54,6 +73,33 @@ services: - signal-api restart: unless-stopped + signal-bot-transcription: + image: ${SIGNAL_BOT_IMAGE} + environment: + - BOT__ROLE=transcription + - SIGNAL__SERVICE_URL=http://signal-api-transcription:8080 + - SIGNAL__PHONE_NUMBER=${TRANSCRIPTION_PHONE} + - SIGNAL__PEER_PHONE=${SIGNAL_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=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} + - 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-transcription + restart: unless-stopped + signal-registration-proxy: image: ${SIGNAL_PROXY_IMAGE} environment: @@ -74,11 +120,37 @@ services: - signal-api restart: unless-stopped + signal-registration-proxy-transcription: + image: ${SIGNAL_PROXY_IMAGE} + environment: + - SIGNAL__API_URL=http://signal-api-transcription:8080 + - REGISTRY__PATH=/data/registry.enc + - SERVER__LISTEN_ADDR=0.0.0.0 + - SERVER__PORT=8082 + - DSTACK__SOCKET_PATH=/var/run/dstack.sock + - RATE_LIMIT__GLOBAL_PER_MINUTE=${RATE_LIMIT_GLOBAL_PER_MINUTE:-60} + - RATE_LIMIT__PER_NUMBER_PER_HOUR=${RATE_LIMIT_PER_NUMBER_PER_HOUR:-10} + - LOG__LEVEL=${LOG_LEVEL:-info} + volumes: + - /var/run/dstack.sock:/var/run/dstack.sock:ro + - registry-data-transcription:/data + ports: + - "8082:8082" + depends_on: + - signal-api-transcription + restart: unless-stopped + volumes: # Keep these names: renaming creates empty volumes and drops Signal identity + prefs. signal-config-translation: driver: local + signal-config-transcription: + driver: local registry-data: driver: local + registry-data-transcription: + driver: local group-prefs-translation: driver: local + group-prefs-transcription: + driver: local diff --git a/docker/transcription.env.example b/docker/transcription.env.example index 058b657..cf12d5b 100644 --- a/docker/transcription.env.example +++ b/docker/transcription.env.example @@ -4,7 +4,13 @@ SIGNAL_PHONE=+1XXXXXXXXXX # Translation bot phone — required so this bot auto-accepts group invites from the hub. PEER_PHONE=+1YYYYYYYYYY -WHISPER_MODEL=small + +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 LOG_LEVEL=info GROUP_PREFERENCES_PERSIST=true diff --git a/docker/translation.env.example b/docker/translation.env.example index 55400af..94517ca 100644 --- a/docker/translation.env.example +++ b/docker/translation.env.example @@ -4,6 +4,7 @@ SIGNAL_PHONE=+1YYYYYYYYYY # Transcription bot phone — used by !transcription to invite/pair into the group. PEER_PHONE=+1XXXXXXXXXX +# Same key as docker/transcription.env (chat here; Whisper STT on the other stack). 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 diff --git a/docs/language-threads.md b/docs/language-threads.md index 1a21473..e276f73 100644 --- a/docs/language-threads.md +++ b/docs/language-threads.md @@ -134,26 +134,24 @@ 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 as the **transcription** bot process (NEAR AI Whisper, same CVM) — 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. +- **Transcription** (worker process) composes with Language Threads or in-chat in the same Signal groups. The **translation hub** invites via `!transcription`; the worker only transcribes voice. - **In-chat** translates inside one group thread; **Language Threads** bridges a multilingual main to N monolingual sidecars. ## Phala / TEE -- Dual CVMs live on Phala (`tdx.medium` = 2 vCPU / 4 GB RAM each): - - **Transcription:** `sigstack-transcription` — Whisper + phone A ([`docker/phala.transcription.yaml`](../docker/phala.transcription.yaml)) - - **Translation:** `sigstack-translation` — hub + phone B, no Whisper ([`docker/phala.translation.yaml`](../docker/phala.translation.yaml)) -- Deploy uses **Docker images** (digest-pinned in env), not a public git clone. -- Env templates: [`docker/phala.transcription.env.example`](../docker/phala.transcription.env.example), [`docker/phala.translation.env.example`](../docker/phala.translation.env.example) (secrets; do not commit filled env). -- Fresh CVM ⇒ register Signal phones against each CVM’s registration proxy (`:8081` via Phala gateway). +- One CVM on Phala (`tdx.medium` = 2 vCPU / 4 GB RAM): [`docker/phala.translation.yaml`](../docker/phala.translation.yaml) — hub (phone B) + transcription worker (phone A). 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). +- Registration proxies on this CVM: `:8081` phone B, `:8082` phone A. ## 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. diff --git a/docs/local-dev/README.md b/docs/local-dev/README.md index 81ff602..8c3ee0f 100644 --- a/docs/local-dev/README.md +++ b/docs/local-dev/README.md @@ -2,7 +2,7 @@ Thorough guide for running the dual Compose stacks on your machine, registering Signal phone numbers (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). +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) (prod is one Phala CVM; local still uses two Compose projects for two phones). 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. @@ -12,7 +12,7 @@ Each stack has its own `signal-api`, network, and Signal CLI data volume. Follow - 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 +- `NEAR_AI_API_KEY` in both env files (translation chat + transcription 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 @@ -88,7 +88,7 @@ Do **not** use `down -v` to “force a refresh” — that wipes Signal CLI stat Compose file: `docker/compose.transcription.yaml` Env file: `docker/transcription.env` -Role: voice transcription (`BOT__ROLE=transcription`). Includes Whisper. +Role: voice transcription (`BOT__ROLE=transcription`). No local Whisper sidecar — STT is NEAR AI Whisper Large V3 (`NEAR_AI_API_KEY` required). ### Env + start @@ -114,9 +114,6 @@ docker network ls | grep sigstack-transcription `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 ``` @@ -219,7 +216,7 @@ After code changes, rebuild/recreate — see [Code changes (rebuild the bot)](#c 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: in-chat translation + Language Threads (`BOT__ROLE=translation`). Needs `NEAR_AI_API_KEY`. ### Env + start @@ -351,7 +348,7 @@ 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. +There is **no** Docker network between the two local stacks — Signal is the bus (prod co-locates both phones on one CVM). **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). @@ -368,4 +365,4 @@ After both numbers are registered: | [voice-transcription.md](../voice-transcription.md) | Transcription ops + pairing | | [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 / two phones; local dual compose | diff --git a/docs/plans/2026-08-05-dual-cvm-phala-deploy.md b/docs/plans/2026-08-05-dual-cvm-phala-deploy.md index 6d5a70b..a69aa4a 100644 --- a/docs/plans/2026-08-05-dual-cvm-phala-deploy.md +++ b/docs/plans/2026-08-05-dual-cvm-phala-deploy.md @@ -1,5 +1,7 @@ # 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 | diff --git a/docs/plans/2026-08-05-phala-deploy-handoff.md b/docs/plans/2026-08-05-phala-deploy-handoff.md index 6dcd141..a651b20 100644 --- a/docs/plans/2026-08-05-phala-deploy-handoff.md +++ b/docs/plans/2026-08-05-phala-deploy-handoff.md @@ -1,5 +1,7 @@ # 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). 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..f4a9cc4 --- /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 **two bot processes** (and two Signal numbers) on **one** Phala CVM (`tdx.medium`) so translation never awaits STT. 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 matter because each bot awaits `dispatch_message` inside its own poll loop. Merging roles into one process with serial dispatch would block translation behind STT. Shared CVM contention is mild I/O (two Java CLIs + two Rust bots 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) — two `signal-api` + two `signal-bot` (`BOT__ROLE` unchanged) +- 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 +- Merge both Signal numbers into one bot process + +## 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 bf630a6..1466ea9 100644 --- a/docs/two-cvm-architecture.md +++ b/docs/two-cvm-architecture.md @@ -1,8 +1,10 @@ -# Two-CVM architecture +# One CVM, two Signal numbers -Product suite split across two Phala CVMs (and two local Docker Compose projects). Signal group chat is the only cross-stack bus. +Prod runs both products on **one** Phala CVM (today’s translation box). Two phone numbers / two bot processes stay. Signal group chat is still the only coupling between roles — they do not HTTP to each other. -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). + +Local development still uses **two Compose projects** (separate Docker networks) as a mock of two phones. ## Bot hierarchy @@ -10,8 +12,8 @@ In a shared Signal group, users interact with **one hub** and one optional **wor | 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` | +| **Translation bot** (Bread Bot hub) | Phone B | Product menus (`!help`, `!info`, `!privacy`, `!translation-*`), Language Threads, in-chat translation, pairing (`!transcription` invite), `!verify` (this CVM quote) | +| **Transcription bot** (worker) | Phone A | Voice only: `!transcription`, `!transcribe*`, `!verify` (same CVM, second Signal number). **No hub** — does not answer `!help` / `!info` / `!privacy` | 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. @@ -21,40 +23,36 @@ Hub vs worker command split: [voice-transcription.md](voice-transcription.md#com ```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 + subgraph oneCvm [Translation_CVM_tdx_medium] + sigA[signal-api_phone_A] + sigB[signal-api_phone_B] + txBot[signal-bot_transcription] + trBot[signal-bot_translation] + txBot --> sigA + trBot --> sigB 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] --> sigA + Users --> sigB + txBot -->|"audio bytes only"| NearWhisper[NEAR_AI_Whisper_large_v3] + NearWhisper -->|text| txBot + trBot -->|"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. +- 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. +- Keep two bot **processes** so a voice note in flight (HTTP wait to NEAR Whisper) cannot stall translation polling. - Same `signal-bot` image; role selected by `BOT__ROLE=transcription|translation`. +- Do not reintroduce `whisper-api` or put Whisper on a larger CPU TEE as the scale path. -## Local dual stack (mock prod) +## Local dual stack (mock two phones) ```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 two different SIGNAL_PHONE values; NEAR_AI_API_KEY in both (STT + chat) 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 @@ -65,54 +63,67 @@ docker network ls | grep sigstack 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. +E2E still needs two registered Signal numbers in a shared test group. Dual compose mocks **two phones**, not two Phala CVMs. ## 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-api-transcription` (phone A) + two `signal-bot` + proxies `:8081` / `:8082`. **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). + +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. -Deploy each compose to its **own** CVM. Do not co-locate Whisper with the translation bot. +Phone A must be **re-registered** on proxy `:8082` after the first one-CVM merge (that session died with the deleted CVM). Phone B on `:8081` stays. ### CVM storage (keep intact) -The suite stays cohesive only if each live CVM keeps two kinds of 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. +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-transcription` / `signal-config-translation` | `signal-api` | That CVM’s **registered Signal phone** (CLI session) | Bot disappears from Signal until ops re-register; re-registering also takes the number over from any other device | -| `group-prefs-transcription` / `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; bots look forgetful | -| `registry-data` / `registry-data-transcription` | registration proxy | Ops registration helper state | Re-register via proxy; does not by itself drop Signal CLI | +| `signal-config-translation` | `signal-api` (phone B) | Translation bot **registered Signal phone** | Hub disappears from Signal until ops re-register | +| `signal-config-transcription` | `signal-api-transcription` (phone A) | Transcription bot session | Voice worker gone until re-register (expected empty after first merge) | +| `group-prefs-translation` | 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 | +| `group-prefs-transcription` | transcription `signal-bot` → `/data/group_prefs.enc` | Encrypted `!transcribe-on` prefs | Users must re-enable auto transcribe | +| `registry-data` / `registry-data-transcription` | registration proxies | Ops registration helper state | Re-register via proxy; does not by itself drop Signal CLI | -**Upgrade live CVMs in place** (`phala deploy --cvm-id ` or the dashboard compose update). Do **not** create a replacement CVM, rename those volumes, or `down -v` for a routine image bump. First bring-up of a **new** CVM is the exception (empty volumes; register phones there). +**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, rename `signal-config-translation` / `group-prefs-translation` / `registry-data`, or `down -v` for a routine image bump. First bring-up of a **new** CVM is the exception (empty volumes; register phones there). | Event | Volumes | Users re-enable prefs? | Re-register phones? | |-------|---------|------------------------|---------------------| -| In-place CVM upgrade (`--cvm-id`) | Kept (Phala reattaches) | No | No | +| In-place CVM upgrade (`--cvm-id`) | Kept (Phala reattaches) | No | No (phone A only if that volume was new/empty) | | 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 each CVM’s `signal-api`. +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 each `signal-api`. -Do not change volume names in [`docker/phala.transcription.yaml`](../docker/phala.transcription.yaml) / [`docker/phala.translation.yaml`](../docker/phala.translation.yaml) without a deliberate migration. [`scripts/deploy_phala.sh`](../scripts/deploy_phala.sh) deploys by **name** (`-n`); that is for first create, not a safe in-place upgrade of an already-registered CVM. +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 | -|---------|-----|-----| +| Product | Bot process | 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) | ### 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. +- **Transcription** (worker) 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-thread`). -## Why split +## Why two processes (not two CVMs) -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. +The old split existed to isolate **CPU Whisper** from translation. That is obsolete: STT is remote GPU. Two CVMs did not make ~5s notes usable and billed a second `tdx.medium` idle. Two **processes** on one CVM still matter — translation must not await STT in a single serial dispatch loop. Do not merge roles into one bot in this architecture. diff --git a/docs/voice-transcription.md b/docs/voice-transcription.md index 6442276..4097a81 100644 --- a/docs/voice-transcription.md +++ b/docs/voice-transcription.md @@ -1,8 +1,8 @@ # Voice transcription -Status: **implemented** on its own Phala / Compose stack (`BOT__ROLE=transcription`). +Status: **implemented** as the worker bot (`BOT__ROLE=transcription`) on the **same** Phala CVM as translation. -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). @@ -10,22 +10,24 @@ This stack is a **specialized worker**, not the Bread Bot hub. Users discover pr | 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 as translation: `signal-api-transcription` (phone A) + `signal-bot` (`BOT__ROLE=transcription`). **No Whisper sidecar.** | +| Local Compose | [`docker/compose.transcription.yaml`](../docker/compose.transcription.yaml) — phone A + 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. It does not imply Whisper weights live here. ## Signal as bus Both bots are members of the same Signal group (two phone numbers). 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`). +2. Transcription bot downloads the attachment, posts audio-only multipart to NEAR 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). +Outbound STT: generic filename (`voice.ogg` / `voice.m4a`). No phone, group id, timestamp, or display name in form fields, headers, or filenames. + ## Pairing (translation leads) -1. Set `PEER_PHONE` in translation env to the transcription bot’s E.164 (`SIGNAL__PEER_PHONE`). +1. Set `PEER_PHONE` / `TRANSCRIPTION_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. @@ -48,9 +50,9 @@ Worker-only — no `!help` / `!info`. Use the translation bot for the Bread Bot | `!transcription` | Product menu (compact command list for this bot) | | `!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; same CVM as the hub) | -Hub `!privacy` (translation bot only) covers both CVMs. In a paired group, `!verify ` returns two quotes (`Translation: …` / `Transcription: …`). +Hub `!privacy` (translation bot only) covers both numbers on this CVM. In a paired group, `!verify ` returns two quotes (one per Signal number). Auto path: inbound voice notes are transcribed only after `!transcribe-on` (default off). @@ -61,6 +63,7 @@ Auto path: inbound voice notes are transcribed only after `!transcribe-on` (defa ```bash cp docker/transcription.env.example docker/transcription.env # Set SIGNAL_PHONE (phone A); PEER_PHONE = translation phone (required for auto-join) +# Set NEAR_AI_API_KEY (same key as translation) docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env up -d ``` @@ -68,18 +71,19 @@ docker compose -f docker/compose.transcription.yaml --env-file docker/transcript Register phone A against this stack’s `signal-api`. 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 ``` ### Phala +In-place upgrade of the **surviving** translation CVM (do not deploy `phala.transcription.yaml`): + ```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. After the first merge, **re-register phone A** on proxy `:8082` (the old transcription CVM was deleted). Attestation: `!verify ` inside Signal. ## Key code @@ -87,7 +91,8 @@ Env template: [`docker/phala.transcription.env.example`](../docker/phala.transcr |------|------| | Voice / `!transcribe*` handlers | [`crates/signal-bot-transcription`](../crates/signal-bot-transcription) | | Shared handler trait / errors | [`crates/signal-bot-core`](../crates/signal-bot-core) | -| Whisper HTTP client | [`crates/whisper-client`](../crates/whisper-client) | +| STT HTTP client | [`crates/whisper-client`](../crates/whisper-client) (NEAR `/audio/transcriptions`) | +| NEAR client (chat + transcribe) | [`crates/near-ai-client`](../crates/near-ai-client) | | Pairing (`!transcription` on translation) | [`crates/signal-bot/src/commands/product_menus.rs`](../crates/signal-bot/src/commands/product_menus.rs) | | 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.transcription.yaml`](../docker/compose.transcription.yaml), [`docker/phala.translation.yaml`](../docker/phala.translation.yaml) | diff --git a/scripts/deploy_phala.sh b/scripts/deploy_phala.sh index 12d74f4..f30e0a9 100755 --- a/scripts/deploy_phala.sh +++ b/scripts/deploy_phala.sh @@ -1,11 +1,19 @@ #!/bin/bash -# First-create both product CVMs on Phala Cloud (2× tdx.medium = 2 vCPU / 4 GB RAM each). -# Uses `phala deploy -n` (new names). Do NOT run this against live registered CVMs — -# that would risk empty volumes (lost Signal sessions + user prefs). Upgrade existing -# CVMs with `phala deploy --cvm-id ` so signal-config-* and group-prefs-* stay. -# See docs/two-cvm-architecture.md#cvm-storage-keep-intact and AGENTS.md. -# Requires: phala CLI logged in; filled docker/phala.transcription.env + docker/phala.translation.env -# (never commit those env files). Images must already be pushed (linux/amd64). +# Deploy the one-CVM product suite (two Signal numbers, 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 sessions + 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 if ! command -v phala &> /dev/null; then @@ -14,39 +22,42 @@ if ! command -v phala &> /dev/null; then fi ROOT="$(cd "$(dirname "$0")/.." && pwd)" -TX_COMPOSE="${TX_COMPOSE:-$ROOT/docker/phala.transcription.yaml}" TR_COMPOSE="${TR_COMPOSE:-$ROOT/docker/phala.translation.yaml}" -TX_ENV="${TX_ENV:-$ROOT/docker/phala.transcription.env}" TR_ENV="${TR_ENV:-$ROOT/docker/phala.translation.env}" -TX_NAME="${TX_NAME:-sigstack-transcription}" TR_NAME="${TR_NAME:-sigstack-translation}" INSTANCE_TYPE="${INSTANCE_TYPE:-tdx.medium}" DISK_SIZE="${DISK_SIZE:-40G}" +# Surviving prod CVM (phone B). Transcription CVM eba19afc-… was deleted. +CVM_ID="${CVM_ID:-0e82fa77-8b15-4dbd-89c4-9045ab911353}" +FIRST_CREATE="${FIRST_CREATE:-0}" -for f in "$TX_COMPOSE" "$TR_COMPOSE" "$TX_ENV" "$TR_ENV"; do - if [[ ! -f "$f" ]]; then - echo "Error: missing $f" - exit 1 - fi -done - -echo "Deploying $TX_NAME ($INSTANCE_TYPE, disk $DISK_SIZE)..." -phala deploy \ - -n "$TX_NAME" \ - -c "$TX_COMPOSE" \ - -e "$TX_ENV" \ - -t "$INSTANCE_TYPE" \ - --disk-size "$DISK_SIZE" \ - --wait +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 -echo "Deploying $TR_NAME ($INSTANCE_TYPE, disk $DISK_SIZE)..." -phala deploy \ - -n "$TR_NAME" \ - -c "$TR_COMPOSE" \ - -e "$TR_ENV" \ - -t "$INSTANCE_TYPE" \ - --disk-size "$DISK_SIZE" \ - --wait +if [[ "$FIRST_CREATE" == "1" ]]; then + echo "First-create $TR_NAME ($INSTANCE_TYPE, disk $DISK_SIZE) — empty volumes; register phones 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 "Both CVMs deployed. Check status with: phala cvms list" +echo "Done. Check status with: phala cvms list" +echo "Phone B stays on this CVM. Re-register phone A on proxy :8082 if this is the first one-CVM merge." diff --git a/scripts/register_phala_phones.sh b/scripts/register_phala_phones.sh index dd4fe08..f1f0a68 100755 --- a/scripts/register_phala_phones.sh +++ b/scripts/register_phala_phones.sh @@ -1,44 +1,67 @@ #!/bin/bash -# Register Signal phones onto the live Phala CVMs via each CVM's registration proxy. +# Register Signal phones onto the live one-CVM Phala stack via registration proxies. +# +# After the transcription CVM was deleted, phone A must be re-registered on :8082 +# of the surviving translation CVM. Phone B on :8081 should already be registered +# — skip CAPTCHA_TR / SMS_TR if accounts already lists it. +# # Usage: -# CAPTCHA_TX='signalcaptcha://...' CAPTCHA_TR='signalcaptcha://...' ./scripts/register_phala_phones.sh +# CAPTCHA_TX='signalcaptcha://...' ./scripts/register_phala_phones.sh +# CAPTCHA_TX='...' CAPTCHA_TR='...' ./scripts/register_phala_phones.sh # both # Then when SMS arrives: +# SMS_TX=123456 ./scripts/register_phala_phones.sh --verify # SMS_TX=123456 SMS_TR=654321 ./scripts/register_phala_phones.sh --verify set -euo pipefail -TX_PROXY="${TX_PROXY:-https://c5df154154e8c61105fefe84d43515e69b0e2537-8081.dstack-pha-prod9.phala.network}" -TR_PROXY="${TR_PROXY:-https://9adac7636fe255182f699940ffd1924960415507-8081.dstack-pha-prod9.phala.network}" +APP_ID="${APP_ID:-9adac7636fe255182f699940ffd1924960415507}" +GATEWAY="${GATEWAY:-dstack-pha-prod9.phala.network}" +# Transcription (phone A) proxy on the surviving CVM. +TX_PROXY="${TX_PROXY:-https://${APP_ID}-8082.${GATEWAY}}" +# Translation (phone B) proxy — already registered; keep for verify/debug. +TR_PROXY="${TR_PROXY:-https://${APP_ID}-8081.${GATEWAY}}" PHONE_TX="${PHONE_TX:-+573103479014}" PHONE_TR="${PHONE_TR:-+573107677679}" MODE="${1:-register}" if [[ "$MODE" == "--verify" ]]; then - : "${SMS_TX:?set SMS_TX}" - : "${SMS_TR:?set SMS_TR}" - echo "Verifying transcription $PHONE_TX..." - curl -sS -X POST "$TX_PROXY/v1/register/${PHONE_TX}/verify/${SMS_TX}" \ - -H 'Content-Type: application/json' -d '{}' - echo - echo "Verifying translation $PHONE_TR..." - curl -sS -X POST "$TR_PROXY/v1/register/${PHONE_TR}/verify/${SMS_TR}" \ - -H 'Content-Type: application/json' -d '{}' - echo + if [[ -n "${SMS_TX:-}" ]]; then + echo "Verifying transcription $PHONE_TX on :8082..." + curl -sS -X POST "$TX_PROXY/v1/register/${PHONE_TX}/verify/${SMS_TX}" \ + -H 'Content-Type: application/json' -d '{}' + echo + fi + if [[ -n "${SMS_TR:-}" ]]; then + echo "Verifying translation $PHONE_TR on :8081..." + curl -sS -X POST "$TR_PROXY/v1/register/${PHONE_TR}/verify/${SMS_TR}" \ + -H 'Content-Type: application/json' -d '{}' + echo + fi + if [[ -z "${SMS_TX:-}" && -z "${SMS_TR:-}" ]]; then + echo "Error: set SMS_TX and/or SMS_TR" >&2 + exit 1 + fi else - : "${CAPTCHA_TX:?set CAPTCHA_TX to full signalcaptcha:// token}" - : "${CAPTCHA_TR:?set CAPTCHA_TR to full signalcaptcha:// token}" - echo "Registering transcription $PHONE_TX on CVM proxy..." - curl -sS -X POST "$TX_PROXY/v1/register/${PHONE_TX}" \ - -H 'Content-Type: application/json' \ - -d "$(python3 -c 'import json,os; print(json.dumps({"captcha":os.environ["CAPTCHA_TX"],"use_voice":False}))')" - echo - echo "Registering translation $PHONE_TR on CVM proxy..." - 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 + if [[ -z "${CAPTCHA_TX:-}" && -z "${CAPTCHA_TR:-}" ]]; then + echo "Error: set CAPTCHA_TX (phone A / :8082) and/or CAPTCHA_TR (phone B / :8081)" >&2 + exit 1 + fi + if [[ -n "${CAPTCHA_TX:-}" ]]; then + echo "Registering transcription $PHONE_TX on :8082..." + curl -sS -X POST "$TX_PROXY/v1/register/${PHONE_TX}" \ + -H 'Content-Type: application/json' \ + -d "$(python3 -c 'import json,os; print(json.dumps({"captcha":os.environ["CAPTCHA_TX"],"use_voice":False}))')" + echo + fi + if [[ -n "${CAPTCHA_TR:-}" ]]; then + echo "Registering translation $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 fi -echo "Accounts:" -curl -sS "$TX_PROXY/v1/debug/signal-accounts"; echo +echo "Accounts (translation :8081 / transcription :8082):" curl -sS "$TR_PROXY/v1/debug/signal-accounts"; echo +curl -sS "$TX_PROXY/v1/debug/signal-accounts"; echo From 9d485df52b4facd51441d2f0d91311be540a3f84 Mon Sep 17 00:00:00 2001 From: daopunk Date: Fri, 14 Aug 2026 00:27:42 -0500 Subject: [PATCH 4/4] feat: collapse to one signal number with in-process transcript fan-out --- .agents/docs/DEVELOPMENT.md | 68 ++--- .cursor/rules/compound-engineering.mdc | 4 +- .cursor/rules/phala-cvm-storage.mdc | 4 +- .env.example | 12 +- AGENTS.md | 18 +- README.md | 86 +++--- crates/signal-bot-transcription/Cargo.toml | 1 + crates/signal-bot-transcription/src/fanout.rs | 90 ++++++ .../signal-bot-transcription/src/handlers.rs | 26 +- crates/signal-bot-transcription/src/lib.rs | 2 + .../src/manual_transcribe.rs | 35 ++- crates/signal-bot-transcription/src/voice.rs | 19 +- crates/signal-bot/src/commands/menu_locale.rs | 67 ++--- crates/signal-bot/src/commands/mod.rs | 4 +- .../signal-bot/src/commands/product_menus.rs | 208 +------------- .../signal-bot/src/commands/translate_all.rs | 184 +++++++++--- .../signal-bot/src/commands/translate_me.rs | 29 ++ crates/signal-bot/src/commands/verify.rs | 20 +- crates/signal-bot/src/config.rs | 58 ++-- .../signal-bot/src/group_invite_acceptor.rs | 86 +----- crates/signal-bot/src/handlers_setup.rs | 262 ++++++++---------- crates/signal-bot/src/lib.rs | 1 + crates/signal-bot/src/main.rs | 38 +-- crates/signal-bot/src/transcript_fanout.rs | 23 ++ crates/signal-client/src/client.rs | 2 +- docker/compose.transcription.yaml | 75 +---- docker/compose.translation.yaml | 13 +- docker/docker-compose.yaml | 4 +- docker/phala.transcription.env.example | 2 +- docker/phala.translation.env.example | 7 +- docker/phala.translation.yaml | 85 +----- docker/transcription.env.example | 19 +- docker/translation.env.example | 7 +- docs/in-chat-translation.md | 4 +- docs/language-threads.md | 10 +- docs/local-dev/README.md | 189 +------------ ...26-08-13-cpu-tee-whisper-does-not-scale.md | 8 +- docs/two-cvm-architecture.md | 98 +++---- docs/voice-transcription.md | 65 ++--- scripts/deploy_phala.sh | 10 +- scripts/register_phala_phones.sh | 63 ++--- 41 files changed, 790 insertions(+), 1216 deletions(-) create mode 100644 crates/signal-bot-transcription/src/fanout.rs create mode 100644 crates/signal-bot/src/transcript_fanout.rs diff --git a/.agents/docs/DEVELOPMENT.md b/.agents/docs/DEVELOPMENT.md index b466d0b..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** — `BOT__ROLE=transcription` (NEAR AI Whisper Large V3, not an in-CVM sidecar) -2. **In-chat group translation** — `BOT__ROLE=translation` (see [`docs/in-chat-translation.md`](../../docs/in-chat-translation.md)) -3. **Language Threads** — translation role, 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: [`docs/two-cvm-architecture.md`](../../docs/two-cvm-architecture.md) (one Phala CVM, two phones). 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). +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. @@ -22,16 +22,13 @@ Fork legacy removed: general AI chat, tool use (`crates/tools`), x402 payments, ### Signal CLI must run in the product TEE -Signal E2E encryption terminates at Signal CLI. Each bot process has its own `signal-api` so plaintext only exists in this TEE. Voice bytes leave the TEE only as metadata-stripped audio to NEAR AI Whisper. +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. -### One CVM, two phones, Signal as bus +### One CVM, one phone -- Same Phala CVM: transcription bot (phone A, **worker**) + translation bot (phone B, **hub**) -- No `whisper-api` sidecar. Transcription posts audio to NEAR AI Whisper Large V3 (GPU TEE). -- Translation sends **text** to NEAR AI chat. Roles do not HTTP to each other; integration = both bots in the same Signal group. -- Two processes so STT HTTP waits never stall translation polling. - -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 @@ -47,16 +44,16 @@ Does **not** prove Signal CLI image integrity beyond pinning, hide network metad | Role | Handlers | Requires | |------|----------|----------| -| `transcription` | Voice, `!transcribe*`, `!transcription` menu, `!help-transcription`, `!verify` (worker — no hub `!help` / `!info` / `!privacy`) | `WHISPER__ENABLED=true` + `NEAR_AI__API_KEY` (remote STT) | -| `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. Do not add a combined role. +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/ # OpenAI-compatible STT client (NEAR Whisper) @@ -65,8 +62,8 @@ crates/ dstack-client/ signal-registration-proxy/ # Ops registration helper docker/ - compose.transcription.yaml # local phone A (no Whisper sidecar) - compose.translation.yaml # local phone B + 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 @@ -78,19 +75,16 @@ docs/ `Dockerfile.whisper` is unused on the live path. -## Local dual Compose +## 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 both env files +# 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 @@ -106,25 +100,24 @@ phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353 \ 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), `TRANSCRIPTION_PHONE` (phone A), `NEAR_AI_API_KEY` (shared). +Encrypted secrets: `SIGNAL_PHONE` (phone B), `NEAR_AI_API_KEY`. -Health: Signal CLI `GET /v1/health` on each `signal-api`. Attestation: `!verify ` (this CVM’s compose, not remote Whisper). +Health: Signal CLI `GET /v1/health` on `signal-api`. Attestation: `!verify ` (this CVM’s compose, not remote Whisper; one reply). -After the first one-CVM merge, re-register phone A on proxy **:8082**. Phone B on **:8081** stays. +Do not re-register phone A. Proxy **:8081** only. ### CVM storage — do not wipe -**In-place upgrades only** once phones are registered. TEE RAM wipe is expected; **disk volumes are the product identity.** +**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 | |-----------|--------|------------------| -| Translation Signal session (phone B) | `signal-config-translation` | Hub gone from groups until re-register | -| Transcription Signal session (phone A) | `signal-config-transcription` | Voice worker gone until re-register | -| User prefs (`!translate-me-on`, Language Threads, `!transcribe-on`) | `group-prefs-*` → `/data/group_prefs.enc` | Users must re-enable; suite looks broken | +| 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 each `signal-api` should still list its account. +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). @@ -132,12 +125,11 @@ Canonical table: [`docs/two-cvm-architecture.md` — CVM storage](../../docs/two | Variable | Notes | |----------|-------| -| `BOT__ROLE` | `transcription` \| `translation` | -| `SIGNAL__SERVICE_URL` | Default `http://signal-api:8080` (transcription container uses `http://signal-api-transcription:8080`) | +| `BOT__ROLE` | Live value `translation` (`transcription` fail-fasts) | +| `SIGNAL__SERVICE_URL` | Default `http://signal-api:8080` | | `SIGNAL__PHONE_NUMBER` | Ops phone for this process | -| `SIGNAL__PEER_PHONE` | Peer product bot. Translation: invites transcription. Transcription: must be translation phone for auto-join | -| `NEAR_AI__*` | Both roles (chat on translation; API key also required for remote Whisper) | -| `WHISPER__*` | Transcription role — `SERVICE_URL` is NEAR `/v1`, not `whisper-api:9000` | +| `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 | @@ -153,7 +145,7 @@ Before finishing Rust work: `npm run ci` / `pnpm run ci` (never bare `pnpm ci`). ## Registration proxy -Ops helper on the one CVM: **:8081** phone B (translation), **:8082** phone A (transcription). 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 e2e371a..46e0dc4 100644 --- a/.cursor/rules/compound-engineering.mdc +++ b/.cursor/rules/compound-engineering.mdc @@ -24,9 +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 / registered Signal phones for a routine TEE upgrade. User prefs (`group-prefs-*`) and Signal CLI sessions (`signal-config-*`) 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) +- **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 index 43eb007..eac03fb 100644 --- a/.cursor/rules/phala-cvm-storage.mdc +++ b/.cursor/rules/phala-cvm-storage.mdc @@ -8,8 +8,8 @@ alwaysApply: false 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-transcription` / `signal-config-translation` on `signal-api`. Losing it unlinks the bot from every group until re-register (which takes over the number). -- **User prefs:** `group-prefs-*` → `/data/group_prefs.enc` (`!translate-me-on`, `!translate-all-on`, Language Threads). Losing it forces every user to opt in again. +- **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. diff --git a/.env.example b/.env.example index 493173d..7c23c06 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ -# Required: which product this process runs -# transcription — voice → text (NEAR AI Whisper); NEAR_AI__API_KEY 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 for both roles) +# 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,9 +22,9 @@ BOT__GITHUB_REPO=https://github.com/BreadchainCoop/sigstack-bot # Dstack Configuration (TEE) DSTACK__SOCKET_PATH=/var/run/dstack.sock -# Whisper / STT (required when BOT__ROLE=transcription; disable on translation) +# Whisper / STT (required on the unified translation bot) # SERVICE_URL is NEAR AI /v1 — not a local whisper-api sidecar. -WHISPER__ENABLED=false +WHISPER__ENABLED=true WHISPER__SERVICE_URL=https://cloud-api.near.ai/v1 WHISPER__MODEL=openai/whisper-large-v3 WHISPER__TIMEOUT=120s diff --git a/AGENTS.md b/AGENTS.md index b783d50..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 bot processes 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). +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 both (STT + chat) +# 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 ``` @@ -63,8 +61,8 @@ 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 one-CVM ops, **CVM volume / Signal identity** | -| [`docs/two-cvm-architecture.md`](docs/two-cvm-architecture.md) | One CVM / two phones, **CVM storage (keep intact)** | -| [`docs/voice-transcription.md`](docs/voice-transcription.md) | Voice transcription product + pairing (NEAR Whisper) | +| [`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) | @@ -77,14 +75,14 @@ docker compose -f docker/compose.translation.yaml --env-file docker/translation. **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 phones** (`signal-config-translation` = phone B; `signal-config-transcription` = phone A) — losing a volume unlinks that bot until ops re-registers (and takes over the number). -2. **Encrypted user prefs** (`group-prefs-*` → `/data/group_prefs.enc`) — `!translate-me-on`, `!translate-all-on`, Language Threads bridges. Losing this forces every user to turn features back on. +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 for Whisper. 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). +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 diff --git a/README.md b/README.md index e92b593..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` | Same Phala CVM as translation (NEAR AI Whisper; no local sidecar) | +| 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. Locally, two Compose projects mock two phones. - -**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. @@ -48,23 +47,21 @@ Language Threads and in-chat auto are mutually exclusive. Details in the product ``` Signal group └── One Phala CVM (tdx.medium) - ├── signal-api + signal-bot (phone B, translation hub) - └── signal-api + signal-bot (phone A, transcription worker) - └── audio bytes → NEAR AI Whisper Large V3 (GPU TEE) + └── signal-api + signal-bot (phone B) + ├── audio bytes → NEAR AI Whisper Large V3 (GPU TEE) + └── text → NEAR AI chat ``` - 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) -- Translation uses NEAR AI on text (including transcripts posted by the transcription bot) +- 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 both env files +# 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 ``` @@ -78,25 +75,26 @@ 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 **registered Signal phones** and **encrypted user prefs**. TEE RAM is cleared on reboot; Phala reattaches named volumes on in-place upgrade. After the first one-CVM merge, re-register phone A on proxy `:8082`. Details: [docs/two-cvm-architecture.md — CVM storage](docs/two-cvm-architecture.md#cvm-storage-keep-intact). +**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 + 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 + 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 # one CVM / two phones; CVM storage + two-cvm-architecture.md # one CVM / one phone; CVM storage in-chat-translation.md language-threads.md ``` @@ -121,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 both roles (chat + remote Whisper) | -| `WHISPER__ENABLED` / `WHISPER__SERVICE_URL` | Transcription role; URL is NEAR `/v1` | -| `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/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 6cfe472..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) => { diff --git a/crates/signal-bot-transcription/src/voice.rs b/crates/signal-bot-transcription/src/voice.rs index 80a9198..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,6 +51,15 @@ 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}") } @@ -146,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); diff --git a/crates/signal-bot/src/commands/menu_locale.rs b/crates/signal-bot/src/commands/menu_locale.rs index c66817d..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. -Voice is decrypted in this TEE, then sent (audio only, no Signal metadata) to NEAR AI Whisper Large V3 in their GPU TEE. The translation hub is a second Signal number on the same CVM. Use !privacy on the translation bot for suite privacy and !verify. +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 two Signal numbers in one Phala TEE/CVM: -- translation bot (hub) -- transcription bot (voice worker) +Bread Bot is one Signal number in one Phala TEE/CVM. Translation: Signal text is processed in this TEE and translated via NEAR AI private inference. 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: !verify attests this CVM's compose, not the remote Whisper weights. In a group with both bots you get two replies (one per Signal number). 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"#; @@ -415,7 +380,7 @@ mod tests { assert!(transcription.contains("Whisper")); assert!(transcription.contains("!transcribe")); assert!(transcription.contains("default off")); - assert!(transcription.contains("same CVM")); + assert!(transcription.contains("Add this bot")); assert!(transcription.contains("!privacy")); assert!(!transcription.trim_end().ends_with("!help")); } @@ -492,10 +457,13 @@ mod tests { fn privacy_menu_covers_near_stt() { let m = privacy_menu(); 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("**")); } @@ -509,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/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 b563cfb..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)] @@ -161,7 +157,6 @@ impl Default for SignalConfig { service_url: default_signal_service(), poll_interval: default_poll_interval(), phone_number: None, - peer_phone: None, } } } @@ -294,19 +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"); - } - let Some(near) = &self.near_ai else { - bail!( - "BOT__ROLE=transcription requires NEAR_AI__API_KEY for remote Whisper STT" - ); - }; - if near.api_key.trim().is_empty() { - bail!("BOT__ROLE=transcription requires a non-empty NEAR_AI__API_KEY"); - } + 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)"); }; @@ -348,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 { @@ -365,7 +355,7 @@ mod tests { }, dstack: DstackConfig::default(), whisper: WhisperConfig { - enabled: false, + enabled: whisper_enabled, ..WhisperConfig::default() }, translate_all: TranslateAllConfig::default(), @@ -374,30 +364,29 @@ mod tests { } #[test] - fn transcription_requires_whisper_and_near_ai() { - assert!(transcription_config(true, Some("sk-test")) - .validate() - .is_ok()); - let err = transcription_config(false, Some("sk-test")) + fn transcription_role_is_retired() { + let err = transcription_config(true, Some("sk-test")) .validate() .unwrap_err(); - assert!(err.to_string().contains("WHISPER__ENABLED")); - let err = transcription_config(true, None).validate().unwrap_err(); - assert!(err.to_string().contains("NEAR_AI__API_KEY")); - let err = transcription_config(true, Some(" ")) - .validate() - .unwrap_err(); - assert!(err.to_string().contains("non-empty")); + 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(None).validate().unwrap_err(); + let err = translation_config(Some("sk-test"), false) + .validate() + .unwrap_err(); + assert!(err.to_string().contains("WHISPER__ENABLED")); + + 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")); } @@ -408,7 +397,6 @@ 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, "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 4d70be3..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,24 +25,44 @@ 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( +/// Unified bot: Language Threads → voice → in-chat → hub menus → quote translate → verify/help. +pub async fn build_translation_handlers( config: &Config, signal: Arc, dstack: Arc, + bot_identity: Arc, ) -> AppResult>> { let near_cfg = config .near_ai .as_ref() .context("NEAR AI config missing after validation")?; + let near_ai = Arc::new( + NearAiClient::new( + &near_cfg.api_key, + &near_cfg.base_url, + &near_cfg.model, + near_cfg.timeout, + ) + .context("Failed to create NEAR AI client")?, + ); + + if near_ai.health_check().await { + info!("NEAR AI healthy - Model: {}", near_cfg.model); + } else { + warn!("NEAR AI health check failed - will retry on requests"); + } + let whisper = Arc::new( WhisperClient::new( &config.whisper.service_url, @@ -72,60 +93,6 @@ pub async fn build_transcription_handlers( ) .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. -pub async fn build_translation_handlers( - config: &Config, - signal: Arc, - dstack: Arc, - bot_identity: Arc, -) -> AppResult>> { - let near_cfg = config - .near_ai - .as_ref() - .context("NEAR AI config missing after validation")?; - - let near_ai = Arc::new( - NearAiClient::new( - &near_cfg.api_key, - &near_cfg.base_url, - &near_cfg.model, - near_cfg.timeout, - ) - .context("Failed to create NEAR AI client")?, - ); - - if near_ai.health_check().await { - info!("NEAR AI healthy - Model: {}", near_cfg.model); - } else { - warn!("NEAR AI health check failed - will retry on requests"); - } - - 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; - if config.group_preferences.persist { info!( "Group preferences persistence enabled: {}", @@ -133,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" ); @@ -169,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, ))); @@ -199,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) } @@ -220,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 { @@ -248,62 +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("/models")) .respond_with(ResponseTemplate::new(200)) - .mount(&whisper) + .mount(&near) .await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200)) + .mount(&near) + .await; + near + } - let mut config = base_config(BotRole::Transcription); - config.whisper.service_url = whisper.uri(); + fn with_near(mut config: Config, near: &MockServer) -> Config { config.near_ai = Some(NearAiConfig { api_key: "test-key".into(), - base_url: whisper.uri(), - model: "openai/whisper-large-v3".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 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 - ] + 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_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; - - let mut config = base_config(BotRole::Translation); - config.near_ai = Some(NearAiConfig { - api_key: "test-key".into(), - base_url: near.uri(), - model: "test-model".into(), - timeout: Duration::from_secs(5), - }); + 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)); @@ -313,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")); @@ -323,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")); @@ -336,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)); @@ -364,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 f87151e..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( @@ -81,20 +64,13 @@ async fn main() -> AppResult<()> { 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..."); 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/docker/compose.transcription.yaml b/docker/compose.transcription.yaml index 80b2971..3064409 100644 --- a/docker/compose.transcription.yaml +++ b/docker/compose.transcription.yaml @@ -1,67 +1,14 @@ -# Local transcription stack — Signal worker only (no Whisper sidecar). -# STT is NEAR AI Whisper Large V3. Own Docker network; Signal is the bus to translation. -# -# 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 - - 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:-} - - 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=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} - - 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 - restart: unless-stopped - -volumes: - signal-config-transcription: - 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 87f404d..5a4bbcb 100644 --- a/docker/compose.translation.yaml +++ b/docker/compose.translation.yaml @@ -1,7 +1,6 @@ -# Local translation stack — phone B / hub. -# Own Docker network; does not share a network with the transcription compose. -# Integration with transcription is via Signal group chat only. -# Prod co-locates both phones on one CVM (docker/phala.translation.yaml). +# 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 @@ -34,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 cc67498..b4e52d5 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -1,7 +1,7 @@ # DEPRECATED monolith stack. -# Local: two Compose projects (two phones). Prod: one CVM in docker/phala.translation.yaml. +# 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.transcription.env.example b/docker/phala.transcription.env.example index 8d68333..79a8d14 100644 --- a/docker/phala.transcription.env.example +++ b/docker/phala.transcription.env.example @@ -1,5 +1,5 @@ # DEPRECATED — transcription no longer has its own CVM. -# Put TRANSCRIPTION_PHONE + NEAR_AI_API_KEY in docker/phala.translation.env instead. +# Use docker/phala.translation.env.example (one Signal number, phone B). # # 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.translation.env.example b/docker/phala.translation.env.example index 7db775a..c2f5535 100644 --- a/docker/phala.translation.env.example +++ b/docker/phala.translation.env.example @@ -3,14 +3,9 @@ # Upgrade live: phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353 \ # -c docker/phala.translation.yaml -e docker/phala.translation.env --wait # -# SIGNAL_PHONE = translation hub (phone B, already registered on this CVM). -# TRANSCRIPTION_PHONE = voice worker (phone A) — re-register on :8082 after first merge -# (the old transcription CVM was deleted; that session does not transfer). +# SIGNAL_PHONE = surviving translation number (phone B), already registered on this CVM. SIGNAL_PHONE=+1YYYYYYYYYY -TRANSCRIPTION_PHONE=+1XXXXXXXXXX -# Legacy alias: translation bot PEER_PHONE. Prefer TRANSCRIPTION_PHONE. -PEER_PHONE=+1XXXXXXXXXX SIGNAL_BOT_IMAGE=YOUR_DOCKERHUB/signal-bot-tee:latest SIGNAL_PROXY_IMAGE=YOUR_DOCKERHUB/signal-registration-proxy:latest diff --git a/docker/phala.translation.yaml b/docker/phala.translation.yaml index 9670726..24753bf 100644 --- a/docker/phala.translation.yaml +++ b/docker/phala.translation.yaml @@ -1,7 +1,8 @@ -# Phala Cloud TEE — both products on one CVM (tdx.medium). +# Phala Cloud TEE — one Signal number on one CVM (tdx.medium). # -# No local Whisper sidecar. Transcription posts audio to NEAR AI Whisper Large V3. -# Two Signal numbers, two bot processes so STT does not block translation. +# 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 . @@ -9,11 +10,11 @@ # # 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 sessions + group prefs). Do not rename volumes below. +# 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: translation proxy :8081 (phone B), transcription proxy :8082 (phone A). +# Registration proxy :8081 (phone B). Do not re-register phone A. services: signal-api: @@ -31,33 +32,20 @@ services: start_period: 30s restart: unless-stopped - signal-api-transcription: - 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 - signal-bot: image: ${SIGNAL_BOT_IMAGE} environment: - BOT__ROLE=translation - SIGNAL__SERVICE_URL=http://signal-api:8080 - SIGNAL__PHONE_NUMBER=${SIGNAL_PHONE} - - SIGNAL__PEER_PHONE=${TRANSCRIPTION_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} @@ -73,33 +61,6 @@ services: - signal-api restart: unless-stopped - signal-bot-transcription: - image: ${SIGNAL_BOT_IMAGE} - environment: - - BOT__ROLE=transcription - - SIGNAL__SERVICE_URL=http://signal-api-transcription:8080 - - SIGNAL__PHONE_NUMBER=${TRANSCRIPTION_PHONE} - - SIGNAL__PEER_PHONE=${SIGNAL_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=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} - - 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-transcription - restart: unless-stopped - signal-registration-proxy: image: ${SIGNAL_PROXY_IMAGE} environment: @@ -120,37 +81,11 @@ services: - signal-api restart: unless-stopped - signal-registration-proxy-transcription: - image: ${SIGNAL_PROXY_IMAGE} - environment: - - SIGNAL__API_URL=http://signal-api-transcription:8080 - - REGISTRY__PATH=/data/registry.enc - - SERVER__LISTEN_ADDR=0.0.0.0 - - SERVER__PORT=8082 - - DSTACK__SOCKET_PATH=/var/run/dstack.sock - - RATE_LIMIT__GLOBAL_PER_MINUTE=${RATE_LIMIT_GLOBAL_PER_MINUTE:-60} - - RATE_LIMIT__PER_NUMBER_PER_HOUR=${RATE_LIMIT_PER_NUMBER_PER_HOUR:-10} - - LOG__LEVEL=${LOG_LEVEL:-info} - volumes: - - /var/run/dstack.sock:/var/run/dstack.sock:ro - - registry-data-transcription:/data - ports: - - "8082:8082" - depends_on: - - signal-api-transcription - restart: unless-stopped - volumes: # Keep these names: renaming creates empty volumes and drops Signal identity + prefs. signal-config-translation: driver: local - signal-config-transcription: - driver: local registry-data: driver: local - registry-data-transcription: - driver: local group-prefs-translation: driver: local - group-prefs-transcription: - driver: local diff --git a/docker/transcription.env.example b/docker/transcription.env.example index cf12d5b..21820e3 100644 --- a/docker/transcription.env.example +++ b/docker/transcription.env.example @@ -1,17 +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 - -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 -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 94517ca..5fe7f8f 100644 --- a/docker/translation.env.example +++ b/docker/translation.env.example @@ -1,14 +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 -# Same key as docker/transcription.env (chat here; Whisper STT on the other stack). 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 4a55a1c..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,7 @@ 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 diff --git a/docs/language-threads.md b/docs/language-threads.md index e276f73..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 @@ -134,19 +134,19 @@ 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 run as the **transcription** bot process (NEAR AI Whisper, same CVM) — 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 process) 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 -- One CVM on Phala (`tdx.medium` = 2 vCPU / 4 GB RAM): [`docker/phala.translation.yaml`](../docker/phala.translation.yaml) — hub (phone B) + transcription worker (phone A). 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). +- 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). -- Registration proxies on this CVM: `:8081` phone B, `:8082` phone A. +- Registration proxy on this CVM: `:8081` phone B. ## Trust / privacy notes diff --git a/docs/local-dev/README.md b/docs/local-dev/README.md index 8c3ee0f..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) (prod is one Phala CVM; local still uses two Compose projects for two phones). 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 both env files (translation chat + transcription STT) +- 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,153 +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. 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`). No local Whisper sidecar — STT is NEAR AI Whisper Large V3 (`NEAR_AI_API_KEY` required). - -### 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 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`). 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 ``` @@ -253,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`. @@ -287,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 @@ -296,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`. @@ -337,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 local stacks — Signal is the bus (prod co-locates both phones on one CVM). - -**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) | One CVM / two phones; local dual compose | +| [two-cvm-architecture.md](../two-cvm-architecture.md) | One CVM / one phone; CVM storage | 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 index f4a9cc4..d2642b6 100644 --- 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 @@ -23,7 +23,7 @@ Dual-CVM was supposed to give Whisper room. It did not. Live `tdx.medium` (2 vCP 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 **two bot processes** (and two Signal numbers) on **one** Phala CVM (`tdx.medium`) so translation never awaits STT. Do **not** add `whisper-api` back to compose. Do **not** put Whisper on a larger CPU TEE as the scale path. +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. @@ -31,7 +31,7 @@ Outbound STT is a multipart file plus model name. Generic filename (`voice.ogg` 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 matter because each bot awaits `dispatch_message` inside its own poll loop. Merging roles into one process with serial dispatch would block translation behind STT. Shared CVM contention is mild I/O (two Java CLIs + two Rust bots waiting on NEAR), not a CPU Whisper queue. +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 @@ -44,7 +44,7 @@ Two processes matter because each bot awaits `dispatch_message` inside its own p **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) — two `signal-api` + two `signal-bot` (`BOT__ROLE` unchanged) +- 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** @@ -52,7 +52,7 @@ Two processes matter because each bot awaits `dispatch_message` inside its own p - 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 -- Merge both Signal numbers into one bot process +- Re-introduce a second Signal number / pairing, or re-home Whisper in this CVM ## Related diff --git a/docs/two-cvm-architecture.md b/docs/two-cvm-architecture.md index 1466ea9..82bdfd3 100644 --- a/docs/two-cvm-architecture.md +++ b/docs/two-cvm-architecture.md @@ -1,78 +1,58 @@ -# One CVM, two Signal numbers +# One CVM, one Signal number -Prod runs both products on **one** Phala CVM (today’s translation box). Two phone numbers / two bot processes stay. Signal group chat is still the only coupling between roles — they do not HTTP to each other. +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) and the architecture learning [CPU TEE Whisper does not scale](solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md). -Local development still uses **two Compose projects** (separate Docker networks) as a mock of two phones. - -## 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` (this CVM quote) | -| **Transcription bot** (worker) | Phone A | Voice only: `!transcription`, `!transcribe*`, `!verify` (same CVM, second Signal number). **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 oneCvm [Translation_CVM_tdx_medium] - sigA[signal-api_phone_A] - sigB[signal-api_phone_B] - txBot[signal-bot_transcription] - trBot[signal-bot_translation] - txBot --> sigA - trBot --> sigB - end - Users[Signal_group] --> sigA - Users --> sigB - txBot -->|"audio bytes only"| NearWhisper[NEAR_AI_Whisper_large_v3] - NearWhisper -->|text| txBot - trBot -->|"text"| NearChat[NEAR_AI_chat] + 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). +- **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. -- Keep two bot **processes** so a voice note in flight (HTTP wait to NEAR Whisper) cannot stall translation polling. -- Same `signal-bot` image; role selected by `BOT__ROLE=transcription|translation`. +- 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 two phones) +## 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; NEAR_AI_API_KEY in both (STT + chat) +# 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 **two phones**, not two Phala CVMs. +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.translation.yaml`](../docker/phala.translation.yaml) | 4 GB (`tdx.medium`) | `signal-api` (phone B) + `signal-api-transcription` (phone A) + two `signal-bot` + proxies `:8081` / `:8082`. **No Whisper sidecar.** | +| [`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). +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: @@ -83,47 +63,45 @@ phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353 \ [`scripts/deploy_phala.sh`](../scripts/deploy_phala.sh) defaults to that `--cvm-id`. Do not `phala deploy -n` against the live CVM. -Phone A must be **re-registered** on proxy `:8082` after the first one-CVM merge (that session died with the deleted CVM). Phone B on `:8081` stays. - ### 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) | Translation bot **registered Signal phone** | Hub disappears from Signal until ops re-register | -| `signal-config-transcription` | `signal-api-transcription` (phone A) | Transcription bot session | Voice worker gone until re-register (expected empty after first merge) | -| `group-prefs-translation` | 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 | -| `group-prefs-transcription` | transcription `signal-bot` → `/data/group_prefs.enc` | Encrypted `!transcribe-on` prefs | Users must re-enable auto transcribe | -| `registry-data` / `registry-data-transcription` | registration proxies | Ops registration helper state | Re-register via proxy; does not by itself drop Signal CLI | +| `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, rename `signal-config-translation` / `group-prefs-translation` / `registry-data`, or `down -v` for a routine image bump. First bring-up of a **new** CVM is the exception (empty volumes; register phones there). +**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 phones? | +| Event | Volumes | Users re-enable prefs? | Re-register phone? | |-------|---------|------------------------|---------------------| -| In-place CVM upgrade (`--cvm-id`) | Kept (Phala reattaches) | No | No (phone A only if that volume was new/empty) | +| 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 each `signal-api`. +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`. Do not change volume names in [`docker/phala.translation.yaml`](../docker/phala.translation.yaml) without a deliberate migration. ## Products on this CVM -| Product | Bot process | 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) 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). +- **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 two processes (not two CVMs) +## Why one process (not two phones) -The old split existed to isolate **CPU Whisper** from translation. That is obsolete: STT is remote GPU. Two CVMs did not make ~5s notes usable and billed a second `tdx.medium` idle. Two **processes** on one CVM still matter — translation must not await STT in a single serial dispatch loop. Do not merge roles into one bot in this architecture. +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 4097a81..e4de8ff 100644 --- a/docs/voice-transcription.md +++ b/docs/voice-transcription.md @@ -1,58 +1,42 @@ # Voice transcription -Status: **implemented** as the worker bot (`BOT__ROLE=transcription`) on the **same** Phala CVM as translation. +Status: **implemented** on the unified Bread Bot (`BOT__ROLE=translation`) on the surviving Phala CVM. 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 | |-------|----------| -| Phala (prod) | Same CVM as translation: `signal-api-transcription` (phone A) + `signal-bot` (`BOT__ROLE=transcription`). **No Whisper sidecar.** | -| Local Compose | [`docker/compose.transcription.yaml`](../docker/compose.transcription.yaml) — phone A + bot; STT is NEAR AI | +| 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 | -`!verify` attests **this** CVM’s compose. It does not imply Whisper weights live here. +`!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, posts audio-only multipart to NEAR 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. Outbound STT: generic filename (`voice.ogg` / `voice.m4a`). No phone, group id, timestamp, or display name in form fields, headers, or filenames. -## Pairing (translation leads) +**Group invites:** this bot auto-accepts any pending group invite. -1. Set `PEER_PHONE` / `TRANSCRIPTION_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). - -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 (NEAR Whisper GPU TEE; same CVM as the hub) | +| `!help-transcription` | How voice transcription works (NEAR Whisper GPU TEE) | -Hub `!privacy` (translation bot only) covers both numbers on this CVM. In a paired group, `!verify ` returns two quotes (one per Signal number). +Hub `!privacy` covers this CVM. `!verify ` returns **one** quote. Auto path: inbound voice notes are transcribed only after `!transcribe-on` (default off). @@ -61,38 +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) -# Set NEAR_AI_API_KEY (same key as translation) +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 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`): +In-place upgrade of the **surviving** translation CVM (do not deploy `phala.transcription.yaml`; do not re-register phone A): ```bash phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353 \ -c docker/phala.translation.yaml -e docker/phala.translation.env --wait ``` -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. After the first merge, **re-register phone A** on proxy `:8082` (the old transcription CVM was deleted). 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) | | STT HTTP client | [`crates/whisper-client`](../crates/whisper-client) (NEAR `/audio/transcriptions`) | | NEAR client (chat + transcribe) | [`crates/near-ai-client`](../crates/near-ai-client) | -| Pairing (`!transcription` on translation) | [`crates/signal-bot/src/commands/product_menus.rs`](../crates/signal-bot/src/commands/product_menus.rs) | | 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.translation.yaml`](../docker/phala.translation.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 f30e0a9..65aac95 100755 --- a/scripts/deploy_phala.sh +++ b/scripts/deploy_phala.sh @@ -1,11 +1,11 @@ #!/bin/bash -# Deploy the one-CVM product suite (two Signal numbers, no local Whisper). +# 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 sessions + user prefs). +# 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 # @@ -27,7 +27,7 @@ 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). Transcription CVM eba19afc-… was deleted. +# Surviving prod CVM (phone B). CVM_ID="${CVM_ID:-0e82fa77-8b15-4dbd-89c4-9045ab911353}" FIRST_CREATE="${FIRST_CREATE:-0}" @@ -41,7 +41,7 @@ if [[ ! -f "$TR_ENV" ]]; then fi if [[ "$FIRST_CREATE" == "1" ]]; then - echo "First-create $TR_NAME ($INSTANCE_TYPE, disk $DISK_SIZE) — empty volumes; register phones after." + echo "First-create $TR_NAME ($INSTANCE_TYPE, disk $DISK_SIZE) — empty volumes; register phone after." phala deploy \ -n "$TR_NAME" \ -c "$TR_COMPOSE" \ @@ -60,4 +60,4 @@ fi echo "" echo "Done. Check status with: phala cvms list" -echo "Phone B stays on this CVM. Re-register phone A on proxy :8082 if this is the first one-CVM merge." +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 index f1f0a68..fa31ab6 100755 --- a/scripts/register_phala_phones.sh +++ b/scripts/register_phala_phones.sh @@ -1,67 +1,42 @@ #!/bin/bash -# Register Signal phones onto the live one-CVM Phala stack via registration proxies. +# Register the surviving Signal number (phone B) via proxy :8081. # -# After the transcription CVM was deleted, phone A must be re-registered on :8082 -# of the surviving translation CVM. Phone B on :8081 should already be registered -# — skip CAPTCHA_TR / SMS_TR if accounts already lists it. +# Phone B should already be registered on the live CVM — skip if +# accounts already lists it. Do not re-register phone A. # # Usage: -# CAPTCHA_TX='signalcaptcha://...' ./scripts/register_phala_phones.sh -# CAPTCHA_TX='...' CAPTCHA_TR='...' ./scripts/register_phala_phones.sh # both +# CAPTCHA_TR='signalcaptcha://...' ./scripts/register_phala_phones.sh # Then when SMS arrives: -# SMS_TX=123456 ./scripts/register_phala_phones.sh --verify -# SMS_TX=123456 SMS_TR=654321 ./scripts/register_phala_phones.sh --verify +# SMS_TR=123456 ./scripts/register_phala_phones.sh --verify set -euo pipefail APP_ID="${APP_ID:-9adac7636fe255182f699940ffd1924960415507}" GATEWAY="${GATEWAY:-dstack-pha-prod9.phala.network}" -# Transcription (phone A) proxy on the surviving CVM. -TX_PROXY="${TX_PROXY:-https://${APP_ID}-8082.${GATEWAY}}" -# Translation (phone B) proxy — already registered; keep for verify/debug. TR_PROXY="${TR_PROXY:-https://${APP_ID}-8081.${GATEWAY}}" -PHONE_TX="${PHONE_TX:-+573103479014}" PHONE_TR="${PHONE_TR:-+573107677679}" MODE="${1:-register}" if [[ "$MODE" == "--verify" ]]; then - if [[ -n "${SMS_TX:-}" ]]; then - echo "Verifying transcription $PHONE_TX on :8082..." - curl -sS -X POST "$TX_PROXY/v1/register/${PHONE_TX}/verify/${SMS_TX}" \ - -H 'Content-Type: application/json' -d '{}' - echo - fi - if [[ -n "${SMS_TR:-}" ]]; then - echo "Verifying translation $PHONE_TR on :8081..." - curl -sS -X POST "$TR_PROXY/v1/register/${PHONE_TR}/verify/${SMS_TR}" \ - -H 'Content-Type: application/json' -d '{}' - echo - fi - if [[ -z "${SMS_TX:-}" && -z "${SMS_TR:-}" ]]; then - echo "Error: set SMS_TX and/or SMS_TR" >&2 + 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_TX:-}" && -z "${CAPTCHA_TR:-}" ]]; then - echo "Error: set CAPTCHA_TX (phone A / :8082) and/or CAPTCHA_TR (phone B / :8081)" >&2 + if [[ -z "${CAPTCHA_TR:-}" ]]; then + echo "Error: set CAPTCHA_TR (phone B / :8081)" >&2 exit 1 fi - if [[ -n "${CAPTCHA_TX:-}" ]]; then - echo "Registering transcription $PHONE_TX on :8082..." - curl -sS -X POST "$TX_PROXY/v1/register/${PHONE_TX}" \ - -H 'Content-Type: application/json' \ - -d "$(python3 -c 'import json,os; print(json.dumps({"captcha":os.environ["CAPTCHA_TX"],"use_voice":False}))')" - echo - fi - if [[ -n "${CAPTCHA_TR:-}" ]]; then - echo "Registering translation $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 "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 / transcription :8082):" +echo "Accounts (translation :8081):" curl -sS "$TR_PROXY/v1/debug/signal-accounts"; echo -curl -sS "$TX_PROXY/v1/debug/signal-accounts"; echo