diff --git a/.env.example b/.env.example index 11500cb5..f0c1dd20 100644 --- a/.env.example +++ b/.env.example @@ -156,6 +156,14 @@ WHATSAPP_BRIDGE_STORE=/opt/acb/data/whatsapp_bridge/bridge-store.db # persisted + triaged but never auto-replied to. ~1yr reliable, ~3yr ceiling. WHATSAPP_BRIDGE_FULL_HISTORY=true WHATSAPP_BRIDGE_FULL_HISTORY_DAYS=365 +# Voice calls (WhatsApp β†’ Calls). Each call's audio is recorded here as 16 kHz +# mono WAV β€” the format the note-taker STT already consumes. Blank disables +# recording; calls still connect. Ended calls stay queryable for REAP_MINS. +WHATSAPP_BRIDGE_CALL_RECORD_DIR=/opt/acb/data/whatsapp_bridge/call-recordings +WHATSAPP_BRIDGE_CALL_REAP_MINS=60 +# Recording runs ~115 MB per hour of call, so audio is swept after this many +# days. 0 keeps it forever β€” only set that if you've sized the disk for it. +WHATSAPP_BRIDGE_CALL_RETENTION_DAYS=7 # --- Gateway / auth --- GATEWAY_HOST=0.0.0.0 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ecec77d1..0b83c48d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -278,6 +278,11 @@ jobs: grep -qE '^WHATSAPP_BRIDGE_GATEWAY_URL=' "$ENV_FILE" || echo "WHATSAPP_BRIDGE_GATEWAY_URL=http://localhost:8080" >> "$ENV_FILE" grep -qE '^WHATSAPP_BRIDGE_ADDR=' "$ENV_FILE" || echo "WHATSAPP_BRIDGE_ADDR=127.0.0.1:8790" >> "$ENV_FILE" grep -qE '^WHATSAPP_BRIDGE_STORE=' "$ENV_FILE" || echo "WHATSAPP_BRIDGE_STORE=/opt/acb/data/whatsapp_bridge/bridge-store.db" >> "$ENV_FILE" + # Voice calls: recorded audio lands beside the session store and is + # swept after RETENTION_DAYS (recording runs ~115 MB per call-hour, + # so unbounded would fill the VPS). Blank RECORD_DIR = no recording. + grep -qE '^WHATSAPP_BRIDGE_CALL_RECORD_DIR=' "$ENV_FILE" || echo "WHATSAPP_BRIDGE_CALL_RECORD_DIR=/opt/acb/data/whatsapp_bridge/call-recordings" >> "$ENV_FILE" + grep -qE '^WHATSAPP_BRIDGE_CALL_RETENTION_DAYS=' "$ENV_FILE" || echo "WHATSAPP_BRIDGE_CALL_RETENTION_DAYS=7" >> "$ENV_FILE" # Generate a strong shared secret once (used by BOTH gateway + bridge). if ! grep -qE '^WHATSAPP_BRIDGE_SECRET=.+' "$ENV_FILE"; then _wbsecret="$(openssl rand -hex 32 2>/dev/null || head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" diff --git a/ai-company-brain/AGENTS.md b/ai-company-brain/AGENTS.md index d3d3cf74..9d12fa38 100644 --- a/ai-company-brain/AGENTS.md +++ b/ai-company-brain/AGENTS.md @@ -123,6 +123,7 @@ to it and to `competitive_hardening_2026-07.md` (CH-*) rather than re-describe t | [`drawio_diagram_svc_contract.md`](specs/drawio_diagram_svc_contract.md) | draw.io β€” `diagram-svc` wire contract (sub-doc of the master) | πŸ”² proposed β€” unbuilt | | [`note_taker_app.md`](specs/note_taker_app.md) | **AI Note Taker (`/notes`)** β€” browser record (mic + Chromium tab audio) β†’ pluggable STT (`acb_stt`: BYOK cloud + self-host faster-whisper/WhisperX + open diarization) β†’ grounded notes via `acb_llm` template compiler β†’ HITL action-itemsβ†’`/tasks`, recapβ†’`/email`, shareβ†’`/chat`; activates the dormant `meeting`/`action_item` tables | πŸ”„ slice 0 built (migration 94, `acb_stt`, gateway `routes/notes/`, uploadβ†’transcribeβ†’segments UI); slice 1 (recorder + SSE + notes generation) next | | [`note_taker_research_2026-07.md`](specs/note_taker_research_2026-07.md) | Note Taker β€” research appendix (sub-doc): Meetily deep dive, 18-project landscape survey, mid-2026 ASR/diarization SOTA, browser-capture constraints, license watch-list | 🟒 research complete | +| [`whatsapp_calls_note_taker.md`](specs/whatsapp_calls_note_taker.md) | **WhatsApp calls β†’ Note Taker** β€” feasibility study + UX design for note-taking on WhatsApp voice calls: the four capture surfaces (Cloud API Business Calling Β· WhatsApp Web Β· whatsmeow+meowcaller Β· device upload), why group calls are impossible officially and only reachable via the unofficial bridge, the "notetaker is a contact you add to the call" UX, and the consent model | πŸ”¬ feasibility study β€” **no code**; Phase A (official 1:1) recommended first | | [`workflows_app.md`](specs/workflows_app.md) | **Workflows app (`/workflows`)** β€” visual automation builder: DB-persisted graphs compiled to MAF Workflows, webhook/schedule/manual triggers, Module Studio (conversational pure-transform code modules), served node catalog. Engineering RFC: `docs/workflow-editor/README.md`. Policy: ADR-028 | πŸ”„ Slices 1+2 built (migration 132, gateway `routes/workflows/` incl. copilot + event triggers + approval pause/resume via the Action Broker inbox, `/workflows` editor) | | [`department_centers.md`](specs/department_centers.md) | **Department Centers** β€” one platform, many projections: nomenclature (Center/module/group/Workshop), nav IA (Personal Center / Centers / Studio / Admin), `center.*` feature gating, and the Phase B–E work plan (groups admin UI β†’ scoped slices β†’ dashboards/Company Center β†’ AI budgets). Registry: `workbench/control_plane/src/lib/centers.ts` | πŸ”„ Phase A shipped (nav scaffold, `/centers/` landings, migration 140); Phase B (groups admin UI) next | | [`skills_registry.md`](specs/skills_registry.md) | **Skills registry + per-agent skill toggles (WS-23)** β€” code-declared skill families with measured token cost, Integrations β†’ Skills catalog tab, per-agent enable/disable stored in `agent_skill_setting` (intersection-only, core floor non-toggleable), tools addendum generated from the enabled set | πŸ”„ S1+S2+S3-generation shipped pending review 2026-08-01; remaining: owner-gated `SKILLS_FAIL_CLOSED` flip (ships OFF) | diff --git a/ai-company-brain/specs/whatsapp_calls_note_taker.md b/ai-company-brain/specs/whatsapp_calls_note_taker.md new file mode 100644 index 00000000..51bffb41 --- /dev/null +++ b/ai-company-brain/specs/whatsapp_calls_note_taker.md @@ -0,0 +1,445 @@ +# WhatsApp Calls β†’ Note Taker β€” feasibility study & UX design + +> **Product:** CommandCenter Β· **Feature:** extend the AI Note Taker (`/notes`) to WhatsApp voice calls, including group calls +> **Created:** 2026-08-01 Β· **Status:** πŸ”¬ feasibility study Β· πŸ”„ **Surface C dialer BUILT 2026-08-01** β€” see Β§12. Placing/answering 1:1 + group calls from `/whatsapp/calls` works end to end and records each call to WAV; transcription is not wired yet. Β§0–§11 remain the design record. +> **Siblings:** [`note_taker_app.md`](note_taker_app.md) (the note taker we're extending) Β· [`meeting_bot_platform_plan.md`](meeting_bot_platform_plan.md) (the bot-joins-a-call pattern) Β· [`whatsapp_message_manager.md`](whatsapp_message_manager.md) (the WhatsApp vertical we'd hang this off) +> **Touches:** `apps/services/meeting_bot/` Β· `apps/services/whatsapp_bridge/` (Go + whatsmeow) Β· `gateway/routes/notes/meeting_bot.py` Β· `gateway/routes/whatsapp/` + +--- + +## 0. Verdict + +**Yes β€” with one large asterisk, and the asterisk decides the whole design.** + +| Question | Answer | +|---|---| +| Can a note taker capture **1:1** WhatsApp calls? | **Yes, officially and supportedly** β€” but only calls to/from a *business* number on the WhatsApp Business Calling API. | +| Can a note taker capture **group** WhatsApp calls? | **Not through any official Meta API.** Meta's Calling API explicitly does not support group voice or video calls. | +| Can it capture group calls *some other way*? | **Yes, technically** β€” via an unofficial protocol client (`whatsmeow` + `meowcaller`), the same family of library our `whatsapp_bridge` already runs. Group support in that library is marked **experimental**, and this route **violates WhatsApp's ToS and risks a number ban**. | +| Can we passively record a call we aren't in? | **No.** WhatsApp calls are end-to-end encrypted. The only way to get audio is to **be an endpoint** β€” a participant, or the device. There is no tap, no server-side copy, no "give me the recording" API. | + +**That last row is the whole story.** It means the note taker cannot be a passive observer the way a Zoom cloud recording is. It has to *join the call as a participant* β€” which is exactly the model our Google Meet bot already uses, and which the rest of our pipeline is already built around. The pipeline (transcribe β†’ diarize β†’ name speakers β†’ summarize β†’ action items β†’ dispatch) is **entirely platform-agnostic and reusable unchanged**. Only the *join and capture* layer is new. + +**Recommended path (detail in Β§6):** + +1. **Phase A β€” official, zero-risk, real value:** note-take **1:1 calls on our Cloud API business number** via the WhatsApp Business Calling API's WebRTC/SIP media leg. Ships without touching any grey area. Covers customer/dealer/vendor calls, which is where the business value actually is for Fracktal. +2. **Phase B β€” the group-call unlock, opt-in and risk-flagged:** a **"notetaker as a participant" bot** on the whatsmeow bridge β€” a dedicated, disposable number you *add to the call like a person*. Gated behind the same explicit ToS warning the bridge already carries, and never on the company's primary number. +3. **Phase C β€” the no-protocol fallback:** device-side capture (record on the phone/desktop, upload). Zero ban risk, zero automation, already 90% built (`/notes` upload path). Worth shipping as the honest escape hatch regardless of A and B. + +--- + +## 1. What WhatsApp actually offers (the four surfaces) + +There are exactly four ways audio from a WhatsApp call can legitimately reach a server. They have wildly different capabilities and risk. + +### Surface A β€” WhatsApp Business Calling API (Cloud API) Β· **official** + +Meta shipped voice calling on the WhatsApp Business Platform in 2025. A business phone number on the Cloud API can place and receive **VoIP voice calls** with WhatsApp users, in the same thread as the chat. + +What matters for us β€” **you terminate the media yourself**: + +- Call setup is an **SDP offer/answer handshake**. Meta sends your webhook an SDP offer describing the media session; your server returns an SDP answer. The offer is standard WebRTC: **ICE, DTLS-SRTP, OPUS**. +- Alternatively you can run **SIP signalling** with default WebRTC media. +- Because *your* media server is one leg of the call, **you have the raw decoded audio**. That is the note taker's whole requirement. + +Constraints, all confirmed: + +| Constraint | Detail | +|---|---| +| **No group calls** | The API does not support voice or video calls in groups. One-to-one only. | +| **No video** | Voice only today; video is signposted as future, not available. | +| **Business number only** | Requires the WhatsApp Business Platform (Cloud API) and `whatsapp_business_messaging` permission β€” not the WhatsApp Business *app*, and never a personal number. | +| **Consent for outbound** | A user must accept an explicit **call permission request** before the business may ring them; the permission expires. User-initiated calls (customer taps call) need no permission. | +| **Region blocks on business-initiated calls** | Blocked in the USA, Canada, Egypt, Vietnam and Nigeria. India β€” our actual market β€” is fine. | +| **Cost** | Outbound billed per minute in 6-second increments, only on answer, volume-tiered by country. Inbound (user-initiated) is free. | +| **Meta gives you no recording** | The API itself exposes no recording or transcript. That's not a blocker β€” *we* are the media endpoint, so we record our own leg. BSPs (Wati, etc.) already sell recording + transcription + summaries built exactly this way, which is proof the pattern is sanctioned. | +| **Availability** | Rolling out through Cloud API and enterprise BSPs; needs enabling on the WABA. | + +**Read:** this is a fully supported, documented way to build a WhatsApp note taker β€” for 1:1 business calls, and only those. + +### Surface B β€” WhatsApp Web calling (browser) Β· **grey** + +As of **July 2026** WhatsApp shipped **calling on WhatsApp Web** β€” one-to-one *and* **group** voice/video calls in a plain browser tab, no desktop app. Alongside it: **call links with a "require approval to join" waiting room**, **call transfer between devices**, screen sharing, noise suppression. + +This is structurally interesting because it makes WhatsApp look, for the first time, like Google Meet: *a group call, in a browser, joinable from a link, with a waiting room*. Our `meeting_bot` worker is already **real Chrome under Playwright with a PulseAudio null sink and ffmpeg** β€” the exact machinery needed. Pointing it at WhatsApp Web instead of Meet is a small architectural step. + +Why it is not the recommended path anyway: + +- **Automating a WhatsApp client violates WhatsApp's Terms of Service**, the same reason our `whatsapp_bridge` carries a ban warning. A browser-driven account is an unauthorised client. +- It requires a **real WhatsApp account paired to a phone** in the container, with all the session-fragility that implies. +- Call links require every invitee to have a WhatsApp account, and historically opening a call link on the web app punted you to your phone. Web group calling is *newly* rolled out and still gradual β€” so the DOM, the flows and the capabilities are a moving target, and DOM automation is the most brittle thing we own (our Meet bot's join flow is already the highest-maintenance code in the repo). +- Compared to Surface C it buys nothing: same ToS exposure, worse reliability, worse audio path. + +**Read:** a real option, and the reason group calls are newly *conceivable* at all β€” but strictly worse than Surface C for the same risk. + +### Surface C β€” whatsmeow + meowcaller (protocol client) Β· **grey, and the most capable** + +We already run **`apps/services/whatsapp_bridge`** β€” Go, `go.mau.fi/whatsmeow`, QR-paired personal number, holding a live WhatsApp multi-device session. It handles messages today and **ignores calls entirely** (`grep -i call *.go` finds nothing call-related). + +whatsmeow itself gives **call signalling only** β€” `events.CallOffer`, `CallOfferNotice` (with a `Media: audio|video` field, primarily for group calls), `CallTerminate`. A 2023 request to add media/answer support was **closed as not planned**. So whatsmeow alone can tell you a call is happening and who from β€” useful on its own β€” but cannot hear it. + +The gap is closed by **[`purpshell/meowcaller`](https://github.com/purpshell/meowcaller)** β€” a pure-Go WhatsApp VoIP library that sits on top of stock upstream whatsmeow (no fork), MIT licensed, ~226 stars / 217 commits: + +- Implements WhatsApp's proprietary **MLOW audio codec entirely in Go**, no CGO, no C bindings. +- Places outbound calls, receives inbound calls, answers them. +- **Raw PCM in and out** via a source/sink API β€” `call.Receive(meowcaller.SinkFunc(func(pcm []float32) { … }))`. That is literally the note taker's input. +- Ships `WAVRecorder` / `MP3File` helpers β€” recording is a first-class use case. +- **Experimental group calling:** ad-hoc and group-bound group calls, add/ring participants, **reusable call links and approval waiting rooms**. +- Known gaps: Opus fallback for non-MLOW peers is in progress; scheduled-call events unimplemented. + +(A second project, `JotaDev66/WaCalls`, does the same on whatsmeow + pion/webrtc with a vendored MLow, reports stable bidirectional 1:1 audio at 16 kHz PCM β€” but is explicitly **1:1 only, no group calls**. It's corroboration that the approach works, not a better option for us.) + +Risk, stated plainly: **this is against WhatsApp's ToS and the number can be banned at any time.** Our bridge README already says exactly this. Group support is *experimental*, i.e. expect breakage. + +**Read:** the only path to **group** call note-taking, and it lands on infrastructure we already run in a language we already ship. Also the highest-risk path. + +### Surface D β€” device-side capture Β· **no protocol involvement** + +Record the call on the endpoint you already control β€” phone recorder, desktop audio capture, a second device in the room β€” then upload. Our `/notes` upload path runs the identical transcribe β†’ notes β†’ actions pipeline. Zero ToS exposure, zero automation, entirely manual, and legally the most exposed on the *recording-consent* axis (no announcement, nobody knows). + +**Read:** the honest fallback. Cheap to surface, worth having. + +--- + +## 2. What is definitively NOT possible + +Stating these so nobody re-litigates them later: + +1. **No passive/server-side recording.** WhatsApp calls are E2EE. Meta does not hold plaintext and does not offer a recording API on the consumer side. Any design that assumes "WhatsApp gives us the audio" is dead on arrival. +2. **No bot participant in a group call via any official API.** Meta's Calling API is 1:1 by construction. A Cloud API business number cannot be added to a consumer group call. +3. **No retroactive capture.** If the call already happened and nothing was in it, there is nothing to transcribe. (This drives a real UX requirement β€” Β§7.6.) +4. **No video note-taking**, on any surface, for business numbers. +5. **No "just add our Cloud API number to the group"** β€” business numbers and consumer group calls are different worlds. + +--- + +## 3. Feasibility matrix + +| Scenario | Surface | Feasible? | Risk | Effort | +|---|---|---|---|---| +| Customer calls our business number, wants notes | **A** (Cloud API) | βœ… Yes | None β€” supported | M | +| We call a customer from the business number | **A** | βœ… Yes (needs call permission accepted) | None | M | +| Founder's personal 1:1 WhatsApp call | **C** (meowcaller) | βœ… Yes | ToS / ban | M–L | +| **WhatsApp group call** (team standup, dealer group) | **C** | ⚠️ Yes β€” experimental | ToS / ban + instability | L | +| WhatsApp group call | **B** (Web + Playwright) | ⚠️ Yes in principle | ToS / ban + DOM brittleness | L–XL | +| WhatsApp group call | **A** | ❌ **No** | β€” | β€” | +| Any WhatsApp call, no automation | **D** (upload) | βœ… Yes | Consent only | **XS β€” mostly built** | + +--- + +## 4. What we already have (the reuse story is very good) + +This is the strongest argument for doing it: **almost none of the hard part is new.** + +| Need | Already exists | Where | +|---|---|---| +| Transcription (AssemblyAI native, Hinglish code-switching, `word_boost` glossary) | βœ… | `packages/acb_stt/` | +| Diarization (native + sherpa-onnx local fallback) | βœ… | `acb_stt/local_diarization.py` | +| Speaker naming (live voiceprints + LLM self-intro inference) | βœ… | `routes/notes/speaker_id.py`, `live_speakers.py` | +| Summarization, templates, action items, grounding | βœ… | `routes/notes/summaries.py`, `templates.py` | +| Action dispatch β†’ tasks / email / documents | βœ… | `routes/notes/dispatch.py` | +| Live streaming ASR + SSE live transcript + live copilot | βœ… | `meeting_bot/app/live.py`, `routes/notes/live*.py` | +| **Speak into the call** (TTS β†’ virtual mic) β€” needed for the audible consent announcement | βœ… | `meeting_bot/app/main.py` `/bots/{id}/say`, `TTS_CMD` | +| Bot lifecycle: `requested β†’ joining β†’ waiting_room β†’ in_call β†’ processing β†’ done` | βœ… | `routes/notes/meeting_bot.py` | +| Provider abstraction for "something that joins calls and returns audio" | βœ… | `SelfHostedProvider` / `RecallProvider` in `meeting_bot.py:225–296` | +| Bot dispatch UI, active-bot list, stop, diagnostics | βœ… | `notes/components/JoinCallModal.tsx`, `ActiveBots.tsx` | +| A live whatsmeow session on a paired number | βœ… | `apps/services/whatsapp_bridge/` | +| WhatsApp contacts, chats, groups, avatars, send | βœ… | `routes/whatsapp/`, migrations 102–111, 128 | +| WhatsApp voice-note transcription (same STT tier!) | βœ… | `routes/whatsapp/automation/transcription.py` | + +**What's genuinely missing:** a media leg for Surface A, a call layer on the bridge for Surface C, and the UX to arm/consent/join. That's it. + +**One structural bonus WhatsApp gives us that Meet does not:** every participant is a **phone number we already have a contact record for**. If per-participant audio is separable (Β§9, open question), diarization stops being clustering-guesswork and becomes an exact join against `wa_contacts` β€” **named speakers, correct, for free**. That is a materially better transcript than our Meet bot produces today. + +**Also worth noting:** `whatsapp_message_manager.md` records a founder decision (v2, 2026-07-23) of "**official WhatsApp Business Platform only; unofficial linked-device routes dropped**" β€” which the shipped `whatsapp_bridge` subsequently reversed. Phase B re-opens that same question for calls, and should be decided explicitly, not inherited. + +--- + +## 5. The hard part isn't the audio β€” it's the trigger + +For Google Meet the note taker has a link and a calendar. **WhatsApp calls have neither.** They are spontaneous, initiated from a phone, with no URL, no invite, no scheduled start. So the central design problem is: + +> **How does the note taker find out a call is happening, in time to be in it?** + +Four answers, in order of how good they are: + +| Trigger | How it works | Verdict | +|---|---|---| +| **Add it to the call** | The notetaker is a WhatsApp contact that's always online. You tap "add participant" mid-call. It answers in <1s. | ⭐ **Primary.** Zero pre-arming, matches how people already add someone to a call, works for 1:1 (which becomes a 3-party call) *and* groups. | +| **Call permission / auto-answer** (Surface A) | Any call on the business number is *inherently* note-taken, because we're the endpoint. Nothing to trigger. | ⭐ **Primary for Phase A.** The best UX is no UX. | +| **Call link with waiting room** | Create a link from `/notes`; notetaker joins and waits for approval. | βœ… Good for *planned* calls. Requires meowcaller's experimental link support. | +| **Arm ahead of time** ("record the next call in this chat") | `CallOffer` event fires on the bridge β†’ notetaker auto-joins/rings itself in. | βœ… Good for known-imminent calls; useless for surprises. | + +**Design consequence:** the note taker must be a **standing, always-available identity** β€” a contact in your address book named something like *"CommandCenter Notes"* β€” not a thing you dispatch per-meeting. That is a genuinely different mental model from `JoinCallModal`'s paste-a-link, and the UX below is built around it. + +--- + +## 6. Recommended plan + +### Phase A β€” Business-number 1:1 note taking (official, ship first) + +Add a **WhatsApp calling media leg** as a new `meeting_bot` provider sibling β€” a small service that terminates the Cloud API SDP offer (WebRTC, OPUS/DTLS-SRTP), records its leg to 16 kHz mono, and hands off to the *existing* ingest path (`_ingest_recording` β†’ `run_transcription`). Reuse `live.py`'s tee for live captions. + +Why first: no ToS exposure, no ban risk, ships on the WABA we already have, and for a hardware company the calls that matter commercially (customers, dealers, vendors, service escalations) are exactly the 1:1 business calls this covers. It also proves the mediaβ†’pipeline seam with a stable, documented counterparty before we bet on an experimental library. + +Blocking dependency: **Calling must be enabled on our WABA** (rollout is gated). Verify before committing engineering time. + +### Phase B β€” The participant bot, for groups (opt-in, risk-flagged) + +Extend `whatsapp_bridge` (Go, already whatsmeow) with **meowcaller**: answer `CallOffer` automatically when armed, expose the PCM sink, POST audio to the gateway on the existing `X-Bridge-Secret` channel, and drive the same `meeting_bot` lifecycle statuses so `ActiveBots` and the live dock work unchanged. + +Gate it exactly like the bridge is gated: **off by default**, its own env flag, its own explicit ToS warning in the UI, and a hard recommendation to pair a **dedicated disposable number**, never the company's primary line. + +Start with 1:1 (production-ready in meowcaller), then group (experimental) behind a second flag. + +### Phase C β€” Upload fallback (do this cheaply, immediately) + +Surface "record it on your phone and drop the file here" in the WhatsApp UI. It's the existing `/notes` upload path with a WhatsApp-shaped entry point. It costs almost nothing and it's the only thing that works on day zero. + +--- + +## 7. UI/UX design + +The rest of this doc answers the second half of the ask: *how does a user actually get a note taker into a WhatsApp call?* + +### 7.1 The core metaphor: **the notetaker is a contact, not a button** + +Everything follows from Β§5. In `/notes` today you paste a link and dispatch a bot. For WhatsApp you instead **set up a notetaker identity once**, and thereafter **add it to calls like a person**. It appears in the participant list with a name and an avatar, so nobody is being recorded by an invisible thing. + +``` + SET UP ONCE THEN, ON EVERY CALL + ─────────── ─────────────────── + /notes β†’ Settings β†’ WhatsApp β†’ you're on a call + pair the notetaker number (QR) β†’ tap "Add participant" + name it, give it an avatar β†’ pick "CommandCenter Notes" + choose auto-join rules β†’ it answers, announces itself, + and the live transcript + appears in /notes +``` + +### 7.2 Setup screen β€” `/notes` β†’ Settings β†’ **WhatsApp calls** + +A new tab in the existing `NotesSettingsModal`, mirroring `BotIdentitySection.tsx` (which already does exactly this shape for the bot's Google account): + +- **Which number takes notes.** Two cards, side by side, honest about the trade: + - **Business number** *(Recommended Β· Official)* β€” "Notes on calls to and from your Cloud API number. 1:1 only β€” WhatsApp doesn't allow group calls on business numbers." Status chip: `Calling enabled on WABA` / `Not enabled β€” ask Meta`. + - **Personal notetaker number** *(Group calls Β· Unofficial)* β€” QR pairing, reusing the bridge's existing `POST /session` β†’ `{status, qr}` flow and its `` renderer. Carries the same warning the bridge README carries, verbatim in tone: *"This uses an unofficial WhatsApp client. The number can be banned at any time. Use a dedicated number you're willing to lose β€” never your main line."* Behind an "I understand" checkbox before the QR renders. +- **Identity.** Display name (default *"CommandCenter Notes"*) + avatar. This is what everyone sees in the participant list β€” it is the consent surface, so it's mandatory and not editable to something misleading. +- **Auto-join rules** (Β§7.4). +- **Announcement** (Β§7.5). + +### 7.3 The in-call join β€” what the user actually does + +**Group call (Surface C):** the user taps *Add participant* in WhatsApp's own call UI and picks the notetaker. Nothing in our app. That's the point β€” **the best interaction is the one WhatsApp already taught them.** Within a second: + +1. The notetaker answers. +2. It speaks the announcement (Β§7.5) into the call via the existing TTSβ†’virtual-mic path. +3. It posts a message into the chat: *"πŸ“ Taking notes on this call β€” @Vijay started it. Notes land in CommandCenter when the call ends."* +4. `/notes` opens a live meeting and the **existing `LiveDock`** lights up. + +**1:1 call:** identical β€” adding a third participant converts it to a group call. Worth saying out loud in the UI, because users will assume 1:1 can't be joined. + +**Business number (Surface A):** nothing to do at all. The call *is* the note taker. The UI's only job is to say so clearly and let you turn it off per-contact. + +### 7.4 Auto-join rules β€” the "ambient" mode + +A rules list in the same settings tab, deliberately narrow: + +| Rule | Behaviour | +|---|---| +| **Ask me every time** *(default)* | On `CallOffer`, push a notification / control-plane toast: *"Nikhil is calling the Dealers group β€” send the notetaker?"* with **Join** / **Not this one**. One tap. | +| **Always take notes** β€” per chat / per group / per contact | Notetaker rings itself in automatically. Best for a recurring standup group. | +| **Never** | Blocklist. Family groups, personal contacts. Must be as easy to reach as "always". | +| **Just this next call** | Arms once, from the chat header, then reverts. The "we're about to hop on a call" case. | + +The rule editor should hang off the WhatsApp app's existing chat list (a **"Take notes on calls"** toggle in each chat's overflow menu), not be buried in `/notes` settings only β€” that's where the user is when they think of it. + +### 7.5 Consent β€” non-negotiable, and it's UX not legalese + +Recording other people is regulated (India: one-party consent; several jurisdictions: all-party). `JoinCallModal` already tells the truth about this today β€” keep that discipline and go further, because WhatsApp is a *personal* channel and the expectation of privacy is higher. + +Three layers, all on by default, none silently disableable: + +1. **Visible identity** β€” the notetaker's name and avatar in the participant list. Can't be hidden. +2. **Audible announcement** on join β€” TTS into the call: *"CommandCenter is taking notes on this call for Vijay."* Configurable text, language-aware (Hindi/English), but **cannot be set to silence** without an explicit admin override that is logged. +3. **A message in the chat thread** when recording starts and when notes are ready, so there's a durable record every participant can see. + +Plus: an **"I object" affordance.** Anyone can reply `STOP` in the chat and the notetaker leaves the call and discards the recording. This is cheap to build (we already parse inbound messages) and it's the difference between a tool people tolerate and one they resent. + +Explicitly rejected: any "record without announcement" mode. Products advertising that exist; we're not one. + +### 7.6 The "I forgot" case + +The single most common failure will be *the call ended and nobody added the notetaker*. There is no retroactive capture (Β§2.3). So the UI must fail usefully: + +- The WhatsApp chat shows a call-ended event β†’ offer an inline chip: **"Missed taking notes on this call. Turn on auto-notes for this chat?"** β€” turning a failure into a rule. +- Offer **"Upload a recording"** (Phase C) right there if they happened to record on the phone. +- Never show an empty meeting for a call we didn't capture. + +### 7.7 During and after β€” reuse everything + +The live and post-call surfaces need **no new design**: a WhatsApp call becomes a `meeting` row with `platform = 'whatsapp'`, and inherits the entire existing workspace β€” `LiveDock`, live captions, live copilot, the Summary/Transcript/Actions/Ask tabbed detail, timecode provenance, action-item HITL triage, follow-up drafting. + +Three WhatsApp-specific additions: + +1. **Speakers are already named.** Participants are phone numbers joined to `wa_contacts` β€” so the transcript shows *"Nikhil Sharma"*, not *"S2"*, with no rename step. Best-in-class, and only possible on this platform. +2. **Notes go back where the conversation lives.** A **"Send notes to the group"** action posting the summary into the same WhatsApp thread, through the existing send path. On WhatsApp, delivering meeting notes by email is the wrong channel. +3. **A `platform: whatsapp` badge** across the notes library, plus the group/chat name as the meeting title by default. + +### 7.8 Surfaces to touch (concrete) + +| File / area | Change | +|---|---| +| `notes/components/NotesSettingsModal.tsx` | New **WhatsApp calls** tab (Β§7.2) | +| `notes/components/BotIdentitySection.tsx` | Precedent to mirror for notetaker identity + pairing | +| `notes/components/JoinCallModal.tsx` | Add a WhatsApp mode β€” but **not** link-paste; a "how to add the notetaker" explainer + arm-next-call | +| `notes/components/ActiveBots.tsx` | Show WhatsApp calls in the active list (statuses already match) | +| WhatsApp chat list / chat header | Per-chat **"Take notes on calls"** toggle; missed-call chip (Β§7.6) | +| `routes/notes/meeting_bot.py` | `detect_platform` β†’ `whatsapp`; new provider; drop `SELFHOSTED_PLATFORMS` assumption | +| `apps/services/whatsapp_bridge/` | meowcaller integration, call events, PCM β†’ gateway | +| Migration | `meeting.platform = 'whatsapp'`, notetaker identity + per-chat auto-join rules | + +--- + +## 8. Risk register + +| Risk | Severity | Mitigation | +|---|---|---| +| **Number ban** (Surfaces B/C) | High | Dedicated disposable number; off by default; explicit consent screen; never the primary line; Phase A carries the business-critical load so a ban degrades rather than breaks. | +| **meowcaller group support is experimental** | High | Ship 1:1 first; group behind its own flag; expect breakage; pin the version. | +| **MLOW codec / protocol churn** | Medium | Upstream tracks it; we pin and update deliberately. Phase A is immune. | +| **Recording-consent law** | High (legal, not technical) | Β§7.5's three layers on by default; per-jurisdiction defaults; the `STOP` affordance; no silent mode. | +| **WABA calling not yet enabled for us** | Medium | Verify with Meta/BSP *before* Phase A engineering. | +| **Founder decision conflict** (official-only, `whatsapp_message_manager.md` v2) | Medium | Re-decide explicitly for calls; don't inherit silently. | +| **Per-participant audio may not be separable** | Medium | Falls back to existing diarization + speaker-naming. Degrades quality, not function. | + +--- + +## 9. Open questions (verify before building) + +1. **Is calling enabled on our WABA?** Hard gate on Phase A. **Run `python3 scripts/check_whatsapp_calling.py`** on a box that has the real credentials β€” it reads `GET /{phone_number_id}/settings` and reports `calling.status`. A 401/403 means Meta hasn't switched it on for our number (rollout is per-number and not something we can engineer around). *Unverified as of 2026-08-01 β€” the study was written in an environment with no credentials.* + + **Zero-credential shortcut:** WhatsApp clients render the call icon in a business conversation and on the business chat profile *only* when calling is enabled, and hide it when disabled. So opening a chat with our business number on any phone answers the gate question in seconds. (Tapping it won't connect until the `calls` webhook field and a media endpoint exist β€” the icon reports provisioning, not readiness.) +2. ~~**Does meowcaller expose per-participant PCM in a group call, or a mixed downlink?**~~ **RESOLVED 2026-08-01 β€” see Β§10.** Per-participant PCM exists, keyed by participant JID. Remaining sub-question is only whether we upstream a hook or fork. +3. **Does the Cloud API SDP offer let us pick a codec/sample rate**, or must we transcode OPUS β†’ 16 kHz for `acb_stt`? +4. **Can a Cloud API call be recorded under Meta's policy** as long as the caller is told? (BSPs do it, which suggests yes β€” confirm in writing.) +5. **How stable is a paired notetaker session under call load?** whatsmeow sessions drop; a bridge that reconnects mid-call is worse than one that never joined. +6. **Group call participant cap** (32 via call links) and whether a notetaker consumes a slot. It does β€” say so in the UI. +7. **Hindi/Hinglish on WhatsApp calls** β€” AssemblyAI was chosen partly for this; validate on real call-quality audio (8–16 kHz, lossy), which is materially harder than meeting audio. + +--- + +## 10. Addendum (2026-08-01) β€” CC *is* the call client, and speakers are attributed not diarized + +Two refinements from review that change the recommended shape. They supersede the framing in Β§7.1 for the personal/group path; Β§7's consent, rules and post-call design all still stand. + +### 10.1 Collapse it: one call surface in CC, two transports underneath + +Rather than "a notetaker contact you add to the call", the stronger model is **CommandCenter takes the call**. It unifies Phases A and B behind one UI: + +| You answer on | Transport | Calls covered | Status | +|---|---|---|---| +| **Business number** | Cloud API Business Calling (SDP/WebRTC leg) | 1:1 only | official | +| **Personal number** | whatsmeow + meowcaller | **1:1 and group** | unofficial | + +For Surface A this isn't a workaround β€” **terminating the media leg yourself *is* the documented architecture**, so "take the call in CC" is exactly what Meta expects. For Surface C, `JotaDev66/WaCalls` already ships this shape (a browser call UI over whatsmeow + pion), which is useful corroboration that a CC-hosted dialer is viable. + +What it buys: no second number, no extra participant consuming one of the 32 slots, no "add the bot" step, and nothing *experimental* about joining β€” CC is simply a participant. + +What it costs, honestly: +- **Behaviour change.** Calls must be taken in the browser, not on the phone. This is the main adoption risk and should be treated as the primary product question, not an implementation detail. +- **Consent regresses.** With no visible notetaker in the participant list, Β§7.5's audible announcement and chat-thread message stop being belt-and-braces and become the *only* disclosure. They must be non-optional on this path. +- Ban risk on the personal transport is unchanged. + +Keep the "add a notetaker participant" model (Β§7.3) as the alternative for users who won't move their calls off the phone. + +### 10.2 Group calls give attribution, not diarization + +**Confirmed in `meowcaller` source.** The structural argument is decisive: E2EE means the server cannot decrypt, therefore cannot mix, therefore mixing happens client-side, therefore the client necessarily receives N separately-decodable streams. + +`group_media_receive.go` decodes per participant, keyed by RTP SSRC, and returns PCM bundled with identity: + +```go +type decodedParticipantAudio struct { + ParticipantID string + UserJID types.JID // the participant's phone number + DeviceJID types.JID + SSRC uint32 + Timestamp uint32 + PCM []float32 +} +``` + +`participantReceiveRegistry.DecodeAudio(packet)` resolves the receiver by SSRC, unprotects via `pipe.UnprotectAudio()`, decodes, and returns the above. Mixing happens strictly afterwards in `group_audio_mixer.go` (`Add(participantID, pcm)` β†’ `MixChunk()`). + +**So this is not diarization β€” it is attribution.** No clustering, no embeddings, no `S1`/`S2` labels, no rename step. `UserJID` joins directly to `wa_contacts` and the transcript is correctly named by construction. This is strictly better than our Meet bot and better than the commercial notetakers, and it is the single strongest product argument for building this at all. + +**Gap:** the public sink (`SinkFunc(func(pcm []float32))`) exposes only the *mix*; the per-participant types are unexported. Exposing them is a small, well-scoped change at the point `DecodeAudio` already returns β€” best done as an **upstream PR** (MIT, actively maintained, and `engine_hook_test.go` suggests a hook mechanism to hang it off) rather than a fork we carry. + +**Cost decision β€” take it early.** Transcribing N streams separately is NΓ— the STT bill. Default instead to: **transcribe the mix once**, and use the per-participant streams purely as a **speaker-activity timeline**, labelling each segment by whichever participant had energy at that timestamp. 1Γ— cost, exact attribution, degrades gracefully under crosstalk. Reserve per-stream STT for calls where overlapping speech genuinely matters. + +--- + +## 11. Sources + +- [Cloud API Calling β€” Meta for Developers](https://developers.facebook.com/documentation/business-messaging/whatsapp/calling) +- [Calling β€” WhatsApp Cloud API docs](https://developers.facebook.com/docs/whatsapp/cloud-api/calling/) +- [SIP Configuration Guide β€” WhatsApp Business Calling](https://developers.facebook.com/documentation/business-messaging/whatsapp/calling/sip) +- [Calling API Pricing β€” Meta for Developers](https://developers.facebook.com/documentation/business-messaging/whatsapp/calling/pricing) +- [How to Integrate the WhatsApp Business Calling API with WebRTC β€” WebRTC.ventures](https://webrtc.ventures/2025/11/how-to-integrate-the-whatsapp-business-calling-api-with-webrtc-to-enable-customer-voice-calls/) +- [WhatsApp Business Calling API: Pricing, Use Cases & FAQs β€” respond.io](https://respond.io/whatsapp-business-calling-api) +- [Understanding WhatsApp Calling restrictions and guidelines β€” Wati](https://support.wati.io/en/articles/12546668-understanding-whatsapp-calling-restrictions-and-guidelines) +- [How to enable WhatsApp call recording, transcription, and summaries in Wati](https://support.wati.io/en/articles/14472037-how-to-enable-whatsapp-call-recording-transcription-and-summaries-in-wati) +- [Introducing Web Calling on WhatsApp, Plus More New Updates β€” WhatsApp Blog](https://blog.whatsapp.com/introducing-web-calling-on-whatsapp-plus-more-new-updates) +- [WhatsApp now lets you make calls using its web app β€” TechCrunch](https://techcrunch.com/2026/07/28/whatsapp-now-lets-you-make-calls-using-its-web-app/) +- [WhatsApp Adds Web Calling, Waiting Room, and Call Transfer to Group Calls β€” gHacks](https://www.ghacks.net/2026/07/29/whatsapp-adds-web-calling-waiting-room-and-call-transfer-features/) +- [WhatsApp Call Links (32 participants) β€” 9to5Google](https://9to5google.com/2022/10/20/whatsapp-call-links/) +- [purpshell/meowcaller β€” WhatsApp VoIP Go library for whatsmeow](https://github.com/purpshell/meowcaller) +- [JotaDev66/WaCalls β€” 1:1 WhatsApp calls via whatsmeow + pion/webrtc](https://github.com/JotaDev66/WaCalls) +- [whatsmeow β€” Calls support (closed as not planned)](https://github.com/WhiskeySockets/Baileys/issues/40) *(Baileys equivalent request; whatsmeow media support likewise absent upstream)* +- [whatsmeow events package β€” CallOffer / CallOfferNotice / CallTerminate](https://pkg.go.dev/go.mau.fi/whatsmeow/types/events) + +--- + +## 12. Build log β€” the dialer (2026-08-01) + +Surface C is no longer theoretical. The bridge can place and answer calls, and +`/whatsapp/calls` drives it. + +**What shipped** + +| Layer | Change | +|---|---| +| `apps/services/whatsapp_bridge/calls.go` | New. meowcaller on the existing whatsmeow session: place 1:1 (`Client.Call`), group by WhatsApp group id (`GroupCallByID`) or ad-hoc (`GroupCall`), answer/reject/hangup, a phase-tracking registry, per-call WAV recording, and a reaper for ended calls. | +| `session.go` | `meowcaller.NewClient` attached in `newClient` β€” **before** `Connect`, which the library requires so its `` interception precedes the receive loop. | +| `main.go` | `POST /call`, `/call/{hangup,answer,reject}`, `GET /calls`. | +| `gateway.go` | `CallEvent` β†’ `/whatsapp/bridge/call-event`. | +| `routes/whatsapp/transport/calls.py` | Authenticated proxy + the inbound event seam. Enforces account ownership, which the bridge cannot: it only knows a shared secret. | +| `core.py` | `provider` exposed on the account model (the dialer must show only bridge numbers); `/bridge/call-event` added to the feature-gate exemptions. | +| `whatsapp/calls/page.tsx` | The dialer: 1:1/group toggle, live call cards with phase + timer + recording flag, answer/decline for inbound, recent list. | +| `tests/unit/test_whatsapp_calls.py` | 21 tests β€” ownership, bridge-down, error propagation, the secret gate. | + +**Deliberate choices** + +- **Inbound calls are never auto-answered.** A number that picks up strangers is + both a consent problem and a ban signal. They surface as ringing; a human + decides. +- **Recording is on by default** because it's the note taker's whole point, but + it's one env var to disable and the UI says when a call is being recorded. +- **Only bridge numbers appear in the dialer.** A Cloud API number would fail at + the gateway; better never to offer it. + +**Not done yet** β€” the seam is in place, the pipeline isn't attached: + +1. **Transcription.** `/bridge/call-event` logs the recording path; nothing feeds + it to `acb_stt` or creates a `meeting` row. That's the next slice. +2. **Per-participant attribution (Β§10.2).** We record the *mixed* sink, so group + calls currently need ordinary diarization. Unlocking the real prize needs the + upstream per-participant hook. +3. **Outbound audio.** No microphone path β€” the call sends silence, so it + listens but can't speak. Fine for note-taking; the consent announcement of + Β§7.5 needs a `Player` fed by TTS. +4. **Consent announcement and the chat-thread notice** are designed (Β§7.5), not + built. + +--- diff --git a/apps/services/gateway/gateway/routes/whatsapp/core.py b/apps/services/gateway/gateway/routes/whatsapp/core.py index f00ceb4f..15c5dac6 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/core.py +++ b/apps/services/gateway/gateway/routes/whatsapp/core.py @@ -39,7 +39,7 @@ "/whatsapp/bridge/labels", "/whatsapp/bridge/avatars", "/whatsapp/bridge/paired", - "/whatsapp/bridge/avatars", + "/whatsapp/bridge/call-event", ])], ) @@ -59,6 +59,9 @@ class WhatsAppAccountModel(BaseModel): quality_rating: str | None = None last_synced_at: str | None = None is_default: bool = False + # 'cloud' (Meta Cloud API) or 'whatsmeow' (the QR-paired personal bridge). + # The dialer needs it: voice calling only exists on the bridge transport. + provider: str = "cloud" class WhatsAppChatModel(BaseModel): diff --git a/apps/services/gateway/gateway/routes/whatsapp/transport/__init__.py b/apps/services/gateway/gateway/routes/whatsapp/transport/__init__.py index 34f86f07..ad531663 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/transport/__init__.py +++ b/apps/services/gateway/gateway/routes/whatsapp/transport/__init__.py @@ -4,6 +4,7 @@ from gateway.routes.whatsapp.transport import ( # noqa: F401 accounts, bridge, + calls, capture, chats, connect, diff --git a/apps/services/gateway/gateway/routes/whatsapp/transport/accounts.py b/apps/services/gateway/gateway/routes/whatsapp/transport/accounts.py index 71ad6fcf..e591a7c3 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/transport/accounts.py +++ b/apps/services/gateway/gateway/routes/whatsapp/transport/accounts.py @@ -48,6 +48,7 @@ def _account_model(row: Any) -> WhatsAppAccountModel: quality_rating=row.quality_rating, last_synced_at=row.last_synced_at.isoformat() if row.last_synced_at else None, is_default=bool(row.is_default), + provider=getattr(row, "provider", None) or "cloud", ) @@ -60,7 +61,7 @@ async def list_accounts(user: UserContext = Depends(get_current_user)): text("""SELECT id, phone_number, phone_number_id, waba_id, display_name, avatar_color, sync_status, sync_error, history_import_phase, quality_rating, last_synced_at, - is_default + is_default, provider FROM wa_accounts WHERE user_id = :uid ORDER BY is_default DESC, created_at"""), {"uid": user.email or "anonymous"}, diff --git a/apps/services/gateway/gateway/routes/whatsapp/transport/calls.py b/apps/services/gateway/gateway/routes/whatsapp/transport/calls.py new file mode 100644 index 00000000..d6d2df2a --- /dev/null +++ b/apps/services/gateway/gateway/routes/whatsapp/transport/calls.py @@ -0,0 +1,242 @@ +"""Transport Β· calls β€” voice calls on a paired personal (whatsmeow) number. + +The gateway holds no WhatsApp session, so this module is a thin, authenticated +proxy in front of the bridge's call API (apps/services/whatsapp_bridge/calls.go), +plus the inbound seam the bridge pushes call state to. + +Why only personal numbers: Meta's Cloud API Calling product is 1:1-only and has +to be enabled per-number by Meta, and our WABA route has no media leg yet. The +whatsmeow bridge + meowcaller path works today and is the only one that can do +group calls at all. See ai-company-brain/specs/whatsapp_calls_note_taker.md. + + POST /whatsapp/calls {account_id, to | group_id | targets} + POST /whatsapp/calls/hangup {account_id, call_id} + POST /whatsapp/calls/answer {account_id, call_id} + POST /whatsapp/calls/reject {account_id, call_id} + GET /whatsapp/calls?account_id= + POST /whatsapp/bridge/call-event (bridge β†’ gateway, shared secret) + +Calls placed from an unofficial client are outside WhatsApp's Terms of Service +and can get the number banned; the UI says so before the first call. +""" + +from __future__ import annotations + +from typing import Any + +from acb_auth import UserContext, get_current_user +from acb_common import get_logger +from fastapi import Depends, HTTPException, Request, Response +from gateway.routes.whatsapp.core import _get_db, router +from gateway.routes.whatsapp.transport.bridge import ( + _bridge_headers, + _bridge_url, + bridge_secret_ok, +) +from pydantic import BaseModel +from sqlalchemy import text + +_log = get_logger("gateway.whatsapp.calls") + +# Bridge calls are user-visible and interactive: a short timeout keeps a wedged +# bridge from hanging the dialer, but placing a call does wait on WhatsApp's +# signalling round-trip, so it isn't as tight as a read. +_TIMEOUT_SECS = 30.0 + + +class PlaceCallRequest(BaseModel): + account_id: str + to: str = "" + group_id: str = "" + targets: list[str] = [] + + +class CallActionRequest(BaseModel): + account_id: str + call_id: str + + +class CallModel(BaseModel): + """One call as the bridge reports it. Mirrors callInfo in calls.go.""" + + call_id: str = "" + account_id: str = "" + peer: str = "" + direction: str = "" + kind: str = "" + phase: str = "" + targets: list[str] = [] + started_at: str = "" + ended_at: str = "" + end_reason: str = "" + recording: str = "" + + +class CallListModel(BaseModel): + calls: list[CallModel] = [] + bridge_reachable: bool = True + + +async def _assert_owns_account(account_id: str, user: UserContext) -> None: + """Confirm the account is this user's, and is a bridge (whatsmeow) number. + + Without this, any signed-in user could drive calls from anyone's paired + phone β€” the bridge itself only checks the shared secret, so ownership has + to be enforced here.""" + if not account_id: + raise HTTPException(status_code=400, detail="account_id required") + db = await _get_db() + try: + row = (await db.execute( + text("""SELECT sync_status FROM wa_accounts + WHERE id = :aid AND user_id = :uid AND provider = 'whatsmeow'"""), + {"aid": account_id, "uid": user.email or "anonymous"}, + )).fetchone() + finally: + await db.close() + if not row: + raise HTTPException( + status_code=404, + detail="No paired personal WhatsApp number with that id.") + if row.sync_status != "live": + raise HTTPException( + status_code=409, + detail="That number isn't paired yet β€” finish QR pairing first.") + + +async def _bridge(method: str, path: str, **kw: Any) -> tuple[int, Any]: + """One request to the bridge. Returns ``(status, parsed_body)``; status 0 + means the bridge was unreachable, which callers report as 503 rather than + letting an httpx error escape as a 500.""" + import httpx + + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(_TIMEOUT_SECS)) as client: + resp = await client.request( + method, f"{_bridge_url()}{path}", headers=_bridge_headers(), **kw) + try: + body = resp.json() + except ValueError: + body = {"detail": resp.text[:300]} + return resp.status_code, body + except Exception as exc: + _log.warning("whatsapp.calls.bridge_unreachable", error=str(exc)[:200]) + return 0, {"detail": "The WhatsApp bridge isn't reachable."} + + +def _detail(body: Any, fallback: str) -> str: + if isinstance(body, dict): + return str(body.get("detail") or fallback) + return str(body or fallback)[:300] + + +def _as_call(body: Any) -> CallModel: + return CallModel(**body) if isinstance(body, dict) else CallModel() + + +@router.post("/calls", response_model=CallModel) +async def place_call( + body: PlaceCallRequest, user: UserContext = Depends(get_current_user), +) -> CallModel: + """Place a 1:1 call (``to``) or a group call (``group_id``, or two or more + ``targets``) from a paired personal number.""" + await _assert_owns_account(body.account_id, user) + if not body.to and not body.group_id and len(body.targets) < 2: + raise HTTPException( + status_code=400, + detail="Give a number to call, a group id, or at least two targets.") + + status, data = await _bridge("POST", "/call", json={ + "session": body.account_id, "to": body.to, + "group_id": body.group_id, "targets": body.targets, + }) + if status == 0: + raise HTTPException(status_code=503, detail=_detail(data, "bridge unreachable")) + if status >= 300: + raise HTTPException(status_code=status, detail=_detail(data, "call failed")) + _log.info("whatsapp.calls.placed", account_id=body.account_id, + kind="group" if (body.group_id or body.targets) else "direct") + return _as_call(data) + + +async def _call_action(action: str, body: CallActionRequest, user: UserContext) -> CallModel: + await _assert_owns_account(body.account_id, user) + status, data = await _bridge("POST", f"/call/{action}", json={ + "session": body.account_id, "call_id": body.call_id, + }) + if status == 0: + raise HTTPException(status_code=503, detail=_detail(data, "bridge unreachable")) + if status >= 300: + raise HTTPException(status_code=status, detail=_detail(data, f"{action} failed")) + return _as_call(data) + + +@router.post("/calls/hangup", response_model=CallModel) +async def hangup_call( + body: CallActionRequest, user: UserContext = Depends(get_current_user), +) -> CallModel: + """End a call this account placed or answered.""" + return await _call_action("hangup", body, user) + + +@router.post("/calls/answer", response_model=CallModel) +async def answer_call( + body: CallActionRequest, user: UserContext = Depends(get_current_user), +) -> CallModel: + """Answer a ringing inbound call. Inbound calls are never auto-answered.""" + return await _call_action("answer", body, user) + + +@router.post("/calls/reject", response_model=CallModel) +async def reject_call( + body: CallActionRequest, user: UserContext = Depends(get_current_user), +) -> CallModel: + """Decline a ringing inbound call.""" + return await _call_action("reject", body, user) + + +@router.get("/calls", response_model=CallListModel) +async def list_calls( + account_id: str, user: UserContext = Depends(get_current_user), +) -> CallListModel: + """Every call the bridge is tracking for this account, live and recently + ended. An unreachable bridge is reported as an empty list plus a flag, not + an error β€” the dialer should render its "bridge is down" state, not a crash.""" + await _assert_owns_account(account_id, user) + status, data = await _bridge("GET", "/calls", params={"session": account_id}) + if status == 0 or status >= 300: + return CallListModel(calls=[], bridge_reachable=status != 0) + raw = data.get("calls") if isinstance(data, dict) else None + return CallListModel( + calls=[_as_call(c) for c in (raw or []) if isinstance(c, dict)], + bridge_reachable=True, + ) + + +@router.post("/bridge/call-event") +async def bridge_call_event(request: Request) -> Response: + """The bridge reports a call's state transition (ringing β†’ active β†’ ended). + + Logged rather than persisted for now: the dialer polls ``GET /calls`` for + authoritative state, and a call row only earns a place in the database once + it carries a transcript. This is the seam the note-taker pipeline hooks when + that lands β€” an ``ended`` event with a recording path is its trigger.""" + if not bridge_secret_ok(request.headers.get("X-Bridge-Secret")): + return Response(status_code=403, content="bad secret") + try: + payload = await request.json() + except Exception: + return Response(status_code=400, content="invalid json") + if not isinstance(payload, dict): + return Response(status_code=400, content="invalid payload") + + _log.info( + "whatsapp.calls.event", + call_id=str(payload.get("call_id") or "")[:64], + account_id=str(payload.get("account_id") or "")[:64], + phase=str(payload.get("phase") or "")[:32], + direction=str(payload.get("direction") or "")[:16], + kind=str(payload.get("kind") or "")[:16], + has_recording=bool(payload.get("recording")), + ) + return Response(status_code=200, content="ok") diff --git a/apps/services/whatsapp_bridge/README.md b/apps/services/whatsapp_bridge/README.md index 4b03e07d..df8d4f19 100644 --- a/apps/services/whatsapp_bridge/README.md +++ b/apps/services/whatsapp_bridge/README.md @@ -63,6 +63,13 @@ Copy `.env.example` β†’ `.env`. Key vars: - `WHATSAPP_BRIDGE_SECRET` β€” shared secret; set the **same** value on the gateway. - `WHATSAPP_BRIDGE_STORE` β€” sqlite path for the paired session (**treat as a secret**: whoever holds this file can send as the paired number). +- `WHATSAPP_BRIDGE_CALL_RECORD_DIR` β€” where call audio is written (default + `./call-recordings`). Empty disables recording; calls still connect. +- `WHATSAPP_BRIDGE_CALL_REAP_MINS` β€” how long an ended call stays queryable + before it's dropped from memory (default `60`). +- `WHATSAPP_BRIDGE_CALL_RETENTION_DAYS` β€” days of recorded audio to keep + (default `7`; `0` keeps forever). Recording runs at roughly **115 MB per hour + of call**, so the sweep is what stops a busy line filling the disk. On the gateway set the matching pair: @@ -94,6 +101,40 @@ Then in the app: **Integrations β†’ WhatsApp β†’ Connect a personal number**, sc the QR, and the number goes live. Sessions survive restarts (re-connected from the sqlite store on boot). +## Voice calls + +The bridge can place and answer WhatsApp voice calls β€” 1:1 and group β€” from the +paired number, the way WhatsApp Desktop does. whatsmeow carries call +*signalling* only, so the media half comes from +[meowcaller](https://github.com/purpshell/meowcaller): a pure-Go WhatsApp VoIP +stack (MLow codec, SRTP, RTP, relay) that wraps the same session. It's attached +in `newClient` **before** `Connect`, which the library requires so its low-level +`` interception is in place before the receive loop starts. + +``` +POST /call {session, to} # 1:1 +POST /call {session, group_id} # ring a WhatsApp group +POST /call {session, targets:[a,b,…]} # ad-hoc group call +POST /call/hangup {session, call_id} +POST /call/answer {session, call_id} # inbound is never auto-answered +POST /call/reject {session, call_id} +GET /calls?session= +``` + +Each call's decoded peer audio is recorded to +`$WHATSAPP_BRIDGE_CALL_RECORD_DIR/.wav` as 16 kHz mono β€” the format +`acb_stt` already transcribes, which is what makes this the media seam for the +note taker. State transitions are pushed to the gateway at +`/whatsapp/bridge/call-event`. + +In the app: **WhatsApp β†’ Calls**. + +> [!WARNING] +> Placing calls from an unofficial client carries the same ban risk as the rest +> of this service, and arguably more β€” automated calling is conspicuous. Group +> calling in meowcaller is marked **experimental**. Recording other people is +> regulated in many places: tell them. + ## History backfill On link, WhatsApp pushes a history-sync payload to the new device β€” the same diff --git a/apps/services/whatsapp_bridge/calls.go b/apps/services/whatsapp_bridge/calls.go new file mode 100644 index 00000000..b57443b2 --- /dev/null +++ b/apps/services/whatsapp_bridge/calls.go @@ -0,0 +1,480 @@ +package main + +// WhatsApp voice calls over the personal (whatsmeow) session. +// +// whatsmeow itself carries call SIGNALLING only β€” it can tell you a call is +// offered and that it ended, but it has no media stack, so it can neither +// place a call that carries audio nor hear one. meowcaller supplies the missing +// half (MLow codec, SRTP, RTP, relay) in pure Go on top of the same session, so +// a paired number can place and answer calls the way WhatsApp Desktop does. +// +// This is the media seam the note taker needs: Call.Receive hands us decoded +// 16 kHz mono PCM, which is exactly what acb_stt transcribes. For now we record +// it to a WAV per call β€” proof the media path is live, and the raw material the +// transcription pipeline will consume next. +// +// > The ToS warning on this whole service applies doubly here. Placing +// > automated calls from an unofficial client is a fast way to get a number +// > banned. Use a number you are willing to lose. + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/purpshell/meowcaller" +) + +// ErrCallNotFound is returned for an unknown (or already reaped) call id. +var ErrCallNotFound = errors.New("call not found") + +// ErrNoCaller means the account has no calling stack β€” the session predates +// pairing, or meowcaller failed to attach. +var ErrNoCaller = errors.New("calling not available for this account") + +// Lifecycle phases as reported to the gateway. These mirror meowcaller's +// CallPhase, kept as strings so the wire contract doesn't depend on the +// library's iota ordering. +const ( + callIdle = "idle" + callCalling = "calling" + callRinging = "ringing" + callConnecting = "connecting" + callActive = "active" + callEnded = "ended" + callWaitingRoom = "waiting_room" +) + +func phaseName(p meowcaller.CallPhase) string { + switch p { + case meowcaller.CallPhaseIdle: + return callIdle + case meowcaller.CallPhaseCalling: + return callCalling + case meowcaller.CallPhaseRinging: + return callRinging + case meowcaller.CallPhaseConnecting: + return callConnecting + case meowcaller.CallPhaseActive: + return callActive + case meowcaller.CallPhaseEnded: + return callEnded + case meowcaller.CallPhaseWaitingRoom: + return callWaitingRoom + } + return "unknown" +} + +// callInfo is the JSON snapshot handed to the gateway (and through it, the UI). +type callInfo struct { + CallID string `json:"call_id"` + AccountID string `json:"account_id"` + Peer string `json:"peer"` + Direction string `json:"direction"` // outgoing | incoming + Kind string `json:"kind"` // direct | group + Phase string `json:"phase"` + Targets []string `json:"targets,omitempty"` + StartedAt string `json:"started_at,omitempty"` + EndedAt string `json:"ended_at,omitempty"` + EndReason string `json:"end_reason,omitempty"` + Recording string `json:"recording,omitempty"` // server-side WAV path +} + +// liveCall is one call plus the bookkeeping the HTTP surface reports on. The +// mutex guards every mutable field: meowcaller fires its callbacks from its own +// goroutines while HTTP handlers read concurrently. +type liveCall struct { + mu sync.RWMutex + + call *meowcaller.Call + accountID string + callID string + peer string + direction string + kind string + targets []string + + phase string + startedAt time.Time + endedAt time.Time + endReason string + + recording string + sink meowcaller.AudioSink + closeOnce sync.Once +} + +func (lc *liveCall) snapshot() callInfo { + lc.mu.RLock() + defer lc.mu.RUnlock() + info := callInfo{ + CallID: lc.callID, AccountID: lc.accountID, Peer: lc.peer, + Direction: lc.direction, Kind: lc.kind, Phase: lc.phase, + Targets: lc.targets, EndReason: lc.endReason, Recording: lc.recording, + } + if !lc.startedAt.IsZero() { + info.StartedAt = lc.startedAt.UTC().Format(time.RFC3339) + } + if !lc.endedAt.IsZero() { + info.EndedAt = lc.endedAt.UTC().Format(time.RFC3339) + } + return info +} + +func (lc *liveCall) setPhase(phase string) { + lc.mu.Lock() + lc.phase = phase + lc.mu.Unlock() +} + +// finish records the terminal state and closes the recorder exactly once. +// +// Idempotent by necessity: meowcaller's OnEnd routinely races an explicit +// Hangup, so this runs twice for most calls. The FIRST reason and timestamp +// win β€” a later overwrite would push endedAt forward and stretch the call's +// apparent duration β€” and the recorder closes once, because finalizing a WAV +// header twice leaves the file unreadable. +func (lc *liveCall) finish(reason string) { + lc.mu.Lock() + if lc.endedAt.IsZero() { + lc.endedAt = time.Now() + lc.endReason = reason + } + lc.phase = callEnded + sink := lc.sink + lc.mu.Unlock() + + lc.closeOnce.Do(func() { + if sink != nil { + // Best effort: a failed WAV flush costs us the recording, but it + // must not stop the call from being marked ended. + _ = sink.Close() + } + }) +} + +// CallManager owns every live call across accounts and reports transitions to +// the gateway. Call ids are 32 hex chars from WhatsApp, unique across accounts, +// so one flat map is enough. +type CallManager struct { + gw *GatewayClient + log logf + recordDir string + + mu sync.RWMutex + calls map[string]*liveCall +} + +// logf is the slice of waLog.Logger this file needs, named so calls.go doesn't +// depend on the logging package directly. +type logf interface { + Warnf(msg string, args ...any) + Errorf(msg string, args ...any) + Infof(msg string, args ...any) +} + +// NewCallManager builds the registry. recordDir may be empty to disable +// recording (calls still work; nothing is written to disk). +func NewCallManager(gw *GatewayClient, log logf, recordDir string) *CallManager { + return &CallManager{gw: gw, log: log, recordDir: recordDir, calls: map[string]*liveCall{}} +} + +func (cm *CallManager) put(lc *liveCall) { + cm.mu.Lock() + cm.calls[lc.callID] = lc + cm.mu.Unlock() +} + +func (cm *CallManager) get(callID string) (*liveCall, bool) { + cm.mu.RLock() + defer cm.mu.RUnlock() + lc, ok := cm.calls[callID] + return lc, ok +} + +// list returns snapshots for one account, or every account when accountID is +// empty. Ended calls are included β€” a UI that polls needs to see the terminal +// state at least once. +func (cm *CallManager) list(accountID string) []callInfo { + cm.mu.RLock() + defer cm.mu.RUnlock() + out := make([]callInfo, 0, len(cm.calls)) + for _, lc := range cm.calls { + if accountID != "" && lc.accountID != accountID { + continue + } + out = append(out, lc.snapshot()) + } + return out +} + +// reap drops calls that ended more than ttl ago, so a long-lived bridge doesn't +// accumulate every call it ever placed. +func (cm *CallManager) reap(ttl time.Duration) int { + cutoff := time.Now().Add(-ttl) + cm.mu.Lock() + defer cm.mu.Unlock() + n := 0 + for id, lc := range cm.calls { + lc.mu.RLock() + ended := lc.endedAt + lc.mu.RUnlock() + if !ended.IsZero() && ended.Before(cutoff) { + delete(cm.calls, id) + n++ + } + } + return n +} + +// sweepRecordings deletes recorded audio older than retentionDays. Recording +// runs at roughly 115 MB per hour of call, so on a VPS an unbounded directory +// is a slow outage rather than a tidiness problem. Zero days disables it. +// Returns the number of files removed. +func (cm *CallManager) sweepRecordings(retentionDays uint32) int { + if cm.recordDir == "" || retentionDays == 0 { + return 0 + } + cutoff := time.Now().AddDate(0, 0, -int(retentionDays)) + entries, err := os.ReadDir(cm.recordDir) + if err != nil { + if !os.IsNotExist(err) { // not yet created is normal, not worth logging + cm.log.Warnf("sweep recordings: %v", err) + } + return 0 + } + + // Only ever delete files this service writes (.wav) β€” the + // directory is operator-configurable and could point somewhere shared. + removed := 0 + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".wav" { + continue + } + info, err := e.Info() + if err != nil || !info.ModTime().Before(cutoff) { + continue + } + if err := os.Remove(filepath.Join(cm.recordDir, e.Name())); err != nil { + cm.log.Warnf("remove old recording %s: %v", e.Name(), err) + continue + } + removed++ + } + if removed > 0 { + cm.log.Infof("swept %d call recording(s) older than %d days", removed, retentionDays) + } + return removed +} + +// recordingPath allocates the WAV path for a call, creating the directory. An +// empty return means recording is off (or the directory is unusable) β€” never a +// reason to fail the call itself. +func (cm *CallManager) recordingPath(callID string) string { + if cm.recordDir == "" { + return "" + } + if err := os.MkdirAll(cm.recordDir, 0o750); err != nil { + cm.log.Warnf("call recording dir %s: %v", cm.recordDir, err) + return "" + } + return filepath.Join(cm.recordDir, callID+".wav") +} + +// track wires our bookkeeping and the recorder onto a live meowcaller Call and +// registers it. Shared by the outbound and inbound paths. +func (cm *CallManager) track(lc *liveCall) { + c := lc.call + lc.callID = c.ID() + if lc.peer == "" { + lc.peer = c.Peer().String() + } + lc.phase = phaseName(c.State()) + lc.startedAt = time.Now() + + if path := cm.recordingPath(lc.callID); path != "" { + sink, err := meowcaller.WAVRecorder(path) + if err != nil { + cm.log.Warnf("call %s: recorder: %v", lc.callID, err) + } else { + lc.sink = sink + lc.recording = path + c.Receive(sink) + } + } + + c.OnStateChange(func(p meowcaller.CallPhase) { + lc.setPhase(phaseName(p)) + cm.push(lc) + }) + c.OnReady(func() { + cm.log.Infof("call %s: media active", lc.callID) + cm.push(lc) + }) + c.OnEnd(func(reason string) { + cm.log.Infof("call %s: ended (%s)", lc.callID, reason) + lc.finish(reason) + cm.push(lc) + }) + if lc.kind == "group" { + c.OnGroupState(func(st meowcaller.GroupCallState) { + names := make([]string, 0, len(st.Participants)) + for _, p := range st.Participants { + names = append(names, p.JID.String()) + } + lc.mu.Lock() + lc.targets = names + lc.mu.Unlock() + cm.push(lc) + }) + } + + cm.put(lc) + cm.push(lc) +} + +// push reports the current snapshot to the gateway, best-effort: a call must +// not drop because the gateway is down. +func (cm *CallManager) push(lc *liveCall) { + if cm.gw == nil { + return + } + info := lc.snapshot() + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := cm.gw.CallEvent(ctx, info); err != nil { + cm.log.Warnf("call %s: notify gateway: %v", info.CallID, err) + } + }() +} + +// ── SessionManager surface (what the HTTP handlers call) ────────────────────── + +// PlaceCall dials a 1:1 voice call from a paired account. target is a phone +// number, a phone JID, or an @lid JID. +func (m *SessionManager) PlaceCall(ctx context.Context, accountID, target string) (callInfo, error) { + s, ok := m.get(accountID) + if !ok || !s.client.IsLoggedIn() { + return callInfo{}, ErrNotConnected + } + if s.caller == nil { + return callInfo{}, ErrNoCaller + } + if target == "" { + return callInfo{}, fmt.Errorf("target required") + } + c, err := s.caller.Call(ctx, target) + if err != nil { + return callInfo{}, fmt.Errorf("place call: %w", err) + } + lc := &liveCall{ + call: c, accountID: accountID, peer: target, + direction: "outgoing", kind: "direct", + } + m.calls.track(lc) + return lc.snapshot(), nil +} + +// PlaceGroupCall dials a group call. Pass groupID to ring every remote member +// of a WhatsApp group, or targets for an ad-hoc group call (two or more). +func (m *SessionManager) PlaceGroupCall( + ctx context.Context, accountID, groupID string, targets []string, +) (callInfo, error) { + s, ok := m.get(accountID) + if !ok || !s.client.IsLoggedIn() { + return callInfo{}, ErrNotConnected + } + if s.caller == nil { + return callInfo{}, ErrNoCaller + } + + var ( + c *meowcaller.Call + err error + ) + switch { + case groupID != "": + c, err = s.caller.GroupCallByID(ctx, groupID) + case len(targets) >= 2: + c, err = s.caller.GroupCall(ctx, targets...) + default: + return callInfo{}, fmt.Errorf("group_id, or at least two targets, required") + } + if err != nil { + return callInfo{}, fmt.Errorf("place group call: %w", err) + } + + lc := &liveCall{ + call: c, accountID: accountID, peer: groupID, + direction: "outgoing", kind: "group", targets: targets, + } + m.calls.track(lc) + return lc.snapshot(), nil +} + +// Hangup ends a call we placed or answered. +func (m *SessionManager) Hangup(accountID, callID string) (callInfo, error) { + lc, ok := m.calls.get(callID) + if !ok || (accountID != "" && lc.accountID != accountID) { + return callInfo{}, ErrCallNotFound + } + if err := lc.call.Hangup(); err != nil { + return lc.snapshot(), fmt.Errorf("hangup: %w", err) + } + lc.finish("hangup") + m.calls.push(lc) + return lc.snapshot(), nil +} + +// Answer accepts a ringing inbound call. +func (m *SessionManager) Answer(accountID, callID string) (callInfo, error) { + lc, ok := m.calls.get(callID) + if !ok || (accountID != "" && lc.accountID != accountID) { + return callInfo{}, ErrCallNotFound + } + if err := lc.call.Answer(); err != nil { + return lc.snapshot(), fmt.Errorf("answer: %w", err) + } + return lc.snapshot(), nil +} + +// Reject declines a ringing inbound call. +func (m *SessionManager) Reject(accountID, callID string) (callInfo, error) { + lc, ok := m.calls.get(callID) + if !ok || (accountID != "" && lc.accountID != accountID) { + return callInfo{}, ErrCallNotFound + } + if err := lc.call.Reject(); err != nil { + return lc.snapshot(), fmt.Errorf("reject: %w", err) + } + lc.finish("rejected") + m.calls.push(lc) + return lc.snapshot(), nil +} + +// ListCalls returns every tracked call for an account (all accounts if empty). +func (m *SessionManager) ListCalls(accountID string) []callInfo { + return m.calls.list(accountID) +} + +// registerCallHandlers attaches the inbound listener for one session. Inbound +// calls are NEVER auto-answered β€” they are surfaced as ringing so the operator +// (or the gateway) decides. Auto-answering would make the number a bot that +// picks up strangers, which is both a consent problem and a ban risk. +func (m *SessionManager) registerCallHandlers(s *Session) { + if s.caller == nil { + return + } + s.caller.OnIncomingCall(func(c *meowcaller.Call) { + lc := &liveCall{ + call: c, accountID: s.accountID, peer: c.Peer().String(), + direction: "incoming", kind: "direct", + } + m.calls.track(lc) + }) +} diff --git a/apps/services/whatsapp_bridge/calls_test.go b/apps/services/whatsapp_bridge/calls_test.go new file mode 100644 index 00000000..5926b4fc --- /dev/null +++ b/apps/services/whatsapp_bridge/calls_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// testLog satisfies logf without printing during tests. +type testLog struct{ t *testing.T } + +func (l testLog) Warnf(msg string, args ...any) { l.t.Logf("WARN "+msg, args...) } +func (l testLog) Errorf(msg string, args ...any) { l.t.Logf("ERROR "+msg, args...) } +func (l testLog) Infof(msg string, args ...any) { l.t.Logf("INFO "+msg, args...) } + +// writeAged creates a file and backdates its mtime by the given age. +func writeAged(t *testing.T, dir, name string, age time.Duration) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte("riff"), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } + when := time.Now().Add(-age) + if err := os.Chtimes(path, when, when); err != nil { + t.Fatalf("chtimes %s: %v", name, err) + } + return path +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func TestSweepRecordingsRemovesOnlyExpiredWAVs(t *testing.T) { + dir := t.TempDir() + cm := NewCallManager(nil, testLog{t}, dir) + + old := writeAged(t, dir, "OLDCALL.wav", 10*24*time.Hour) + fresh := writeAged(t, dir, "NEWCALL.wav", 1*time.Hour) + // A non-WAV in the same directory must survive: the path is operator + // configurable and could point somewhere shared. + other := writeAged(t, dir, "notes.txt", 30*24*time.Hour) + + if got := cm.sweepRecordings(7); got != 1 { + t.Fatalf("removed %d files, want 1", got) + } + if exists(old) { + t.Error("expired recording survived the sweep") + } + if !exists(fresh) { + t.Error("recent recording was deleted") + } + if !exists(other) { + t.Error("a non-.wav file was deleted") + } +} + +func TestSweepRecordingsZeroRetentionKeepsEverything(t *testing.T) { + dir := t.TempDir() + cm := NewCallManager(nil, testLog{t}, dir) + old := writeAged(t, dir, "ANCIENT.wav", 365*24*time.Hour) + + if got := cm.sweepRecordings(0); got != 0 { + t.Fatalf("removed %d files with retention disabled, want 0", got) + } + if !exists(old) { + t.Error("retention 0 must mean keep forever") + } +} + +func TestSweepRecordingsToleratesMissingDir(t *testing.T) { + cm := NewCallManager(nil, testLog{t}, filepath.Join(t.TempDir(), "not-created-yet")) + if got := cm.sweepRecordings(7); got != 0 { + t.Fatalf("removed %d from a missing dir, want 0", got) + } +} + +func TestSweepRecordingsNoopWhenRecordingDisabled(t *testing.T) { + cm := NewCallManager(nil, testLog{t}, "") + if got := cm.sweepRecordings(7); got != 0 { + t.Fatalf("removed %d with recording disabled, want 0", got) + } +} + +// reap drops ended calls past their TTL but must never drop a live one. +func TestReapKeepsLiveCalls(t *testing.T) { + cm := NewCallManager(nil, testLog{t}, "") + live := &liveCall{callID: "LIVE", accountID: "a", phase: callActive} + recent := &liveCall{callID: "RECENT", accountID: "a", phase: callEnded, endedAt: time.Now()} + stale := &liveCall{ + callID: "STALE", accountID: "a", phase: callEnded, + endedAt: time.Now().Add(-2 * time.Hour), + } + for _, c := range []*liveCall{live, recent, stale} { + cm.put(c) + } + + if got := cm.reap(time.Hour); got != 1 { + t.Fatalf("reaped %d calls, want 1", got) + } + if _, ok := cm.get("LIVE"); !ok { + t.Error("an in-progress call was reaped") + } + if _, ok := cm.get("RECENT"); !ok { + t.Error("a recently ended call was reaped too early") + } + if _, ok := cm.get("STALE"); ok { + t.Error("a long-ended call survived the reaper") + } +} + +func TestListFiltersByAccount(t *testing.T) { + cm := NewCallManager(nil, testLog{t}, "") + cm.put(&liveCall{callID: "A", accountID: "acct-1", phase: callActive}) + cm.put(&liveCall{callID: "B", accountID: "acct-2", phase: callActive}) + + if got := cm.list("acct-1"); len(got) != 1 || got[0].CallID != "A" { + t.Fatalf("list(acct-1) = %+v, want just A", got) + } + if got := cm.list(""); len(got) != 2 { + t.Fatalf("list(all) returned %d calls, want 2", len(got)) + } +} + +// finish must be idempotent: OnEnd can race an explicit Hangup, and closing a +// WAV recorder twice would otherwise corrupt the finalized header. +func TestFinishIsIdempotent(t *testing.T) { + closes := 0 + lc := &liveCall{callID: "X", sink: sinkFunc(func() error { closes++; return nil })} + + lc.finish("hangup") + lc.finish("timeout") + + if closes != 1 { + t.Fatalf("sink closed %d times, want exactly 1", closes) + } + if lc.phase != callEnded { + t.Errorf("phase = %q, want %q", lc.phase, callEnded) + } + // The first reason wins β€” it's the one that actually ended the call. + if lc.endReason != "hangup" { + t.Errorf("endReason = %q, want %q", lc.endReason, "hangup") + } +} + +// sinkFunc is a meowcaller.AudioSink whose Close is observable. +type sinkFunc func() error + +func (f sinkFunc) WriteFrame(_ []float32) error { return nil } +func (f sinkFunc) Close() error { return f() } diff --git a/apps/services/whatsapp_bridge/config.go b/apps/services/whatsapp_bridge/config.go index 359f945d..0f60c30d 100644 --- a/apps/services/whatsapp_bridge/config.go +++ b/apps/services/whatsapp_bridge/config.go @@ -4,6 +4,7 @@ import ( "os" "strconv" "strings" + "time" ) // Config is the bridge's runtime configuration, all from the environment so the @@ -26,6 +27,17 @@ type Config struct { FullHistory bool // FullHistoryDays caps the requested history window (server ceiling β‰ˆ 3 years). FullHistoryDays uint32 + // CallRecordDir is where each voice call's decoded peer audio is written as + // a 16 kHz mono WAV β€” the raw material the note-taker pipeline transcribes. + // Empty disables recording entirely (calls still connect). + CallRecordDir string + // CallReapAfter is how long an ended call stays queryable before it's + // dropped from the in-memory registry. + CallReapAfter time.Duration + // CallRetentionDays bounds how long recorded call audio stays on disk. + // Recording runs at ~115 MB per hour of call, so an unbounded directory + // fills a VPS quietly; 0 keeps recordings forever (opt in deliberately). + CallRetentionDays uint32 } func envBool(key string, def bool) bool { @@ -64,5 +76,9 @@ func LoadConfig() Config { StorePath: env("WHATSAPP_BRIDGE_STORE", "./bridge-store.db"), FullHistory: envBool("WHATSAPP_BRIDGE_FULL_HISTORY", true), FullHistoryDays: envUint("WHATSAPP_BRIDGE_FULL_HISTORY_DAYS", 365), + CallRecordDir: env("WHATSAPP_BRIDGE_CALL_RECORD_DIR", "./call-recordings"), + CallReapAfter: time.Duration(envUint("WHATSAPP_BRIDGE_CALL_REAP_MINS", 60)) * time.Minute, + + CallRetentionDays: envUint("WHATSAPP_BRIDGE_CALL_RETENTION_DAYS", 7), } } diff --git a/apps/services/whatsapp_bridge/gateway.go b/apps/services/whatsapp_bridge/gateway.go index 1d3502d1..c191cb0f 100644 --- a/apps/services/whatsapp_bridge/gateway.go +++ b/apps/services/whatsapp_bridge/gateway.go @@ -182,3 +182,11 @@ type avatarsPayload struct { func (g *GatewayClient) IngestAvatars(ctx context.Context, p avatarsPayload) error { return g.post(ctx, "/whatsapp/bridge/avatars", p) } + +// CallEvent reports one voice call's current state (ringing, active, ended, …) +// so the gateway can surface it live instead of polling. The body is the +// callInfo snapshot from calls.go; the gateway treats it as advisory and always +// re-reads the bridge for authoritative state. +func (g *GatewayClient) CallEvent(ctx context.Context, info callInfo) error { + return g.post(ctx, "/whatsapp/bridge/call-event", info) +} diff --git a/apps/services/whatsapp_bridge/go.mod b/apps/services/whatsapp_bridge/go.mod index 16323547..cd54a50f 100644 --- a/apps/services/whatsapp_bridge/go.mod +++ b/apps/services/whatsapp_bridge/go.mod @@ -3,6 +3,7 @@ module github.com/fracktalworks/commandcenter/whatsapp_bridge go 1.25.0 require ( + github.com/purpshell/meowcaller v0.0.0-20260726180203-6d9b7b2c1807 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e go.mau.fi/whatsmeow v0.0.0-20260722203353-e9a033b24933 google.golang.org/protobuf v1.36.11 @@ -16,10 +17,18 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/hajimehoshi/go-mp3 v0.3.4 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/petermattis/goid v0.0.0-20260713124913-97594f28f5ca // indirect + github.com/pion/datachannel v1.6.0 // indirect + github.com/pion/dtls/v3 v3.1.2 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/opus v0.1.0 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/sctp v1.9.4 // indirect + github.com/pion/transport/v4 v4.0.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rs/zerolog v1.35.1 // indirect github.com/vektah/gqlparser/v2 v2.5.27 // indirect diff --git a/apps/services/whatsapp_bridge/go.sum b/apps/services/whatsapp_bridge/go.sum index 9a3a57fc..89972462 100644 --- a/apps/services/whatsapp_bridge/go.sum +++ b/apps/services/whatsapp_bridge/go.sum @@ -22,6 +22,9 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= +github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= +github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -34,8 +37,24 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/petermattis/goid v0.0.0-20260713124913-97594f28f5ca h1:GHSUVE4yOgX4E7kTRzpxCPbCOYkd3Kj8Dgdod30OI1E= github.com/petermattis/goid v0.0.0-20260713124913-97594f28f5ca/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= +github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= +github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= +github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/opus v0.1.0 h1:GgK/a3DNDrffKjUFsK39rZKqfv7bQ2S2eqRKt0BnqAE= +github.com/pion/opus v0.1.0/go.mod h1:t5Xog2n682JnawoykACE6nKVmupFvmJvkpM7x6bTv6g= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/sctp v1.9.4 h1:cMxEu0F5tbP4qH07bKf1Zjf4rUih9LIo0qQt424e258= +github.com/pion/sctp v1.9.4/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw= +github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= +github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/purpshell/meowcaller v0.0.0-20260726180203-6d9b7b2c1807 h1:SnLX76CnagumooXRm63BK9Rn2/e/Th6aWWJJKTqOk2k= +github.com/purpshell/meowcaller v0.0.0-20260726180203-6d9b7b2c1807/go.mod h1:kSME01MaSkwul6tSmExmgBWUOmfkw3DNpfnozsn01eE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= @@ -64,11 +83,14 @@ golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= +golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/apps/services/whatsapp_bridge/main.go b/apps/services/whatsapp_bridge/main.go index 0d4a376e..65aa47d3 100644 --- a/apps/services/whatsapp_bridge/main.go +++ b/apps/services/whatsapp_bridge/main.go @@ -11,6 +11,11 @@ // POST /send {session,to,body,reply_to} -> {id} // POST /media {session,media_id} -> raw bytes // POST /read {session,message_id,chat,sender} -> {ok} +// POST /call {session,to|group_id|targets} -> callInfo +// POST /call/hangup {session,call_id} -> callInfo +// POST /call/answer {session,call_id} -> callInfo +// POST /call/reject {session,call_id} -> callInfo +// GET /calls?session= -> {calls} // GET /health -> {ok} // // It holds NO gateway credentials and never talks to Meta's Graph API. NOTE: @@ -69,8 +74,12 @@ func main() { defer meta.Close() gw := NewGatewayClient(cfg.GatewayURL, cfg.Secret) - mgr := NewSessionManager(container, meta, gw, logger) + mgr := NewSessionManager(container, meta, gw, logger, cfg.CallRecordDir) mgr.RestoreSessions(ctx) + // Sweep once at boot so a restart reclaims disk even if the process never + // stayed up long enough for the periodic pass to fire. + mgr.calls.sweepRecordings(cfg.CallRetentionDays) + go reapCalls(mgr, cfg.CallReapAfter, cfg.CallRetentionDays) srv := &Server{cfg: cfg, mgr: mgr, log: logger} httpSrv := &http.Server{Addr: cfg.Addr, Handler: srv.routes(), ReadHeaderTimeout: 10 * time.Second} @@ -114,9 +123,97 @@ func (s *Server) routes() http.Handler { mux.HandleFunc("POST /send", s.auth(s.handleSend)) mux.HandleFunc("POST /media", s.auth(s.handleMedia)) mux.HandleFunc("POST /read", s.auth(s.handleRead)) + mux.HandleFunc("POST /call", s.auth(s.handleCall)) + mux.HandleFunc("POST /call/hangup", s.auth(s.handleCallAction)) + mux.HandleFunc("POST /call/answer", s.auth(s.handleCallAction)) + mux.HandleFunc("POST /call/reject", s.auth(s.handleCallAction)) + mux.HandleFunc("GET /calls", s.auth(s.handleCallList)) return mux } +// handleCall places an outbound call. A `to` places a 1:1 call; a `group_id` or +// two-plus `targets` places a group call. +func (s *Server) handleCall(w http.ResponseWriter, r *http.Request) { + var body struct { + Session string `json:"session"` + To string `json:"to"` + GroupID string `json:"group_id"` + Targets []string `json:"targets"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if body.Session == "" { + http.Error(w, "session required", http.StatusBadRequest) + return + } + + var ( + info callInfo + err error + ) + if body.GroupID != "" || len(body.Targets) > 0 { + info, err = s.mgr.PlaceGroupCall(r.Context(), body.Session, body.GroupID, body.Targets) + } else { + info, err = s.mgr.PlaceCall(r.Context(), body.Session, body.To) + } + if err != nil { + http.Error(w, err.Error(), statusForCall(err)) + return + } + writeJSON(w, http.StatusOK, info) +} + +// handleCallAction serves hangup/answer/reject β€” same body, different verb, +// picked off the request path so the three stay in step. +func (s *Server) handleCallAction(w http.ResponseWriter, r *http.Request) { + var body struct { + Session string `json:"session"` + CallID string `json:"call_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.CallID == "" { + http.Error(w, "call_id required", http.StatusBadRequest) + return + } + var ( + info callInfo + err error + ) + switch r.URL.Path { + case "/call/answer": + info, err = s.mgr.Answer(body.Session, body.CallID) + case "/call/reject": + info, err = s.mgr.Reject(body.Session, body.CallID) + default: + info, err = s.mgr.Hangup(body.Session, body.CallID) + } + if err != nil { + http.Error(w, err.Error(), statusForCall(err)) + return + } + writeJSON(w, http.StatusOK, info) +} + +// handleCallList returns tracked calls, optionally filtered to one session. +func (s *Server) handleCallList(w http.ResponseWriter, r *http.Request) { + calls := s.mgr.ListCalls(r.URL.Query().Get("session")) + writeJSON(w, http.StatusOK, map[string]any{"calls": calls}) +} + +// statusForCall maps a call error: unknown call β†’ 404, no session/stack β†’ 409 +// (a state precondition the caller can fix by pairing), otherwise 502. +func statusForCall(err error) int { + switch { + case errors.Is(err, ErrCallNotFound): + return http.StatusNotFound + case errors.Is(err, ErrNotConnected), errors.Is(err, ErrNoCaller): + return http.StatusConflict + default: + return http.StatusBadGateway + } +} + // auth wraps a handler with the constant-time shared-secret check. An unset // secret allows everything (local dev). func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc { @@ -248,3 +345,19 @@ func writeJSON(w http.ResponseWriter, status int, v any) { func strPtr(s string) *string { return &s } func boolPtr(b bool) *bool { return &b } func uint32Ptr(n uint32) *uint32 { return &n } + +// reapCalls periodically drops long-ended calls from the in-memory registry so +// a bridge that runs for weeks doesn't retain every call it ever placed, and +// sweeps recorded audio past its retention. A non-positive interval disables +// both. +func reapCalls(mgr *SessionManager, ttl time.Duration, retentionDays uint32) { + if ttl <= 0 { + return + } + ticker := time.NewTicker(ttl) + defer ticker.Stop() + for range ticker.C { + mgr.calls.reap(ttl) + mgr.calls.sweepRecordings(retentionDays) + } +} diff --git a/apps/services/whatsapp_bridge/session.go b/apps/services/whatsapp_bridge/session.go index f9980950..092345cb 100644 --- a/apps/services/whatsapp_bridge/session.go +++ b/apps/services/whatsapp_bridge/session.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "github.com/purpshell/meowcaller" qrcode "github.com/skip2/go-qrcode" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/proto/waE2E" @@ -40,6 +41,10 @@ var ( type Session struct { accountID string client *whatsmeow.Client + // caller is the voice-call stack wrapped around client. whatsmeow carries + // call signalling but no media, so this is what lets the number actually + // place and hear calls. Constructed before Connect (see newClient). + caller *meowcaller.Client mu sync.RWMutex qr string // data-URI PNG of the current pairing code, "" once live @@ -86,15 +91,22 @@ type SessionManager struct { // Bounded background queue for profile-picture fetches (W17) β€” see avatars.go. avatarQueue chan avatarJob + + // Live voice calls across every account β€” see calls.go. + calls *CallManager } // NewSessionManager wires the manager to its stores + gateway client, and starts // the fixed-size worker pool that drains avatarQueue for the process lifetime. -func NewSessionManager(c *sqlstore.Container, meta *MetaStore, gw *GatewayClient, log waLog.Logger) *SessionManager { +func NewSessionManager( + c *sqlstore.Container, meta *MetaStore, gw *GatewayClient, + log waLog.Logger, callRecordDir string, +) *SessionManager { m := &SessionManager{ container: c, meta: meta, gw: gw, log: log, sessions: map[string]*Session{}, avatarQueue: make(chan avatarJob, avatarQueueSize), + calls: NewCallManager(gw, log, callRecordDir), } for range avatarWorkerCount { go m.avatarWorker() @@ -151,6 +163,11 @@ func (m *SessionManager) newClient(accountID string, device *store.Device) *Sess client.EmitAppStateEventsOnFullSync = true s := &Session{accountID: accountID, client: client, status: statusPairing} client.AddEventHandler(m.handler(s)) + // The calling stack intercepts low-level / stanzas, so it has to + // be installed BEFORE Connect starts the receive loop β€” every caller of + // newClient connects afterwards, which is what makes this safe here. + s.caller = meowcaller.NewClient(client) + m.registerCallHandlers(s) return s } diff --git a/scripts/check_whatsapp_calling.py b/scripts/check_whatsapp_calling.py new file mode 100644 index 00000000..938475dd --- /dev/null +++ b/scripts/check_whatsapp_calling.py @@ -0,0 +1,126 @@ +"""Is the WhatsApp Business Calling API enabled on our number? + +Hard gate for Phase A of ai-company-brain/specs/whatsapp_calls_note_taker.md: +we can't build a WhatsApp note taker on the official path until Meta has +switched calling on for the WABA. Rollout is gated per-number, so this is a +question only the live API can answer. + +Reads WHATSAPP_PHONE_NUMBER_ID / WHATSAPP_ACCESS_TOKEN from the environment, +falling back to the repo-root .env. Stdlib only β€” run it anywhere: + + python3 scripts/check_whatsapp_calling.py + +Read-only. It never flips a setting; enabling calling is a deliberate act and +the command to do it is printed rather than run. +""" +import json +import os +import sys +import urllib.error +import urllib.request +from pathlib import Path + +GRAPH = "https://graph.facebook.com" +VERSION = os.environ.get("WHATSAPP_GRAPH_VERSION", "v21.0").strip() or "v21.0" + + +def load_env() -> None: + """Fill missing WHATSAPP_* vars from the repo-root .env (real env wins).""" + env = Path(__file__).resolve().parent.parent / ".env" + if not env.exists(): + return + for line in env.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, _, v = line.partition("=") + k, v = k.strip(), v.strip().strip('"').strip("'") + if k.startswith("WHATSAPP_") and v and not os.environ.get(k): + os.environ[k] = v + + +def get(path: str, token: str): + req = urllib.request.Request( + f"{GRAPH}/{VERSION}/{path}", + headers={"Authorization": f"Bearer {token}"}, + ) + try: + with urllib.request.urlopen(req, timeout=20) as r: + return r.status, json.loads(r.read()) + except urllib.error.HTTPError as e: + body = e.read().decode()[:600] + try: + return e.code, json.loads(body) + except ValueError: + return e.code, {"raw": body} + except Exception as e: # network, DNS, TLS + return None, {"error": str(e)[:300]} + + +def main() -> int: + load_env() + pnid = os.environ.get("WHATSAPP_PHONE_NUMBER_ID", "").strip() + token = os.environ.get("WHATSAPP_ACCESS_TOKEN", "").strip() + + if not pnid or not token: + print("βœ— No credentials.") + print(" Set WHATSAPP_PHONE_NUMBER_ID and WHATSAPP_ACCESS_TOKEN (or put") + print(" them in the repo-root .env) and run this again.") + return 2 + + print(f"Graph {VERSION} Β· phone_number_id {pnid}\n") + + status, num = get(f"{pnid}?fields=display_phone_number,verified_name,quality_rating", token) + if status == 200: + print(f" number {num.get('display_phone_number', '?')} " + f"({num.get('verified_name', '?')}) Β· quality {num.get('quality_rating', '?')}") + else: + err = (num.get("error") or {}).get("message", num) + print(f"βœ— Can't read the phone number ({status}): {err}") + print(" The token is wrong, expired, or lacks whatsapp_business_management.") + return 2 + + # The calling switch lives on the phone number's settings edge. + status, settings = get(f"{pnid}/settings", token) + + if status == 200: + calling = settings.get("calling") + if calling is None: + print("\n? The settings edge returned no `calling` object.") + print(" Calling almost certainly isn't provisioned for this number yet.") + print(" Full response for the record:") + print(" " + json.dumps(settings)[:400]) + return 1 + state = str(calling.get("status", "")).upper() + if state == "ENABLED": + print("\nβœ“ CALLING IS ENABLED on this number.") + print(f" call_icon_visibility: {calling.get('call_icon_visibility', 'DEFAULT')}") + print("\n Phase A is unblocked. Next: subscribe the webhook to the `calls`") + print(" field and stand up a media endpoint to answer the SDP offer.") + return 0 + print(f"\nβ—‹ Calling is provisioned but currently {state or 'DISABLED'}.") + print(" Nothing is blocking us β€” it's a switch we own. To turn it on:") + print(f"\n curl -X POST '{GRAPH}/{VERSION}/{pnid}/settings' \\") + print(" -H \"Authorization: Bearer $WHATSAPP_ACCESS_TOKEN\" \\") + print(" -H 'Content-Type: application/json' \\") + print(" -d '{\"calling\":{\"status\":\"ENABLED\"}}'") + return 0 + + err = (settings.get("error") or {}) if isinstance(settings, dict) else {} + msg = err.get("message", settings) + if status in (400, 401, 403): + print(f"\nβœ— CALLING IS NOT AVAILABLE on this number ({status}).") + print(f" Meta says: {msg}") + print("\n Meta gates the Calling API per-number during rollout; a 401/403 here") + print(" means it hasn't been switched on for us. This is not something we can") + print(" engineer around β€” ask Meta or our BSP to enable it on the WABA.") + print(" Until then Phase A is blocked and only the unofficial path (Β§1 Surface C)") + print(" or the upload fallback (Β§1 Surface D) can carry WhatsApp call notes.") + return 1 + + print(f"\n? Unexpected response ({status}): {msg}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/unit/test_org_access_enforcement.py b/tests/unit/test_org_access_enforcement.py index ee489051..8dacfa9f 100644 --- a/tests/unit/test_org_access_enforcement.py +++ b/tests/unit/test_org_access_enforcement.py @@ -121,7 +121,10 @@ def test_service_principal_passes_the_gate() -> None: # The bridge posts freshly-fetched profile-picture URLs (W17). Same # X-Bridge-Secret scheme as its siblings; gating it would silently # stop avatar sync, not restrict any member. - "/whatsapp/bridge/avatars", + # The bridge reports voice-call state transitions. Same secret scheme; + # it arrives from the Go service with no user session, so gating it + # would drop call events rather than restrict anyone. + "/whatsapp/bridge/call-event", }, "gateway.routes.email": { "/email/oauth/{provider}/callback", diff --git a/tests/unit/test_whatsapp_calls.py b/tests/unit/test_whatsapp_calls.py new file mode 100644 index 00000000..8513a798 --- /dev/null +++ b/tests/unit/test_whatsapp_calls.py @@ -0,0 +1,263 @@ +"""Unit tests for WhatsApp voice calls (transport/calls.py). + +Covers the parts that hold real risk: account ownership (the bridge only checks +a shared secret, so the gateway is the only thing stopping one user driving +another's paired phone), the bridge-unreachable path, and the shared-secret gate +on the inbound call-event seam. +""" + +from __future__ import annotations + +import pytest +from fastapi import HTTPException + +import gateway.routes.whatsapp.transport.calls as calls + + +# ── fakes ───────────────────────────────────────────────────────────────────── + +class _Row: + def __init__(self, sync_status: str): + self.sync_status = sync_status + + +class _Result: + def __init__(self, row): + self._row = row + + def fetchone(self): + return self._row + + +class _FakeDB: + """Minimal async DB stub: every execute returns the same canned row.""" + + def __init__(self, row): + self._row = row + self.closed = False + + async def execute(self, *_a, **_kw): + return _Result(self._row) + + async def close(self): + self.closed = True + + +class _User: + def __init__(self, email: str = "someone@example.com"): + self.email = email + + +def _patch_db(monkeypatch, row) -> _FakeDB: + db = _FakeDB(row) + + async def _get_db(): + return db + + monkeypatch.setattr(calls, "_get_db", _get_db) + return db + + +def _patch_bridge(monkeypatch, status: int, body): + """Replace the bridge transport, recording what it was called with.""" + seen: dict = {} + + async def _bridge(method, path, **kw): + seen["method"], seen["path"] = method, path + seen.update(kw) + return status, body + + monkeypatch.setattr(calls, "_bridge", _bridge) + return seen + + +# ── ownership guard ─────────────────────────────────────────────────────────── + +async def test_unknown_account_is_404(monkeypatch) -> None: + _patch_db(monkeypatch, None) + with pytest.raises(HTTPException) as exc: + await calls._assert_owns_account("someone-elses-id", _User()) + assert exc.value.status_code == 404 + + +async def test_account_still_pairing_is_409(monkeypatch) -> None: + _patch_db(monkeypatch, _Row("pairing")) + with pytest.raises(HTTPException) as exc: + await calls._assert_owns_account("acct", _User()) + assert exc.value.status_code == 409 + assert "pairing" in exc.value.detail.lower() + + +async def test_live_account_passes_and_closes_db(monkeypatch) -> None: + db = _patch_db(monkeypatch, _Row("live")) + await calls._assert_owns_account("acct", _User()) + assert db.closed is True + + +async def test_missing_account_id_is_400(monkeypatch) -> None: + _patch_db(monkeypatch, _Row("live")) + with pytest.raises(HTTPException) as exc: + await calls._assert_owns_account("", _User()) + assert exc.value.status_code == 400 + + +# ── placing a call ──────────────────────────────────────────────────────────── + +async def test_place_direct_call_forwards_to_bridge(monkeypatch) -> None: + _patch_db(monkeypatch, _Row("live")) + seen = _patch_bridge(monkeypatch, 200, { + "call_id": "ABC123", "account_id": "acct", "peer": "+919876543210", + "direction": "outgoing", "kind": "direct", "phase": "calling", + }) + out = await calls.place_call( + calls.PlaceCallRequest(account_id="acct", to="+919876543210"), _User()) + + assert (seen["method"], seen["path"]) == ("POST", "/call") + assert seen["json"]["session"] == "acct" + assert seen["json"]["to"] == "+919876543210" + assert out.call_id == "ABC123" + assert out.phase == "calling" + + +async def test_place_group_call_by_group_id(monkeypatch) -> None: + _patch_db(monkeypatch, _Row("live")) + seen = _patch_bridge(monkeypatch, 200, {"call_id": "G1", "kind": "group"}) + out = await calls.place_call( + calls.PlaceCallRequest(account_id="acct", group_id="12036300@g.us"), _User()) + + assert seen["json"]["group_id"] == "12036300@g.us" + assert out.kind == "group" + + +async def test_place_call_with_no_target_is_400(monkeypatch) -> None: + _patch_db(monkeypatch, _Row("live")) + _patch_bridge(monkeypatch, 200, {}) + with pytest.raises(HTTPException) as exc: + await calls.place_call(calls.PlaceCallRequest(account_id="acct"), _User()) + assert exc.value.status_code == 400 + + +async def test_single_target_is_not_a_group_call(monkeypatch) -> None: + """One target can't form a group call β€” reject rather than silently + turning it into a 1:1 to whoever happens to be first.""" + _patch_db(monkeypatch, _Row("live")) + _patch_bridge(monkeypatch, 200, {}) + with pytest.raises(HTTPException) as exc: + await calls.place_call( + calls.PlaceCallRequest(account_id="acct", targets=["+911111111111"]), _User()) + assert exc.value.status_code == 400 + + +async def test_unreachable_bridge_is_503_not_500(monkeypatch) -> None: + _patch_db(monkeypatch, _Row("live")) + _patch_bridge(monkeypatch, 0, {"detail": "The WhatsApp bridge isn't reachable."}) + with pytest.raises(HTTPException) as exc: + await calls.place_call( + calls.PlaceCallRequest(account_id="acct", to="+91999"), _User()) + assert exc.value.status_code == 503 + + +async def test_bridge_error_status_is_propagated(monkeypatch) -> None: + _patch_db(monkeypatch, _Row("live")) + _patch_bridge(monkeypatch, 409, {"detail": "account not connected"}) + with pytest.raises(HTTPException) as exc: + await calls.place_call( + calls.PlaceCallRequest(account_id="acct", to="+91999"), _User()) + assert exc.value.status_code == 409 + assert "not connected" in exc.value.detail + + +# ── call actions ────────────────────────────────────────────────────────────── + +@pytest.mark.parametrize( + ("fn", "action"), + [(calls.hangup_call, "hangup"), + (calls.answer_call, "answer"), + (calls.reject_call, "reject")], +) +async def test_actions_hit_their_bridge_path(monkeypatch, fn, action) -> None: + _patch_db(monkeypatch, _Row("live")) + seen = _patch_bridge(monkeypatch, 200, {"call_id": "X", "phase": "ended"}) + out = await fn(calls.CallActionRequest(account_id="acct", call_id="X"), _User()) + + assert seen["path"] == f"/call/{action}" + assert seen["json"]["call_id"] == "X" + assert out.phase == "ended" + + +async def test_action_on_unknown_call_is_404(monkeypatch) -> None: + _patch_db(monkeypatch, _Row("live")) + _patch_bridge(monkeypatch, 404, {"detail": "call not found"}) + with pytest.raises(HTTPException) as exc: + await calls.hangup_call( + calls.CallActionRequest(account_id="acct", call_id="nope"), _User()) + assert exc.value.status_code == 404 + + +# ── listing ─────────────────────────────────────────────────────────────────── + +async def test_list_calls_maps_bridge_rows(monkeypatch) -> None: + _patch_db(monkeypatch, _Row("live")) + _patch_bridge(monkeypatch, 200, {"calls": [ + {"call_id": "A", "phase": "active", "kind": "direct"}, + {"call_id": "B", "phase": "ended", "kind": "group"}, + ]}) + out = await calls.list_calls("acct", _User()) + assert [c.call_id for c in out.calls] == ["A", "B"] + assert out.bridge_reachable is True + + +async def test_list_calls_survives_a_down_bridge(monkeypatch) -> None: + """A dead bridge must render as an empty dialer, not a 500.""" + _patch_db(monkeypatch, _Row("live")) + _patch_bridge(monkeypatch, 0, {}) + out = await calls.list_calls("acct", _User()) + assert out.calls == [] + assert out.bridge_reachable is False + + +async def test_list_calls_ignores_malformed_rows(monkeypatch) -> None: + _patch_db(monkeypatch, _Row("live")) + _patch_bridge(monkeypatch, 200, {"calls": [{"call_id": "A"}, "junk", None]}) + out = await calls.list_calls("acct", _User()) + assert [c.call_id for c in out.calls] == ["A"] + + +# ── inbound call-event seam ─────────────────────────────────────────────────── + +class _Req: + def __init__(self, payload, secret="s3cret"): + self._payload = payload + self.headers = {"X-Bridge-Secret": secret} if secret else {} + + async def json(self): + if isinstance(self._payload, Exception): + raise self._payload + return self._payload + + +async def test_call_event_rejects_a_bad_secret(monkeypatch) -> None: + monkeypatch.setattr(calls, "bridge_secret_ok", lambda _h: False) + resp = await calls.bridge_call_event(_Req({"call_id": "A"}, secret="wrong")) + assert resp.status_code == 403 + + +async def test_call_event_accepts_a_good_secret(monkeypatch) -> None: + monkeypatch.setattr(calls, "bridge_secret_ok", lambda _h: True) + resp = await calls.bridge_call_event(_Req({ + "call_id": "A", "account_id": "acct", "phase": "active", + "direction": "outgoing", "kind": "direct", "recording": "/x/A.wav", + })) + assert resp.status_code == 200 + + +async def test_call_event_rejects_invalid_json(monkeypatch) -> None: + monkeypatch.setattr(calls, "bridge_secret_ok", lambda _h: True) + resp = await calls.bridge_call_event(_Req(ValueError("not json"))) + assert resp.status_code == 400 + + +async def test_call_event_rejects_a_non_object_payload(monkeypatch) -> None: + monkeypatch.setattr(calls, "bridge_secret_ok", lambda _h: True) + resp = await calls.bridge_call_event(_Req(["not", "an", "object"])) + assert resp.status_code == 400 diff --git a/workbench/control_plane/src/app/whatsapp/calls/page.tsx b/workbench/control_plane/src/app/whatsapp/calls/page.tsx new file mode 100644 index 00000000..3c01c050 --- /dev/null +++ b/workbench/control_plane/src/app/whatsapp/calls/page.tsx @@ -0,0 +1,420 @@ +"use client"; + +/** + * WhatsApp voice calls β€” the dialer. + * + * Places 1:1 and group calls from a QR-paired PERSONAL number, the way + * WhatsApp Desktop does. Calling rides the whatsmeow bridge + meowcaller, so it + * exists only on bridge accounts: Meta's Cloud API calling product is 1:1-only, + * has to be enabled per-number by Meta, and has no media leg here yet. + * + * This is the media seam for the note taker β€” every call's audio is recorded to + * a WAV on the bridge, which is what the transcription pipeline will consume. + * See ai-company-brain/specs/whatsapp_calls_note_taker.md. + */ + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + AlertTriangle, + Loader2, + Mic, + Phone, + PhoneIncoming, + PhoneOff, + Users, +} from "lucide-react"; +import { + callAction, + fetchAccounts, + fetchCalls, + placeCall, +} from "../lib/api"; +import type { WaAccount, WaCall } from "../lib/types"; + +/** Phases where the call is still going β€” drives polling and the hangup button. */ +const LIVE_PHASES = new Set([ + "idle", + "calling", + "ringing", + "connecting", + "active", + "waiting_room", +]); + +const PHASE_LABEL: Record = { + idle: "Starting", + calling: "Calling", + ringing: "Ringing", + connecting: "Connecting", + active: "In call", + waiting_room: "Waiting room", + ended: "Ended", +}; + +const isLive = (c: WaCall) => LIVE_PHASES.has(c.phase); + +function phaseTone(phase: string): string { + if (phase === "active") return "bg-success/15 text-success"; + if (phase === "ended") return "bg-secondary text-muted-foreground"; + return "bg-warning/15 text-warning"; +} + +/** Elapsed mm:ss. `now` is passed in (not read from the clock here) so the + * value is a pure function of state β€” no server/client hydration mismatch. */ +function clockFrom(iso: string | undefined, now: number): string { + if (!iso || !now) return ""; + const started = new Date(iso).getTime(); + if (Number.isNaN(started)) return ""; + const secs = Math.max(0, Math.floor((now - started) / 1000)); + const m = String(Math.floor(secs / 60)).padStart(2, "0"); + const s = String(secs % 60).padStart(2, "0"); + return `${m}:${s}`; +} + +export default function WhatsAppCallsPage() { + const [accounts, setAccounts] = useState(null); + const [accountId, setAccountId] = useState(""); + const [calls, setCalls] = useState([]); + const [bridgeUp, setBridgeUp] = useState(true); + const [target, setTarget] = useState(""); + const [mode, setMode] = useState<"direct" | "group">("direct"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + // Starts at 0 so the first server render has no clock to mismatch on; the + // poll below fills it in once mounted. + const [now, setNow] = useState(0); + + // Calling only exists on the bridge transport; a Cloud API number in this + // picker would just fail at the gateway, so it never gets offered. + const callable = useMemo( + () => (accounts ?? []).filter((a) => a.provider === "whatsmeow"), + [accounts] + ); + + useEffect(() => { + void fetchAccounts().then((rows) => { + setAccounts(rows); + const bridge = rows.filter((a) => a.provider === "whatsmeow"); + const paired = bridge.find((a) => a.sync_status === "live") ?? bridge[0]; + if (paired) setAccountId(paired.id); + }); + }, []); + + const refresh = useCallback(async () => { + if (!accountId) return; + const res = await fetchCalls(accountId); + setCalls(res.calls); + setBridgeUp(res.bridge_reachable); + }, [accountId]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + // Poll while anything is live, and re-render every second so the in-call + // timer counts even when the phase itself hasn't changed. + const anyLive = calls.some(isLive); + useEffect(() => { + if (!accountId) return; + const every = anyLive ? 1500 : 8000; + const id = setInterval(() => { + setNow(Date.now()); + void refresh(); + }, every); + return () => clearInterval(id); + }, [accountId, anyLive, refresh]); + + async function dial() { + const raw = target.trim(); + if (!raw || !accountId) return; + setBusy(true); + setError(null); + + const parts = raw + .split(/[,\n]/) + .map((p) => p.trim()) + .filter(Boolean); + + const res = await placeCall( + mode === "group" + ? raw.includes("@g.us") + ? { accountId, groupId: raw } + : { accountId, targets: parts } + : { accountId, to: parts[0] ?? raw } + ); + + setBusy(false); + if (!res.ok) { + setError(res.error ?? "The call couldn't be placed."); + return; + } + setTarget(""); + void refresh(); + } + + async function act(action: "hangup" | "answer" | "reject", call: WaCall) { + setError(null); + const res = await callAction(action, accountId, call.call_id); + if (!res.ok) setError(res.error ?? `Couldn't ${action} that call.`); + void refresh(); + } + + const live = calls.filter(isLive); + const recent = calls.filter((c) => !isLive(c)); + + // ── empty states ─────────────────────────────────────────────────────────── + + if (accounts === null) { + return ( +
+ +
+ ); + } + + if (callable.length === 0) { + return ( +
+

WhatsApp calls

+
+

+ No personal number is paired. +

+

+ Calling runs over the QR-paired personal (bridge) transport β€” a Cloud + API business number can't place calls from here. Pair a number + under Numbers β†’ Personal WhatsApp{" "} + and come back. +

+
+
+ ); + } + + return ( +
+
+

+ + WhatsApp calls +

+

+ Place 1:1 and group calls from your paired personal number. Audio is + recorded on the bridge β€” the raw material for meeting notes. +

+
+ + {/* The ToS posture is the first thing on the page, not a footnote: an + automated client placing calls is the fastest way to lose a number. */} +
+ + + Calling from an unofficial client is outside WhatsApp's Terms of + Service and the number can be banned. Use a number you're willing + to lose β€” never the company's main line. Everyone on the call + should know it's being recorded. + +
+ + {!bridgeUp && ( +
+ The WhatsApp bridge isn't reachable, so calls can't be placed. + Check that the whatsapp_bridge{" "} + service is running. +
+ )} + + {/* dialer */} +
+ {callable.length > 1 && ( + + )} + +
+ {(["direct", "group"] as const).map((m) => ( + + ))} +
+ +
+ setTarget(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void dial(); + }} + placeholder={ + mode === "direct" + ? "+91 98765 43210" + : "Group id (…@g.us), or two or more numbers, comma-separated" + } + className="flex-1 rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/70 focus:outline-none focus:ring-1 focus:ring-ring" + /> + +
+ + {mode === "group" && ( +

+ A group call needs a WhatsApp group id, or at least two numbers for + an ad-hoc call. Group calling is experimental β€” expect rough edges. +

+ )} + + {error && ( +

+ {error} +

+ )} +
+ + {/* live calls */} + {live.length > 0 && ( +
+

+ On a call +

+
+ {live.map((c) => ( +
+ + {c.direction === "incoming" ? ( + + ) : c.kind === "group" ? ( + + ) : ( + + )} + +
+

+ {c.peer || c.targets?.join(", ") || "Unknown"} +

+

+ + {PHASE_LABEL[c.phase] ?? c.phase} + + {c.phase === "active" && ( + + {clockFrom(c.started_at, now)} + + )} + {c.recording && ( + + recording + + )} +

+
+ {c.direction === "incoming" && c.phase === "ringing" ? ( +
+ + +
+ ) : ( + + )} +
+ ))} +
+
+ )} + + {/* recent */} + {recent.length > 0 && ( +
+

+ Recent +

+
+ {recent.map((c) => ( +
+ + + +
+

+ {c.peer || c.targets?.join(", ") || "Unknown"} +

+

+ {c.direction === "incoming" ? "Incoming" : "Outgoing"} + {c.kind === "group" ? " group call" : ""} + {c.end_reason ? ` Β· ${c.end_reason}` : ""} +

+
+ {c.recording && ( + + saved + + )} +
+ ))} +
+
+ )} + + {live.length === 0 && recent.length === 0 && bridgeUp && ( +

+ No calls yet. Dial a number above to place the first one. +

+ )} +
+ ); +} diff --git a/workbench/control_plane/src/app/whatsapp/layout.tsx b/workbench/control_plane/src/app/whatsapp/layout.tsx index 431f7cfa..95e30c82 100644 --- a/workbench/control_plane/src/app/whatsapp/layout.tsx +++ b/workbench/control_plane/src/app/whatsapp/layout.tsx @@ -19,6 +19,7 @@ import { Inbox, MessageSquare, MessageSquareText, + Phone, Plus, SlidersHorizontal, Smartphone, @@ -32,6 +33,7 @@ type Tab = { href: string; label: string; icon: LucideIcon; exact?: boolean }; const TABS: Tab[] = [ { href: "/whatsapp", label: "Inbox", icon: Inbox, exact: true }, + { href: "/whatsapp/calls", label: "Calls", icon: Phone }, { href: "/whatsapp/insights", label: "Pulse", icon: Activity }, { href: "/whatsapp/settings/categories", label: "Categories", icon: Tags }, { href: "/whatsapp/settings/replies", label: "Replies", icon: MessageSquareText }, diff --git a/workbench/control_plane/src/app/whatsapp/lib/api.ts b/workbench/control_plane/src/app/whatsapp/lib/api.ts index 9f565125..efda9567 100644 --- a/workbench/control_plane/src/app/whatsapp/lib/api.ts +++ b/workbench/control_plane/src/app/whatsapp/lib/api.ts @@ -5,6 +5,8 @@ import type { WaAccount, WaBridgeSession, + WaCall, + WaCallList, WaCategory, WaChat, WaChatContext, @@ -322,6 +324,41 @@ export function deleteSavedReply(id: string) { return deleteJSON(`saved-replies/${id}`); } +// ── voice calls (personal/bridge numbers only) ─────────────────────────────── + +export function fetchCalls(accountId: string): Promise { + return getJSON( + `calls?account_id=${encodeURIComponent(accountId)}`, + { calls: [], bridge_reachable: false } + ); +} + +/** Place a 1:1 call (`to`) or a group call (`groupId`, or 2+ `targets`). */ +export function placeCall(input: { + accountId: string; + to?: string; + groupId?: string; + targets?: string[]; +}) { + return sendJSON("calls", "POST", { + account_id: input.accountId, + to: input.to ?? "", + group_id: input.groupId ?? "", + targets: input.targets ?? [], + }); +} + +export function callAction( + action: "hangup" | "answer" | "reject", + accountId: string, + callId: string +) { + return sendJSON(`calls/${action}`, "POST", { + account_id: accountId, + call_id: callId, + }); +} + export function fetchPulse(accountId: string, days = 7): Promise { return getJSON( `pulse?account_id=${encodeURIComponent(accountId)}&days=${days}`, diff --git a/workbench/control_plane/src/app/whatsapp/lib/types.ts b/workbench/control_plane/src/app/whatsapp/lib/types.ts index f6453b66..da2fa4fd 100644 --- a/workbench/control_plane/src/app/whatsapp/lib/types.ts +++ b/workbench/control_plane/src/app/whatsapp/lib/types.ts @@ -24,6 +24,29 @@ export type WaAccount = { quality_rating: string | null; last_synced_at: string | null; is_default: boolean; + /** 'cloud' (Meta Cloud API) or 'whatsmeow' (QR-paired personal bridge). + * Voice calling exists only on the bridge transport. */ + provider?: string; +}; + +/** One voice call as the bridge reports it. Mirrors callInfo in calls.go. */ +export type WaCall = { + call_id: string; + account_id: string; + peer: string; + direction: "outgoing" | "incoming" | ""; + kind: "direct" | "group" | ""; + phase: string; + targets?: string[]; + started_at?: string; + ended_at?: string; + end_reason?: string; + recording?: string; +}; + +export type WaCallList = { + calls: WaCall[]; + bridge_reachable: boolean; }; // Connect wizard (W11) + Embedded Signup (W12).