feat(whatsapp): place and answer WhatsApp calls from CommandCenter - #321
Merged
Conversation
Can the note taker cover WhatsApp calls, including group calls? Studied both sides — what WhatsApp actually exposes, and what of ours is reusable. Verdict: yes for 1:1 officially (Business Calling API hands us the media leg via an SDP/WebRTC handshake, so we record our own leg); no for group calls through any Meta API; group calls are reachable only through an unofficial protocol client, on the same whatsmeow session our bridge already runs. Calls are E2EE, so a note taker can never be a passive observer — it has to be an endpoint. That single fact drives the design. The pipeline needs no changes: transcribe → diarize → name → summarize → dispatch is platform-agnostic, and WhatsApp actually improves it, since every participant is a phone number we already have a contact for. UX is built around the trigger problem — WhatsApp calls have no link and no calendar — so the notetaker becomes a contact you add mid-call rather than a bot you dispatch, with consent visible in the participant list, audible on join, and durable in the chat thread. Doc only; nothing implemented. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1ugwhr7f6mNrbkTsy27YW
…re attributed not diarized Two refinements from review, added as §10. Taking the call inside CommandCenter is the better shape: it collapses the official and unofficial paths behind one dialer, since terminating the media leg yourself is exactly what the Business Calling API already expects. The honest costs are named — calls move to the browser, and with no visible notetaker the join announcement becomes the only disclosure there is. The open question flagged as highest-value is now resolved against the library source. Because end-to-end encryption forbids the server from mixing, the client necessarily receives each participant separately, and meowcaller decodes per participant keyed by SSRC, returning PCM bundled with the sender's JID before any mixing. That phone number joins straight to our contacts, so group transcripts come out correctly named by construction — attribution rather than diarization, and the strongest product argument for building this at all. Records the cost decision that follows: label a single mixed transcript from per-participant activity rather than paying N times for N streams. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1ugwhr7f6mNrbkTsy27YW
…n our number Phase A of the WhatsApp note taker is gated on something only the live API can answer: Meta turns the Calling API on per-number during rollout. This script asks, from any box that holds the real credentials. It reads the phone number's settings edge and reports calling.status, and it distinguishes the two failure modes that matter — a 401/403, meaning Meta hasn't switched calling on for us and no amount of engineering will change that, versus provisioned-but-disabled, which is a switch we own and whose enabling command it prints rather than runs. Stdlib only, falls back to the repo-root .env, and never writes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1ugwhr7f6mNrbkTsy27YW
WhatsApp only renders the call icon in a business conversation when calling is enabled on the number, so opening that chat on any phone settles Phase A's hard gate in seconds — no token, no script, no access to a box that holds secrets. Notes the limit too: the icon reports provisioning, not readiness, since tapping it can't connect until the calls webhook field and a media endpoint exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1ugwhr7f6mNrbkTsy27YW
WhatsApp Desktop can call because it ships a full VoIP stack; whatsmeow carries call signalling only, so our bridge could see a call happening but never hear or place one. meowcaller supplies the missing half in pure Go — MLow codec, SRTP, RTP, relay — on the session we already hold, which makes the paired number a real calling client. The dialer lives at WhatsApp -> Calls: 1:1 by number, group by WhatsApp group id or an ad-hoc set of numbers, with live phase, an in-call timer, and hang up. Inbound calls surface as ringing and are never auto-answered; a number that picks up strangers is both a consent problem and the kind of behaviour that gets a number banned. Every call's decoded audio is recorded as 16 kHz mono WAV, which is the format acb_stt already transcribes. That is the point of the whole change: it is the media seam the note taker needs, and the recording is the raw material the transcription pipeline consumes next. Ownership is enforced at the gateway because the bridge cannot enforce it — the bridge only knows a shared secret, so without this any signed-in user could drive calls from anyone's paired phone. The dialer offers only bridge numbers, since a Cloud API number would fail downstream, and the page leads with the ban risk rather than burying it. Not yet wired: transcription, per-participant attribution, and an outbound microphone path, so the call listens but cannot speak. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1ugwhr7f6mNrbkTsy27YW
Recording writes 16 kHz mono WAV at roughly 115 MB per hour of call, and nothing deleted it. On a box with a fixed disk that is a slow outage, not an untidiness problem, so recordings are now swept after a configurable retention (7 days by default, 0 to keep forever) — once at boot so a restart reclaims space, and on the existing reaper tick thereafter. Only this service's own .wav files are touched, since the directory is operator-configurable and could point somewhere shared. The deploy seeds the record directory and retention into .env alongside the bridge's other settings, so both are visible and tunable on the server rather than living only as compiled-in defaults. Writing the test for terminal call state turned up a real bug: finish() rewrote endedAt and endReason every time it ran, and it runs twice for most calls because meowcaller's OnEnd races an explicit hangup. The second pass pushed the end timestamp forward and stretched the call's apparent duration. The first reason and timestamp now win, which is the one that actually ended the call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1ugwhr7f6mNrbkTsy27YW
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a working dialer at WhatsApp → Calls so a QR-paired personal number can place and answer WhatsApp voice calls, and records each call's audio as the media seam the note taker needs.
Merging this deploys it —
deploy.ymlfires on push tomain, rebuilds the bridge from source (CGO_ENABLED=0), and restarts the service.Why this shape
WhatsApp Desktop can call because it ships a full VoIP stack.
whatsmeowcarries call signalling only, so the bridge could see a call happening but never hear or place one.meowcallersupplies the missing half in pure Go — MLow codec, SRTP, RTP, relay — on the session we already hold.Meta's Cloud API calling product can't do this: it's 1:1-only, needs per-number enablement by Meta, and has no media leg here yet. The bridge transport is the only path that reaches group calls at all. Background and alternatives:
ai-company-brain/specs/whatsapp_calls_note_taker.md.What's included
whatsapp_bridge/calls.gosession.gomeowcaller.NewClientattached innewClient— beforeConnect, which the library requires.main.goPOST /call,/call/{hangup,answer,reject},GET /calls.routes/whatsapp/transport/calls.pycore.pyprovideron the account model;/bridge/call-eventadded to feature-gate exemptions.whatsapp/calls/page.tsxWHATSAPP_BRIDGE_CALL_RECORD_DIRand..._RETENTION_DAYSinto.env.Known limits — read before testing
acb_sttyet.Risk
Calling from an unofficial client is outside WhatsApp's Terms of Service and the number can be banned;
meowcaller's group support is marked experimental. Use a number you're willing to lose, never the main line. The dialer leads with this rather than burying it, and only offers bridge numbers.Recording is on by default (it's the point) at ~115 MB per call-hour, swept after 7 days.
WHATSAPP_BRIDGE_CALL_RECORD_DIR=disables it entirely.Verification
Go builds, vets and tests clean (
gofmtclean);next buildpasses with/whatsapp/callsregistered;tscclean onsrc; 3285 Python tests pass, including 21 new ones covering ownership, bridge-down, error propagation and the secret gate.Two bugs the tests caught and fixed:
/bridge/call-eventwas missing its feature-gate exemption (there's a test that enforces exactly that), andfinish()rewrote the call's end timestamp on its second invocation — which happens on most calls, sinceOnEndraces an explicit hangup — stretching the recorded duration.Generated by Claude Code