From 967928d3e1d5446779ce0d464d8d5af467d1690f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 22:53:11 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(twilio):=20scaffolding=20=E2=80=94=20t?= =?UTF-8?q?ypes,=20format=20converter=20(vercel/chat#558,=20PR=201=20of=20?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 1 of 2 for the Twilio adapter port (upstream 25ebc3b, chat@4.30.0). Non-adapter scaffolding only; the adapter (webhook verification, Messages API, voice helpers, send/stream) lands in PR 2. Mirrors how the Messenger port was split (#118 / #124). Adds under src/chat_sdk/adapters/twilio/: - types.py: TwilioAdapterConfig (all-optional, mirroring upstream; TWILIO_MESSAGING_SERVICE_SID / TWILIO_PHONE_NUMBER env fallbacks with `??` semantics at construction time, TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN resolved lazily at API-call time), TwilioThreadId, TwilioCredential/TwilioCredentials, TwilioHttpRequest/TwilioHttpResponse (the Python analog of upstream's injectable `fetch`), form-param aliases, TwilioWebhookUrl + TwilioWebhookVerifier (SECURITY surface, Slack-verifier-style contract), webhook payload dataclasses (text/status/unsupported + media), webhook error classes, and the TwilioMessageResource / TwilioCallResource wire TypedDicts (functional syntax — `from` is a Python keyword; Twilio's wire keys are already snake_case). - format_converter.py: TWILIO_MESSAGE_LIMIT (1600), truncate_twilio_text (TypeError on non-positive/non-int limits incl. bools), twilio_text_or_placeholder, and TwilioFormatConverter (markdown passes through as literal text; tables rewritten to ASCII code blocks). Mirrors upstream markdown.ts + format/index.ts. - cards.py: card_to_twilio_text — shared fallback text with `*` markers stripped. Mirrors upstream cards.ts. - __init__.py: exports types/format/cards only (PR 2 adds the adapter). Tests (28): tests/test_twilio_format.py (format/index.test.ts + markdown.test.ts ports + config env-fallback `??` regressions), tests/test_twilio_cards.py (cards.test.ts port), and tests/test_twilio_boundaries.py (Python analog of upstream's four boundary.test.ts files: no top-level aiohttp, no official `twilio` SDK, helper modules never import the adapter — auto-extends to the PR 2 modules). Validation: ruff check + format, audit_test_quality, pyrefly (0 errors), fidelity --strict vs chat@4.29.0, full suite 4474 passed / 6 skipped. Refs vercel/chat#558. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 --- src/chat_sdk/adapters/twilio/__init__.py | 70 ++++ src/chat_sdk/adapters/twilio/cards.py | 16 + .../adapters/twilio/format_converter.py | 99 ++++++ src/chat_sdk/adapters/twilio/types.py | 323 ++++++++++++++++++ tests/test_twilio_boundaries.py | 69 ++++ tests/test_twilio_cards.py | 51 +++ tests/test_twilio_format.py | 145 ++++++++ 7 files changed, 773 insertions(+) create mode 100644 src/chat_sdk/adapters/twilio/__init__.py create mode 100644 src/chat_sdk/adapters/twilio/cards.py create mode 100644 src/chat_sdk/adapters/twilio/format_converter.py create mode 100644 src/chat_sdk/adapters/twilio/types.py create mode 100644 tests/test_twilio_boundaries.py create mode 100644 tests/test_twilio_cards.py create mode 100644 tests/test_twilio_format.py diff --git a/src/chat_sdk/adapters/twilio/__init__.py b/src/chat_sdk/adapters/twilio/__init__.py new file mode 100644 index 0000000..833a281 --- /dev/null +++ b/src/chat_sdk/adapters/twilio/__init__.py @@ -0,0 +1,70 @@ +"""Twilio adapter for chat-sdk. + +Python port of upstream ``packages/adapter-twilio``. Supports SMS and MMS +bots over Twilio Messaging webhooks and the Messages REST API, plus +low-level voice helpers for custom Twilio voice routes. +""" + +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.types import ( + TwilioAdapterConfig, + TwilioCallResource, + TwilioCredential, + TwilioCredentials, + TwilioFormFields, + TwilioFormParams, + TwilioHttpRequest, + TwilioHttpResponse, + TwilioMediaPayload, + TwilioMessageResource, + TwilioRawMessage, + TwilioStatusPayload, + TwilioTextPayload, + TwilioThreadId, + TwilioUnsupportedPayload, + TwilioVerifiedRequest, + TwilioWebhookError, + TwilioWebhookParseError, + TwilioWebhookPayload, + TwilioWebhookUrl, + TwilioWebhookVerificationError, + TwilioWebhookVerifier, +) + +__all__ = [ + "TWILIO_MESSAGE_LIMIT", + "TwilioAdapterConfig", + "TwilioCallResource", + "TwilioCredential", + "TwilioCredentials", + "TwilioFormFields", + "TwilioFormParams", + "TwilioFormatConverter", + "TwilioHttpRequest", + "TwilioHttpResponse", + "TwilioMediaPayload", + "TwilioMessageResource", + "TwilioRawMessage", + "TwilioStatusPayload", + "TwilioTextPayload", + "TwilioTextResult", + "TwilioThreadId", + "TwilioUnsupportedPayload", + "TwilioVerifiedRequest", + "TwilioWebhookError", + "TwilioWebhookParseError", + "TwilioWebhookPayload", + "TwilioWebhookUrl", + "TwilioWebhookVerificationError", + "TwilioWebhookVerifier", + "card_to_twilio_text", + "truncate_twilio_text", + "twilio_text_or_placeholder", +] diff --git a/src/chat_sdk/adapters/twilio/cards.py b/src/chat_sdk/adapters/twilio/cards.py new file mode 100644 index 0000000..70feff0 --- /dev/null +++ b/src/chat_sdk/adapters/twilio/cards.py @@ -0,0 +1,16 @@ +"""Card rendering for the Twilio adapter. + +SMS is plain text, so cards collapse to the shared fallback text with the +``*bold*`` markers stripped (SMS clients render asterisks literally). +Mirrors upstream ``adapter-twilio/src/cards.ts``. +""" + +from __future__ import annotations + +from chat_sdk.cards import CardElement +from chat_sdk.shared.card_utils import card_to_fallback_text + + +def card_to_twilio_text(card: CardElement) -> str: + """Render a card as plain SMS fallback text (no ``*`` markers).""" + return card_to_fallback_text(card).replace("*", "") diff --git a/src/chat_sdk/adapters/twilio/format_converter.py b/src/chat_sdk/adapters/twilio/format_converter.py new file mode 100644 index 0000000..0dcad28 --- /dev/null +++ b/src/chat_sdk/adapters/twilio/format_converter.py @@ -0,0 +1,99 @@ +"""Twilio format conversion and SMS text helpers. + +SMS has no rich-text rendering, so markdown is passed through as plain +literal text; only tables are rewritten (to ASCII blocks) because pipe +tables are unreadable on a phone. Mirrors upstream +``adapter-twilio/src/markdown.ts`` plus the ``src/format/index.ts`` +length helpers. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from chat_sdk.shared.base_format_converter import ( + BaseFormatConverter, + Content, + PostableMessageInput, + Root, + parse_markdown, + stringify_markdown, + table_to_ascii, + walk_ast, +) + +# Maximum body length accepted by the Messages API in a single message. +TWILIO_MESSAGE_LIMIT = 1600 + + +@dataclass +class TwilioTextResult: + """Result of :func:`truncate_twilio_text`.""" + + text: str + truncated: bool + + +def truncate_twilio_text(text: str, *, limit: int | None = None) -> TwilioTextResult: + """Truncate ``text`` to ``limit`` characters (default 1600). + + Mirrors upstream ``truncateTwilioText``: a non-integer or sub-1 limit + raises ``TypeError``. ``bool`` is rejected explicitly because Python + bools are ints (``Number.isInteger(true)`` is false upstream). + """ + resolved_limit = limit if limit is not None else TWILIO_MESSAGE_LIMIT + if isinstance(resolved_limit, bool) or not isinstance(resolved_limit, int) or resolved_limit < 1: + raise TypeError("limit must be a positive integer") + if len(text) <= resolved_limit: + return TwilioTextResult(text=text, truncated=False) + return TwilioTextResult(text=text[:resolved_limit], truncated=True) + + +def twilio_text_or_placeholder(text: str) -> str: + """Return ``text``, or a single space when empty. + + The Messages API rejects empty ``Body`` values; mirrors upstream + ``twilioTextOrPlaceholder``. + """ + return text if len(text) > 0 else " " + + +class TwilioFormatConverter(BaseFormatConverter): + """Format converter for the Twilio adapter.""" + + def to_ast(self, platform_text: str) -> Root: + """Parse inbound SMS text into an AST using the shared parser.""" + return parse_markdown(platform_text) + + def from_ast(self, ast: Root) -> str: + """Stringify an AST to SMS text, rewriting tables to ASCII blocks.""" + + def visitor(node: Content) -> Content: + if node.get("type") == "table": + return { + "type": "code", + "value": table_to_ascii(node), + "lang": None, + } + return node + + # ``walk_ast`` deep-copies, covering upstream's ``structuredClone``. + transformed = walk_ast(ast, visitor) + return stringify_markdown(transformed).strip() + + def render_postable(self, message: PostableMessageInput) -> str: + """Render an ``AdapterPostableMessage`` to Twilio SMS text. + + Handles ``str`` / ``raw`` / ``markdown`` / ``ast`` shapes directly + and defers to the base implementation for anything else. + """ + if isinstance(message, str): + return message + if isinstance(message, dict): + if "raw" in message: + return message["raw"] + if "markdown" in message: + return self.from_markdown(message["markdown"]) + if "ast" in message: + return self.from_ast(message["ast"]) + return super().render_postable(message) diff --git a/src/chat_sdk/adapters/twilio/types.py b/src/chat_sdk/adapters/twilio/types.py new file mode 100644 index 0000000..ec1114c --- /dev/null +++ b/src/chat_sdk/adapters/twilio/types.py @@ -0,0 +1,323 @@ +"""Type definitions for the Twilio adapter. + +Based on Twilio Programmable Messaging (SMS/MMS webhooks + the Messages +REST API) and the Programmable Voice webhook surface. +See: https://www.twilio.com/docs/messaging + +Python port of upstream ``packages/adapter-twilio`` (``src/types.ts``, +``src/webhook/types.ts``, and the option/resource interfaces from +``src/api/index.ts``). Internal fields are snake_case; the Twilio REST +resources (:data:`TwilioMessageResource` / :data:`TwilioCallResource`) +keep Twilio's own snake_case wire keys verbatim. +""" + +from __future__ import annotations + +import os +from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence +from dataclasses import dataclass +from typing import Any, Literal, Required, TypedDict + +from chat_sdk.logger import Logger + +# Environment-variable fallbacks for credentials and senders, matching +# upstream (TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN are resolved lazily at +# API-call time; TWILIO_MESSAGING_SERVICE_SID / TWILIO_PHONE_NUMBER at +# adapter construction time). +ENV_ACCOUNT_SID = "TWILIO_ACCOUNT_SID" +ENV_AUTH_TOKEN = "TWILIO_AUTH_TOKEN" +ENV_MESSAGING_SERVICE_SID = "TWILIO_MESSAGING_SERVICE_SID" +ENV_PHONE_NUMBER = "TWILIO_PHONE_NUMBER" + +# ============================================================================= +# Credentials +# ============================================================================= + +# A credential value: either the literal string or a zero-argument resolver +# (sync or async), mirroring upstream ``TwilioCredential``. +TwilioCredential = str | Callable[[], "str | Awaitable[str]"] + + +@dataclass +class TwilioCredentials: + """Account SID + auth token pair used by the low-level API helpers.""" + + account_sid: TwilioCredential | None = None + auth_token: TwilioCredential | None = None + + +# ============================================================================= +# HTTP plumbing (upstream's injectable ``fetch``) +# ============================================================================= + + +@dataclass +class TwilioHttpResponse: + """Minimal HTTP response shape returned by a :data:`TwilioHttpRequest`.""" + + status: int + body: bytes = b"" + + @property + def ok(self) -> bool: + """True for 2xx statuses, mirroring the WHATWG ``Response.ok``.""" + return 200 <= self.status < 300 + + +# Injectable HTTP transport: ``(method, url, headers, body) -> response``. +# ``body`` is the URL-encoded form string or ``None``. The Python analog of +# upstream's ``fetch?: TwilioFetch`` config option; when omitted the API +# helpers lazily import aiohttp. +TwilioHttpRequest = Callable[ + [str, str, Mapping[str, str], "str | None"], + Awaitable[TwilioHttpResponse], +] + +# ============================================================================= +# Form parameters +# ============================================================================= + +# Value accepted when building a form (``encode_twilio_form``); sequences +# append one pair per item, ``None`` is omitted. Mirrors ``TwilioFormValue``. +TwilioFormValue = bool | float | int | str | Sequence[str] | None + +# Field mapping accepted when building a form. Mirrors ``TwilioFormFields``. +TwilioFormFields = Mapping[str, TwilioFormValue] + +# Decoded form parameters as ordered (name, value) pairs — the Python stand-in +# for upstream's ``URLSearchParams`` (preserves duplicates and order). +TwilioFormParams = list[tuple[str, str]] + +# Loose input shape accepted by the parse helpers: a mapping or any iterable +# of (name, value) pairs. Normalized to :data:`TwilioFormParams` internally. +TwilioParamsInput = Mapping[str, str] | Iterable[tuple[str, str]] + +# ============================================================================= +# Webhook hooks +# ============================================================================= + +# Static webhook URL or a resolver called with the incoming request, +# mirroring upstream ``TwilioWebhookUrl``. Twilio signs the exact public URL +# it POSTs to, so deployments behind proxies must supply the external URL. +TwilioWebhookUrl = str | Callable[[Any], "str | Awaitable[str]"] + +# SECURITY surface — custom webhook verifier, mirroring upstream +# ``TwilioWebhookVerifier``. Called with ``(request, body)`` and fully +# replaces X-Twilio-Signature HMAC verification when set (it takes +# precedence over ``auth_token`` config and the TWILIO_AUTH_TOKEN env var). +# Returning a truthy value passes the request; falsy rejects it (401); a +# ``str`` return substitutes the request body before parsing. +TwilioWebhookVerifier = Callable[[Any, str], "bool | str | Awaitable[bool | str]"] + +# ============================================================================= +# Configuration +# ============================================================================= + + +@dataclass +class TwilioAdapterConfig: + """Twilio adapter configuration. + + Every field is optional, mirroring upstream: ``account_sid`` and + ``auth_token`` fall back to the ``TWILIO_ACCOUNT_SID`` / + ``TWILIO_AUTH_TOKEN`` env vars lazily at API-call time, and + ``messaging_service_sid`` / ``phone_number`` fall back to their env + vars when the adapter is constructed. + + See: https://www.twilio.com/docs/messaging/guides/webhook-request + """ + + # Account SID credential (string or resolver). Lazy env fallback: + # TWILIO_ACCOUNT_SID at API-call time. + account_sid: TwilioCredential | None = None + # Override the REST API base URL (default https://api.twilio.com). + api_url: str | None = None + # Auth token credential (string or resolver). Lazy env fallback: + # TWILIO_AUTH_TOKEN at API-call time. Also the webhook signing key. + auth_token: TwilioCredential | None = None + # Injectable HTTP transport (upstream's ``fetch``); defaults to aiohttp. + http_request: TwilioHttpRequest | None = None + # Logger instance (defaults to a console logger named "twilio"). + logger: Logger | None = None + # Messaging Service SID sender. Falls back to + # TWILIO_MESSAGING_SERVICE_SID at construction time. + messaging_service_sid: str | None = None + # Phone number sender. Falls back to TWILIO_PHONE_NUMBER at + # construction time. + phone_number: str | None = None + # StatusCallback URL forwarded on outbound messages. + status_callback_url: str | None = None + # Bot display name (default "bot"). + user_name: str | None = None + # Public webhook URL (or resolver) used for signature verification. + webhook_url: TwilioWebhookUrl | None = None + # Custom verifier replacing signature verification (SECURITY surface). + webhook_verifier: TwilioWebhookVerifier | None = None + + def resolved_messaging_service_sid(self) -> str | None: + """Messaging Service SID with the env fallback (``??`` semantics).""" + if self.messaging_service_sid is not None: + return self.messaging_service_sid + return os.environ.get(ENV_MESSAGING_SERVICE_SID) + + def resolved_phone_number(self) -> str | None: + """Phone number with the env fallback (``??`` semantics).""" + if self.phone_number is not None: + return self.phone_number + return os.environ.get(ENV_PHONE_NUMBER) + + +# ============================================================================= +# Thread ID +# ============================================================================= + + +@dataclass(frozen=True) +class TwilioThreadId: + """Decoded thread ID for Twilio. + + A conversation is the (sender, recipient) address pair: ``sender`` is + the bot-side address (a phone number or an ``MG...`` Messaging Service + SID), ``recipient`` is the user's address. Channel-prefixed addresses + (e.g. ``whatsapp:+1555...``) are preserved verbatim. + """ + + recipient: str + sender: str + + +# ============================================================================= +# REST resources (Twilio wire shapes — snake_case keys are Twilio's own) +# ============================================================================= + +# Message resource returned by the Messages API. Functional TypedDict syntax +# because ``from`` is a Python keyword; only ``sid`` is guaranteed. +TwilioMessageResource = TypedDict( + "TwilioMessageResource", + { + "account_sid": str, + "body": "str | None", + "date_created": "str | None", + "date_sent": "str | None", + "date_updated": "str | None", + "direction": str, + "error_code": "int | None", + "error_message": "str | None", + "from": "str | None", + "messaging_service_sid": "str | None", + "num_media": str, + "sid": Required[str], + "status": str, + "to": "str | None", + "uri": str, + }, + total=False, +) + +# Call resource returned by the Calls API (used by ``update_twilio_call``). +TwilioCallResource = TypedDict( + "TwilioCallResource", + { + "account_sid": str, + "answered_by": "str | None", + "caller_name": "str | None", + "date_created": "str | None", + "date_updated": "str | None", + "direction": str, + "duration": "str | None", + "end_time": "str | None", + "from": "str | None", + "parent_call_sid": "str | None", + "sid": Required[str], + "start_time": "str | None", + "status": str, + "to": "str | None", + "uri": str, + }, + total=False, +) + +# ============================================================================= +# Webhook payloads +# ============================================================================= + + +@dataclass +class TwilioMediaPayload: + """A single inbound MMS media item (``MediaUrl{N}``).""" + + url: str + content_type: str | None = None + + +@dataclass +class TwilioTextPayload: + """An inbound SMS/MMS message webhook.""" + + body: str + from_: str + to: str + media: list[TwilioMediaPayload] + raw: TwilioFormParams + account_sid: str | None = None + message_sid: str | None = None + kind: Literal["text"] = "text" + + +@dataclass +class TwilioStatusPayload: + """A message status callback webhook (``MessageStatus``/``SmsStatus``).""" + + message_status: str + raw: TwilioFormParams + account_sid: str | None = None + from_: str | None = None + message_sid: str | None = None + to: str | None = None + kind: Literal["status"] = "status" + + +@dataclass +class TwilioUnsupportedPayload: + """Any webhook the parser does not recognize.""" + + raw: TwilioFormParams + kind: Literal["unsupported"] = "unsupported" + + +TwilioWebhookPayload = TwilioTextPayload | TwilioStatusPayload | TwilioUnsupportedPayload + + +@dataclass +class TwilioVerifiedRequest: + """Result of webhook verification: the raw body and its decoded params.""" + + body: str + params: TwilioFormParams + + +# ============================================================================= +# Webhook errors +# ============================================================================= + + +class TwilioWebhookError(Exception): + """Base error for Twilio webhook handling.""" + + +class TwilioWebhookParseError(TwilioWebhookError): + """The webhook request body could not be read or parsed.""" + + +class TwilioWebhookVerificationError(TwilioWebhookError): + """The webhook request failed signature / verifier checks.""" + + +# ============================================================================= +# Raw Message Type +# ============================================================================= + +# Platform-specific raw message type for Twilio: either a Messages API +# resource (dict) or a parsed webhook payload (dataclass with ``kind``), +# mirroring upstream ``TwilioRawMessage``. +TwilioRawMessage = TwilioMessageResource | TwilioWebhookPayload diff --git a/tests/test_twilio_boundaries.py b/tests/test_twilio_boundaries.py new file mode 100644 index 0000000..493008a --- /dev/null +++ b/tests/test_twilio_boundaries.py @@ -0,0 +1,69 @@ +"""Port of adapter-twilio/src/{api,format,voice,webhook}/boundary.test.ts. + +Upstream keeps the ``api`` / ``format`` / ``voice`` / ``webhook`` subpaths +runtime-light: they must not import the full adapter ("../index") or the +``twilio`` npm package. The Python analog enforced here: + +- no module in ``chat_sdk.adapters.twilio`` imports ``aiohttp`` at the top + level (hazard #10: optional deps stay lazy — the upstream analog of + keeping subpaths free of runtime-heavy imports); +- no module imports the official ``twilio`` SDK (the port hand-rolls the + REST calls to mirror upstream's explicit no-`twilio`-dependency choice); +- the helper modules never import ``.adapter`` (upstream's "../index" rule), + so voice/webhook/api helpers stay usable without the adapter. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import chat_sdk.adapters.twilio as twilio_pkg + +TWILIO_PKG_DIR = Path(twilio_pkg.__file__).parent +TWILIO_MODULES = sorted(TWILIO_PKG_DIR.glob("*.py")) + + +def _top_level_imports(path: Path) -> set[str]: + """Module names imported at the top level (lazy imports excluded).""" + tree = ast.parse(path.read_text(encoding="utf-8")) + names: set[str] = set() + for node in tree.body: + if isinstance(node, ast.Import): + names.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + names.add(node.module) + return names + + +def _imports_package(imports: set[str], package: str) -> bool: + return any(name == package or name.startswith(f"{package}.") for name in imports) + + +class TestTwilioImportBoundaries: + """Runtime-light import boundaries for the Twilio package.""" + + def test_discovers_the_twilio_modules(self): + # Guard the globbing itself: if the package moves, the boundary + # tests below would silently assert over an empty list. + names = {p.name for p in TWILIO_MODULES} + assert "__init__.py" in names + assert "types.py" in names + assert "format_converter.py" in names + + def test_no_module_imports_aiohttp_at_top_level(self): + offenders = [p.name for p in TWILIO_MODULES if _imports_package(_top_level_imports(p), "aiohttp")] + assert offenders == [] + + def test_no_module_imports_the_twilio_sdk(self): + offenders = [p.name for p in TWILIO_MODULES if _imports_package(_top_level_imports(p), "twilio")] + assert offenders == [] + + def test_helper_modules_do_not_import_the_adapter(self): + adapter_module = "chat_sdk.adapters.twilio.adapter" + offenders = [ + p.name + for p in TWILIO_MODULES + if p.name not in ("__init__.py", "adapter.py") and _imports_package(_top_level_imports(p), adapter_module) + ] + assert offenders == [] diff --git a/tests/test_twilio_cards.py b/tests/test_twilio_cards.py new file mode 100644 index 0000000..7c1a0cb --- /dev/null +++ b/tests/test_twilio_cards.py @@ -0,0 +1,51 @@ +"""Port of adapter-twilio/src/cards.test.ts -- SMS card fallback rendering.""" + +from __future__ import annotations + +from chat_sdk.adapters.twilio.cards import card_to_twilio_text +from chat_sdk.cards import CardElement + + +def _deploy_card() -> CardElement: + return { + "type": "card", + "title": "Deploy", + "children": [ + { + "type": "section", + "children": [ + {"type": "text", "content": "Approve production deploy?"}, + { + "type": "fields", + "children": [ + {"type": "field", "label": "version", "value": "1.2.3"}, + ], + }, + ], + }, + { + "type": "actions", + "children": [ + {"type": "button", "id": "approve", "label": "Approve"}, + ], + }, + ], + } + + +class TestCardToTwilioText: + """Tests for card_to_twilio_text.""" + + def test_renders_cards_as_plain_sms_fallback_text(self): + text = card_to_twilio_text(_deploy_card()) + assert "Deploy" in text + assert "Approve production deploy?" in text + assert "version: 1.2.3" in text + assert "[Approve]" not in text + + def test_strips_bold_markers_from_titles(self): + # The shared fallback emits "*Deploy*"; SMS clients render asterisks + # literally, so the Twilio variant must strip them entirely. + text = card_to_twilio_text(_deploy_card()) + assert "*" not in text + assert text.startswith("Deploy") diff --git a/tests/test_twilio_format.py b/tests/test_twilio_format.py new file mode 100644 index 0000000..c215f61 --- /dev/null +++ b/tests/test_twilio_format.py @@ -0,0 +1,145 @@ +"""Port of adapter-twilio/src/format/index.test.ts + src/markdown.test.ts. + +Covers the SMS length helpers (``truncate_twilio_text`` / +``twilio_text_or_placeholder``), the ``TwilioFormatConverter`` (plain +pass-through, markdown preserved literally, tables rewritten to ASCII +blocks), and the Python-specific config env-fallback semantics for the +scaffolding added in PR 1. +""" + +from __future__ import annotations + +import os +from collections.abc import Iterator + +import pytest + +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.types import ( + ENV_MESSAGING_SERVICE_SID, + ENV_PHONE_NUMBER, + TwilioAdapterConfig, +) + +converter = TwilioFormatConverter() + + +# --------------------------------------------------------------------------- +# Length helpers (format/index.test.ts) +# --------------------------------------------------------------------------- + + +class TestTwilioFormatHelpers: + """Tests for the Twilio SMS length helpers.""" + + def test_keeps_text_within_the_twilio_message_limit(self): + text = "x" * TWILIO_MESSAGE_LIMIT + assert truncate_twilio_text(text) == TwilioTextResult(text=text, truncated=False) + + def test_truncates_text_over_the_twilio_message_limit(self): + result = truncate_twilio_text("x" * (TWILIO_MESSAGE_LIMIT + 1)) + assert len(result.text) == TWILIO_MESSAGE_LIMIT + assert result.truncated is True + + @pytest.mark.parametrize("limit", [0, -1, 1.5, True]) + def test_rejects_invalid_limits(self, limit): + # ``True`` is an int subclass in Python; upstream's Number.isInteger + # rejects booleans, so the port must too (input sweep, not just 0). + with pytest.raises(TypeError, match="limit must be a positive integer"): + truncate_twilio_text("hello", limit=limit) + + def test_truncates_at_a_custom_limit(self): + result = truncate_twilio_text("hello", limit=2) + assert result == TwilioTextResult(text="he", truncated=True) + + def test_uses_a_placeholder_for_empty_bodies(self): + assert twilio_text_or_placeholder("") == " " + assert twilio_text_or_placeholder("hello") == "hello" + + +# --------------------------------------------------------------------------- +# Format converter (markdown.test.ts) +# --------------------------------------------------------------------------- + + +class TestTwilioFormatConverter: + """Tests for TwilioFormatConverter.""" + + def test_keeps_raw_strings_plain(self): + assert converter.render_postable("hello") == "hello" + + def test_converts_markdown_to_twilio_text(self): + # SMS renders no markdown: markers survive as literal text. + assert converter.render_postable({"markdown": "**hello**"}) == "**hello**" + + def test_renders_tables_as_ascii_blocks(self): + text = converter.from_ast(converter.to_ast("| name | age |\n| --- | --- |\n| Ada | 36 |")) + assert "name | age" in text + assert "| --- |" not in text + + def test_renders_raw_postable_shape(self): + assert converter.render_postable({"raw": "raw *text*"}) == "raw *text*" + + def test_renders_ast_postable_shape(self): + ast = converter.to_ast("hello from ast") + assert converter.render_postable({"ast": ast}) == "hello from ast" + + def test_to_ast_parses_markdown_into_root(self): + ast = converter.to_ast("**bold** text") + assert ast["type"] == "root" + assert len(ast.get("children", [])) > 0 + + +# --------------------------------------------------------------------------- +# Config env fallbacks (Python scaffolding) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def _clear_twilio_sender_env() -> Iterator[None]: + """Save and clear the TWILIO_* sender env vars for a test.""" + saved: dict[str, str | None] = {k: os.environ.pop(k, None) for k in (ENV_MESSAGING_SERVICE_SID, ENV_PHONE_NUMBER)} + yield + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +class TestTwilioAdapterConfig: + """Env-fallback semantics for the sender fields (`??`, not `||`).""" + + def test_all_fields_are_optional(self, _clear_twilio_sender_env: None): + config = TwilioAdapterConfig() + assert config.resolved_messaging_service_sid() is None + assert config.resolved_phone_number() is None + + def test_explicit_values_win_over_env(self, _clear_twilio_sender_env: None): + os.environ[ENV_MESSAGING_SERVICE_SID] = "MGenv" + os.environ[ENV_PHONE_NUMBER] = "+15550000009" + config = TwilioAdapterConfig(messaging_service_sid="MG123", phone_number="+15550000001") + assert config.resolved_messaging_service_sid() == "MG123" + assert config.resolved_phone_number() == "+15550000001" + + def test_env_fallback_when_omitted(self, _clear_twilio_sender_env: None): + os.environ[ENV_MESSAGING_SERVICE_SID] = "MGenv" + os.environ[ENV_PHONE_NUMBER] = "+15550000009" + config = TwilioAdapterConfig() + assert config.resolved_messaging_service_sid() == "MGenv" + assert config.resolved_phone_number() == "+15550000009" + + def test_empty_string_does_not_fall_back_to_env(self, _clear_twilio_sender_env: None): + # Upstream uses `??` (nullish), so an explicit empty string must NOT + # be replaced by the env var — the `or` truthiness trap would. + os.environ[ENV_MESSAGING_SERVICE_SID] = "MGenv" + os.environ[ENV_PHONE_NUMBER] = "+15550000009" + config = TwilioAdapterConfig(messaging_service_sid="", phone_number="") + assert config.resolved_messaging_service_sid() == "" + assert config.resolved_phone_number() == "" From 341c7904a6a50e1f4c71290213f04de73af021f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 07:20:22 +0000 Subject: [PATCH 2/2] 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 --- CHANGELOG.md | 13 + docs/UPSTREAM_SYNC.md | 2 +- pyproject.toml | 1 + src/chat_sdk/adapters/twilio/__init__.py | 79 ++- src/chat_sdk/adapters/twilio/adapter.py | 730 +++++++++++++++++++++++ src/chat_sdk/adapters/twilio/api.py | 457 ++++++++++++++ src/chat_sdk/adapters/twilio/thread.py | 44 ++ src/chat_sdk/adapters/twilio/utils.py | 170 ++++++ src/chat_sdk/adapters/twilio/voice.py | 282 +++++++++ src/chat_sdk/adapters/twilio/webhook.py | 231 +++++++ tests/test_twilio_adapter.py | 585 ++++++++++++++++++ tests/test_twilio_api.py | 439 ++++++++++++++ tests/test_twilio_voice.py | 192 ++++++ tests/test_twilio_webhook.py | 359 +++++++++++ 14 files changed, 3581 insertions(+), 3 deletions(-) create mode 100644 src/chat_sdk/adapters/twilio/adapter.py create mode 100644 src/chat_sdk/adapters/twilio/api.py create mode 100644 src/chat_sdk/adapters/twilio/thread.py create mode 100644 src/chat_sdk/adapters/twilio/utils.py create mode 100644 src/chat_sdk/adapters/twilio/voice.py create mode 100644 src/chat_sdk/adapters/twilio/webhook.py create mode 100644 tests/test_twilio_adapter.py create mode 100644 tests/test_twilio_api.py create mode 100644 tests/test_twilio_voice.py create mode 100644 tests/test_twilio_webhook.py diff --git a/CHANGELOG.md b/CHANGELOG.md index def8d2c..098b929 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index 78ee097..9bce5dd 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -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. | diff --git a/pyproject.toml b/pyproject.toml index 2fe0761..aed3748 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/src/chat_sdk/adapters/twilio/__init__.py b/src/chat_sdk/adapters/twilio/__init__.py index 833a281..3ed2c2e 100644 --- a/src/chat_sdk/adapters/twilio/__init__.py +++ b/src/chat_sdk/adapters/twilio/__init__.py @@ -1,10 +1,27 @@ """Twilio adapter for chat-sdk. Python port of upstream ``packages/adapter-twilio``. Supports SMS and MMS -bots over Twilio Messaging webhooks and the Messages REST API, plus -low-level voice helpers for custom Twilio voice routes. +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, @@ -13,6 +30,11 @@ 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, @@ -37,16 +59,41 @@ 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", @@ -58,13 +105,41 @@ "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", ] diff --git a/src/chat_sdk/adapters/twilio/adapter.py b/src/chat_sdk/adapters/twilio/adapter.py new file mode 100644 index 0000000..f49354b --- /dev/null +++ b/src/chat_sdk/adapters/twilio/adapter.py @@ -0,0 +1,730 @@ +"""Twilio adapter for chat SDK. + +Supports SMS and MMS via Twilio Programmable Messaging: inbound message +webhooks (with X-Twilio-Signature verification) and outbound sends through +the Messages REST API. Conversations are 1:1 DMs keyed by the +(sender, recipient) address pair. + +Python port of ``packages/adapter-twilio/src/index.ts`` (PR 2 of 2 of the +Twilio port; PR 1 added types, the format converter, and cards). Like +upstream — which deliberately avoids the ``twilio`` npm package — the +send paths are hand-rolled over the small REST surface in +:mod:`chat_sdk.adapters.twilio.api` (lazy aiohttp), keeping the adapter +free of the official SDK dependency. + +See: https://www.twilio.com/docs/messaging +""" + +from __future__ import annotations + +import asyncio +import time +from collections.abc import AsyncIterable, Awaitable, Callable, Mapping +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime +from typing import Any, TypedDict +from urllib.parse import urlparse + +from chat_sdk.adapters.twilio.api import ( + delete_twilio_message, + fetch_twilio_media, + fetch_twilio_message, + list_twilio_messages, + send_twilio_message, +) +from chat_sdk.adapters.twilio.cards import card_to_twilio_text +from chat_sdk.adapters.twilio.format_converter import ( + TWILIO_MESSAGE_LIMIT, + TwilioFormatConverter, + 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, + TwilioCredential, + TwilioCredentials, + TwilioHttpRequest, + TwilioHttpResponse, + TwilioMediaPayload, + TwilioMessageResource, + TwilioRawMessage, + TwilioStatusPayload, + TwilioTextPayload, + TwilioThreadId, + TwilioUnsupportedPayload, + TwilioWebhookParseError, + TwilioWebhookUrl, + TwilioWebhookVerificationError, + TwilioWebhookVerifier, +) +from chat_sdk.adapters.twilio.utils import attachment_type, sender_fields, twiml_response +from chat_sdk.adapters.twilio.webhook import read_twilio_webhook +from chat_sdk.errors import ChatNotImplementedError +from chat_sdk.logger import ConsoleLogger, Logger +from chat_sdk.shared.adapter_utils import ( + extract_card, + extract_files, + extract_postable_attachments, +) +from chat_sdk.shared.errors import ValidationError +from chat_sdk.types import ( + AdapterPostableMessage, + Attachment, + Author, + ChatInstance, + FetchOptions, + FetchResult, + FormattedContent, + LockScope, + Message, + MessageMetadata, + PostableMarkdown, + RawMessage, + StreamChunk, + StreamOptions, + ThreadInfo, + UserInfo, + WebhookOptions, +) + +# SSRF / credential-exfiltration guard for authenticated media downloads. +# Twilio media lives on api.twilio.com (redirecting to the Twilio CDN); +# Basic auth must never be forwarded to an arbitrary host rehydrated from +# persisted state. Divergence from upstream — see docs/UPSTREAM_SYNC.md +# (`rehydrate_attachment` URL allowlist rows; upstream fetches blindly). +_TRUSTED_MEDIA_HOSTS = frozenset({"twilio.com", "api.twilio.com"}) +_TRUSTED_MEDIA_HOST_SUFFIXES = (".twilio.com", ".twiliocdn.com") + + +class _TwilioApiKwargs(TypedDict): + """Common kwargs the adapter passes to the low-level API helpers.""" + + api_url: str | None + credentials: TwilioCredentials + http_request: TwilioHttpRequest + + +class TwilioAdapter: + """Twilio adapter for chat SDK. + + Implements the chat-sdk ``Adapter`` interface for Twilio Programmable + Messaging (SMS/MMS). Voice webhooks are intentionally NOT routed + through this adapter — see :mod:`chat_sdk.adapters.twilio.voice`. + """ + + def __init__(self, config: TwilioAdapterConfig | None = None) -> None: + # Upstream's config is all-optional by design: ``account_sid`` / + # ``auth_token`` resolve lazily (env fallback) at first API call, + # so — unlike the Messenger adapter (#110 Q1) — neither the + # constructor nor the factory can require credentials without + # breaking upstream's `createTwilioAdapter()` contract (webhook + # verification may be fully delegated to ``webhook_verifier`` and + # the voice/webhook helpers are usable standalone). Misconfig + # surfaces as ``AuthenticationError`` naming the missing env var. + resolved = config if config is not None else TwilioAdapterConfig() + + self._name = "twilio" + self._lock_scope: LockScope = "channel" + self._persist_thread_history = True + + self._account_sid: TwilioCredential | None = resolved.account_sid + self._api_url = resolved.api_url + self._auth_token: TwilioCredential | None = resolved.auth_token + self._config_http_request: TwilioHttpRequest | None = resolved.http_request + self._logger: Logger = resolved.logger if resolved.logger is not None else ConsoleLogger("info").child("twilio") + self._messaging_service_sid = resolved.resolved_messaging_service_sid() + self._phone_number = resolved.resolved_phone_number() + self._status_callback_url = resolved.status_callback_url + self._user_name = resolved.user_name if resolved.user_name is not None else "bot" + self._webhook_url: TwilioWebhookUrl | None = resolved.webhook_url + self._webhook_verifier: TwilioWebhookVerifier | None = resolved.webhook_verifier + self._format_converter = TwilioFormatConverter() + + self._chat: ChatInstance | None = None + + # Shared aiohttp session for connection pooling; created lazily. + self._http_session: Any | None = None + + # ========================================================================= + # Adapter interface properties + # ========================================================================= + + @property + def name(self) -> str: + return self._name + + @property + def lock_scope(self) -> LockScope: + return self._lock_scope + + @property + def persist_thread_history(self) -> bool: + return self._persist_thread_history + + @property + def user_name(self) -> str: + return self._user_name + + @property + def bot_user_id(self) -> str | None: + # Twilio has no bot identity; authorship is derived from the + # message ``direction``, mirroring upstream (no ``botUserId``). + return None + + # ========================================================================= + # Lifecycle + # ========================================================================= + + async def initialize(self, chat: ChatInstance) -> None: + """Initialize the adapter (no identity fetch — Twilio has none).""" + self._chat = chat + self._logger.info( + "Twilio adapter initialized", + { + "messagingServiceSid": self._messaging_service_sid, + "phoneNumber": self._phone_number, + }, + ) + + async def disconnect(self) -> None: + """Cleanup hook. Close the shared HTTP session if it was created.""" + if self._http_session is not None and not getattr(self._http_session, "closed", True): + await self._http_session.close() + self._http_session = None + + async def _get_http_session(self) -> Any: + """Return the shared aiohttp session, creating it lazily on first use.""" + import aiohttp + + if self._http_session is None or getattr(self._http_session, "closed", True): + self._http_session = aiohttp.ClientSession() + return self._http_session + + async def _session_http_request( + self, + method: str, + url: str, + headers: Mapping[str, str], + body: str | None, + ) -> TwilioHttpResponse: + """Shared-session transport handed to the low-level API helpers.""" + session = await self._get_http_session() + async with session.request(method, url, headers=dict(headers), data=body) as response: + return TwilioHttpResponse(status=response.status, body=await response.read()) + + def _http_request(self) -> TwilioHttpRequest: + """The configured transport, else the shared-session default.""" + if self._config_http_request is not None: + return self._config_http_request + return self._session_http_request + + # ========================================================================= + # Webhook + # ========================================================================= + + async def handle_webhook( + self, + request: Any, + options: WebhookOptions | None = None, + ) -> Any: + """Handle an incoming Twilio messaging webhook. + + Signature failures return 401 (upstream's choice, matching this + repo's convention); parse failures return 400; everything else is + acknowledged with an empty TwiML response so Twilio doesn't send + an error SMS back to the user. + """ + try: + payload = await read_twilio_webhook( + request, + auth_token=self._auth_token, + webhook_url=self._webhook_url, + webhook_verifier=self._webhook_verifier, + ) + except TwilioWebhookVerificationError: + return self._make_response("Invalid signature", 401) + except TwilioWebhookParseError: + return self._make_response("Invalid webhook", 400) + + if not isinstance(payload, TwilioTextPayload) or not self._chat: + return twiml_response() + + thread_id = self.encode_thread_id(TwilioThreadId(recipient=payload.from_, sender=payload.to)) + message = self._parse_twilio_text_payload(payload, thread_id) + self._chat.process_message(self, thread_id, message, options) + return twiml_response() + + # ========================================================================= + # Sending messages + # ========================================================================= + + async def post_message( + self, + thread_id: str, + message: AdapterPostableMessage, + ) -> RawMessage: + """Send an SMS/MMS through the Messages API.""" + thread = self.decode_thread_id(thread_id) + body = self._render_postable_text(message) + media_url = self._media_urls(message) + if not body and len(media_url) == 0: + raise ValidationError("twilio", "Message text cannot be empty") + + raw = await send_twilio_message( + to=thread.recipient, + body=(twilio_text_or_placeholder(body) if (body or len(media_url) == 0) else None), + media_url=media_url, + status_callback_url=self._status_callback_url, + **sender_fields(thread.sender), + **self._api_options(), + ) + + return RawMessage( + id=raw["sid"], + thread_id=self._thread_id_for_resource(raw, thread), + raw=raw, + ) + + async def edit_message( + self, + thread_id: str, + message_id: str, + message: AdapterPostableMessage, + ) -> RawMessage: + """Twilio does not support editing sent messages — raises.""" + raise ChatNotImplementedError("twilio", "editMessage") + + async def delete_message(self, thread_id: str, message_id: str) -> None: + """Delete a sent message resource by SID.""" + await delete_twilio_message(message_id, **self._api_options()) + + async def add_reaction( + self, + thread_id: str, + message_id: str, + emoji: Any, + ) -> None: + """Twilio does not support message reactions — raises.""" + raise ChatNotImplementedError("twilio", "addReaction") + + async def remove_reaction( + self, + thread_id: str, + message_id: str, + emoji: Any, + ) -> None: + """Twilio does not support message reactions — raises.""" + raise ChatNotImplementedError("twilio", "removeReaction") + + async def start_typing(self, thread_id: str, status: str | None = None) -> None: + """No-op: SMS has no typing indicator (mirrors upstream).""" + + async def stream( + self, + thread_id: str, + text_stream: AsyncIterable[str | StreamChunk], + options: StreamOptions | None = None, + ) -> RawMessage: + """Buffer all stream chunks and send as a single message. + + Twilio can't edit sent messages, so incremental post+edit + streaming is impossible; accumulate-and-post mirrors the + Messenger / WhatsApp adapters. + """ + accumulated = "" + async for chunk in text_stream: + if isinstance(chunk, str): + accumulated += chunk + elif getattr(chunk, "type", None) == "markdown_text": + accumulated += getattr(chunk, "text", "") + return await self.post_message(thread_id, PostableMarkdown(markdown=accumulated)) + + # ========================================================================= + # Parsing + # ========================================================================= + + def parse_message(self, raw: TwilioRawMessage) -> Message: + """Parse a raw webhook payload or Messages API resource.""" + if isinstance(raw, (TwilioTextPayload, TwilioStatusPayload, TwilioUnsupportedPayload)): + if not isinstance(raw, TwilioTextPayload): + raise ValidationError("twilio", "Cannot parse unsupported webhook") + return self._parse_twilio_text_payload( + raw, + self.encode_thread_id(TwilioThreadId(recipient=raw.from_, sender=raw.to)), + ) + return self._parse_twilio_resource(raw, None) + + def render_formatted(self, content: FormattedContent) -> str: + """Render formatted AST content back to SMS text.""" + return self._format_converter.from_ast(content) + + # ========================================================================= + # Fetching + # ========================================================================= + + async def fetch_message(self, thread_id: str, message_id: str) -> Message | None: + """Fetch a single message by SID; ``None`` on any API failure.""" + thread = self.decode_thread_id(thread_id) + try: + raw = await fetch_twilio_message(message_id, **self._api_options()) + return self._parse_twilio_resource(raw, thread) + except Exception: + return None + + async def fetch_messages( + self, + thread_id: str, + options: FetchOptions | None = None, + ) -> FetchResult: + """Fetch both directions of the conversation, merged by date.""" + opts = options if options is not None else FetchOptions() + thread = self.decode_thread_id(thread_id) + limit = opts.limit if opts.limit is not None else 50 + outbound, inbound = await asyncio.gather( + list_twilio_messages( + from_=thread.sender, + limit=limit, + to=thread.recipient, + **self._api_options(), + ), + list_twilio_messages( + from_=thread.recipient, + limit=limit, + to=thread.sender, + **self._api_options(), + ), + ) + messages = [self._parse_twilio_resource(raw, thread) for raw in [*outbound, *inbound]] + messages.sort(key=self._date_sort_key) + return FetchResult(messages=messages[-limit:]) + + async def fetch_thread(self, thread_id: str) -> ThreadInfo: + """Thread info derived from the address pair (always a DM).""" + thread = self.decode_thread_id(thread_id) + return ThreadInfo( + id=thread_id, + channel_id=self.channel_id_from_thread_id(thread_id), + channel_name=thread.sender, + is_dm=True, + metadata={"recipient": thread.recipient, "sender": thread.sender}, + ) + + async def get_user(self, user_id: str) -> UserInfo | None: + """Phone numbers are the only identity; echo the address back.""" + return UserInfo( + full_name=user_id, + is_bot=False, + user_id=user_id, + user_name=user_id, + ) + + # ========================================================================= + # Thread ID encoding + # ========================================================================= + + async def open_dm(self, user_id: str) -> str: + """Open a DM with a phone number using the configured sender.""" + return self.encode_thread_id(TwilioThreadId(recipient=user_id, sender=self._default_sender())) + + def is_dm(self, thread_id: str) -> bool: + """Every Twilio conversation is a DM.""" + return thread_id.startswith("twilio:") + + def channel_id_from_thread_id(self, thread_id: str) -> str: + """Channel = the bot-side sender address.""" + return twilio_channel_id(thread_id) + + def encode_thread_id(self, platform_data: TwilioThreadId) -> str: + """Encode. Format: ``twilio:{sender}:{recipient}`` (URI-escaped).""" + return encode_twilio_thread_id(platform_data) + + def decode_thread_id(self, thread_id: str) -> TwilioThreadId: + """Decode a ``twilio:{sender}:{recipient}`` thread ID.""" + return decode_twilio_thread_id(thread_id) + + # ========================================================================= + # Attachments + # ========================================================================= + + def rehydrate_attachment(self, attachment: Attachment) -> Attachment: + """Reconstruct ``fetch_data`` on a deserialized Twilio attachment. + + Reads the media URL from ``fetch_metadata`` (key ``twilioMediaUrl`` + — camelCase for cross-SDK state parity with upstream) and rebuilds + the authenticated downloader. Returns the attachment unchanged when + no URL is present. + """ + meta = attachment.fetch_metadata if attachment.fetch_metadata is not None else {} + meta_url = meta.get("twilioMediaUrl") + url = meta_url if meta_url is not None else attachment.url + if not url: + return attachment + return self._twilio_attachment(TwilioMediaPayload(url=url, content_type=attachment.mime_type)) + + def _twilio_attachment(self, media: TwilioMediaPayload) -> Attachment: + """Build an Attachment with an authenticated lazy downloader.""" + return Attachment( + type=attachment_type(media.content_type), + url=media.url, + mime_type=media.content_type, + fetch_data=self._make_media_downloader(media.url), + fetch_metadata={"twilioMediaUrl": media.url}, + ) + + def _make_media_downloader(self, url: str) -> Callable[[], Awaitable[bytes]]: + """Closure downloading ``url`` with adapter credentials. + + Named helper so the ``url`` capture isn't subject to late binding + when a message carries multiple media items. The URL is validated + inside the closure (not at build time) so a trusted-at-parse-time + URL still fails closed if the allowlist tightens later. + """ + + async def _download() -> bytes: + if not _is_trusted_twilio_media_url(url): + # Divergence from upstream — see docs/UPSTREAM_SYNC.md + # (SSRF guard: never forward Basic auth off-platform). + raise ValidationError( + "twilio", + f"Refusing to fetch Twilio media from untrusted URL: {url}", + ) + return await fetch_twilio_media( + url, + credentials=self._credentials(), + http_request=self._http_request(), + ) + + return _download + + # ========================================================================= + # Message parsing internals + # ========================================================================= + + def _parse_twilio_text_payload(self, raw: TwilioTextPayload, thread_id: str) -> Message: + message_id = raw.message_sid if raw.message_sid is not None else f"twilio:{int(time.time() * 1000)}" + return Message( + id=message_id, + thread_id=thread_id, + text=raw.body, + formatted=self._format_converter.to_ast(raw.body), + author=self._author(raw.from_, False), + metadata=MessageMetadata( + date_sent=datetime.now(tz=timezone.utc), + edited=False, + ), + attachments=[self._twilio_attachment(media) for media in raw.media], + raw=raw, + ) + + def _parse_twilio_resource( + self, + raw: TwilioMessageResource, + fallback_thread: TwilioThreadId | None, + ) -> Message: + direction = raw.get("direction") + is_me = direction.startswith("outbound") if direction is not None else False + + from_value = raw.get("from") + if from_value is None: + from_value = raw.get("messaging_service_sid") + if from_value is None and fallback_thread is not None: + from_value = fallback_thread.sender if is_me else fallback_thread.recipient + + to_value = raw.get("to") + if to_value is None and fallback_thread is not None: + to_value = fallback_thread.recipient if is_me else fallback_thread.sender + + if not (from_value and to_value): + raise ValidationError("twilio", "Twilio message is missing routing") + + text = raw.get("body") + text = text if text is not None else "" + + if is_me: + thread = TwilioThreadId( + recipient=fallback_thread.recipient if fallback_thread is not None else to_value, + sender=fallback_thread.sender if fallback_thread is not None else from_value, + ) + else: + thread = TwilioThreadId(recipient=from_value, sender=to_value) + + date_value = raw.get("date_sent") + if date_value is None: + date_value = raw.get("date_created") + + return Message( + id=raw["sid"], + thread_id=self.encode_thread_id(thread), + text=text, + formatted=self._format_converter.to_ast(text), + author=self._author(thread.sender if is_me else from_value, is_me), + metadata=MessageMetadata( + date_sent=_date_from_twilio(date_value), + edited=False, + ), + attachments=[], + raw=raw, + ) + + def _thread_id_for_resource(self, raw: TwilioMessageResource, fallback: TwilioThreadId) -> str: + """The stable thread ID for a sent resource (fallback-pinned).""" + return self._parse_twilio_resource(raw, fallback).thread_id + + @staticmethod + def _author(user_id: str, is_me: bool) -> Author: + return Author( + full_name=user_id, + is_bot=is_me, + is_me=is_me, + user_id=user_id, + user_name=user_id, + ) + + @staticmethod + def _date_sort_key(message: Message) -> float: + return message.metadata.date_sent.timestamp() if message.metadata else 0.0 + + # ========================================================================= + # Send helpers + # ========================================================================= + + def _render_postable_text(self, message: AdapterPostableMessage) -> str: + card = extract_card(message) + text = card_to_twilio_text(card) if card else self._format_converter.render_postable(message) + return truncate_twilio_text(text, limit=TWILIO_MESSAGE_LIMIT).text + + @staticmethod + def _media_urls(message: AdapterPostableMessage) -> list[str]: + """Public media URLs from postable attachments. + + Binary ``files`` uploads are rejected: Twilio's Messages API only + accepts media by URL it can fetch itself. + """ + files = extract_files(message) + if len(files) > 0: + raise ValidationError( + "twilio", + "Twilio adapter supports media attachments by public URL only", + ) + media_url: list[str] = [] + for attachment in extract_postable_attachments(message): + # Duck-typed like upstream: postable attachments may arrive as + # ``Attachment`` dataclasses or raw dicts. + url = attachment.get("url") if isinstance(attachment, dict) else getattr(attachment, "url", None) + if not isinstance(url, str) or len(url) == 0: + raise ValidationError( + "twilio", + "Twilio adapter supports media attachments by public URL only", + ) + media_url.append(url) + return media_url + + def _credentials(self) -> TwilioCredentials: + return TwilioCredentials(account_sid=self._account_sid, auth_token=self._auth_token) + + def _api_options(self) -> _TwilioApiKwargs: + """Common kwargs for the low-level API helpers.""" + return { + "api_url": self._api_url, + "credentials": self._credentials(), + "http_request": self._http_request(), + } + + def _default_sender(self) -> str: + sender = self._phone_number if self._phone_number is not None else self._messaging_service_sid + if not sender: + raise ValidationError("twilio", "phoneNumber or messagingServiceSid is required") + return sender + + @staticmethod + def _make_response(body: str, status: int) -> dict[str, Any]: + """Create a framework-agnostic response dict.""" + return {"body": body, "status": status} + + +# ============================================================================= +# Module helpers +# ============================================================================= + + +def _date_from_twilio(value: str | None) -> datetime: + """Parse a Twilio date (RFC 2822, occasionally ISO 8601) to aware UTC. + + Falls back to "now" for missing/unparseable values, mirroring + upstream's ``dateFromTwilio`` NaN guard. + """ + if value: + try: + parsed = parsedate_to_datetime(value) + return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=timezone.utc) + except (TypeError, ValueError): + pass + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=timezone.utc) + except ValueError: + pass + return datetime.now(tz=timezone.utc) + + +def _is_trusted_twilio_media_url(url: str) -> bool: + """True when ``url`` is an https URL on a Twilio-owned host.""" + try: + parsed = urlparse(url) + except ValueError: + return False + if parsed.scheme != "https": + return False + host = (parsed.hostname or "").lower() + if not host: + return False + if host in _TRUSTED_MEDIA_HOSTS: + return True + return host.endswith(_TRUSTED_MEDIA_HOST_SUFFIXES) + + +# ============================================================================= +# Factory +# ============================================================================= + + +def create_twilio_adapter( + *, + account_sid: TwilioCredential | None = None, + api_url: str | None = None, + auth_token: TwilioCredential | None = None, + http_request: TwilioHttpRequest | None = None, + logger: Logger | None = None, + messaging_service_sid: str | None = None, + phone_number: str | None = None, + status_callback_url: str | None = None, + user_name: str | None = None, + webhook_url: TwilioWebhookUrl | None = None, + webhook_verifier: TwilioWebhookVerifier | None = None, +) -> TwilioAdapter: + """Factory for ``TwilioAdapter`` with TWILIO_* env fallbacks. + + Mirrors upstream ``createTwilioAdapter``: every argument is optional + (see the constructor note on why required-credential validation — + the Messenger #110 Q1 answer — does not transfer to Twilio). + """ + return TwilioAdapter( + TwilioAdapterConfig( + account_sid=account_sid, + api_url=api_url, + auth_token=auth_token, + http_request=http_request, + logger=logger, + messaging_service_sid=messaging_service_sid, + phone_number=phone_number, + status_callback_url=status_callback_url, + user_name=user_name, + webhook_url=webhook_url, + webhook_verifier=webhook_verifier, + ) + ) diff --git a/src/chat_sdk/adapters/twilio/api.py b/src/chat_sdk/adapters/twilio/api.py new file mode 100644 index 0000000..3ff19ae --- /dev/null +++ b/src/chat_sdk/adapters/twilio/api.py @@ -0,0 +1,457 @@ +"""Low-level Twilio REST API helpers (Messages, Calls, Media). + +Python port of upstream ``adapter-twilio/src/api/index.ts``. Upstream +intentionally avoids the ``twilio`` npm runtime dependency so these +helpers stay runtime-light; the Python port mirrors that choice and +hand-rolls the small REST surface over an injectable HTTP transport +(:data:`~chat_sdk.adapters.twilio.types.TwilioHttpRequest`), defaulting +to a lazily imported aiohttp session per call. The adapter supplies its +own shared-session transport for connection reuse. + +Error mapping (Python adaptation): upstream throws a bare +``TwilioApiError`` for every non-2xx response; here common statuses map +to the typed :mod:`chat_sdk.shared.errors` hierarchy (400 validation, +401 auth, 403 permission, 404 not-found, 429 rate-limit) and anything +else raises :class:`TwilioApiError` (a :class:`NetworkError` subclass +carrying upstream's ``status`` / ``body`` fields). +""" + +from __future__ import annotations + +import base64 +import inspect +import json +import os +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any, Literal, cast +from urllib.parse import urlencode, urljoin + +from chat_sdk.adapters.twilio.types import ( + ENV_ACCOUNT_SID, + ENV_AUTH_TOKEN, + TwilioCallResource, + TwilioCredential, + TwilioCredentials, + TwilioFormFields, + TwilioFormParams, + TwilioHttpRequest, + TwilioHttpResponse, + TwilioMessageResource, +) +from chat_sdk.adapters.twilio.utils import encode_uri_component +from chat_sdk.shared.errors import ( + AdapterPermissionError, + AdapterRateLimitError, + AuthenticationError, + ResourceNotFoundError, + ValidationError, +) +from chat_sdk.shared.errors import ( + NetworkError as _NetworkError, +) + +DEFAULT_API_URL = "https://api.twilio.com" + + +class TwilioApiError(_NetworkError): + """A Twilio REST API call failed. + + Carries upstream's ``status`` / ``body`` fields. ``status`` is ``0`` + for transport-level failures that never produced an HTTP response. + """ + + def __init__(self, message: str, *, body: Any = None, status: int = 0) -> None: + super().__init__("twilio", message) + self.body = body + self.status = status + + +@dataclass +class TwilioApiResponse: + """Decoded response from :func:`call_twilio_api`.""" + + body: Any + ok: bool + status: int + + +# ============================================================================= +# Credentials +# ============================================================================= + + +async def resolve_twilio_credential(value: TwilioCredential | None, env_name: str) -> str: + """Resolve a credential: explicit value/resolver, else the env var. + + Raises :class:`~chat_sdk.shared.errors.AuthenticationError` naming the + env var when nothing is configured (the typed mapping of upstream's + ``TwilioApiError`` with status 0). + """ + source: TwilioCredential | None = value if value is not None else os.environ.get(env_name) + if not source: + raise AuthenticationError("twilio", f"{env_name} is required") + if callable(source): + resolved = source() + return await resolved if inspect.isawaitable(resolved) else resolved + return source + + +def _basic_authorization(account_sid: str, auth_token: str) -> str: + credentials = f"{account_sid}:{auth_token}".encode() + return f"Basic {base64.b64encode(credentials).decode('ascii')}" + + +# ============================================================================= +# Form encoding +# ============================================================================= + + +def encode_twilio_form(fields: TwilioFormFields) -> TwilioFormParams: + """Encode a field mapping as ordered form pairs. + + ``None`` values are omitted (hazard #7: omit absent optional keys); + sequences append one pair per item; booleans serialize as JS would + (``true`` / ``false``). Mirrors upstream ``encodeTwilioForm``. + """ + params: TwilioFormParams = [] + for key, value in fields.items(): + if value is None: + continue + if isinstance(value, bool): + params.append((key, "true" if value else "false")) + continue + if isinstance(value, str): + params.append((key, value)) + continue + if isinstance(value, Sequence): + params.extend((key, str(item)) for item in value) + continue + params.append((key, str(value))) + return params + + +def _parse_twilio_response_body(body: bytes) -> Any: + """Decode a response body: empty -> None, JSON when valid, else text.""" + text = body.decode("utf-8", errors="replace") + if not text: + return None + try: + return json.loads(text) + except ValueError: + return text + + +def _twilio_error_message(body: Any, status: int) -> str: + """Best error message: Twilio's body ``message`` field when present.""" + if isinstance(body, dict): + message = body.get("message") + if isinstance(message, str) and message: + return message + return f"Twilio API returned HTTP {status}" + + +def _raise_for_status(status: int, body: Any, *, action: str) -> None: + """Translate a non-2xx Twilio response to a typed adapter error.""" + message = _twilio_error_message(body, status) + if status == 429: + raise AdapterRateLimitError("twilio") + if status == 401: + raise AuthenticationError("twilio", message) + if status == 403: + raise AdapterPermissionError("twilio", action) + if status == 404: + raise ResourceNotFoundError("twilio", "Twilio resource", action) + if status == 400: + raise ValidationError("twilio", message) + raise TwilioApiError(f"Twilio API returned HTTP {status}", body=body, status=status) + + +# ============================================================================= +# Transport +# ============================================================================= + + +async def _default_http_request( + method: str, + url: str, + headers: dict[str, str], + body: str | None, +) -> TwilioHttpResponse: + """Default transport: one-shot aiohttp session (lazy optional import). + + Standalone helper calls pay a session per request; the adapter passes + a shared-session transport instead (hazard #11). + """ + import aiohttp + + async with ( + aiohttp.ClientSession() as session, + session.request(method, url, headers=headers, data=body) as response, + ): + return TwilioHttpResponse(status=response.status, body=await response.read()) + + +# ============================================================================= +# Raw API call +# ============================================================================= + + +async def call_twilio_api( + path: str, + *, + api_base_url: str | None = None, + api_url: str | None = None, + body: TwilioFormFields | TwilioFormParams | None = None, + credentials: TwilioCredentials | None = None, + http_request: TwilioHttpRequest | None = None, + method: Literal["DELETE", "GET", "POST"] | None = None, + search: TwilioFormFields | TwilioFormParams | None = None, +) -> TwilioApiResponse: + """Call the Twilio REST API with Basic auth and form encoding. + + Mirrors upstream ``callTwilioApi`` (the object/string dual signature + collapses to ``path`` + keyword options in Python). + """ + creds = credentials if credentials is not None else TwilioCredentials() + account_sid = await resolve_twilio_credential(creds.account_sid, ENV_ACCOUNT_SID) + auth_token = await resolve_twilio_credential(creds.auth_token, ENV_AUTH_TOKEN) + + base = api_url if api_url is not None else (api_base_url if api_base_url is not None else DEFAULT_API_URL) + url = urljoin(base, path) + search_params = _form_params(search) + if search_params: + separator = "&" if "?" in url else "?" + url = f"{url}{separator}{urlencode(search_params)}" + + body_params = _form_params(body) + encoded_body = urlencode(body_params) if body_params is not None else None + headers = {"authorization": _basic_authorization(account_sid, auth_token)} + if encoded_body is not None: + headers["content-type"] = "application/x-www-form-urlencoded;charset=UTF-8" + + request = http_request if http_request is not None else _default_http_request + resolved_method = method if method is not None else "POST" + response = await request(resolved_method, url, headers, encoded_body) + response_body = _parse_twilio_response_body(response.body) + if not response.ok: + _raise_for_status(response.status, response_body, action=f"{resolved_method} {path}") + return TwilioApiResponse(body=response_body, ok=response.ok, status=response.status) + + +def _form_params( + fields: TwilioFormFields | TwilioFormParams | None, +) -> TwilioFormParams | None: + """Normalize form input: ``None`` passthrough, pairs kept, mappings encoded.""" + if fields is None: + return None + if isinstance(fields, list): + return fields + return encode_twilio_form(fields) + + +# ============================================================================= +# Messages +# ============================================================================= + + +async def send_twilio_message( + *, + to: str, + api_base_url: str | None = None, + api_url: str | None = None, + body: str | None = None, + credentials: TwilioCredentials | None = None, + from_: str | None = None, + http_request: TwilioHttpRequest | None = None, + media_url: Sequence[str] | str | None = None, + messaging_service_sid: str | None = None, + status_callback_url: str | None = None, +) -> TwilioMessageResource: + """Send an SMS/MMS via the Messages API.""" + creds = credentials if credentials is not None else TwilioCredentials() + account_sid = await resolve_twilio_credential(creds.account_sid, ENV_ACCOUNT_SID) + media_urls = _array_value(media_url) + if not body and len(media_urls) == 0: + raise ValidationError("twilio", "body or mediaUrl is required") + if not (from_ or messaging_service_sid): + raise ValidationError("twilio", "from or messagingServiceSid is required") + form = encode_twilio_form( + { + "Body": body, + "From": from_, + "MediaUrl": media_urls, + "MessagingServiceSid": messaging_service_sid, + "StatusCallback": status_callback_url, + "To": to, + } + ) + response = await call_twilio_api( + f"/2010-04-01/Accounts/{_encode_path_segment(account_sid)}/Messages.json", + api_base_url=api_base_url, + api_url=api_url, + body=form, + credentials=creds, + http_request=http_request, + ) + return cast("TwilioMessageResource", response.body) + + +async def fetch_twilio_message( + message_sid: str, + *, + api_base_url: str | None = None, + api_url: str | None = None, + credentials: TwilioCredentials | None = None, + http_request: TwilioHttpRequest | None = None, +) -> TwilioMessageResource: + """Fetch a single message resource by SID.""" + creds = credentials if credentials is not None else TwilioCredentials() + account_sid = await resolve_twilio_credential(creds.account_sid, ENV_ACCOUNT_SID) + response = await call_twilio_api( + f"/2010-04-01/Accounts/{_encode_path_segment(account_sid)}/Messages/{_encode_path_segment(message_sid)}.json", + api_base_url=api_base_url, + api_url=api_url, + credentials=creds, + http_request=http_request, + method="GET", + ) + return cast("TwilioMessageResource", response.body) + + +async def delete_twilio_message( + message_sid: str, + *, + api_base_url: str | None = None, + api_url: str | None = None, + credentials: TwilioCredentials | None = None, + http_request: TwilioHttpRequest | None = None, +) -> None: + """Delete a message resource by SID.""" + creds = credentials if credentials is not None else TwilioCredentials() + account_sid = await resolve_twilio_credential(creds.account_sid, ENV_ACCOUNT_SID) + await call_twilio_api( + f"/2010-04-01/Accounts/{_encode_path_segment(account_sid)}/Messages/{_encode_path_segment(message_sid)}.json", + api_base_url=api_base_url, + api_url=api_url, + credentials=creds, + http_request=http_request, + method="DELETE", + ) + + +async def list_twilio_messages( + *, + api_base_url: str | None = None, + api_url: str | None = None, + credentials: TwilioCredentials | None = None, + from_: str | None = None, + http_request: TwilioHttpRequest | None = None, + limit: int | None = None, + page_size: int | None = None, + to: str | None = None, +) -> list[TwilioMessageResource]: + """List message resources filtered by From/To, newest first.""" + creds = credentials if credentials is not None else TwilioCredentials() + account_sid = await resolve_twilio_credential(creds.account_sid, ENV_ACCOUNT_SID) + search = encode_twilio_form( + { + "From": from_, + "PageSize": page_size, + "To": to, + } + ) + response = await call_twilio_api( + f"/2010-04-01/Accounts/{_encode_path_segment(account_sid)}/Messages.json", + api_base_url=api_base_url, + api_url=api_url, + credentials=creds, + http_request=http_request, + method="GET", + search=search, + ) + body = response.body if isinstance(response.body, dict) else {} + messages = body.get("messages") + resources = cast("list[TwilioMessageResource]", messages if messages is not None else []) + return resources[:limit] + + +# ============================================================================= +# Calls +# ============================================================================= + + +async def update_twilio_call( + call_sid: str, + *, + api_base_url: str | None = None, + api_url: str | None = None, + credentials: TwilioCredentials | None = None, + http_request: TwilioHttpRequest | None = None, + method: Literal["GET", "POST"] | None = None, + status: Literal["canceled", "completed"] | None = None, + twiml: str | None = None, + url: str | None = None, +) -> TwilioCallResource: + """Update a live call with TwiML, a redirect URL, or a status.""" + creds = credentials if credentials is not None else TwilioCredentials() + account_sid = await resolve_twilio_credential(creds.account_sid, ENV_ACCOUNT_SID) + if not (twiml or url or status): + raise ValidationError("twilio", "twiml, url, or status is required") + if twiml and url: + raise ValidationError("twilio", "twiml and url are mutually exclusive") + form = encode_twilio_form( + { + "Method": method, + "Status": status, + "Twiml": twiml, + "Url": url, + } + ) + response = await call_twilio_api( + f"/2010-04-01/Accounts/{_encode_path_segment(account_sid)}/Calls/{_encode_path_segment(call_sid)}.json", + api_base_url=api_base_url, + api_url=api_url, + body=form, + credentials=creds, + http_request=http_request, + ) + return cast("TwilioCallResource", response.body) + + +# ============================================================================= +# Media +# ============================================================================= + + +async def fetch_twilio_media( + url: str, + *, + credentials: TwilioCredentials | None = None, + http_request: TwilioHttpRequest | None = None, +) -> bytes: + """Download a (private) media URL with Basic auth, returning raw bytes.""" + creds = credentials if credentials is not None else TwilioCredentials() + account_sid = await resolve_twilio_credential(creds.account_sid, ENV_ACCOUNT_SID) + auth_token = await resolve_twilio_credential(creds.auth_token, ENV_AUTH_TOKEN) + request = http_request if http_request is not None else _default_http_request + headers = {"authorization": _basic_authorization(account_sid, auth_token)} + response = await request("GET", url, headers, None) + if not response.ok: + body = _parse_twilio_response_body(response.body) + _raise_for_status(response.status, body, action=f"GET {url}") + return response.body + + +def _encode_path_segment(value: str) -> str: + """``encodeURIComponent`` for path segments (SIDs are alphanumeric).""" + return encode_uri_component(value) + + +def _array_value(value: Sequence[str] | str | None) -> list[str]: + """Normalize an optional string-or-sequence into a list of strings.""" + if value is None: + return [] + if isinstance(value, str): + return [value] + return list(value) diff --git a/src/chat_sdk/adapters/twilio/thread.py b/src/chat_sdk/adapters/twilio/thread.py new file mode 100644 index 0000000..79bc4d9 --- /dev/null +++ b/src/chat_sdk/adapters/twilio/thread.py @@ -0,0 +1,44 @@ +"""Twilio thread ID encoding/decoding. + +Format: ``twilio:{encodeURIComponent(sender)}:{encodeURIComponent(recipient)}`` +(e.g. ``twilio:%2B15550000001:%2B15550000002``). Must match the TS adapter +byte-for-byte for cross-language state sharing. Mirrors upstream +``adapter-twilio/src/thread.ts``. +""" + +from __future__ import annotations + +from chat_sdk.adapters.twilio.types import TwilioThreadId +from chat_sdk.adapters.twilio.utils import decode_uri_component, encode_uri_component +from chat_sdk.shared.errors import ValidationError + + +def encode_twilio_thread_id(platform_data: TwilioThreadId) -> str: + """Encode a Twilio (sender, recipient) pair into a thread ID.""" + sender = encode_uri_component(platform_data.sender) + recipient = encode_uri_component(platform_data.recipient) + return f"twilio:{sender}:{recipient}" + + +def decode_twilio_thread_id(thread_id: str) -> TwilioThreadId: + """Decode a ``twilio:{sender}:{recipient}`` thread ID. + + Mirrors upstream's destructuring: segments beyond the third are + ignored (encoded values never contain ``:``). + """ + parts = thread_id.split(":") + adapter = parts[0] if parts else "" + sender = parts[1] if len(parts) > 1 else "" + recipient = parts[2] if len(parts) > 2 else "" + if adapter != "twilio" or not sender or not recipient: + raise ValidationError("twilio", f"Invalid Twilio thread ID: {thread_id}") + return TwilioThreadId( + recipient=decode_uri_component(recipient), + sender=decode_uri_component(sender), + ) + + +def twilio_channel_id(thread_id: str) -> str: + """Channel ID for a thread: ``twilio:{encodeURIComponent(sender)}``.""" + thread = decode_twilio_thread_id(thread_id) + return f"twilio:{encode_uri_component(thread.sender)}" diff --git a/src/chat_sdk/adapters/twilio/utils.py b/src/chat_sdk/adapters/twilio/utils.py new file mode 100644 index 0000000..293f3b9 --- /dev/null +++ b/src/chat_sdk/adapters/twilio/utils.py @@ -0,0 +1,170 @@ +"""Small shared helpers for the Twilio adapter. + +Mirrors upstream ``adapter-twilio/src/utils.ts`` plus the Python-only +request/param plumbing (the framework-agnostic stand-ins for the WHATWG +``Request`` / ``URLSearchParams`` objects upstream relies on). +""" + +from __future__ import annotations + +import inspect +from collections.abc import Mapping +from typing import Any, Literal, TypedDict +from urllib.parse import quote, unquote + +from chat_sdk.adapters.twilio.types import TwilioFormParams, TwilioParamsInput + +# Characters JavaScript's encodeURIComponent leaves unescaped beyond +# Python's always-safe set (letters, digits, ``_.-~``). +_ENCODE_URI_COMPONENT_SAFE = "!*'()" + + +def encode_uri_component(value: str) -> str: + """Percent-encode ``value`` exactly like JS ``encodeURIComponent``. + + Thread IDs are shared cross-SDK state, so the escaping must match the + TS adapter byte-for-byte (e.g. ``+`` -> ``%2B``, ``:`` -> ``%3A``). + """ + return quote(value, safe=_ENCODE_URI_COMPONENT_SAFE) + + +def decode_uri_component(value: str) -> str: + """Percent-decode ``value`` like JS ``decodeURIComponent``.""" + return unquote(value) + + +class TwilioSenderFields(TypedDict, total=False): + """Sender kwargs for ``send_twilio_message`` (exactly one key set).""" + + from_: str + messaging_service_sid: str + + +def sender_fields(sender: str) -> TwilioSenderFields: + """Split a thread sender into Messages API sender fields. + + ``MG``-prefixed senders are Messaging Service SIDs; anything else is a + phone number / channel address. Mirrors upstream ``senderFields``. + """ + if sender.startswith("MG"): + return {"messaging_service_sid": sender} + return {"from_": sender} + + +def attachment_type(content_type: str | None) -> Literal["audio", "file", "image", "video"]: + """Map a media content type to the SDK ``Attachment.type`` enum.""" + if content_type is not None and content_type.startswith("image/"): + return "image" + if content_type is not None and content_type.startswith("video/"): + return "video" + if content_type is not None and content_type.startswith("audio/"): + return "audio" + return "file" + + +def twiml_response() -> dict[str, Any]: + """Empty TwiML acknowledgment returned by the messaging webhook. + + Mirrors upstream ``twimlResponse`` (note: ``application/xml`` here; + the voice helpers use ``text/xml;charset=UTF-8`` like upstream). + """ + return { + "body": "", + "status": 200, + "headers": {"content-type": "application/xml"}, + } + + +__all__ = [ + "as_param_pairs", + "attachment_type", + "coalesce", + "decode_uri_component", + "encode_uri_component", + "first_param", + "get_request_header", + "get_request_method", + "get_request_text", + "get_request_url", + "sender_fields", + "twiml_response", +] + + +# ============================================================================= +# Form-param plumbing (URLSearchParams stand-in) +# ============================================================================= + + +def as_param_pairs(params: TwilioParamsInput) -> TwilioFormParams: + """Normalize a mapping / iterable of pairs to ordered (name, value) pairs.""" + if isinstance(params, Mapping): + return [(str(name), str(value)) for name, value in params.items()] + return [(str(name), str(value)) for name, value in params] + + +def first_param(params: TwilioFormParams, name: str) -> str | None: + """First value for ``name``, treating empty strings as missing. + + Mirrors the upstream ``value()`` helper (``URLSearchParams.get`` + + empty-string normalization) shared by the webhook and voice parsers. + """ + for key, value in params: + if key == name: + return value if len(value) > 0 else None + return None + + +def coalesce(first: str | None, second: str | None) -> str | None: + """``??`` for two optional strings (empty strings are real values).""" + return first if first is not None else second + + +# ============================================================================= +# Framework-agnostic request access (duck-typed, like the other adapters) +# ============================================================================= + + +def get_request_method(request: Any) -> str: + """The request HTTP method, defaulting to POST.""" + method = getattr(request, "method", None) + return str(method).upper() if method else "POST" + + +def get_request_url(request: Any) -> str: + """The full request URL as a string.""" + return str(getattr(request, "url", "")) + + +def get_request_header(request: Any, name: str) -> str | None: + """Get a header value from a request object (case-insensitive).""" + headers = getattr(request, "headers", None) + if headers is None: + return None + if isinstance(headers, Mapping): + for key, value in headers.items(): + if str(key).lower() == name.lower(): + return value + return None + return headers.get(name) + + +async def get_request_text(request: Any) -> str: + """Extract the request body as text (sync or async ``text``/``body``). + + Twilio signs the decoded form parameter *values* (not the raw bytes), + so a text body is sufficient for signature verification — unlike the + Meta adapters, which HMAC the raw wire bytes. + """ + for attr_name in ("text", "body"): + value = getattr(request, attr_name, None) + if value is None: + continue + if callable(value): + result = value() + value = await result if inspect.isawaitable(result) else result + if isinstance(value, (bytes, bytearray)): + return bytes(value).decode("utf-8") + if isinstance(value, str): + return value + return "" diff --git a/src/chat_sdk/adapters/twilio/voice.py b/src/chat_sdk/adapters/twilio/voice.py new file mode 100644 index 0000000..478bc2f --- /dev/null +++ b/src/chat_sdk/adapters/twilio/voice.py @@ -0,0 +1,282 @@ +"""Low-level Twilio voice helpers. + +Python port of upstream ``adapter-twilio/src/voice/index.ts``. Voice calls +are intentionally NOT routed through the SMS/MMS adapter: apps that own a +Twilio voice route use these helpers (with +:mod:`chat_sdk.adapters.twilio.webhook` for signature verification) to +parse call / transcription webhooks and build TwiML responses. + +TwiML responses are framework-agnostic dicts (``body`` / ``status`` / +``headers``), matching the response shape the adapters return from +``handle_webhook``. + +Custom voice routes should verify the Twilio signature and apply their own +caller allow-list before returning TwiML. +""" + +from __future__ import annotations + +import json +import math +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any, Literal + +from chat_sdk.adapters.twilio.types import TwilioFormParams, TwilioParamsInput +from chat_sdk.adapters.twilio.utils import as_param_pairs, coalesce, first_param + +# ============================================================================= +# Payloads +# ============================================================================= + + +@dataclass +class TwilioVoiceCallPayload: + """An inbound voice-call webhook (``From``/``Caller`` + ``CallSid``).""" + + from_: str + raw: TwilioFormParams + account_sid: str | None = None + call_sid: str | None = None + to: str | None = None + + +@dataclass +class TwilioVoiceTranscriptionPayload: + """A final transcription result. + + Unifies the three Twilio shapes: ```` action + callbacks (``SpeechResult``), recording transcription callbacks + (``TranscriptionText``), and real-time Transcription events + (``TranscriptionData`` JSON). + """ + + text: str + raw: TwilioFormParams + account_sid: str | None = None + call_sid: str | None = None + confidence: float | None = None + final: bool | None = None + from_: str | None = None + sequence_id: str | None = None + timestamp: str | None = None + to: str | None = None + track: str | None = None + transcription_event: str | None = None + transcription_sid: str | None = None + + +@dataclass +class TwilioGatherSpeechResponseOptions: + """Options for :func:`gather_speech_twilio_response`.""" + + action_url: str + prompt: str + action_on_empty_result: bool | None = None + hints: Sequence[str] | str | None = None + language: str | None = None + method: Literal["GET", "POST"] | None = None + profanity_filter: bool | None = None + speech_model: str | None = None + speech_timeout: str | None = None + timeout_seconds: int | None = None + voice: str | None = None + + +# ============================================================================= +# Parsing +# ============================================================================= + + +def parse_twilio_voice_call(params: TwilioParamsInput) -> TwilioVoiceCallPayload | None: + """Parse an inbound voice-call webhook; ``None`` when not a call.""" + pairs = as_param_pairs(params) + from_ = coalesce(first_param(pairs, "From"), first_param(pairs, "Caller")) + if from_ is None: + return None + return TwilioVoiceCallPayload( + account_sid=first_param(pairs, "AccountSid"), + call_sid=first_param(pairs, "CallSid"), + from_=from_, + raw=pairs, + to=coalesce(first_param(pairs, "To"), first_param(pairs, "Called")), + ) + + +def parse_twilio_voice_transcription( + params: TwilioParamsInput, +) -> TwilioVoiceTranscriptionPayload | None: + """Parse a transcription webhook; ``None`` for partial/empty results.""" + pairs = as_param_pairs(params) + data = _parse_transcription_data(first_param(pairs, "TranscriptionData")) + final = _parse_boolean(first_param(pairs, "Final")) + if final is False: + return None + text_value = coalesce( + coalesce(first_param(pairs, "SpeechResult"), first_param(pairs, "TranscriptionText")), + data.get("transcript") if data is not None else None, + ) + text = text_value if text_value is not None else "" + if len(text.strip()) == 0: + return None + confidence_raw = coalesce( + first_param(pairs, "Confidence"), + data.get("confidence") if data is not None else None, + ) + return TwilioVoiceTranscriptionPayload( + account_sid=first_param(pairs, "AccountSid"), + call_sid=first_param(pairs, "CallSid"), + confidence=_parse_number(confidence_raw), + final=final, + from_=coalesce(first_param(pairs, "From"), first_param(pairs, "Caller")), + raw=pairs, + sequence_id=first_param(pairs, "SequenceId"), + text=text, + timestamp=first_param(pairs, "Timestamp"), + to=coalesce(first_param(pairs, "To"), first_param(pairs, "Called")), + track=first_param(pairs, "Track"), + transcription_event=first_param(pairs, "TranscriptionEvent"), + transcription_sid=first_param(pairs, "TranscriptionSid"), + ) + + +# ============================================================================= +# TwiML responses +# ============================================================================= + + +def empty_twilio_response() -> dict[str, Any]: + """An empty ```` TwiML response.""" + return twilio_response("") + + +def say_twilio_response(message: str) -> dict[str, Any]: + """A single ```` TwiML response with the message XML-escaped.""" + return twilio_response(f"{escape_xml(message)}") + + +def gather_speech_twilio_response( + options: TwilioGatherSpeechResponseOptions, +) -> dict[str, Any]: + """A ```` TwiML response wrapping a ````. + + Attribute order and defaults mirror upstream exactly (``method`` + defaults to POST, ``actionOnEmptyResult`` to true; optional + attributes are omitted when unset). + """ + if isinstance(options.hints, str): + hints: str | None = options.hints + elif options.hints is not None: + hints = ",".join(options.hints) + else: + hints = None + method = options.method if options.method is not None else "POST" + action_on_empty = "false" if options.action_on_empty_result is False else "true" + language = options.language + speech_model = options.speech_model + speech_timeout = options.speech_timeout + voice = options.voice + attributes = [ + 'input="speech"', + f'action="{escape_xml(options.action_url)}"', + f'method="{method}"', + f'actionOnEmptyResult="{action_on_empty}"', + f'language="{escape_xml(language)}"' if language else None, + f'speechModel="{escape_xml(speech_model)}"' if speech_model else None, + None if options.timeout_seconds is None else f'timeout="{options.timeout_seconds}"', + f'speechTimeout="{escape_xml(speech_timeout)}"' if speech_timeout else None, + f'hints="{escape_xml(hints)}"' if hints else None, + None + if options.profanity_filter is None + else f'profanityFilter="{"true" if options.profanity_filter else "false"}"', + ] + gather_attributes = " ".join(attribute for attribute in attributes if attribute is not None) + say_attribute_parts = [ + f'voice="{escape_xml(voice)}"' if voice else None, + f'language="{escape_xml(language)}"' if language else None, + ] + say_attributes = " ".join(attribute for attribute in say_attribute_parts if attribute is not None) + say_open = f"" if say_attributes else "" + return twilio_response( + f"{say_open}{escape_xml(options.prompt)}" + ) + + +def twilio_response(twiml: str) -> dict[str, Any]: + """Wrap a TwiML string in a framework-agnostic 200 response dict.""" + return { + "body": twiml, + "status": 200, + "headers": {"content-type": "text/xml;charset=UTF-8"}, + } + + +def escape_xml(value: str) -> str: + """Escape XML special characters for TwiML content and attributes.""" + return ( + value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'") + ) + + +# ============================================================================= +# Internal parse helpers +# ============================================================================= + + +def _parse_transcription_data(data: str | None) -> dict[str, str | None] | None: + """Decode the ``TranscriptionData`` JSON blob (real-time events).""" + if data is None: + return None + try: + parsed = json.loads(data) + except ValueError: + return None + if not isinstance(parsed, dict): + # JSON scalars/arrays carry neither transcript nor confidence; + # upstream's property reads would yield undefined for both. + return {"confidence": None, "transcript": None} + confidence = parsed.get("confidence") + transcript = parsed.get("transcript") + if isinstance(confidence, bool): + confidence_str: str | None = None # JS typeof true is "boolean", not number + elif isinstance(confidence, (int, float)): + confidence_str = _js_number_string(confidence) + elif isinstance(confidence, str): + confidence_str = confidence + else: + confidence_str = None + return { + "confidence": confidence_str, + "transcript": transcript if isinstance(transcript, str) else None, + } + + +def _js_number_string(value: int | float) -> str: + """``String(number)`` semantics: integral floats drop the ``.0``.""" + if isinstance(value, float) and value.is_integer() and math.isfinite(value): + return str(int(value)) + return str(value) + + +def _parse_boolean(value: str | None) -> bool | None: + if value is None: + return None + if value == "true": + return True + if value == "false": + return False + return None + + +def _parse_number(value: str | None) -> float | None: + if value is None: + return None + try: + parsed = float(value) + except ValueError: + return None + return parsed if math.isfinite(parsed) else None diff --git a/src/chat_sdk/adapters/twilio/webhook.py b/src/chat_sdk/adapters/twilio/webhook.py new file mode 100644 index 0000000..46fe6c7 --- /dev/null +++ b/src/chat_sdk/adapters/twilio/webhook.py @@ -0,0 +1,231 @@ +"""Twilio webhook verification and parsing. + +Python port of upstream ``adapter-twilio/src/webhook/{index,parse,verify}.ts``. + +Verification follows Twilio's documented scheme: the ``X-Twilio-Signature`` +header is HMAC-SHA1 (base64) over the exact public webhook URL concatenated +with the sorted POST form parameters (``name`` + ``value`` per pair, names +sorted, duplicate values deduplicated and sorted — matching upstream's +``twilioSignatureBase``). GET requests sign the URL only (the query string +is already part of it). Comparison is constant-time +(``hmac.compare_digest``). + +See: https://www.twilio.com/docs/usage/security#validating-requests +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import inspect +import math +from typing import Any +from urllib.parse import parse_qsl, urlparse + +from chat_sdk.adapters.twilio.api import resolve_twilio_credential +from chat_sdk.adapters.twilio.types import ( + ENV_AUTH_TOKEN, + TwilioCredential, + TwilioFormParams, + TwilioMediaPayload, + TwilioParamsInput, + TwilioStatusPayload, + TwilioTextPayload, + TwilioUnsupportedPayload, + TwilioVerifiedRequest, + TwilioWebhookPayload, + TwilioWebhookUrl, + TwilioWebhookVerificationError, + TwilioWebhookVerifier, +) +from chat_sdk.adapters.twilio.utils import ( + as_param_pairs, + coalesce, + first_param, + get_request_header, + get_request_method, + get_request_text, + get_request_url, +) + +# ============================================================================= +# Verification +# ============================================================================= + + +async def read_twilio_webhook( + request: Any, + *, + auth_token: TwilioCredential | None = None, + webhook_url: TwilioWebhookUrl | None = None, + webhook_verifier: TwilioWebhookVerifier | None = None, +) -> TwilioWebhookPayload: + """Verify an incoming webhook request and parse its payload.""" + verified = await verify_twilio_request( + request, + auth_token=auth_token, + webhook_url=webhook_url, + webhook_verifier=webhook_verifier, + ) + return parse_twilio_webhook_body(verified.params) + + +async def verify_twilio_request( + request: Any, + *, + auth_token: TwilioCredential | None = None, + webhook_url: TwilioWebhookUrl | None = None, + webhook_verifier: TwilioWebhookVerifier | None = None, +) -> TwilioVerifiedRequest: + """Verify a webhook request, returning its body and decoded params. + + ``webhook_verifier`` (when set) fully replaces signature verification: + a falsy result rejects the request, a ``str`` result substitutes the + body. Otherwise the ``X-Twilio-Signature`` header is checked against + the auth token (explicit value/resolver, else ``TWILIO_AUTH_TOKEN``). + """ + body = await get_request_text(request) + + if webhook_verifier is not None: + result = webhook_verifier(request, body) + if inspect.isawaitable(result): + result = await result + if not result: + raise TwilioWebhookVerificationError("Twilio webhook verifier rejected the request") + effective_body = result if isinstance(result, str) else body + return TwilioVerifiedRequest( + body=effective_body, + params=_params_for_request(request, effective_body), + ) + + signature = get_request_header(request, "x-twilio-signature") + if not signature: + raise TwilioWebhookVerificationError("Twilio signature header is required") + + token = await resolve_twilio_credential(auth_token, ENV_AUTH_TOKEN) + url = await resolve_twilio_webhook_url(request, webhook_url) + params = _params_for_request(request, body) + signed_params = None if get_request_method(request) == "GET" else params + expected = sign_twilio_request(auth_token=token, params=signed_params, url=url) + if not hmac.compare_digest(expected.encode("utf-8"), signature.encode("utf-8")): + raise TwilioWebhookVerificationError("Twilio signature is invalid") + return TwilioVerifiedRequest(body=body, params=params) + + +def sign_twilio_request( + *, + auth_token: str, + url: str, + params: TwilioParamsInput | None = None, +) -> str: + """Compute the ``X-Twilio-Signature`` value for a URL + form params. + + Sync in Python (upstream is async only because of WebCrypto). + """ + base = twilio_signature_base(url, params) + digest = hmac.new(auth_token.encode("utf-8"), base.encode("utf-8"), hashlib.sha1).digest() + return base64.b64encode(digest).decode("ascii") + + +def twilio_signature_base(url: str, params: TwilioParamsInput | None = None) -> str: + """Build Twilio's validation base string: URL + sorted name/value pairs. + + Mirrors upstream exactly: pairs are grouped by name, values are + deduplicated (set semantics) and sorted, and names are sorted. + """ + if params is None: + return url + grouped: dict[str, set[str]] = {} + for name, value in as_param_pairs(params): + grouped.setdefault(name, set()).add(value) + base = url + for name in sorted(grouped): + for value in sorted(grouped[name]): + base += f"{name}{value}" + return base + + +async def resolve_twilio_webhook_url(request: Any, webhook_url: TwilioWebhookUrl | None) -> str: + """Resolve the URL Twilio signed: explicit/resolved, else the request URL.""" + if callable(webhook_url): + result = webhook_url(request) + return await result if inspect.isawaitable(result) else result + return webhook_url if webhook_url is not None else get_request_url(request) + + +def _params_for_request(request: Any, body: str) -> TwilioFormParams: + """Decoded params: the URL query for GET, the form body otherwise.""" + if get_request_method(request) == "GET": + return parse_qsl(urlparse(get_request_url(request)).query, keep_blank_values=True) + return parse_qsl(body, keep_blank_values=True) + + +# ============================================================================= +# Parsing +# ============================================================================= + + +def parse_twilio_webhook_body(params: TwilioParamsInput) -> TwilioWebhookPayload: + """Classify a decoded webhook into text / status / unsupported.""" + pairs = as_param_pairs(params) + status = coalesce(first_param(pairs, "MessageStatus"), first_param(pairs, "SmsStatus")) + body = first_param(pairs, "Body") + from_ = first_param(pairs, "From") + to = first_param(pairs, "To") + message_sid = coalesce(first_param(pairs, "MessageSid"), first_param(pairs, "SmsMessageSid")) + + if status is not None and body is None: + return TwilioStatusPayload( + account_sid=first_param(pairs, "AccountSid"), + from_=from_, + message_sid=message_sid, + message_status=status, + raw=pairs, + to=to, + ) + + if from_ is not None and to is not None and (body is not None or _media_count(pairs) > 0): + return TwilioTextPayload( + account_sid=first_param(pairs, "AccountSid"), + body=body if body is not None else "", + from_=from_, + media=_media_payloads(pairs), + message_sid=message_sid, + raw=pairs, + to=to, + ) + + return TwilioUnsupportedPayload(raw=pairs) + + +def _media_payloads(pairs: TwilioFormParams) -> list[TwilioMediaPayload]: + media: list[TwilioMediaPayload] = [] + for index in range(_media_count(pairs)): + url = first_param(pairs, f"MediaUrl{index}") + if url is None: + continue + media.append( + TwilioMediaPayload( + url=url, + content_type=first_param(pairs, f"MediaContentType{index}"), + ) + ) + return media + + +def _media_count(pairs: TwilioFormParams) -> int: + """``NumMedia`` as a loop bound, with JS ``Number`` edge semantics.""" + raw = first_param(pairs, "NumMedia") + if raw is None: + return 0 + try: + count = float(raw) + except ValueError: + return 0 + if math.isnan(count) or count <= 0: + return 0 + try: + return math.ceil(count) + except (OverflowError, ValueError): + return 0 diff --git a/tests/test_twilio_adapter.py b/tests/test_twilio_adapter.py new file mode 100644 index 0000000..e2014a8 --- /dev/null +++ b/tests/test_twilio_adapter.py @@ -0,0 +1,585 @@ +"""Port of adapter-twilio/src/index.test.ts -- the TwilioAdapter itself. + +Covers thread-ID encode/decode/channel for phone and channel-prefixed +addresses, ``open_dm``, webhook routing into ``chat.process_message`` +(including MMS media attachments), private-media rehydration with adapter +credentials, the outbound send paths (SMS, MMS by URL, media-only, the +messaging-service sender, thread-ID stability), inbound REST parsing, the +``NotImplementedError`` surfaces, and the Python-only SSRF guard on media +downloads (a documented divergence from upstream). + +Upstream injects a ``fetch`` stand-in; the Python adapter injects an +``http_request`` transport returning :class:`TwilioHttpResponse`, so the +mocks here speak that shape. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any +from unittest.mock import AsyncMock, MagicMock +from urllib.parse import parse_qsl, urlencode + +import pytest + +from chat_sdk.adapters.twilio import ( + TwilioAdapter, + TwilioMessageResource, + create_twilio_adapter, +) +from chat_sdk.adapters.twilio.types import TwilioHttpResponse, TwilioThreadId +from chat_sdk.errors import ChatNotImplementedError +from chat_sdk.shared.errors import ValidationError +from chat_sdk.types import Attachment, Author, FetchOptions, Message, PostableMarkdown + +BASIC_AUTH = "Basic QUMxMjM6dG9rZW4=" # base64("AC123:token") + + +@dataclass +class _FakeRequest: + """Minimal request-like object (``url``/``method``/``headers``/``text``).""" + + _body: str = "" + url: str = "https://example.com/twilio" + method: str = "POST" + headers: dict[str, str] = field(default_factory=dict) + + async def text(self) -> str: + return self._body + + +def _form_request(fields: dict[str, str]) -> _FakeRequest: + return _FakeRequest( + _body=urlencode(fields), + headers={"content-type": "application/x-www-form-urlencoded"}, + ) + + +def _mock_http(payload: Any) -> AsyncMock: + """An ``http_request`` transport returning a 200 JSON (or raw) body.""" + body = payload.encode("utf-8") if isinstance(payload, str) else json.dumps(payload).encode("utf-8") + return AsyncMock(return_value=TwilioHttpResponse(status=200, body=body)) + + +def _sent_form(mock: AsyncMock) -> dict[str, str]: + """The form body of the (single) request the transport received.""" + body = mock.await_args.args[3] + assert body is not None + return dict(parse_qsl(body, keep_blank_values=True)) + + +def _sent_form_pairs(mock: AsyncMock) -> list[tuple[str, str]]: + body = mock.await_args.args[3] + assert body is not None + return parse_qsl(body, keep_blank_values=True) + + +def _mock_chat() -> MagicMock: + """A ChatInstance double; ``process_message`` is sync (returns None).""" + chat = MagicMock() + chat.process_message = MagicMock(return_value=None) + return chat + + +class TestThreadIds: + """Thread-ID encode/decode/channel through the adapter facade.""" + + def test_encodes_and_decodes_phone_and_channel_address_thread_ids(self): + adapter = create_twilio_adapter() + thread = TwilioThreadId(recipient="whatsapp:+15550000002", sender="whatsapp:+15550000001") + + thread_id = adapter.encode_thread_id(thread) + + assert thread_id == "twilio:whatsapp%3A%2B15550000001:whatsapp%3A%2B15550000002" + assert adapter.decode_thread_id(thread_id) == thread + assert adapter.channel_id_from_thread_id(thread_id) == "twilio:whatsapp%3A%2B15550000001" + + def test_is_dm_is_true_for_twilio_thread_ids(self): + adapter = create_twilio_adapter() + assert adapter.is_dm("twilio:%2B1:%2B2") is True + assert adapter.is_dm("slack:C1:1.2") is False + + @pytest.mark.asyncio + async def test_opens_dms_with_the_configured_phone_number(self): + adapter = create_twilio_adapter(phone_number="+15550000001") + assert await adapter.open_dm("+15550000002") == "twilio:%2B15550000001:%2B15550000002" + + @pytest.mark.asyncio + async def test_open_dm_without_a_sender_raises(self): + adapter = create_twilio_adapter() + with pytest.raises(ValidationError, match="phoneNumber or messagingServiceSid is required"): + await adapter.open_dm("+15550000002") + + +class TestWebhookRouting: + """handle_webhook -> chat.process_message.""" + + @pytest.mark.asyncio + async def test_routes_incoming_message_webhooks_to_chat_processing(self): + chat = _mock_chat() + adapter = create_twilio_adapter( + http_request=_mock_http("media"), + webhook_verifier=lambda request, body: True, + ) + await adapter.initialize(chat) + + response = await adapter.handle_webhook( + _form_request( + { + "Body": "hello", + "From": "+15550000002", + "MediaContentType0": "image/jpeg", + "MediaUrl0": "https://api.twilio.com/media/photo", + "MessageSid": "SM123", + "NumMedia": "1", + "To": "+15550000001", + } + ) + ) + + assert response["status"] == 200 + assert response["body"] == "" + chat.process_message.assert_called_once() + _adapter_arg, thread_id, message, _options = chat.process_message.call_args.args + assert thread_id == "twilio:%2B15550000001:%2B15550000002" + assert isinstance(message, Message) + assert message.text == "hello" + assert message.attachments[0].mime_type == "image/jpeg" + assert message.attachments[0].type == "image" + assert message.attachments[0].url == "https://api.twilio.com/media/photo" + + @pytest.mark.asyncio + async def test_acknowledges_non_text_webhooks_without_processing(self): + chat = _mock_chat() + adapter = create_twilio_adapter(webhook_verifier=lambda request, body: True) + await adapter.initialize(chat) + + # A status callback is not a text payload: ack, but do not route. + response = await adapter.handle_webhook(_form_request({"MessageStatus": "delivered", "MessageSid": "SM1"})) + + assert response["body"] == "" + chat.process_message.assert_not_called() + + @pytest.mark.asyncio + async def test_invalid_signature_returns_401(self): + adapter = create_twilio_adapter(auth_token="token") + request = _FakeRequest(_body="Body=hello", headers={"x-twilio-signature": "invalid"}) + await adapter.initialize(_mock_chat()) + + response = await adapter.handle_webhook(request) + + assert response["status"] == 401 + + +class TestRehydrateAttachment: + """rehydrate_attachment rebuilds the authenticated downloader.""" + + @pytest.mark.asyncio + async def test_rehydrates_private_media_fetchers_with_adapter_credentials(self): + http = _mock_http("photo") + adapter = create_twilio_adapter(account_sid="AC123", auth_token="token", http_request=http) + + attachment = adapter.rehydrate_attachment( + Attachment(type="image", fetch_metadata={"twilioMediaUrl": "https://api.twilio.com/media/photo"}) + ) + + assert attachment.fetch_data is not None + data = await attachment.fetch_data() + assert data == b"photo" + method, url, headers, _body = http.await_args.args + assert method == "GET" + assert url == "https://api.twilio.com/media/photo" + assert headers["authorization"] == BASIC_AUTH + + def test_rehydrate_without_a_url_returns_the_attachment_unchanged(self): + adapter = create_twilio_adapter() + original = Attachment(type="file") + assert adapter.rehydrate_attachment(original) is original + + @pytest.mark.asyncio + async def test_media_downloader_refuses_untrusted_hosts(self): + # Divergence from upstream: the SSRF guard fails closed rather than + # forwarding Basic auth to an arbitrary host rehydrated from state. + http = _mock_http("photo") + adapter = create_twilio_adapter(account_sid="AC123", auth_token="token", http_request=http) + + attachment = adapter.rehydrate_attachment( + Attachment(type="image", fetch_metadata={"twilioMediaUrl": "https://evil.example/steal"}) + ) + + assert attachment.fetch_data is not None + with pytest.raises(ValidationError, match="untrusted URL"): + await attachment.fetch_data() + http.assert_not_awaited() + + +class TestPostMessage: + """Outbound send paths through the Messages API.""" + + @pytest.mark.asyncio + async def test_posts_sms_messages_through_the_messages_api(self): + http = _mock_http( + { + "body": "hello", + "direction": "outbound-api", + "from": "+15550000001", + "sid": "SM123", + "to": "+15550000002", + } + ) + adapter = create_twilio_adapter( + account_sid="AC123", + auth_token="token", + http_request=http, + phone_number="+15550000001", + ) + + result = await adapter.post_message("twilio:%2B15550000001:%2B15550000002", "hello") + + assert result.id == "SM123" + assert result.thread_id == "twilio:%2B15550000001:%2B15550000002" + form = _sent_form(http) + assert form["Body"] == "hello" + assert form["From"] == "+15550000001" + assert form["To"] == "+15550000002" + + @pytest.mark.asyncio + async def test_keeps_messaging_service_threads_stable_after_sending(self): + http = _mock_http( + { + "body": "hello", + "direction": "outbound-api", + "from": "+15550000001", + "messaging_service_sid": "MG123", + "sid": "SM123", + "to": "+15550000002", + } + ) + adapter = create_twilio_adapter(account_sid="AC123", auth_token="token", http_request=http) + + result = await adapter.post_message("twilio:MG123:%2B15550000002", "hello") + + assert result.thread_id == "twilio:MG123:%2B15550000002" + + @pytest.mark.asyncio + async def test_posts_mms_messages_from_attachment_urls(self): + http = _mock_http( + { + "body": "photo", + "direction": "outbound-api", + "from": "+15550000001", + "sid": "SM123", + "to": "+15550000002", + } + ) + adapter = create_twilio_adapter(account_sid="AC123", auth_token="token", http_request=http) + + await adapter.post_message( + "twilio:%2B15550000001:%2B15550000002", + PostableMarkdown( + markdown="photo", + attachments=[Attachment(type="image", url="https://example.com/photo.jpg")], + ), + ) + + assert _sent_form(http)["MediaUrl"] == "https://example.com/photo.jpg" + + @pytest.mark.asyncio + async def test_posts_media_only_mms_without_a_blank_body(self): + http = _mock_http( + { + "direction": "outbound-api", + "from": "+15550000001", + "sid": "SM123", + "to": "+15550000002", + } + ) + adapter = create_twilio_adapter(account_sid="AC123", auth_token="token", http_request=http) + + await adapter.post_message( + "twilio:%2B15550000001:%2B15550000002", + PostableMarkdown( + markdown="", + attachments=[Attachment(type="image", url="https://example.com/photo.jpg")], + ), + ) + + names = [name for name, _value in _sent_form_pairs(http)] + assert "Body" not in names + assert _sent_form(http)["MediaUrl"] == "https://example.com/photo.jpg" + + @pytest.mark.asyncio + async def test_rejects_media_attachments_without_public_urls(self): + adapter = create_twilio_adapter( + account_sid="AC123", + auth_token="token", + http_request=_mock_http({"sid": "SM123"}), + ) + + with pytest.raises(ValidationError, match="public URL"): + await adapter.post_message( + "twilio:%2B15550000001:%2B15550000002", + PostableMarkdown(markdown="photo", attachments=[Attachment(type="image")]), + ) + + @pytest.mark.asyncio + async def test_uses_messaging_service_senders(self): + http = _mock_http( + { + "body": "hello", + "direction": "outbound-api", + "from": "MG123", + "messaging_service_sid": "MG123", + "sid": "SM123", + "to": "+15550000002", + } + ) + adapter = create_twilio_adapter( + account_sid="AC123", + auth_token="token", + http_request=http, + messaging_service_sid="MG123", + ) + + await adapter.post_message("twilio:MG123:%2B15550000002", "hello") + + form = _sent_form(http) + assert form["MessagingServiceSid"] == "MG123" + assert "From" not in form + + @pytest.mark.asyncio + async def test_rejects_empty_messages(self): + adapter = create_twilio_adapter( + account_sid="AC123", + auth_token="token", + http_request=_mock_http({"sid": "SM123"}), + ) + with pytest.raises(ValidationError, match="Message text cannot be empty"): + await adapter.post_message("twilio:%2B15550000001:%2B15550000002", "") + + +class TestParseMessage: + """parse_message over Messages API resources and webhook payloads.""" + + def test_parses_inbound_rest_messages_with_the_sender_as_author(self): + adapter = create_twilio_adapter() + raw: TwilioMessageResource = { + "body": "hello", + "date_created": "Tue, 01 Apr 2025 12:00:00 +0000", + "direction": "inbound", + "from": "+15550000002", + "sid": "SM123", + "to": "+15550000001", + } + + message = adapter.parse_message(raw) + + assert message.author.user_id == "+15550000002" + assert message.author.is_me is False + assert message.thread_id == "twilio:%2B15550000001:%2B15550000002" + + def test_parses_outbound_rest_messages_as_the_bot(self): + adapter = create_twilio_adapter() + raw: TwilioMessageResource = { + "body": "hi back", + "direction": "outbound-api", + "from": "+15550000001", + "sid": "SM124", + "to": "+15550000002", + } + + message = adapter.parse_message(raw) + + assert message.author.is_me is True + assert message.author.user_id == "+15550000001" + assert message.thread_id == "twilio:%2B15550000001:%2B15550000002" + + def test_resource_missing_routing_raises(self): + adapter = create_twilio_adapter() + with pytest.raises(ValidationError, match="missing routing"): + adapter.parse_message({"sid": "SM1", "direction": "inbound"}) + + +class TestNotImplemented: + """Surfaces Twilio genuinely cannot support.""" + + @pytest.mark.asyncio + async def test_edit_message_raises(self): + adapter = create_twilio_adapter() + with pytest.raises(ChatNotImplementedError, match="editMessage"): + await adapter.edit_message("twilio:%2B1:%2B2", "SM1", "x") + + @pytest.mark.asyncio + async def test_add_reaction_raises(self): + adapter = create_twilio_adapter() + with pytest.raises(ChatNotImplementedError, match="addReaction"): + await adapter.add_reaction("twilio:%2B1:%2B2", "SM1", "👍") + + @pytest.mark.asyncio + async def test_remove_reaction_raises(self): + adapter = create_twilio_adapter() + with pytest.raises(ChatNotImplementedError, match="removeReaction"): + await adapter.remove_reaction("twilio:%2B1:%2B2", "SM1", "👍") + + +class TestAdapterProperties: + """Static adapter metadata.""" + + def test_exposes_twilio_adapter_metadata(self): + adapter = create_twilio_adapter() + assert adapter.name == "twilio" + assert adapter.lock_scope == "channel" + assert adapter.persist_thread_history is True + assert adapter.user_name == "bot" + assert adapter.bot_user_id is None + + def test_user_name_is_configurable(self): + assert create_twilio_adapter(user_name="concierge").user_name == "concierge" + + def test_constructs_directly_without_config(self): + adapter = TwilioAdapter() + assert adapter.name == "twilio" + + +class TestStreaming: + """stream() buffers chunks and posts a single message.""" + + @pytest.mark.asyncio + async def test_stream_accumulates_chunks_into_one_send(self): + http = _mock_http( + { + "body": "hello world", + "direction": "outbound-api", + "from": "+15550000001", + "sid": "SM123", + "to": "+15550000002", + } + ) + adapter = create_twilio_adapter(account_sid="AC123", auth_token="token", http_request=http) + + async def chunks() -> Any: + yield "hello " + yield "world" + + result = await adapter.stream("twilio:%2B15550000001:%2B15550000002", chunks()) + + assert result.id == "SM123" + http.assert_awaited_once() + assert _sent_form(http)["Body"] == "hello world" + + +class TestFetchMessages: + """fetch_message / fetch_messages over the Messages API.""" + + @pytest.mark.asyncio + async def test_fetch_message_returns_none_on_api_failure(self): + # fetch_twilio_message raises ResourceNotFoundError on 404; the + # adapter swallows it and yields None (upstream's try/catch). + async def failing(method: str, url: str, headers: Any, body: Any) -> TwilioHttpResponse: + return TwilioHttpResponse(status=404, body=b"") + + adapter = create_twilio_adapter(account_sid="AC123", auth_token="token", http_request=failing) + assert await adapter.fetch_message("twilio:%2B15550000001:%2B15550000002", "SM404") is None + + @pytest.mark.asyncio + async def test_fetch_messages_merges_both_directions_sorted_by_date(self): + outbound = { + "messages": [ + { + "sid": "SMout", + "direction": "outbound-api", + "from": "+15550000001", + "to": "+15550000002", + "body": "from bot", + "date_sent": "Tue, 01 Apr 2025 12:00:02 +0000", + } + ] + } + inbound = { + "messages": [ + { + "sid": "SMin", + "direction": "inbound", + "from": "+15550000002", + "to": "+15550000001", + "body": "from user", + "date_sent": "Tue, 01 Apr 2025 12:00:01 +0000", + } + ] + } + + async def transport(method: str, url: str, headers: Any, body: Any) -> TwilioHttpResponse: + # Both list calls hit Messages.json (GET); the bot-as-sender call + # carries From= in the query, the inbound call From=. + payload = outbound if "From=%2B15550000001" in url else inbound + return TwilioHttpResponse(status=200, body=json.dumps(payload).encode("utf-8")) + + adapter = create_twilio_adapter(account_sid="AC123", auth_token="token", http_request=transport) + + result = await adapter.fetch_messages("twilio:%2B15550000001:%2B15550000002") + + # Sorted ascending by date_sent: inbound (12:00:01) before outbound (12:00:02). + assert [message.id for message in result.messages] == ["SMin", "SMout"] + assert result.messages[0].author.is_me is False + assert result.messages[1].author.is_me is True + + @pytest.mark.asyncio + async def test_fetch_messages_respects_the_limit_after_merge(self): + def page(prefix: str, count: int, base_second: int) -> dict[str, Any]: + return { + "messages": [ + { + "sid": f"{prefix}{index}", + "direction": "inbound" if prefix == "in" else "outbound-api", + "from": "+15550000002" if prefix == "in" else "+15550000001", + "to": "+15550000001" if prefix == "in" else "+15550000002", + "body": "x", + "date_sent": f"Tue, 01 Apr 2025 12:00:{base_second + index:02d} +0000", + } + for index in range(count) + ] + } + + async def transport(method: str, url: str, headers: Any, body: Any) -> TwilioHttpResponse: + payload = page("out", 2, 30) if "From=%2B15550000001" in url else page("in", 2, 10) + return TwilioHttpResponse(status=200, body=json.dumps(payload).encode("utf-8")) + + adapter = create_twilio_adapter(account_sid="AC123", auth_token="token", http_request=transport) + + result = await adapter.fetch_messages("twilio:%2B15550000001:%2B15550000002", FetchOptions(limit=3)) + + # 4 messages merged, newest 3 kept (slice(-limit)); the oldest inbound drops. + assert len(result.messages) == 3 + assert [message.id for message in result.messages] == ["in1", "out0", "out1"] + + +class TestFetchThreadAndUser: + """fetch_thread / get_user echo the address pair.""" + + @pytest.mark.asyncio + async def test_fetch_thread_is_a_dm_keyed_on_the_address_pair(self): + adapter = create_twilio_adapter() + info = await adapter.fetch_thread("twilio:%2B15550000001:%2B15550000002") + assert info.is_dm is True + assert info.channel_id == "twilio:%2B15550000001" + assert info.channel_name == "+15550000001" + assert info.metadata == {"recipient": "+15550000002", "sender": "+15550000001"} + + @pytest.mark.asyncio + async def test_get_user_echoes_the_phone_number(self): + adapter = create_twilio_adapter() + user = await adapter.get_user("+15550000002") + assert user is not None + assert user.user_id == "+15550000002" + assert user.full_name == "+15550000002" + assert user.is_bot is False + + +def test_author_helper_marks_bot_authorship(): + # The bot side is both ``is_me`` and ``is_bot`` (no Twilio bot identity). + author = TwilioAdapter._author("+15550000001", True) + assert isinstance(author, Author) + assert author.is_me is True + assert author.is_bot is True + assert author.user_id == "+15550000001" diff --git a/tests/test_twilio_api.py b/tests/test_twilio_api.py new file mode 100644 index 0000000..fcf4f89 --- /dev/null +++ b/tests/test_twilio_api.py @@ -0,0 +1,439 @@ +"""Port of adapter-twilio/src/api/index.test.ts -- REST API helpers. + +Covers the raw call surface (Basic auth, form encoding, search params), +send/fetch/list/delete message helpers, live-call updates, authenticated +media fetch, credential resolution (explicit / resolver / env), and the +typed error mapping (the Python adaptation of upstream's TwilioApiError). +""" + +from __future__ import annotations + +import json +import os +from collections.abc import Iterator +from typing import Any +from unittest.mock import AsyncMock +from urllib.parse import parse_qsl, urlsplit + +import pytest + +from chat_sdk.adapters.twilio.api import ( + TwilioApiError, + 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.types import ( + ENV_ACCOUNT_SID, + ENV_AUTH_TOKEN, + TwilioCredentials, + TwilioHttpResponse, +) +from chat_sdk.shared.errors import ( + AdapterPermissionError, + AdapterRateLimitError, + AuthenticationError, + ResourceNotFoundError, + ValidationError, +) + +BASIC_AUTH = "Basic QUMxMjM6dG9rZW4=" # base64("AC123:token") + + +def _credentials() -> TwilioCredentials: + return TwilioCredentials(account_sid="AC123", auth_token="token") + + +def _mock_http(payload: Any, status: int = 200) -> AsyncMock: + body = b"" if payload is None else json.dumps(payload).encode("utf-8") + return AsyncMock(return_value=TwilioHttpResponse(status=status, body=body)) + + +def _request_of(mock: AsyncMock) -> tuple[str, str, dict[str, str], str | None]: + method, url, headers, body = mock.await_args.args + return method, url, dict(headers), body + + +def _form_of(body: str | None) -> dict[str, str]: + assert body is not None + return dict(parse_qsl(body, keep_blank_values=True)) + + +@pytest.fixture +def _clear_twilio_credential_env() -> Iterator[None]: + saved: dict[str, str | None] = {k: os.environ.pop(k, None) for k in (ENV_ACCOUNT_SID, ENV_AUTH_TOKEN)} + yield + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +class TestCallTwilioApi: + """Tests for call_twilio_api.""" + + @pytest.mark.asyncio + async def test_supports_raw_api_calls_with_base_url_override(self): + request = _mock_http({"ok": True}) + + response = await call_twilio_api( + "/2010-04-01/Accounts/AC123/Messages.json", + api_base_url="https://twilio.test", + body={"Body": "hello", "Optional": None, "To": "+15550000002"}, + credentials=_credentials(), + http_request=request, + ) + + assert response.ok is True + assert response.status == 200 + method, url, headers, body = _request_of(request) + assert method == "POST" + assert url == "https://twilio.test/2010-04-01/Accounts/AC123/Messages.json" + assert headers["authorization"] == BASIC_AUTH + assert headers["content-type"] == "application/x-www-form-urlencoded;charset=UTF-8" + # `Optional: None` must be omitted, not serialized as "None". + assert _form_of(body) == {"Body": "hello", "To": "+15550000002"} + + @pytest.mark.asyncio + async def test_omits_the_form_content_type_without_a_body(self): + request = _mock_http({"ok": True}) + + await call_twilio_api( + "/2010-04-01/Accounts/AC123/Messages.json", + credentials=_credentials(), + http_request=request, + method="GET", + ) + + method, url, headers, body = _request_of(request) + assert method == "GET" + assert url == "https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json" + assert body is None + assert "content-type" not in headers + + +class TestSendTwilioMessage: + """Tests for send_twilio_message.""" + + @pytest.mark.asyncio + async def test_sends_form_encoded_messages_with_phone_number_sender(self): + request = _mock_http({"sid": "SM123"}) + + message = await send_twilio_message( + body="hello", + credentials=_credentials(), + http_request=request, + from_="+15550000001", + media_url=["https://example.com/photo.jpg"], + status_callback_url="https://example.com/status", + to="+15550000002", + ) + + assert message["sid"] == "SM123" + method, url, headers, body = _request_of(request) + assert method == "POST" + assert url == "https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json" + assert headers["authorization"] == BASIC_AUTH + assert _form_of(body) == { + "Body": "hello", + "From": "+15550000001", + "MediaUrl": "https://example.com/photo.jpg", + "StatusCallback": "https://example.com/status", + "To": "+15550000002", + } + + @pytest.mark.asyncio + async def test_sends_messages_with_a_messaging_service_sid(self): + request = _mock_http({"sid": "SM123"}) + + await send_twilio_message( + body="hello", + credentials=_credentials(), + http_request=request, + messaging_service_sid="MG123", + to="+15550000002", + ) + + form = _form_of(_request_of(request)[3]) + assert form["MessagingServiceSid"] == "MG123" + assert "From" not in form + + @pytest.mark.asyncio + async def test_appends_one_media_url_pair_per_item(self): + request = _mock_http({"sid": "SM123"}) + + await send_twilio_message( + credentials=_credentials(), + http_request=request, + from_="+15550000001", + media_url=["https://example.com/a.jpg", "https://example.com/b.jpg"], + to="+15550000002", + ) + + body = _request_of(request)[3] + assert body is not None + pairs = parse_qsl(body, keep_blank_values=True) + assert pairs.count(("MediaUrl", "https://example.com/a.jpg")) == 1 + assert pairs.count(("MediaUrl", "https://example.com/b.jpg")) == 1 + + @pytest.mark.asyncio + async def test_requires_body_or_media(self): + with pytest.raises(ValidationError, match="body or mediaUrl is required"): + await send_twilio_message( + credentials=_credentials(), + http_request=_mock_http({"sid": "SM123"}), + from_="+15550000001", + to="+15550000002", + ) + + @pytest.mark.asyncio + async def test_requires_a_sender(self): + with pytest.raises(ValidationError, match="from or messagingServiceSid is required"): + await send_twilio_message( + body="hello", + credentials=_credentials(), + http_request=_mock_http({"sid": "SM123"}), + to="+15550000002", + ) + + +class TestFetchListDelete: + """Tests for fetch/list/delete message helpers.""" + + @pytest.mark.asyncio + async def test_fetches_messages_by_sid(self): + request = _mock_http({"sid": "SM123"}) + + await fetch_twilio_message("SM123", credentials=_credentials(), http_request=request) + + method, url, _headers, body = _request_of(request) + assert method == "GET" + assert url == "https://api.twilio.com/2010-04-01/Accounts/AC123/Messages/SM123.json" + assert body is None + + @pytest.mark.asyncio + async def test_lists_messages_with_from_and_to_filters(self): + request = _mock_http({"messages": [{"sid": "SM123"}, {"sid": "SM124"}]}) + + messages = await list_twilio_messages( + credentials=_credentials(), + http_request=request, + from_="+15550000001", + limit=1, + page_size=50, + to="+15550000002", + ) + + assert messages == [{"sid": "SM123"}] + method, url, _headers, _body = _request_of(request) + assert method == "GET" + assert url == ( + "https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json" + "?From=%2B15550000001&PageSize=50&To=%2B15550000002" + ) + + @pytest.mark.asyncio + async def test_lists_messages_tolerate_missing_messages_key(self): + request = _mock_http({}) + assert await list_twilio_messages(credentials=_credentials(), http_request=request) == [] + + @pytest.mark.asyncio + async def test_deletes_messages_by_sid(self): + request = _mock_http(None) + + await delete_twilio_message("SM123", credentials=_credentials(), http_request=request) + + method, url, _headers, _body = _request_of(request) + assert method == "DELETE" + assert url == "https://api.twilio.com/2010-04-01/Accounts/AC123/Messages/SM123.json" + + +class TestUpdateTwilioCall: + """Tests for update_twilio_call.""" + + @pytest.mark.asyncio + async def test_updates_live_calls_with_twiml(self): + request = _mock_http({"sid": "CA123"}) + + call = await update_twilio_call( + "CA123", + credentials=_credentials(), + http_request=request, + twiml="hello", + ) + + assert call["sid"] == "CA123" + method, url, _headers, body = _request_of(request) + assert method == "POST" + assert url == "https://api.twilio.com/2010-04-01/Accounts/AC123/Calls/CA123.json" + assert _form_of(body) == {"Twiml": "hello"} + + @pytest.mark.asyncio + async def test_updates_live_calls_with_a_redirect_url(self): + request = _mock_http({"sid": "CA123"}) + + await update_twilio_call( + "CA123", + credentials=_credentials(), + http_request=request, + method="GET", + url="https://example.com/voice", + ) + + form = _form_of(_request_of(request)[3]) + assert form == {"Method": "GET", "Url": "https://example.com/voice"} + + @pytest.mark.asyncio + async def test_rejects_ambiguous_call_updates(self): + with pytest.raises(ValidationError, match="mutually exclusive"): + await update_twilio_call( + "CA123", + credentials=_credentials(), + http_request=_mock_http({"sid": "CA123"}), + twiml="", + url="https://example.com/voice", + ) + + @pytest.mark.asyncio + async def test_requires_twiml_url_or_status(self): + with pytest.raises(ValidationError, match="twiml, url, or status is required"): + await update_twilio_call( + "CA123", + credentials=_credentials(), + http_request=_mock_http({"sid": "CA123"}), + ) + + +class TestFetchTwilioMedia: + """Tests for fetch_twilio_media.""" + + @pytest.mark.asyncio + async def test_fetches_media_with_basic_auth(self): + request = AsyncMock(return_value=TwilioHttpResponse(status=200, body=b"photo")) + + media = await fetch_twilio_media( + "https://api.twilio.com/media/photo", + credentials=_credentials(), + http_request=request, + ) + + assert media == b"photo" + method, url, headers, body = _request_of(request) + assert method == "GET" + assert url == "https://api.twilio.com/media/photo" + assert headers == {"authorization": BASIC_AUTH} + assert body is None + + @pytest.mark.asyncio + async def test_raises_typed_errors_for_failed_media_downloads(self): + request = AsyncMock(return_value=TwilioHttpResponse(status=404, body=b"")) + with pytest.raises(ResourceNotFoundError): + await fetch_twilio_media( + "https://api.twilio.com/media/missing", + credentials=_credentials(), + http_request=request, + ) + + +class TestErrorMapping: + """Typed error mapping for non-2xx responses (Python adaptation).""" + + async def _send(self, status: int, payload: Any) -> None: + await send_twilio_message( + body="hello", + credentials=_credentials(), + http_request=_mock_http(payload, status=status), + from_="+15550000001", + to="+15550000002", + ) + + @pytest.mark.asyncio + async def test_maps_400_to_validation_error_with_twilio_message(self): + with pytest.raises(ValidationError, match="not a valid phone number"): + await self._send(400, {"code": 21211, "message": "The 'To' number is not a valid phone number."}) + + @pytest.mark.asyncio + async def test_maps_401_to_authentication_error(self): + with pytest.raises(AuthenticationError): + await self._send(401, {"message": "Authentication Error"}) + + @pytest.mark.asyncio + async def test_maps_403_to_permission_error(self): + with pytest.raises(AdapterPermissionError): + await self._send(403, {"message": "Forbidden"}) + + @pytest.mark.asyncio + async def test_maps_429_to_rate_limit_error(self): + with pytest.raises(AdapterRateLimitError): + await self._send(429, {"message": "Too Many Requests"}) + + @pytest.mark.asyncio + async def test_other_statuses_raise_twilio_api_error_with_status_and_body(self): + with pytest.raises(TwilioApiError) as excinfo: + await self._send(500, {"message": "Server Error"}) + assert excinfo.value.status == 500 + assert excinfo.value.body == {"message": "Server Error"} + assert excinfo.value.adapter == "twilio" + + +class TestResolveTwilioCredential: + """Tests for resolve_twilio_credential.""" + + @pytest.mark.asyncio + async def test_resolves_explicit_values(self, _clear_twilio_credential_env: None): + assert await resolve_twilio_credential("AC123", ENV_ACCOUNT_SID) == "AC123" + + @pytest.mark.asyncio + async def test_resolves_sync_and_async_resolvers(self, _clear_twilio_credential_env: None): + assert await resolve_twilio_credential(lambda: "from-sync", ENV_ACCOUNT_SID) == "from-sync" + + async def resolver() -> str: + return "from-async" + + assert await resolve_twilio_credential(resolver, ENV_ACCOUNT_SID) == "from-async" + + @pytest.mark.asyncio + async def test_falls_back_to_the_env_var(self, _clear_twilio_credential_env: None): + os.environ[ENV_ACCOUNT_SID] = "ACenv" + assert await resolve_twilio_credential(None, ENV_ACCOUNT_SID) == "ACenv" + + @pytest.mark.asyncio + async def test_missing_credential_names_the_env_var(self, _clear_twilio_credential_env: None): + with pytest.raises(AuthenticationError, match="TWILIO_ACCOUNT_SID is required"): + await resolve_twilio_credential(None, ENV_ACCOUNT_SID) + + +class TestEncodeTwilioForm: + """Tests for encode_twilio_form value semantics.""" + + def test_encodes_scalars_sequences_and_omits_none(self): + params = encode_twilio_form( + { + "Flag": True, + "Off": False, + "Items": ["a", "b"], + "Missing": None, + "Number": 50, + "Text": "hi", + } + ) + assert params == [ + ("Flag", "true"), + ("Off", "false"), + ("Items", "a"), + ("Items", "b"), + ("Number", "50"), + ("Text", "hi"), + ] + + def test_url_query_assembly_keeps_existing_query(self): + # Defensive coverage of the search-merge branch in call_twilio_api. + split = urlsplit("https://api.twilio.com/x.json?A=1") + assert split.query == "A=1" diff --git a/tests/test_twilio_voice.py b/tests/test_twilio_voice.py new file mode 100644 index 0000000..c077531 --- /dev/null +++ b/tests/test_twilio_voice.py @@ -0,0 +1,192 @@ +"""Port of adapter-twilio/src/voice/index.test.ts -- voice helpers. + +Covers inbound call parsing, the three transcription webhook shapes +(Gather speech results, real-time TranscriptionData events, recording +transcription callbacks), TwiML response builders (exact wire strings), +and XML escaping. +""" + +from __future__ import annotations + +from chat_sdk.adapters.twilio.voice import ( + TwilioGatherSpeechResponseOptions, + empty_twilio_response, + escape_xml, + gather_speech_twilio_response, + parse_twilio_voice_call, + parse_twilio_voice_transcription, + say_twilio_response, +) + + +class TestParseTwilioVoiceCall: + """Tests for parse_twilio_voice_call.""" + + def test_parses_inbound_voice_call_webhooks(self): + call = parse_twilio_voice_call( + { + "AccountSid": "AC123", + "CallSid": "CA123", + "Called": "+15550000001", + "Caller": "+15550000002", + } + ) + + assert call is not None + assert call.account_sid == "AC123" + assert call.call_sid == "CA123" + assert call.from_ == "+15550000002" + assert call.to == "+15550000001" + + def test_prefers_from_and_to_over_caller_and_called(self): + call = parse_twilio_voice_call({"Called": "+2", "Caller": "+1", "From": "+15550000009", "To": "+15550000008"}) + assert call is not None + assert call.from_ == "+15550000009" + assert call.to == "+15550000008" + + def test_returns_none_without_a_caller(self): + assert parse_twilio_voice_call({"CallSid": "CA123"}) is None + + +class TestParseTwilioVoiceTranscription: + """Tests for parse_twilio_voice_transcription.""" + + def test_parses_gather_speech_results(self): + transcription = parse_twilio_voice_transcription( + { + "CallSid": "CA123", + "Confidence": "0.9", + "From": "+15550000002", + "SpeechResult": "hello there", + "To": "+15550000001", + } + ) + + assert transcription is not None + assert transcription.call_sid == "CA123" + assert transcription.confidence == 0.9 + assert transcription.from_ == "+15550000002" + assert transcription.text == "hello there" + assert transcription.to == "+15550000001" + + def test_parses_final_real_time_transcription_content(self): + transcription = parse_twilio_voice_transcription( + { + "AccountSid": "AC123", + "CallSid": "CA123", + "Final": "true", + "SequenceId": "2", + "Timestamp": "2024-06-25T18:45:21.454203Z", + "Track": "outbound_track", + "TranscriptionData": '{"transcript":"hello from the call","confidence":0.9956335}', + "TranscriptionEvent": "transcription-content", + "TranscriptionSid": "GT123", + } + ) + + assert transcription is not None + assert transcription.confidence == 0.9956335 + assert transcription.final is True + assert transcription.sequence_id == "2" + assert transcription.text == "hello from the call" + assert transcription.track == "outbound_track" + assert transcription.transcription_event == "transcription-content" + assert transcription.transcription_sid == "GT123" + + def test_ignores_partial_real_time_transcription_content(self): + transcription = parse_twilio_voice_transcription( + { + "CallSid": "CA123", + "Final": "false", + "TranscriptionData": '{"transcript":"partial words"}', + } + ) + assert transcription is None + + def test_parses_recording_transcription_callbacks(self): + transcription = parse_twilio_voice_transcription( + { + "CallSid": "CA123", + "From": "+15550000002", + "To": "+15550000001", + "TranscriptionSid": "TR123", + "TranscriptionText": "recording text", + } + ) + + assert transcription is not None + assert transcription.call_sid == "CA123" + assert transcription.text == "recording text" + assert transcription.transcription_sid == "TR123" + + def test_returns_none_for_whitespace_only_text(self): + assert parse_twilio_voice_transcription({"CallSid": "CA1", "SpeechResult": " "}) is None + + def test_ignores_non_numeric_confidence(self): + transcription = parse_twilio_voice_transcription({"Confidence": "high", "SpeechResult": "hello"}) + assert transcription is not None + assert transcription.confidence is None + + +class TestTwiMLResponses: + """Tests for the TwiML response builders.""" + + def test_renders_gather_speech_twiml(self): + response = gather_speech_twilio_response( + TwilioGatherSpeechResponseOptions( + action_url="https://example.com/voice/result", + hints=["billing", "support"], + language="en-US", + profanity_filter=False, + prompt='say "hello" & continue', + speech_model="phone_call", + speech_timeout="auto", + timeout_seconds=4, + voice="Polly.Joanna-Neural", + ) + ) + + assert response["status"] == 200 + assert response["headers"]["content-type"] == "text/xml;charset=UTF-8" + assert response["body"] == ( + '' + '' + "say "hello" & continue" + ) + + def test_renders_minimal_gather_twiml_with_defaults(self): + response = gather_speech_twilio_response( + TwilioGatherSpeechResponseOptions( + action_url="https://example.com/voice", + prompt="hi", + ) + ) + assert response["body"] == ( + 'hi' + ) + + def test_action_on_empty_result_false_is_explicit(self): + response = gather_speech_twilio_response( + TwilioGatherSpeechResponseOptions( + action_url="https://example.com/voice", + prompt="hi", + action_on_empty_result=False, + ) + ) + assert 'actionOnEmptyResult="false"' in response["body"] + + def test_renders_simple_twiml_responses(self): + empty = empty_twilio_response() + assert empty["body"] == "" + assert empty["status"] == 200 + assert empty["headers"]["content-type"] == "text/xml;charset=UTF-8" + + say = say_twilio_response("hello ") + assert say["body"] == "hello <there>" + + def test_escapes_xml_attributes_and_content(self): + assert escape_xml("\"fish\" & 'chips' ") == (""fish" & 'chips' <ok>") diff --git a/tests/test_twilio_webhook.py b/tests/test_twilio_webhook.py new file mode 100644 index 0000000..0844f66 --- /dev/null +++ b/tests/test_twilio_webhook.py @@ -0,0 +1,359 @@ +"""Port of adapter-twilio/src/webhook/index.test.ts -- verification + parsing. + +Covers the X-Twilio-Signature scheme (documented Twilio example vector, +sorted/deduplicated base string, GET vs POST signing), the read pipeline +(verified POST/GET webhooks, custom verifier precedence), and webhook body +classification (text / MMS media / status / unsupported). +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import Any +from urllib.parse import urlencode + +import pytest + +from chat_sdk.adapters.twilio.types import ( + ENV_AUTH_TOKEN, + TwilioStatusPayload, + TwilioTextPayload, + TwilioUnsupportedPayload, + TwilioWebhookVerificationError, +) +from chat_sdk.adapters.twilio.webhook import ( + parse_twilio_webhook_body, + read_twilio_webhook, + sign_twilio_request, + twilio_signature_base, + verify_twilio_request, +) +from chat_sdk.shared.errors import AuthenticationError + +WEBHOOK_URL = "https://example.com/twilio" + + +@dataclass +class _FakeRequest: + """Minimal request-like object (``url``/``method``/``headers``/``text``).""" + + url: str = WEBHOOK_URL + method: str = "POST" + _body: str = "" + headers: dict[str, str] = field(default_factory=dict) + + async def text(self) -> str: + return self._body + + +def _signed_post(fields: dict[str, str], *, auth_token: str = "token", url: str = WEBHOOK_URL) -> _FakeRequest: + body = urlencode(fields) + signature = sign_twilio_request(auth_token=auth_token, params=fields, url=url) + return _FakeRequest( + url=url, + method="POST", + _body=body, + headers={ + "content-type": "application/x-www-form-urlencoded", + "x-twilio-signature": signature, + }, + ) + + +# --------------------------------------------------------------------------- +# Signature computation +# --------------------------------------------------------------------------- + + +class TestTwilioSignature: + """Tests for sign_twilio_request / twilio_signature_base.""" + + def test_matches_twilios_documented_form_signature_example(self): + signature = sign_twilio_request( + auth_token="12345", + params={ + "CallSid": "CA1234567890ABCDE", + "Caller": "+12349013030", + "Digits": "1234", + "From": "+12349013030", + "To": "+18005551212", + }, + url="https://mycompany.com/myapp", + ) + assert signature == "3KI2uRuYyAdhZIJXcpU0izDUzWI=" + + def test_builds_a_form_post_signature_base_with_sorted_parameters(self): + base = twilio_signature_base( + WEBHOOK_URL, + [("To", "+15550000002"), ("From", "+15550000001"), ("Body", "hello")], + ) + assert base == "https://example.com/twilioBodyhelloFrom+15550000001To+15550000002" + + def test_sorts_duplicate_form_parameters_like_twilio_node(self): + base = twilio_signature_base( + WEBHOOK_URL, + [ + ("MediaUrl", "https://example.com/b.jpg"), + ("MediaUrl", "https://example.com/a.jpg"), + ], + ) + assert base == ("https://example.com/twilioMediaUrlhttps://example.com/a.jpgMediaUrlhttps://example.com/b.jpg") + + def test_deduplicates_identical_duplicate_values(self): + # Upstream groups values into a Set, so an exact duplicate pair + # contributes once. Pinned: changing this breaks signature parity. + base = twilio_signature_base(WEBHOOK_URL, [("A", "1"), ("A", "1")]) + assert base == "https://example.com/twilioA1" + + def test_signs_url_only_when_params_are_none(self): + assert twilio_signature_base(f"{WEBHOOK_URL}?A=1") == f"{WEBHOOK_URL}?A=1" + + +# --------------------------------------------------------------------------- +# Verification + read pipeline +# --------------------------------------------------------------------------- + + +class TestReadTwilioWebhook: + """Tests for read_twilio_webhook / verify_twilio_request.""" + + @pytest.mark.asyncio + async def test_reads_verified_post_form_webhooks(self): + request = _signed_post( + { + "Body": "hello", + "From": "+15550000001", + "MessageSid": "SM123", + "NumMedia": "0", + "To": "+15550000002", + } + ) + + payload = await read_twilio_webhook(request, auth_token="token") + + assert isinstance(payload, TwilioTextPayload) + assert payload.body == "hello" + assert payload.from_ == "+15550000001" + assert payload.message_sid == "SM123" + assert payload.to == "+15550000002" + + @pytest.mark.asyncio + async def test_reads_verified_get_webhooks(self): + url = f"{WEBHOOK_URL}?Body=hello&From=%2B15550000001&To=%2B15550000002" + signature = sign_twilio_request(auth_token="token", params=None, url=url) + request = _FakeRequest(url=url, method="GET", headers={"x-twilio-signature": signature}) + + payload = await read_twilio_webhook(request, auth_token="token") + + assert isinstance(payload, TwilioTextPayload) + assert payload.body == "hello" + assert payload.from_ == "+15550000001" + assert payload.to == "+15550000002" + + @pytest.mark.asyncio + async def test_rejects_invalid_signatures(self): + request = _FakeRequest( + _body="Body=hello", + headers={ + "content-type": "application/x-www-form-urlencoded", + "x-twilio-signature": "invalid", + }, + ) + with pytest.raises(TwilioWebhookVerificationError, match="signature is invalid"): + await read_twilio_webhook(request, auth_token="token") + + @pytest.mark.asyncio + async def test_rejects_requests_without_a_signature_header(self): + request = _FakeRequest(_body="Body=hello") + with pytest.raises(TwilioWebhookVerificationError, match="signature header is required"): + await read_twilio_webhook(request, auth_token="token") + + @pytest.mark.asyncio + async def test_signature_header_lookup_is_case_insensitive(self): + request = _signed_post({"Body": "hello", "From": "+1", "To": "+2"}) + request.headers["X-Twilio-Signature"] = request.headers.pop("x-twilio-signature") + + payload = await read_twilio_webhook(request, auth_token="token") + + assert isinstance(payload, TwilioTextPayload) + + @pytest.mark.asyncio + async def test_verifies_against_an_explicit_webhook_url(self): + # Twilio signs the public URL; behind a proxy the request URL + # differs, so an explicit webhook_url must drive verification. + public_url = "https://public.example/twilio" + fields = {"Body": "hello", "From": "+1", "To": "+2"} + request = _signed_post(fields, url=public_url) + request.url = "http://internal:8080/twilio" + + payload = await read_twilio_webhook(request, auth_token="token", webhook_url=public_url) + assert isinstance(payload, TwilioTextPayload) + + with pytest.raises(TwilioWebhookVerificationError): + await read_twilio_webhook(request, auth_token="token") + + @pytest.mark.asyncio + async def test_resolves_a_callable_webhook_url(self): + public_url = "https://public.example/twilio" + request = _signed_post({"Body": "hi", "From": "+1", "To": "+2"}, url=public_url) + request.url = "http://internal:8080/twilio" + + async def resolver(req: Any) -> str: + assert req is request + return public_url + + payload = await read_twilio_webhook(request, auth_token="token", webhook_url=resolver) + assert isinstance(payload, TwilioTextPayload) + + @pytest.mark.asyncio + async def test_falls_back_to_the_env_auth_token(self): + saved = os.environ.get(ENV_AUTH_TOKEN) + os.environ[ENV_AUTH_TOKEN] = "env-token" + try: + request = _signed_post({"Body": "hello", "From": "+1", "To": "+2"}, auth_token="env-token") + payload = await read_twilio_webhook(request) + assert isinstance(payload, TwilioTextPayload) + finally: + if saved is None: + os.environ.pop(ENV_AUTH_TOKEN, None) + else: + os.environ[ENV_AUTH_TOKEN] = saved + + @pytest.mark.asyncio + async def test_missing_auth_token_raises_authentication_error(self): + saved = os.environ.pop(ENV_AUTH_TOKEN, None) + try: + request = _FakeRequest(_body="Body=x", headers={"x-twilio-signature": "sig"}) + with pytest.raises(AuthenticationError, match="TWILIO_AUTH_TOKEN is required"): + await read_twilio_webhook(request) + finally: + if saved is not None: + os.environ[ENV_AUTH_TOKEN] = saved + + +class TestWebhookVerifier: + """Custom webhook_verifier contract (SECURITY surface).""" + + @pytest.mark.asyncio + async def test_verifier_replaces_signature_verification(self): + # No signature header, no auth token: a passing verifier is enough. + request = _FakeRequest(_body="Body=hello&From=%2B1&To=%2B2") + seen: list[tuple[Any, str]] = [] + + def verifier(req: Any, body: str) -> bool: + seen.append((req, body)) + return True + + payload = await read_twilio_webhook(request, webhook_verifier=verifier) + + assert isinstance(payload, TwilioTextPayload) + assert seen == [(request, "Body=hello&From=%2B1&To=%2B2")] + + @pytest.mark.asyncio + async def test_falsy_verifier_result_rejects_the_request(self): + request = _FakeRequest(_body="Body=hello") + + async def verifier(req: Any, body: str) -> bool: + return False + + with pytest.raises(TwilioWebhookVerificationError, match="verifier rejected"): + await read_twilio_webhook(request, webhook_verifier=verifier) + + @pytest.mark.asyncio + async def test_string_verifier_result_substitutes_the_body(self): + request = _FakeRequest(_body="Body=original&From=%2B1&To=%2B2") + + def verifier(req: Any, body: str) -> str: + return "Body=replaced&From=%2B9&To=%2B8" + + verified = await verify_twilio_request(request, webhook_verifier=verifier) + + assert verified.body == "Body=replaced&From=%2B9&To=%2B8" + payload = parse_twilio_webhook_body(verified.params) + assert isinstance(payload, TwilioTextPayload) + assert payload.body == "replaced" + assert payload.from_ == "+9" + + +# --------------------------------------------------------------------------- +# Body parsing +# --------------------------------------------------------------------------- + + +class TestParseTwilioWebhookBody: + """Tests for parse_twilio_webhook_body.""" + + def test_parses_mms_media_parameters(self): + payload = parse_twilio_webhook_body( + { + "Body": "photo", + "From": "+15550000001", + "MediaContentType0": "image/jpeg", + "MediaUrl0": "https://api.twilio.com/media/one", + "MessageSid": "SM123", + "NumMedia": "1", + "To": "+15550000002", + } + ) + + assert isinstance(payload, TwilioTextPayload) + assert len(payload.media) == 1 + assert payload.media[0].content_type == "image/jpeg" + assert payload.media[0].url == "https://api.twilio.com/media/one" + + def test_parses_status_callbacks_separately(self): + payload = parse_twilio_webhook_body( + { + "From": "+15550000002", + "MessageSid": "SM123", + "MessageStatus": "delivered", + "To": "+15550000001", + } + ) + + assert isinstance(payload, TwilioStatusPayload) + assert payload.message_status == "delivered" + assert payload.message_sid == "SM123" + + def test_classifies_unrecognized_webhooks_as_unsupported(self): + payload = parse_twilio_webhook_body({"CallSid": "CA123", "CallStatus": "ringing"}) + assert isinstance(payload, TwilioUnsupportedPayload) + + def test_parses_media_only_messages_with_no_body(self): + # Body absent but NumMedia > 0 still classifies as text with body "". + payload = parse_twilio_webhook_body( + { + "From": "+15550000001", + "MediaUrl0": "https://api.twilio.com/media/one", + "NumMedia": "1", + "To": "+15550000002", + } + ) + assert isinstance(payload, TwilioTextPayload) + assert payload.body == "" + assert [media.url for media in payload.media] == ["https://api.twilio.com/media/one"] + assert payload.media[0].content_type is None + + def test_skips_media_indexes_without_urls(self): + payload = parse_twilio_webhook_body( + { + "Body": "x", + "From": "+1", + "MediaUrl1": "https://api.twilio.com/media/two", + "NumMedia": "2", + "To": "+2", + } + ) + assert isinstance(payload, TwilioTextPayload) + assert [media.url for media in payload.media] == ["https://api.twilio.com/media/two"] + + def test_falls_back_to_sms_prefixed_fields(self): + # SmsMessageSid / SmsStatus are the legacy aliases Twilio still sends. + status = parse_twilio_webhook_body({"SmsStatus": "sent", "MessageSid": "SM1"}) + assert isinstance(status, TwilioStatusPayload) + assert status.message_status == "sent" + + text = parse_twilio_webhook_body({"Body": "hi", "From": "+1", "SmsMessageSid": "SM2", "To": "+2"}) + assert isinstance(text, TwilioTextPayload) + assert text.message_sid == "SM2"