Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## Unreleased

In flight toward `vercel/chat@4.30.0` (landing incrementally; `UPSTREAM_PARITY`
stays `4.29.0` until the wave completes).

### New adapter: Twilio (SMS / MMS / Voice)

- **`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.

#### Python-specific (divergence from upstream)

- **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`.

## 0.4.29 (2026-06-12)

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`.
Expand Down
2 changes: 1 addition & 1 deletion docs/UPSTREAM_SYNC.md
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,7 @@ stay explicit instead of being rediscovered in code review.
| `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. |
| 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. |
| `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. |
| `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}`. |
| `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`. |
| `_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. |
| 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. |
| 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. |
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ teams = ["aiohttp>=3.9"]
telegram = ["aiohttp>=3.9"]
whatsapp = ["aiohttp>=3.9"]
messenger = ["aiohttp>=3.9"]
twilio = ["aiohttp>=3.9"]
google-chat = ["aiohttp>=3.9", "pyjwt[crypto]>=2.8", "google-auth>=2.0"]
linear = ["aiohttp>=3.9"]
all = [
Expand Down
145 changes: 145 additions & 0 deletions src/chat_sdk/adapters/twilio/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""Twilio adapter for chat-sdk.

Python port of upstream ``packages/adapter-twilio``. Supports SMS and MMS
bots over Twilio Messaging webhooks (X-Twilio-Signature verification) and
the Messages REST API, plus low-level ``api`` / ``webhook`` / ``voice``
helpers for apps that only need Twilio primitives — like upstream, none
of it depends on the official ``twilio`` SDK.
"""

from chat_sdk.adapters.twilio.adapter import TwilioAdapter, create_twilio_adapter
from chat_sdk.adapters.twilio.api import (
DEFAULT_API_URL,
TwilioApiError,
TwilioApiResponse,
call_twilio_api,
delete_twilio_message,
encode_twilio_form,
fetch_twilio_media,
fetch_twilio_message,
list_twilio_messages,
resolve_twilio_credential,
send_twilio_message,
update_twilio_call,
)
from chat_sdk.adapters.twilio.cards import card_to_twilio_text
from chat_sdk.adapters.twilio.format_converter import (
TWILIO_MESSAGE_LIMIT,
TwilioFormatConverter,
TwilioTextResult,
truncate_twilio_text,
twilio_text_or_placeholder,
)
from chat_sdk.adapters.twilio.thread import (
decode_twilio_thread_id,
encode_twilio_thread_id,
twilio_channel_id,
)
from chat_sdk.adapters.twilio.types import (
TwilioAdapterConfig,
TwilioCallResource,
TwilioCredential,
TwilioCredentials,
TwilioFormFields,
TwilioFormParams,
TwilioHttpRequest,
TwilioHttpResponse,
TwilioMediaPayload,
TwilioMessageResource,
TwilioRawMessage,
TwilioStatusPayload,
TwilioTextPayload,
TwilioThreadId,
TwilioUnsupportedPayload,
TwilioVerifiedRequest,
TwilioWebhookError,
TwilioWebhookParseError,
TwilioWebhookPayload,
TwilioWebhookUrl,
TwilioWebhookVerificationError,
TwilioWebhookVerifier,
)
from chat_sdk.adapters.twilio.voice import (
TwilioGatherSpeechResponseOptions,
TwilioVoiceCallPayload,
TwilioVoiceTranscriptionPayload,
empty_twilio_response,
escape_xml,
gather_speech_twilio_response,
parse_twilio_voice_call,
parse_twilio_voice_transcription,
say_twilio_response,
twilio_response,
)
from chat_sdk.adapters.twilio.webhook import (
parse_twilio_webhook_body,
read_twilio_webhook,
resolve_twilio_webhook_url,
sign_twilio_request,
twilio_signature_base,
verify_twilio_request,
)

__all__ = [
"DEFAULT_API_URL",
"TWILIO_MESSAGE_LIMIT",
"TwilioAdapter",
"TwilioAdapterConfig",
"TwilioApiError",
"TwilioApiResponse",
"TwilioCallResource",
"TwilioCredential",
"TwilioCredentials",
"TwilioFormFields",
"TwilioFormParams",
"TwilioFormatConverter",
"TwilioGatherSpeechResponseOptions",
"TwilioHttpRequest",
"TwilioHttpResponse",
"TwilioMediaPayload",
"TwilioMessageResource",
"TwilioRawMessage",
"TwilioStatusPayload",
"TwilioTextPayload",
"TwilioTextResult",
"TwilioThreadId",
"TwilioUnsupportedPayload",
"TwilioVerifiedRequest",
"TwilioVoiceCallPayload",
"TwilioVoiceTranscriptionPayload",
"TwilioWebhookError",
"TwilioWebhookParseError",
"TwilioWebhookPayload",
"TwilioWebhookUrl",
"TwilioWebhookVerificationError",
"TwilioWebhookVerifier",
"call_twilio_api",
"card_to_twilio_text",
"create_twilio_adapter",
"decode_twilio_thread_id",
"delete_twilio_message",
"empty_twilio_response",
"encode_twilio_form",
"encode_twilio_thread_id",
"escape_xml",
"fetch_twilio_media",
"fetch_twilio_message",
"gather_speech_twilio_response",
"list_twilio_messages",
"parse_twilio_voice_call",
"parse_twilio_voice_transcription",
"parse_twilio_webhook_body",
"read_twilio_webhook",
"resolve_twilio_credential",
"resolve_twilio_webhook_url",
"say_twilio_response",
"send_twilio_message",
"sign_twilio_request",
"truncate_twilio_text",
"twilio_channel_id",
"twilio_response",
"twilio_signature_base",
"twilio_text_or_placeholder",
"update_twilio_call",
"verify_twilio_request",
]
Loading
Loading