feat(channels): WhatsApp via Twilio — per-agent binding (#299) - #463
Merged
Merged
Conversation
…#299) Phase 1 MVP: adds WhatsApp as a per-agent channel via Twilio's Programmable Messaging API. Each agent owner brings their own Twilio account and WhatsApp sender number — no platform-level Twilio account required. Follows the Telegram adapter pattern exactly (919+190+629+507 LOC precedent). What ships: - WhatsAppAdapter + TwilioWebhookTransport + routers/whatsapp.py + db/whatsapp_channels.py - HMAC-SHA1 webhook verification via twilio.request_validator.RequestValidator (handles Twilio's empty-param inclusion gotcha that inline HMAC would miss) - AuthToken AES-256-GCM encrypted at rest via existing CredentialEncryptionService - SSRF allowlist on media downloads: *.twilio.com only, follow_redirects=False - MessageSid dedup ring (2048-entry) to absorb Twilio retries - Sandbox auto-detected from well-known number whatsapp:+14155238886 - Message splitting at Twilio's 1600-char WhatsApp limit - WhatsAppChannelPanel.vue in Agent Detail → Sharing tab - Settings back-fill hook piggybacked on public_chat_url save (Telegram pattern) Prerequisite fixes in this PR: - uvicorn --proxy-headers --forwarded-allow-ips='*' so request.url reconstructs correctly behind Cloudflare Tunnel + nginx (without this, every Twilio signature check would fail — also fixes latent Slack webhook-mode issue) - PUBLIC_EXTERNAL_ACCESS_SETUP.md adds /api/whatsapp/webhook/* to the Cloudflare Tunnel ingress allowlist Deferred (explicitly out of scope): - Phase 2 — unified access control (#311) /login flow; schema columns (verified_email, verified_at) shipped up-front so Phase 2 is additive - Phase 3 — SMS on same binding, message templates (24h window), interactive buttons, voice-note transcription - Group chats — Twilio's WhatsApp API does not support them Tests: 38 unit tests covering HMAC valid/invalid/tampered/empty-param, SSRF allowlist incl. 5 spoof vectors, dedup + eviction, parse_message edge cases, message splitting, URL reconstruction with X-Forwarded-Proto, webhook transport accept/reject/dedup. Security: /cso --diff reports 0 critical/high findings. RequestValidator's constant-time compare verified in library source. AuthToken never logged, never in responses. Phone numbers masked in error-path logs. Dependency: twilio==9.10.5 (imported only for RequestValidator; outbound send stays in httpx matching Telegram pattern). No known CVEs per Snyk/NVD. Closes #299 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3 tasks
vybe
added a commit
that referenced
this pull request
Apr 23, 2026
… (#469) Wire WhatsApp into the unified cross-channel access control system (#311) so WhatsApp identities can be gated by the same `agent_sharing` / `access_requests` primitives as Telegram/Slack/web. Pure application-only code — Phase 1 (#299) shipped the schema columns up-front. What ships: - `/login` / `/logout` / `/whoami` command handlers, dispatched by the Twilio webhook transport (short-circuiting the router gate so verification- state-changing commands aren't themselves gated on verification) - Redis-backed pending-login state with 10-minute TTL (`whatsapp_pending_login:{binding_id}:{phone}`) - Post-verification access gate inlined into `/login` so users learn their access status (shared / open_access / pending-approval) in the same message they verify on — matches Telegram UX - `access_requests.channel='whatsapp'` for restrictive-policy DMs - `proactive_message_service._deliver_whatsapp()` — explicit-only channel (not part of `auto` fallback), with chunking and partial-failure handling - Markdown → WhatsApp-native syntax conversion in `send_response` (`*bold*` not `**bold**`, `[text](url)` → `text (url)`) so agent markdown renders correctly on WhatsApp Tests: 28 new unit tests (67 total in test_whatsapp_adapter.py) + 10 new live-backend integration tests (test_whatsapp_integration.py). Integration tests POST real Twilio-signed webhook payloads and assert on DB state transitions for /login round-trip, pending-login Redis race, invalid-code rejection, restrictive/open-access gate outcomes, /logout, /whoami, bad-signature 403, unknown-secret 200. Related to #299 (Phase 1 merged as #463). Closes #467. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AndriiPasternak31
pushed a commit
to AndriiPasternak31/trinity
that referenced
this pull request
Apr 25, 2026
…lityai#467) Wire WhatsApp into the unified cross-channel access control system (Abilityai#311) so WhatsApp identities can be gated by the same `agent_sharing` / `access_requests` primitives as Telegram/Slack/web. Pure application-only code — Phase 1 (Abilityai#299) shipped the schema columns up-front. What ships: - `/login` / `/logout` / `/whoami` command handlers, dispatched by the Twilio webhook transport (short-circuiting the router gate so verification- state-changing commands aren't themselves gated on verification) - Redis-backed pending-login state with 10-minute TTL (`whatsapp_pending_login:{binding_id}:{phone}`) - Post-verification access gate inlined into `/login` so users learn their access status (shared / open_access / pending-approval) in the same message they verify on — matches Telegram UX - `access_requests.channel='whatsapp'` for restrictive-policy DMs - `proactive_message_service._deliver_whatsapp()` — explicit-only channel (not part of `auto` fallback), with chunking and partial-failure handling - Markdown → WhatsApp-native syntax conversion in `send_response` (`*bold*` not `**bold**`, `[text](url)` → `text (url)`) so agent markdown renders correctly on WhatsApp Tests: 28 new unit tests (67 total in test_whatsapp_adapter.py) + 10 new live-backend integration tests (test_whatsapp_integration.py). Integration tests POST real Twilio-signed webhook payloads and assert on DB state transitions for /login round-trip, pending-login Redis race, invalid-code rejection, restrictive/open-access gate outcomes, /logout, /whoami, bad-signature 403, unknown-secret 200. Related to Abilityai#299 (Phase 1 merged as Abilityai#463). Closes Abilityai#467. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced Aug 1, 2026
vybe
added a commit
that referenced
this pull request
Aug 3, 2026
#1932) (#1937) * fix(whatsapp): allow Twilio's media CDN as a validated redirect target (#1932) Inbound WhatsApp media has never worked. Twilio serves message media from mms.twiliocdn.com, reached via a 302 from api.twilio.com, but the SSRF allowlist accepted only the .twilio.com suffix — so every attachment was refused at the redirect gate and download_file returned None (since #463). Widen only the gate that had the bug. The allowlist is now two tiers: _TWILIO_MEDIA_SOURCE_HOST_SUFFIXES (*.twilio.com) — webhook MediaUrl{N} parse gate + hop 1, the hop that carries the tenant's Basic auth _TWILIO_MEDIA_ALLOWED_HOST_SUFFIXES (+ *.twiliocdn.com) — validated redirect targets only, always unauthenticated The credentialed hop stays exactly as narrow as before, so the widening doesn't depend on the webhook HMAC gate holding forever. s3-external-1 .amazonaws.com (the target for accounts without media auth) stays out: it is path-style, so allowlisting it admits arbitrary buckets under an allowlisted host. Also: follow_redirects=False stays and every hop is still re-validated, but the single manual follow becomes a bounded budget (_MAX_MEDIA_REDIRECTS=3) so a future 302→302 chain degrades to "still works"; a transport size cap (_WA_MEDIA_DOWNLOAD_MAX_BYTES, parity with Telegram/Slack); the off-domain refusal logs at ERROR; and the generic handler logs the exception type only (some httpx exceptions embed the URL, and the signed CDN URL is a ~4h bearer capability). download_file's signature and its None-on-failure contract are unchanged (#1933 depends on them). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(whatsapp): redirect-chain coverage for inbound media download (#1932) New tests/unit/test_whatsapp_inbound_media.py — all download_file coverage lives here, NOT in tests/test_whatsapp_adapter.py whose module-level `db = MagicMock()` makes the credentials guard pass vacuously. download_file returns None on every failure path and its bare `except Exception` swallows AssertionError, so `result is None` alone is a vacuous assertion. Every test here asserts the recorded (url, auth) call list — a broken harness (unpatched db seam, capital-L Location key, assert inside the fake) shows up as a wrong hop count instead of a green pass. TDD evidence: against the un-widened constant 6 of the 23 fail, including the AC #5 headline test_redirect_to_cdn_returns_bytes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(whatsapp): CDN-shaped spoof vectors + two-tier allowlist pins (#1932) Extends TestTwilioMediaUrlAllowlist in place. The six #463 spoof tests keep byte-identical bodies and node-ids — the only removed line in this file is the class docstring, which claimed "only *.twilio.com hosts allowed" and is now false. Adds: mms./media.twiliocdn.com accepted, bare apex, case-insensitivity; and the CDN-shaped spoofs — eviltwiliocdn.com, twiliocdn.com.evil.com, non-https, userinfo @, trailing-dot FQDN, punycode lookalike, scheme-relative. Pins D2 (S3 refused, path-style rationale in the docstring) and D3 (the source predicate excludes the CDN, and inherits every spoof guard). Pins both named constants, incl. source ⊆ allowed. Also pins the parse gate at the narrow tier: a twiliocdn MediaUrl0 off the wire yields msg.files == []. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(whatsapp): document the real inbound-media redirect chain (#1932) - architecture.md: the catalog entry claimed a single `*.twilio.com` gate; name both tiers in one clause. - feature-flows/whatsapp-integration.md: rewrite "SSRF defense on media downloads" — the actual two-hop chain, the two-tier allowlist table, the bounded follow budget, the no-env-override decision and why, the S3 exclusion and why, and the transport size cap. Status line notes #1932. - requirements/public-access.md: ":338" asserted "*.twilio.com only, no redirects to other hosts" — after this change BOTH clauses are false, and it is a live security claim, so it is corrected rather than left standing. ":309" goes stale the same way. - user-docs/integrations/whatsapp-integration.md: the mitigation the no-env-override decision actually leans on. Prerequisites gains the opt-in Twilio Console setting inbound media requires; Troubleshooting gains "Inbound images arrive as — download failed" (with the exact log line and what each host means) and distinguishes it from the separate "unsupported format" policy rejection. docs/security-reports/ is untouched — historical point-in-time report. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(feature-flows): index the #1932 inbound-media fix /sync-feature-flows: the only changed code file is src/backend/adapters/whatsapp_adapter.py, which maps to the existing whatsapp-integration.md flow (already rewritten in the previous commit). No new flow doc — this is a bug fix inside an existing adapter, not a new feature. Adds the Recent Updates row, matching the fix(...) rows that already point at existing flow docs (#1445, #1444, #903). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(whatsapp): drift-proof the HMAC citation, cover every media log line (#1932) /review follow-ups on the #1932 SSRF fix. No behaviour change. 1. The two-tier constant comment cited `transports/twilio_webhook.py:123-128` and `:140-142` for the HMAC gate its security reasoning leans on. Both were accurate at 8e92452, but that file is live and the citation is exactly the kind a future reader must actually follow to re-verify the argument. Anchored on symbols instead (`TwilioWebhookTransport.handle_webhook`, `RequestValidator(...).validate(...)`, `raw_event = dict(params)`), which survives line drift. 2. The user-doc troubleshooting entry enumerated three of the media log lines an operator can hit and missed two — including the generic handler, which is now the LEAST self-explanatory path: narrowing it to `type(e).__name__` (so a signed CDN URL embedded in an httpx message can never reach the log) collapses DNS, TLS and connection-refused into a bare `ConnectError`. Replaced the prose with a table covering all five, and said plainly why the type name is all there is, so nobody reads it as a bug. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(whatsapp): make both SOURCE-tier media rejections loud (#1932 review) #1932's root cause was never the allowlist — it was that a fail-closed gate logged a 100% inbound-media outage at WARNING, where it sat unnoticed for three months. The fix raised only the redirect gate to ERROR; both SOURCE-tier gates (webhook `MediaUrl{N}` parse, credentialed hop 1) stayed at WARNING. Before the two-tier split these shared one constant and could not diverge. The split makes SOURCE strictly *stricter* than the redirect tier, so it is now the asymmetry most likely to break on the next vendor change — the exact #1932 signature — and it was the half still guarded at the quieter level. Both are now ERROR, pinned by `TestSourceTierRejectionsAreVisible` (mutation- proven: reverting either level REDs the pair on `{30} != {40}`), since the level is a one-word revert with a three-month observed detection latency. Also corrects two stale doc claims flagged in review. `feature-flows/ whatsapp-integration.md` and `requirements/public-access.md` both described inbound media as "images/audio/PDFs", which `upload_service.UNSUPPORTED_MIMES` contradicts — it rejects `application/pdf`, `audio/`, `video/` and archives for every channel, so only images (plus text/CSV/JSON) reach a workspace. A third claim was wrong in the same place and is corrected here: WhatsApp has no voice-transcription route at all. `message_router._maybe_transcribe_voice` returns early for `channel != "telegram"` and calls `telegram_media .process_voice` — it never touches `adapter.download_file`, so a WhatsApp voice note is fetched and then rejected as "unsupported format", full stop. User doc: media auth has been mandatory on accounts created since 2023-07-31 (per Twilio's changelog), so the S3 residual reaches only pre-July-2023 grandfathered accounts — most operators need no action. Refs #1932 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Eugene Vyborov <eugene@beingluminous.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
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.
Summary
message_router.py— the ChannelAdapter abstraction from SLACK-002 was already general enough.uvicorn --proxy-headers --forwarded-allow-ips='*'so Twilio signature verification works behind Cloudflare Tunnel + nginx.What's new
Backend
adapters/whatsapp_adapter.py— WhatsApp/Twilio adapter; SSRF-gated media downloads (*.twilio.comallowlist,follow_redirects=False); 1600-char splitting; phone masking in logs.adapters/transports/twilio_webhook.py— HMAC-SHA1 verification viatwilio.request_validator.RequestValidator(handles Twilio's empty-param inclusion gotcha); MessageSid dedup ring (2048 cap); URL reconstruction honoringX-Forwarded-Proto.routers/whatsapp.py— GET/PUT/DELETE/test endpoints (allOwnedAgentByName) + public webhook receiver (HMAC-gated); returns empty TwiML; asynchronous processing viaasyncio.create_task.db/whatsapp_channels.py— AuthToken encrypted via existingCredentialEncryptionService(AES-256-GCM);verified_email/verified_atcolumns shipped up-front so Phase 2 (feat: Unified channel access control — verified-email identity, per-agent allow-list, access requests (web / Telegram / Slack) #311) is additive application-only code.db/migrations.py—_migrate_whatsapp_bindings(idempotent,CREATE TABLE IF NOT EXISTS).main.py— lifespan integration (start/stop transport, webhook-URL backfill on startup).routers/settings.py— piggybacks on the existingpublic_chat_urlsave hook to refresh WhatsApp webhook URLs for UI display.docker/backend/Dockerfile—twilio==9.10.5added;uvicornnow runs with--proxy-headers --forwarded-allow-ips='*'.Frontend
components/WhatsAppChannelPanel.vue— Sharing-tab panel (connect form, sandbox indicator, copy-webhook-URL button, disconnect/verify actions, Cloudflare ingress warning).components/SharingPanel.vue— mounts the new panel between Telegram and Public Links.Docs
docs/memory/feature-flows/whatsapp-integration.md— new feature-flow doc with full inbound/outbound pipeline, Twilio setup guide (sandbox + production), schema, security notes.docs/requirements/PUBLIC_EXTERNAL_ACCESS_SETUP.md— adds/api/whatsapp/webhook/*to the Cloudflare Tunnel ingress allowlist table.docs/memory/architecture.md— new adapter/transport/DB entries; new tables in schema section; router list updated.docs/memory/requirements.md— new WHATSAPP-001 entry (15.1f) with full phase breakdown.docs/memory/feature-flows.md— index entry.Deployment prerequisite
Cloudflare Tunnel ingress rules must include
/api/whatsapp/webhook/*→http://backend:8000. This is a manual dashboard step per instance, documented inPUBLIC_EXTERNAL_ACCESS_SETUP.md. Without this, Twilio webhooks return 404 at the Cloudflare edge. The UI surfaces a yellow banner reminding the admin.Explicitly out of scope
#311unified access control/loginflow (schema is already compatible)Test plan
pytest tests/test_whatsapp_adapter.py -v): HMAC valid/invalid/tampered/empty-param; SSRF allowlist (6 spoof vectors:eviltwilio.com,api.twilio.com.evil.com,http://scheme, garbage); dedup ring + eviction; parse_message edge cases (text-only, media, non-Twilio media rejection, media-only placeholder); message splitting worst-case; URL reconstruction withX-Forwarded-Proto; webhook transport accept/reject/dedup./reviewpre-landing pass: 0 critical, 1 fix applied during review (GETendpoint tightened toOwnedAgentByName), 4 informational findings documented./cso --diffsecurity audit: 0 critical/high findings. Validated constant-time HMAC compare in library source; verified AuthToken non-disclosure via grep; verified SSRF strictness via tests. Full report atdocs/security-reports/cso-2026-04-22-whatsapp.md.Key design decisions
twilio==9.10.5forRequestValidatoronly — chosen over inline HMAC-SHA1 because Twilio includes empty-value params in the base string (Python'sparse_qsdrops them silently, causing cryptic signature failures). Outbound send stays inhttpxmatching Telegram precedent.--proxy-headersadded — prerequisite for signature verification under nginx; also repairs a latent issue for Slack webhook mode.verified_email/verified_at— Phase 2 (feat: Unified channel access control — verified-email identity, per-agent allow-list, access requests (web / Telegram / Slack) #311) becomes application-only, no schema churn.whatsapp:+14155238886sender; avoids an error-prone UI checkbox.Closes #299
🤖 Generated with Claude Code