Skip to content

Commit 341c790

Browse files
claudepatrick-chinchill
authored andcommitted
feat(twilio): adapter, api, voice, webhook (vercel/chat#558, PR 2 of 2)
Completes the Twilio Programmable Messaging adapter (10th platform), porting packages/adapter-twilio from upstream chat@4.30.0. Builds on the PR-1 scaffolding (types, format converter, cards). Source: - adapter.py -- TwilioAdapter + create_twilio_adapter: webhook routing, SMS/MMS send, REST/webhook message parsing, thread-ID encode/decode (twilio:{sender}:{recipient}), attachment rehydration, not-implemented surfaces (edit/reactions), accumulate-and-post streaming. - api.py -- hand-rolled Messages/Calls/Media REST helpers over an injectable transport (no official twilio SDK, mirroring upstream); non-2xx responses mapped to the typed shared.errors hierarchy. - webhook.py -- X-Twilio-Signature HMAC-SHA1 verification (sorted/ deduplicated base string, GET vs POST signing) compared with hmac.compare_digest; custom webhook_verifier precedence; body parsing (text / status / unsupported). - voice.py -- standalone call + transcription webhook parsing and TwiML response builders (Say / Gather speech), XML-escaped. - utils.py / thread.py -- request/param plumbing, encodeURIComponent parity, sender-field split, thread-ID helpers. - __init__.py / pyproject.toml -- public exports + the [twilio] extra (aiohttp); imports stay lazy so the package loads without aiohttp. Tests (122 total across the package): test_twilio_api.py (28), test_twilio_webhook.py (23), test_twilio_voice.py (14), and a new test_twilio_adapter.py (33) porting upstream src/index.test.ts (thread IDs, webhook routing, media rehydration, SMS/MMS/media-only/messaging- service sends, REST parsing, fetch_messages merge/sort/limit, streaming). Real assertions, AsyncMock for async transports, env isolation. Python-specific divergence (documented in docs/UPSTREAM_SYNC.md and CHANGELOG.md): the media-download path validates the rehydrated URL (https + Twilio-owned host) before forwarding HTTP Basic credentials, where upstream fetchTwilioMedia GETs blindly -- folded into the existing rehydrate_attachment URL allowlist non-parity row, with a regression test. Validation: ruff check + format clean; pyrefly 0 errors; audit_test_quality 0 hard failures; full suite 4572 passed / 6 skipped. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64
1 parent 967928d commit 341c790

14 files changed

Lines changed: 3581 additions & 3 deletions

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
In flight toward `vercel/chat@4.30.0` (landing incrementally; `UPSTREAM_PARITY`
6+
stays `4.29.0` until the wave completes).
7+
8+
### New adapter: Twilio (SMS / MMS / Voice)
9+
10+
- **`chat_sdk.adapters.twilio`** (vercel/chat#558). Twilio Programmable Messaging adapter (10th platform): inbound message webhooks with `X-Twilio-Signature` HMAC-SHA1 verification (`hmac.compare_digest`), outbound SMS/MMS through the Messages REST API (hand-rolled over an injectable transport — no official `twilio` SDK, mirroring upstream), 1:1 DM threads keyed `twilio:{sender}:{recipient}`, plus standalone `api` / `webhook` / `voice` helpers (TwiML builders, call + transcription parsing). New extra: `chat-sdk-python[twilio]`. Imports stay lazy so the package loads without `aiohttp` installed.
11+
12+
#### Python-specific (divergence from upstream)
13+
14+
- **Twilio media-download SSRF guard.** `rehydrate_attachment` validates the rehydrated media URL (https + Twilio-owned host) inside the download closure before forwarding the account SID / auth token as HTTP Basic, where upstream `fetchTwilioMedia` GETs the URL blindly. Folded into the existing `rehydrate_attachment` URL allowlist non-parity row (Slack / Teams / Google Chat / **Twilio**); enforces `CLAUDE.md`'s SSRF rule. Regression: `tests/test_twilio_adapter.py::TestRehydrateAttachment::test_media_downloader_refuses_untrusted_hosts`.
15+
316
## 0.4.29 (2026-06-12)
417

518
Synced to upstream `vercel/chat@4.29.0` (release commit `6581d31`, May 18 2026; upstream never tagged `chat@4.27.0`/`chat@4.28.0`). Headlines: **Meta Messenger adapter** (9th platform), **`chat/ai` tool factories** (`create_chat_tools`), **`callback_url` on buttons and modals**, **Transcripts API + `thread_history` rename**, **`burst` concurrency strategy**, a Slack feature wave (verifier precedence flip, external installation providers, native `markdown_text`, `web_client`), the upstream adapter-hardening security pass, and a Python floor bump to 3.12. Sets `UPSTREAM_PARITY = "4.29.0"`; CI fidelity re-pinned to `chat@4.29.0`.

docs/UPSTREAM_SYNC.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -633,7 +633,7 @@ stay explicit instead of being rediscovered in code review.
633633
| `ConcurrencyConfig.max_concurrent` slot scope | **Single global `asyncio.Semaphore`** — caps total in-flight handlers across all threads to `max_concurrent` | **Per-thread slot map** — `acquireConcurrentSlot(threadId, maxConcurrent)` keys the in-flight counter by `threadId`, so each thread has its own N-way bound | When upstream caught up (vercel/chat#419) it implemented per-thread slots; the Python port shipped earlier with a global semaphore and the slot-scope distinction wasn't visible in the original divergence row. Result: a deployment with `max_concurrent=2` and 100 active threads serializes everything globally on Python (peak in-flight = 2 across all threads) but allows 200 concurrent handlers on TS (2 per thread × 100). The `chat.test.ts > should track slots per thread independently` fidelity entry is `pytest.mark.skip`-ped in `tests/test_chat_faithful.py` until the implementation is restructured to a `dict[thread_id, asyncio.Semaphore]` (with cleanup-on-empty to avoid unbounded growth). Tracked as a follow-up. |
634634
| Redis lock token format | `{token_prefix}_{ms}_{secrets.token_hex(16)}` — always 32 hex chars, CSPRNG-sourced | `ioredis_${Date.now()}_${Math.random().toString(36).substring(2, 15)}` — base36, ≤13 chars, **not** CSPRNG | Interop via `IoRedisStateAdapter(token_prefix="ioredis")` still works for lock-release (release/extend compare by full-string equality, and each runtime only releases what it issued), but the token byte-shape diverges. Intentional — CSPRNG should not be regressed to `Math.random()` for cosmetic byte-for-byte compatibility. |
635635
| `StreamingPlan.is_supported()` / `get_fallback_text()` | Raise `RuntimeError` to fail loudly if a generic posting path (e.g. `ChannelImpl.post`, `post_postable_object`) tries to consume a `StreamingPlan` as a normal `PostableObject` | Silently return `True` / `""``ChannelImpl.post` would route through `postPostableObject` and post an empty-string fallback | Prevents `StreamingPlan` being silently routed through non-stream-aware posting paths where upstream would post a blank message or attempt a wrong-shape `adapter.post_object("stream", ...)` call. Internal dispatch is guarded by the `kind == "stream"` short-circuit in `post_postable_object` / `Thread.post`; this also protects third-party code that duck-types PostableObjects. |
636-
| `rehydrate_attachment` URL allowlist (Slack / Teams / Google Chat) | Validates the downloaded URL's scheme + host against a per-adapter allowlist inside the fetch closure; raises `ValidationError` on untrusted hosts before forwarding bearer tokens | No validation — `fetchData` blindly GETs `fetchMetadata.url` and forwards the workspace/bot token | SSRF + token-exfil risk upstream: after the 4.26 `rehydrateAttachment` hook lands, a crafted `fetchMetadata` in persisted state can redirect auth'd downloads to an arbitrary host. Python port enforces `CLAUDE.md`'s "Validate external URLs before requests (SSRF)" rule. Allowlist: Slack = `{files.slack.com, slack.com, *.slack.com, *.slack-edge.com}`; Teams = `{smba.trafficmanager.net, graph.microsoft.com, attachments.office.net, *.botframework.com, *.graph.microsoft.com, *.sharepoint.com, *.officeapps.live.com, *.office.com, *.office365.com, *.onedrive.com, *.microsoft.com}`; Google Chat = `{chat.googleapis.com, googleapis.com, *.googleapis.com, *.googleusercontent.com, *.google.com}`. |
636+
| `rehydrate_attachment` URL allowlist (Slack / Teams / Google Chat / Twilio) | Validates the downloaded URL's scheme (https) + host against a per-adapter allowlist inside the fetch closure; raises `ValidationError` on untrusted hosts before forwarding bearer/Basic credentials | No validation — `fetchData` blindly GETs `fetchMetadata.url` (Twilio: `fetchMetadata.twilioMediaUrl`) and forwards the workspace/bot token (Twilio: the account SID + auth token as HTTP Basic) | SSRF + token-exfil risk upstream: after the 4.26 `rehydrateAttachment` hook lands, a crafted `fetchMetadata` in persisted state can redirect auth'd downloads to an arbitrary host. The Twilio adapter (vercel/chat#558) shares the exact pattern — `fetchTwilioMedia` GETs the rehydrated URL with the adapter's Basic auth and no host check. Python port enforces `CLAUDE.md`'s "Validate external URLs before requests (SSRF)" rule. The check runs inside the download closure (not at build time) so an attachment trusted at parse time still fails closed if the allowlist tightens later. Allowlist: Slack = `{files.slack.com, slack.com, *.slack.com, *.slack-edge.com}`; Teams = `{smba.trafficmanager.net, graph.microsoft.com, attachments.office.net, *.botframework.com, *.graph.microsoft.com, *.sharepoint.com, *.officeapps.live.com, *.office.com, *.office365.com, *.onedrive.com, *.microsoft.com}`; Google Chat = `{chat.googleapis.com, googleapis.com, *.googleapis.com, *.googleusercontent.com, *.google.com}`; Twilio = `{twilio.com, api.twilio.com, *.twilio.com, *.twiliocdn.com}`. Regression coverage: `tests/test_twilio_adapter.py::TestRehydrateAttachment::test_media_downloader_refuses_untrusted_hosts`. |
637637
| `_rehydrate_message` with `Message` input | Falls through to the `rehydrate_attachment` pass even when the dequeued entry is already a `Message` instance | Early-returns on `raw instanceof Message` before rehydration | The Python port's Redis + Postgres `dequeue()` upgrade raw JSON to `Message.from_json(...)` before returning (upstream's dequeue returns the raw JSON.parse'd dict). Upstream's `instanceof Message` shortcut therefore only fires for in-memory state, but ours would fire for persistent backends too, leaving `fetch_data` stripped forever. The rehydrate pass still skips any attachment that already has `fetch_data`, so in-memory callers pay no cost. |
638638
| Slack Socket Mode reconnect loop | Outer reconnect loop on top of `slack_sdk.socket_mode.aiohttp.SocketModeClient` (which itself has `auto_reconnect_enabled=True`). Exponential backoff (1s → 30s) with explicit shutdown signaling and a tracked `asyncio.Task` so `disconnect()` can cancel cleanly | Single `SocketModeClient` instance from `@slack/socket-mode`; relies entirely on the package's internal reconnect | Hazard #5 (async task lifecycle): a long-lived WebSocket needs an explicit shutdown path so `disconnect()` doesn't leak the loop, and a guarded outer reconnect path so the adapter survives `connect()` itself raising (which the inner client doesn't retry). Inner auto-reconnect still runs; the outer loop is belt-and-suspenders, not a divergence in observable behavior. |
639639
| Slack Socket Mode listener serverless variant | Not ported | `startSocketModeListener()` / `runSocketModeListener()` open a transient socket for `durationMs` and forward events via HTTP POST | Vercel-specific pattern (cron-triggered ephemeral listener with `waitUntil`). The forwarded-event receiver (`x-slack-socket-token` handling in `handle_webhook`) is ported so a separate Python process can run the long-lived listener; the deployment glue itself isn't part of the SDK. |

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ teams = ["aiohttp>=3.9"]
5656
telegram = ["aiohttp>=3.9"]
5757
whatsapp = ["aiohttp>=3.9"]
5858
messenger = ["aiohttp>=3.9"]
59+
twilio = ["aiohttp>=3.9"]
5960
google-chat = ["aiohttp>=3.9", "pyjwt[crypto]>=2.8", "google-auth>=2.0"]
6061
linear = ["aiohttp>=3.9"]
6162
all = [

src/chat_sdk/adapters/twilio/__init__.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,27 @@
11
"""Twilio adapter for chat-sdk.
22
33
Python port of upstream ``packages/adapter-twilio``. Supports SMS and MMS
4-
bots over Twilio Messaging webhooks and the Messages REST API, plus
5-
low-level voice helpers for custom Twilio voice routes.
4+
bots over Twilio Messaging webhooks (X-Twilio-Signature verification) and
5+
the Messages REST API, plus low-level ``api`` / ``webhook`` / ``voice``
6+
helpers for apps that only need Twilio primitives — like upstream, none
7+
of it depends on the official ``twilio`` SDK.
68
"""
79

10+
from chat_sdk.adapters.twilio.adapter import TwilioAdapter, create_twilio_adapter
11+
from chat_sdk.adapters.twilio.api import (
12+
DEFAULT_API_URL,
13+
TwilioApiError,
14+
TwilioApiResponse,
15+
call_twilio_api,
16+
delete_twilio_message,
17+
encode_twilio_form,
18+
fetch_twilio_media,
19+
fetch_twilio_message,
20+
list_twilio_messages,
21+
resolve_twilio_credential,
22+
send_twilio_message,
23+
update_twilio_call,
24+
)
825
from chat_sdk.adapters.twilio.cards import card_to_twilio_text
926
from chat_sdk.adapters.twilio.format_converter import (
1027
TWILIO_MESSAGE_LIMIT,
@@ -13,6 +30,11 @@
1330
truncate_twilio_text,
1431
twilio_text_or_placeholder,
1532
)
33+
from chat_sdk.adapters.twilio.thread import (
34+
decode_twilio_thread_id,
35+
encode_twilio_thread_id,
36+
twilio_channel_id,
37+
)
1638
from chat_sdk.adapters.twilio.types import (
1739
TwilioAdapterConfig,
1840
TwilioCallResource,
@@ -37,16 +59,41 @@
3759
TwilioWebhookVerificationError,
3860
TwilioWebhookVerifier,
3961
)
62+
from chat_sdk.adapters.twilio.voice import (
63+
TwilioGatherSpeechResponseOptions,
64+
TwilioVoiceCallPayload,
65+
TwilioVoiceTranscriptionPayload,
66+
empty_twilio_response,
67+
escape_xml,
68+
gather_speech_twilio_response,
69+
parse_twilio_voice_call,
70+
parse_twilio_voice_transcription,
71+
say_twilio_response,
72+
twilio_response,
73+
)
74+
from chat_sdk.adapters.twilio.webhook import (
75+
parse_twilio_webhook_body,
76+
read_twilio_webhook,
77+
resolve_twilio_webhook_url,
78+
sign_twilio_request,
79+
twilio_signature_base,
80+
verify_twilio_request,
81+
)
4082

4183
__all__ = [
84+
"DEFAULT_API_URL",
4285
"TWILIO_MESSAGE_LIMIT",
86+
"TwilioAdapter",
4387
"TwilioAdapterConfig",
88+
"TwilioApiError",
89+
"TwilioApiResponse",
4490
"TwilioCallResource",
4591
"TwilioCredential",
4692
"TwilioCredentials",
4793
"TwilioFormFields",
4894
"TwilioFormParams",
4995
"TwilioFormatConverter",
96+
"TwilioGatherSpeechResponseOptions",
5097
"TwilioHttpRequest",
5198
"TwilioHttpResponse",
5299
"TwilioMediaPayload",
@@ -58,13 +105,41 @@
58105
"TwilioThreadId",
59106
"TwilioUnsupportedPayload",
60107
"TwilioVerifiedRequest",
108+
"TwilioVoiceCallPayload",
109+
"TwilioVoiceTranscriptionPayload",
61110
"TwilioWebhookError",
62111
"TwilioWebhookParseError",
63112
"TwilioWebhookPayload",
64113
"TwilioWebhookUrl",
65114
"TwilioWebhookVerificationError",
66115
"TwilioWebhookVerifier",
116+
"call_twilio_api",
67117
"card_to_twilio_text",
118+
"create_twilio_adapter",
119+
"decode_twilio_thread_id",
120+
"delete_twilio_message",
121+
"empty_twilio_response",
122+
"encode_twilio_form",
123+
"encode_twilio_thread_id",
124+
"escape_xml",
125+
"fetch_twilio_media",
126+
"fetch_twilio_message",
127+
"gather_speech_twilio_response",
128+
"list_twilio_messages",
129+
"parse_twilio_voice_call",
130+
"parse_twilio_voice_transcription",
131+
"parse_twilio_webhook_body",
132+
"read_twilio_webhook",
133+
"resolve_twilio_credential",
134+
"resolve_twilio_webhook_url",
135+
"say_twilio_response",
136+
"send_twilio_message",
137+
"sign_twilio_request",
68138
"truncate_twilio_text",
139+
"twilio_channel_id",
140+
"twilio_response",
141+
"twilio_signature_base",
69142
"twilio_text_or_placeholder",
143+
"update_twilio_call",
144+
"verify_twilio_request",
70145
]

0 commit comments

Comments
 (0)