Skip to content

feat(channels): WhatsApp via Twilio — per-agent binding (#299) - #463

Merged
vybe merged 1 commit into
mainfrom
feature/299-whatsapp-twilio
Apr 23, 2026
Merged

vybe merged 1 commit into
mainfrom
feature/299-whatsapp-twilio

Conversation

@vybe

@vybe vybe commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Summary

  • WhatsApp as a channel adapter via Twilio — per-agent integration (each owner brings their own AccountSid + AuthToken + WhatsApp from-number). Phase 1 MVP: direct messages only.
  • Follows the Telegram adapter pattern exactly (adapter + transport + db module + router + Vue panel). Zero changes to message_router.py — the ChannelAdapter abstraction from SLACK-002 was already general enough.
  • Ships prerequisite: 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.com allowlist, follow_redirects=False); 1600-char splitting; phone masking in logs.
  • adapters/transports/twilio_webhook.py — HMAC-SHA1 verification via twilio.request_validator.RequestValidator (handles Twilio's empty-param inclusion gotcha); MessageSid dedup ring (2048 cap); URL reconstruction honoring X-Forwarded-Proto.
  • routers/whatsapp.py — GET/PUT/DELETE/test endpoints (all OwnedAgentByName) + public webhook receiver (HMAC-gated); returns empty TwiML; asynchronous processing via asyncio.create_task.
  • db/whatsapp_channels.py — AuthToken encrypted via existing CredentialEncryptionService (AES-256-GCM); verified_email/verified_at columns 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 existing public_chat_url save hook to refresh WhatsApp webhook URLs for UI display.
  • docker/backend/Dockerfiletwilio==9.10.5 added; uvicorn now 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 in PUBLIC_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

  • Phase 2 — #311 unified access control /login flow (schema is already compatible)
  • Phase 3 — SMS on same binding, message templates for the 24h customer-service window, interactive buttons, voice-note transcription
  • Group chats — Twilio's WhatsApp API does not support them

Test plan

  • 38 unit tests pass (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 with X-Forwarded-Proto; webhook transport accept/reject/dedup.
  • /review pre-landing pass: 0 critical, 1 fix applied during review (GET endpoint tightened to OwnedAgentByName), 4 informational findings documented.
  • /cso --diff security 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 at docs/security-reports/cso-2026-04-22-whatsapp.md.
  • Manual: connect a Twilio Sandbox sender, paste webhook URL into Twilio Console, send test message from a phone joined to the sandbox.
  • Manual: verify Cloudflare Tunnel ingress allowlist updated before first production bind.

Key design decisions

  1. Dependency on twilio==9.10.5 for RequestValidator only — chosen over inline HMAC-SHA1 because Twilio includes empty-value params in the base string (Python's parse_qs drops them silently, causing cryptic signature failures). Outbound send stays in httpx matching Telegram precedent.
  2. AuthToken encrypted; AccountSid plaintext — AccountSid is a public identifier (in URLs by design); AuthToken is the secret.
  3. --proxy-headers added — prerequisite for signature verification under nginx; also repairs a latent issue for Slack webhook mode.
  4. Schema includes 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.
  5. Sandbox auto-detected — from well-known whatsapp:+14155238886 sender; avoids an error-prone UI checkbox.

Closes #299

🤖 Generated with Claude Code

…#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>
@vybe
vybe merged commit 671daac into main Apr 23, 2026
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: WhatsApp channel adapter via Twilio — per-agent integration (WHATSAPP-001)

1 participant