From 228ea2391703a86b629b9fa32c9c56fa22c8ebfe Mon Sep 17 00:00:00 2001 From: patrick-chinchill Date: Fri, 29 May 2026 19:47:23 -0700 Subject: [PATCH 01/14] =?UTF-8?q?feat(messenger):=20adapter=20=E2=80=94=20?= =?UTF-8?q?webhook,=20Graph=20API,=20send/stream=20(PR=202=20of=202)=20(#1?= =?UTF-8?q?24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(messenger): adapter — webhook, Graph API, send/stream (vercel/chat#461) Port of `packages/adapter-messenger/src/index.ts` (PR 2 of 2). Builds on the scaffolding (types/format converter/cards) added in PR #118. Includes: - `MessengerAdapter`: webhook routing (GET verification + POST events), X-Hub-Signature-256 HMAC-SHA256 verification, Graph API client backed by aiohttp, send paths (text / Generic template / Button template / text fallback), buffered streaming, postback / reaction / echo / delivery / read handling, attachment extraction with lazy download, message cache (Messenger has no history API), user-profile cache, thread / channel helpers, typed Graph-API error mapping. - `create_messenger_adapter` factory with FACEBOOK_* env fallbacks. - Exports wired through `chat_sdk.adapters.messenger`. Q1 (init-failure behavior, see #110): match `WhatsAppAdapter` — raise `ValidationError` from both the factory and the constructor when any required credential is missing, so config errors surface loudly at startup. Q3 (signature verification, see #110): pin upstream's X-Hub-Signature-256 + App Secret HMAC contract. A swappable verifier (Slack-style) would diverge from Meta's protocol with no offsetting benefit for a single-secret integration; flagged as a possible future divergence but not introduced here. Tests: `tests/test_messenger_webhook.py` (57) and `tests/test_messenger_api.py` (64) — mirrors the Telegram/WhatsApp file split. Covers signature valid/invalid/missing/wrong-algo/replay, webhook routing for all event types, postback decoding (raw + chat: prefix), card → template paths, stream buffering, truncation, error mapping, and the Q1 constructor failure path. 121 new tests, full suite: 4220 passed, 3 skipped. * fix(messenger): verify HMAC on raw bytes + URL/profile defenses (gemini review) Four follow-ups to PR #124 from Gemini's review of the Messenger adapter: - `_handle_verification` casts `request.url` to `str` before `urlparse`. Starlette/yarl `URL` objects are non-`str` and would raise. - `_get_request_body` now returns `bytes` instead of `str`. Decoding to UTF-8 and re-encoding for the HMAC step risks replacement characters (U+FFFD) for any non-UTF-8 byte sequence — that breaks signature parity with Meta's reference implementation and silently rejects legitimate webhooks. Body sources are also reordered: `body` attribute first (canonical raw bytes on Starlette/Django), `text` attribute fallback (aiohttp's str path). `is not None` everywhere so a legitimately empty `b""` body isn't skipped. - `_verify_signature(body: bytes, ...)` matches the new contract; the `body.encode("utf-8")` step is dropped — HMAC operates on the exact wire bytes Meta signed. JSON parsing at the call site still works (`json.loads` accepts both str and bytes). - `_fetch_user_profile` now `isinstance(profile, dict)` checks the Graph API response before caching. A non-dict response (None, list, unexpected shape) used to poison `_user_profile_cache` and raise `AttributeError` on the next `.get` call in `_profile_display_name`; we now fall back to the minimal `{"id": user_id}` profile and leave the cache untouched. Regression coverage (+7 tests): - `TestVerifySignature::test_verifies_raw_bytes_without_encoding_roundtrip` feeds a body with lone continuation bytes (0x80 0xff) that don't survive UTF-8 round-trip. Fails on the old decode+re-encode path. - `TestGetRequestBody` (new class, 5 cases) pins the bytes return type across all four framework-shaped inputs: bytes body attribute, str body attribute, async-callable body (Starlette/FastAPI), async-callable text (aiohttp), and missing-body → `b""`. - `test_non_dict_profile_response_falls_back` exercises both `None` and list responses, asserting the fallback display name and that the cache stays empty. Existing `TestVerifySignature` cases updated to pass `bytes` bodies, matching the new contract. `_sign` test helper accepts `bytes | str` for convenience. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj * fix(messenger): accept uppercase-hex signatures + clarify init-failure docstring (review) https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj * fix(messenger): implement rehydrate_attachment for queue/debounce safety (codex) Codex P2: Messenger attachments processed under queue/debounce/burst concurrency lose their fetch_data closure during JSON serialization in the state backend. Without a rehydrate_attachment hook, dequeued handlers see attachment.fetch_data is None and cannot download the file. Mirrors WhatsAppAdapter.rehydrate_attachment (same Meta family, same queue-mode failure shape): - _extract_attachments now persists the download URL on attachment.fetch_metadata={"url": url} - New rehydrate_attachment reads that URL and rebuilds the lazy downloader via _make_attachment_downloader, reusing the shared aiohttp session. Returns the attachment unchanged when metadata is missing/incomplete (degraded mode, matches the documented "leave unchanged when no hook" behavior). No auth headers are attached by the rebuilt closure — Messenger payload URLs are signature-gated by Meta and the original _download_attachment already operated without Bearer tokens. Upstream parity: vercel/chat's TS adapter-messenger does not implement this hook because queue mode is Python-only. Tests (4 new, load-bearing): - fetch_metadata carries the URL after extraction - queue/serialize roundtrip + rehydrate restores a working downloader that hits the original URL (would fail without the hook — verified by temporary revert) - two degraded-mode pins (no metadata, metadata without url key) * fix(messenger): use is-not-None for limit/user_name/cache checks + regression tests (audit) Targeted audit follow-up — three real truthiness bugs in src/chat_sdk/adapters/messenger/adapter.py, same Port Rule #1 root pattern: 1. _paginate_messages: ``options.limit or 50`` silently swallowed an explicit ``limit=0`` and substituted 50. Switched to ``is not None``. Regression: tests/test_messenger_api.py::test_explicit_zero_limit_is_not_swallowed_to_default. 2. __init__: ``config.user_name or "bot"`` paired with ``bool(config.user_name)`` silently replaced an explicit ``user_name=""`` with the ``"bot"`` fallback AND left ``_has_explicit_user_name`` False, so ``initialize()`` would then overwrite it from ``chat.get_user_name()`` / ``/me``. Switched both sites to ``is not None`` to match upstream's ``hasExplicitUserName`` semantics. Regression: tests/test_messenger_webhook.py::test_explicit_empty_user_name_is_respected. 3. _fetch_user_profile: ``if cached:`` treated a cached ``{}`` (empty dict, falsy) as a miss and re-fetched every call. Switched to ``if cached is not None``. Theoretical (Graph API rarely returns ``{}``) but cheap to make robust. Regression: tests/test_messenger_api.py::test_empty_dict_cache_entry_is_a_hit_not_a_miss. All three regression tests are load-bearing: each asserts an outcome the old code would have produced differently. Other Meta-family adapters (Teams, Discord, Google Chat) already use the ``is not None`` pattern for the same code site. Validation: ruff check / format clean, audit_test_quality.py 0 hard failures, 4235 passed / 3 skipped (was 4232 + 3 new tests = 4235). * fix(messenger): thread echo events by recipient PSID in parse_message (codex review) For a Messenger echo event (a bot-sent message echoed back), sender.id is the Page ID and recipient.id is the user's PSID — the reverse of a normal inbound message. parse_message previously keyed the thread ID off sender.id unconditionally, so a replayed echo was threaded under messenger: instead of messenger:, causing fetch_messages('messenger:') to miss the bot's own echoed message. Branch on message.is_echo so the thread ID keys off recipient.id for echoes (matching _handle_echo) and sender.id otherwise. Non-echo behavior unchanged. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj * build(messenger): add messenger extra declaring aiohttp (codex review) The Messenger adapter lazy-imports aiohttp on its runtime paths (initialize, send, profile lookup, attachment downloads), but the package ships `dependencies = []` and had no `messenger` optional-dependencies extra. A base install enabling only Messenger would raise `ModuleNotFoundError: aiohttp` the moment the adapter talks to Meta. Add `messenger = ["aiohttp>=3.9"]`, matching the sibling aiohttp adapters (telegram/whatsapp/teams/linear). The `all` extra already includes aiohttp>=3.9, so no change there. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj --------- Co-authored-by: Claude (cherry picked from commit 3ad373a14b45a41dad7096f3cff2f790e4988fe2) https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 --- pyproject.toml | 1 + src/chat_sdk/adapters/messenger/__init__.py | 18 +- src/chat_sdk/adapters/messenger/adapter.py | 1197 +++++++++++++++++++ tests/test_messenger_api.py | 989 +++++++++++++++ tests/test_messenger_webhook.py | 1105 +++++++++++++++++ 5 files changed, 3307 insertions(+), 3 deletions(-) create mode 100644 src/chat_sdk/adapters/messenger/adapter.py create mode 100644 tests/test_messenger_api.py create mode 100644 tests/test_messenger_webhook.py diff --git a/pyproject.toml b/pyproject.toml index 74bd268..fb28d8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ discord = ["pynacl>=1.5", "aiohttp>=3.9"] teams = ["aiohttp>=3.9"] telegram = ["aiohttp>=3.9"] whatsapp = ["aiohttp>=3.9"] +messenger = ["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/messenger/__init__.py b/src/chat_sdk/adapters/messenger/__init__.py index 0e86386..11d9d6b 100644 --- a/src/chat_sdk/adapters/messenger/__init__.py +++ b/src/chat_sdk/adapters/messenger/__init__.py @@ -1,10 +1,17 @@ """Messenger (Meta) adapter for chat-sdk. -PR 1 of 2 (scaffolding): types, format converter, and card conversion. -The adapter itself (webhook routing, Graph API, signature verification, -send/stream) is added in PR 2. +Python port of upstream ``packages/adapter-messenger``. Supports webhook +routing (with HMAC-SHA256 signature verification), Send API integration via +the Meta Graph API, and message/card/streaming primitives. """ +from chat_sdk.adapters.messenger.adapter import ( + DEFAULT_API_VERSION, + GRAPH_API_BASE, + MESSENGER_MESSAGE_LIMIT, + MessengerAdapter, + create_messenger_adapter, +) from chat_sdk.adapters.messenger.cards import ( MessengerCardResult, card_to_messenger, @@ -30,6 +37,10 @@ ) __all__ = [ + "DEFAULT_API_VERSION", + "GRAPH_API_BASE", + "MESSENGER_MESSAGE_LIMIT", + "MessengerAdapter", "MessengerAdapterConfig", "MessengerButton", "MessengerButtonTemplatePayload", @@ -47,6 +58,7 @@ "MessengerWebhookPayload", "card_to_messenger", "card_to_messenger_text", + "create_messenger_adapter", "decode_messenger_callback_data", "encode_messenger_callback_data", ] diff --git a/src/chat_sdk/adapters/messenger/adapter.py b/src/chat_sdk/adapters/messenger/adapter.py new file mode 100644 index 0000000..9d30434 --- /dev/null +++ b/src/chat_sdk/adapters/messenger/adapter.py @@ -0,0 +1,1197 @@ +"""Messenger (Meta) adapter for chat SDK. + +Supports messaging via the Meta Messenger Platform (Graph API). +All conversations are 1:1 DMs between the Page and a user (PSID). + +Python port of ``packages/adapter-messenger/src/index.ts`` (PR 2 of 2 of +the Messenger port; PR 1 added types, format converter, and cards). + +See: https://developers.facebook.com/docs/messenger-platform +""" + +from __future__ import annotations + +import hashlib +import hmac +import inspect +import json +import os +import time +from collections.abc import AsyncIterable +from datetime import datetime, timezone +from typing import Any, cast +from urllib.parse import parse_qs, urlparse + +from chat_sdk.adapters.messenger.cards import ( + MessengerCardResultTemplate, + MessengerCardResultText, + card_to_messenger, + decode_messenger_callback_data, +) +from chat_sdk.adapters.messenger.format_converter import MessengerFormatConverter +from chat_sdk.adapters.messenger.types import ( + ENV_APP_SECRET, + ENV_PAGE_ACCESS_TOKEN, + ENV_VERIFY_TOKEN, + MessengerAdapterConfig, + MessengerMessagingEvent, + MessengerRawMessage, + MessengerSendApiResponse, + MessengerTemplatePayload, + MessengerThreadId, + MessengerUserProfile, + MessengerWebhookPayload, +) +from chat_sdk.emoji import convert_emoji_placeholders, default_emoji_resolver +from chat_sdk.logger import ConsoleLogger, Logger +from chat_sdk.shared.adapter_utils import extract_card +from chat_sdk.shared.errors import ( + AdapterRateLimitError, + AuthenticationError, + NetworkError, + ResourceNotFoundError, + ValidationError, +) +from chat_sdk.types import ( + ActionEvent, + AdapterPostableMessage, + Attachment, + Author, + ChannelInfo, + ChatInstance, + EmojiValue, + FetchOptions, + FetchResult, + FormattedContent, + LockScope, + Message, + MessageMetadata, + PostableMarkdown, + RawMessage, + ReactionEvent, + StreamChunk, + StreamOptions, + ThreadInfo, + WebhookOptions, +) + +# Meta Graph API base URL +GRAPH_API_BASE = "https://graph.facebook.com" +# Default Graph API version (matches upstream) +DEFAULT_API_VERSION = "v21.0" +# Maximum text length the Send API will accept in a single message +MESSENGER_MESSAGE_LIMIT = 2000 +# Suffix that encodes a sequence number on Send-API mids (``mid.abc:2`` etc.) +# Used to disambiguate identical-timestamp messages in the local cache. +_MESSAGE_SEQUENCE_SUFFIX = ":" + + +class MessengerAdapter: + """Messenger (Meta) adapter for chat SDK. + + Implements the chat-sdk ``Adapter`` interface for the Messenger Platform. + """ + + def __init__(self, config: MessengerAdapterConfig) -> None: + # Upstream's constructor takes the resolved credentials directly. Our + # ``MessengerAdapterConfig`` exposes them via ``resolved_*`` helpers so + # the constructor and the factory both go through the same fallback + # chain. Constructing the adapter without resolved credentials is a + # programmer error — the factory enforces presence, and direct callers + # opt in to env-var fallbacks by leaving fields as ``None``. + app_secret = config.resolved_app_secret() + page_access_token = config.resolved_page_access_token() + verify_token = config.resolved_verify_token() + if not app_secret: + raise ValidationError( + "messenger", + f"appSecret is required. Set {ENV_APP_SECRET} or provide it in config.", + ) + if not page_access_token: + raise ValidationError( + "messenger", + f"pageAccessToken is required. Set {ENV_PAGE_ACCESS_TOKEN} or provide it in config.", + ) + if not verify_token: + raise ValidationError( + "messenger", + f"verifyToken is required. Set {ENV_VERIFY_TOKEN} or provide it in config.", + ) + + self._name = "messenger" + self._lock_scope: LockScope = "channel" + self._persist_message_history = True + + self._app_secret = app_secret + self._page_access_token = page_access_token + self._verify_token = verify_token + self._api_version = config.api_version or DEFAULT_API_VERSION + self._graph_api_url = f"{GRAPH_API_BASE}/{self._api_version}" + self._logger: Logger = config.logger + self._format_converter = MessengerFormatConverter() + + # If a user_name is provided we treat it as explicit and never + # overwrite it from ``/me`` / ``chat.get_user_name()`` — matches the + # upstream ``hasExplicitUserName`` gate. ``is not None`` (not truthy) + # so an explicit ``user_name=""`` is respected rather than silently + # replaced by the ``"bot"`` fallback. + self._has_explicit_user_name = config.user_name is not None + self._user_name = config.user_name if config.user_name is not None else "bot" + + self._chat: ChatInstance | None = None + self._bot_user_id: str | None = None + + # Local caches. Messenger has no message-history API, so the adapter + # holds every parsed message in memory to back ``fetch_messages``. + self._message_cache: dict[str, list[Message]] = {} + self._user_profile_cache: dict[str, MessengerUserProfile] = {} + + # 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_message_history(self) -> bool: + return self._persist_message_history + + @property + def user_name(self) -> str: + return self._user_name + + @property + def bot_user_id(self) -> str | None: + return self._bot_user_id + + # ========================================================================= + # Lifecycle + # ========================================================================= + + async def initialize(self, chat: ChatInstance) -> None: + """Initialize the adapter and fetch Page identity (best-effort).""" + self._chat = chat + + if not self._has_explicit_user_name: + self._user_name = chat.get_user_name() + + try: + me = await self._graph_api_fetch("me", method="GET") + self._bot_user_id = cast(str, me.get("id")) + name = me.get("name") + if not self._has_explicit_user_name and name: + self._user_name = name + self._logger.info( + "Messenger adapter initialized", + {"botUserId": self._bot_user_id, "userName": self._user_name}, + ) + except Exception as error: + # Match upstream: identity fetch failure is non-fatal; the + # adapter still functions for incoming webhook events. + self._logger.warn( + "Failed to fetch Messenger page identity", + {"error": str(error)}, + ) + + 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 + + # ========================================================================= + # Webhook + # ========================================================================= + + async def handle_webhook( + self, + request: Any, + options: WebhookOptions | None = None, + ) -> Any: + """Handle an incoming webhook request. + + - GET: webhook subscription verification challenge. + - POST: event notifications (messages, postbacks, reactions, ...). + """ + method = getattr(request, "method", "POST") + if method == "GET": + return self._handle_verification(request) + + body = await self._get_request_body(request) + + signature = self._get_header(request, "x-hub-signature-256") + if not self._verify_signature(body, signature): + self._logger.warn("Messenger webhook rejected due to invalid signature") + return self._make_response("Invalid signature", 403) + + try: + payload: MessengerWebhookPayload = json.loads(body) + except (json.JSONDecodeError, ValueError): + return self._make_response("Invalid JSON", 400) + + if payload.get("object") != "page": + return self._make_response("Not a page subscription", 404) + + if not self._chat: + self._logger.warn("Chat instance not initialized, ignoring Messenger webhook") + return self._make_response("EVENT_RECEIVED", 200) + + for entry in payload.get("entry", []): + for event in entry.get("messaging", []): + message = event.get("message") + if message and not message.get("is_echo"): + self._handle_incoming_message(event, options) + elif message and message.get("is_echo"): + self._handle_echo(event) + + if event.get("postback"): + self._handle_postback(event, options) + + if event.get("reaction"): + self._handle_reaction(event, options) + + delivery = event.get("delivery") + if delivery: + self._logger.debug( + "Message delivery confirmation", + { + "watermark": delivery.get("watermark"), + "mids": delivery.get("mids"), + }, + ) + + read = event.get("read") + if read: + self._logger.debug( + "Message read confirmation", + {"watermark": read.get("watermark")}, + ) + + return self._make_response("EVENT_RECEIVED", 200) + + def _handle_verification(self, request: Any) -> Any: + """Handle the GET subscription challenge.""" + url = getattr(request, "url", "") + parsed = urlparse(str(url)) + params = parse_qs(parsed.query) + + mode = (params.get("hub.mode") or [None])[0] + token = (params.get("hub.verify_token") or [None])[0] + challenge = (params.get("hub.challenge") or [""])[0] + + if mode == "subscribe" and token == self._verify_token: + self._logger.info("Messenger webhook verified") + return self._make_response(challenge, 200) + + self._logger.warn("Messenger webhook verification failed") + return self._make_response("Forbidden", 403) + + def _verify_signature(self, body: bytes, signature: str | None) -> bool: + """Verify the ``X-Hub-Signature-256`` header (HMAC-SHA256, App Secret). + + Format: ``sha256=``. Returns ``False`` on any malformed input. + Q3 (see #110): we keep upstream's hard-wired ``app_secret``-based + HMAC scheme. A swappable verifier (the pattern used by the Slack + adapter) would diverge from upstream's contract and isn't justified + for a single-secret Meta integration; flagged as a possible future + divergence but not introduced here. + """ + if not signature: + return False + + # Header is ``algo=hash``. Reject anything that isn't sha256 hex. + parts = signature.split("=", 1) + if len(parts) != 2: + return False + algo, hash_hex = parts + if algo != "sha256" or not hash_hex: + return False + + try: + computed_hex = hmac.new( + self._app_secret.encode("utf-8"), + body, + hashlib.sha256, + ).hexdigest() + # Compare hex strings. ``hexdigest()`` is lowercase; Node's + # ``Buffer.from(hex)`` is case-insensitive, so upstream accepts an + # uppercase-hex signature. Normalize the header hash to lowercase + # before the constant-time compare to match that behavior. + return hmac.compare_digest(hash_hex.lower(), computed_hex) + except Exception: + self._logger.warn("Failed to verify Messenger webhook signature") + return False + + # ========================================================================= + # Event handlers + # ========================================================================= + + def _handle_incoming_message( + self, + event: MessengerMessagingEvent, + options: WebhookOptions | None = None, + ) -> None: + if not self._chat: + return + + sender = event.get("sender") or {} + thread_id = self.encode_thread_id(MessengerThreadId(recipient_id=sender.get("id", ""))) + + parsed = self._parse_messenger_message(event, thread_id) + self._cache_message(parsed) + self._chat.process_message(self, thread_id, parsed, options) + + def _handle_echo(self, event: MessengerMessagingEvent) -> None: + """Cache echoed (bot-sent) messages but don't dispatch them.""" + if not event.get("message"): + return + + recipient = event.get("recipient") or {} + thread_id = self.encode_thread_id(MessengerThreadId(recipient_id=recipient.get("id", ""))) + + parsed = self._parse_messenger_message(event, thread_id) + self._cache_message(parsed) + + def _handle_postback( + self, + event: MessengerMessagingEvent, + options: WebhookOptions | None = None, + ) -> None: + postback = event.get("postback") + if not (self._chat and postback): + return + + sender = event.get("sender") or {} + sender_id = sender.get("id", "") + thread_id = self.encode_thread_id(MessengerThreadId(recipient_id=sender_id)) + + decoded = decode_messenger_callback_data(postback.get("payload")) + action_id = decoded.get("action_id") or "" + value = decoded.get("value") + + message_id = postback.get("mid") or f"postback:{event.get('timestamp', 0)}" + + self._chat.process_action( + ActionEvent( + adapter=self, + thread=None, # filled in by Chat + thread_id=thread_id, + message_id=message_id, + user=Author( + user_id=sender_id, + user_name=sender_id, + full_name=sender_id, + is_bot=False, + is_me=False, + ), + action_id=action_id, + value=value, + raw=event, + ), + options, + ) + + def _handle_reaction( + self, + event: MessengerMessagingEvent, + options: WebhookOptions | None = None, + ) -> None: + reaction = event.get("reaction") + if not (self._chat and reaction): + return + + sender = event.get("sender") or {} + sender_id = sender.get("id", "") + thread_id = self.encode_thread_id(MessengerThreadId(recipient_id=sender_id)) + + added = reaction.get("action") == "react" + raw_emoji = reaction.get("emoji", "") + # Resolve to an EmojiValue using the gchat-style raw unicode resolver, + # mirroring upstream's ``defaultEmojiResolver.fromGChat(...)`` call. + emoji_value = default_emoji_resolver.from_gchat(raw_emoji) + + self._chat.process_reaction( + ReactionEvent( + adapter=self, + thread=None, # pyrefly: ignore[bad-argument-type] # filled in by Chat + thread_id=thread_id, + message_id=reaction.get("mid", ""), + user=Author( + user_id=sender_id, + user_name=sender_id, + full_name=sender_id, + is_bot=False, + is_me=False, + ), + emoji=emoji_value, + raw_emoji=raw_emoji, + added=added, + raw=event, + ), + options, + ) + + # ========================================================================= + # Sending messages + # ========================================================================= + + async def post_message( + self, + thread_id: str, + message: AdapterPostableMessage, + ) -> RawMessage: + """Send a message to a Messenger user.""" + card = extract_card(message) + if card: + result = card_to_messenger(card) + if result.get("type") == "template": + template_payload = cast(MessengerCardResultTemplate, result)["payload"] + # Convert emoji placeholders inside the JSON-encoded payload + # to mirror upstream's ``convertEmojiPlaceholders`` pass. + converted = cast( + MessengerTemplatePayload, + json.loads( + convert_emoji_placeholders( + json.dumps(template_payload), + "messenger", + ) + ), + ) + return await self._send_template_message(thread_id, converted) + # Text fallback + return await self._send_text_message( + thread_id, + convert_emoji_placeholders( + cast(MessengerCardResultText, result)["text"], + "messenger", + ), + ) + + # Regular text/markdown/AST message + text = convert_emoji_placeholders( + self._format_converter.render_postable(message), + "messenger", + ) + return await self._send_text_message(thread_id, text) + + async def _send_text_message(self, thread_id: str, text: str) -> RawMessage: + """Send a plain text message via the Send API.""" + recipient_id = self._resolve_thread_id(thread_id).recipient_id + truncated = self._truncate_message(text) + + if not truncated.strip(): + raise ValidationError("messenger", "Message text cannot be empty") + + result: MessengerSendApiResponse = await self._graph_api_fetch( + "me/messages", + method="POST", + body={ + "recipient": {"id": recipient_id}, + "message": {"text": truncated}, + "messaging_type": "RESPONSE", + }, + ) + + raw_event: MessengerMessagingEvent = { + "sender": {"id": self._bot_user_id or ""}, + "recipient": {"id": recipient_id}, + "timestamp": int(time.time() * 1000), + "message": { + "mid": result["message_id"], + "text": truncated, + "is_echo": True, + }, + } + + parsed = self._parse_messenger_message(raw_event, thread_id) + self._cache_message(parsed) + + return RawMessage( + id=result["message_id"], + thread_id=thread_id, + raw=raw_event, + ) + + async def _send_template_message( + self, + thread_id: str, + payload: MessengerTemplatePayload, + ) -> RawMessage: + """Send a Generic / Button template message via the Send API.""" + recipient_id = self._resolve_thread_id(thread_id).recipient_id + + result: MessengerSendApiResponse = await self._graph_api_fetch( + "me/messages", + method="POST", + body={ + "recipient": {"id": recipient_id}, + "message": { + "attachment": { + "type": "template", + "payload": payload, + }, + }, + "messaging_type": "RESPONSE", + }, + ) + + raw_event: MessengerMessagingEvent = { + "sender": {"id": self._bot_user_id or ""}, + "recipient": {"id": recipient_id}, + "timestamp": int(time.time() * 1000), + "message": { + "mid": result["message_id"], + "is_echo": True, + }, + } + + parsed = self._parse_messenger_message(raw_event, thread_id) + self._cache_message(parsed) + + return RawMessage( + id=result["message_id"], + thread_id=thread_id, + raw=raw_event, + ) + + async def edit_message( + self, + thread_id: str, + message_id: str, + message: AdapterPostableMessage, + ) -> RawMessage: + """Messenger Send API does not support editing — raises.""" + raise ValidationError("messenger", "Messenger does not support editing messages") + + 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. + + Messenger doesn't support message edits, so we can't incrementally + update the same message. Mirrors upstream's behavior exactly. + """ + 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)) + + async def delete_message(self, thread_id: str, message_id: str) -> None: + """Messenger Send API does not support deleting — raises.""" + raise ValidationError("messenger", "Messenger does not support deleting messages") + + async def add_reaction( + self, + thread_id: str, + message_id: str, + emoji: EmojiValue | str, + ) -> None: + """Messenger Send API does not expose reaction send — raises.""" + raise ValidationError("messenger", "Messenger does not support reactions via API") + + async def remove_reaction( + self, + thread_id: str, + message_id: str, + emoji: EmojiValue | str, + ) -> None: + """Messenger Send API does not expose reaction send — raises.""" + raise ValidationError("messenger", "Messenger does not support reactions via API") + + async def start_typing(self, thread_id: str, status: str | None = None) -> None: + """Send a ``typing_on`` sender_action via the Send API.""" + recipient_id = self._resolve_thread_id(thread_id).recipient_id + await self._graph_api_fetch( + "me/messages", + method="POST", + body={ + "recipient": {"id": recipient_id}, + "sender_action": "typing_on", + }, + ) + + # ========================================================================= + # Fetching + # ========================================================================= + + async def fetch_messages( + self, + thread_id: str, + options: FetchOptions | None = None, + ) -> FetchResult: + """Fetch messages from the local cache (Messenger has no history API).""" + opts = options or FetchOptions() + messages = list(self._message_cache.get(thread_id, [])) + messages.sort(key=self._sort_key) + return self._paginate_messages(messages, opts) + + async def fetch_message( + self, + thread_id: str, + message_id: str, + ) -> Message | None: + """Fetch a single cached message by ID across all thread caches.""" + for messages in self._message_cache.values(): + for msg in messages: + if msg.id == message_id: + return msg + return None + + async def fetch_thread(self, thread_id: str) -> ThreadInfo: + """Fetch thread info, hydrated with the user profile when available.""" + recipient_id = self._resolve_thread_id(thread_id).recipient_id + profile = await self._fetch_user_profile(recipient_id) + display_name = self._profile_display_name(profile) + + # On Messenger every conversation is a 1:1 DM, so channel == thread. + return ThreadInfo( + id=thread_id, + channel_id=thread_id, + channel_name=display_name, + is_dm=True, + metadata={"profile": dict(profile)}, + ) + + async def fetch_channel_info(self, channel_id: str) -> ChannelInfo: + """Fetch channel info (channel == thread on Messenger).""" + recipient_id = self._resolve_thread_id(channel_id).recipient_id + profile = await self._fetch_user_profile(recipient_id) + display_name = self._profile_display_name(profile) + + return ChannelInfo( + id=channel_id, + name=display_name, + is_dm=True, + metadata={"profile": dict(profile)}, + ) + + # ========================================================================= + # Thread ID encoding + # ========================================================================= + + def channel_id_from_thread_id(self, thread_id: str) -> str: + """On Messenger every conversation is a 1:1 DM, channel == thread.""" + return thread_id + + def is_dm(self, thread_id: str) -> bool: + """All Messenger conversations are DMs.""" + return True + + async def open_dm(self, user_id: str) -> str: + """Open a DM with a user. Returns the encoded thread ID.""" + return self.encode_thread_id(MessengerThreadId(recipient_id=user_id)) + + def encode_thread_id(self, platform_data: MessengerThreadId) -> str: + """Encode a Messenger thread ID. Format: ``messenger:{recipientId}``.""" + return f"messenger:{platform_data.recipient_id}" + + def decode_thread_id(self, thread_id: str) -> MessengerThreadId: + """Decode a Messenger thread ID. Format: ``messenger:{recipientId}``.""" + parts = thread_id.split(":") + if parts[0] != "messenger" or len(parts) != 2: + raise ValidationError("messenger", f"Invalid Messenger thread ID: {thread_id}") + recipient_id = parts[1] + if not recipient_id: + raise ValidationError("messenger", f"Invalid Messenger thread ID: {thread_id}") + return MessengerThreadId(recipient_id=recipient_id) + + def _resolve_thread_id(self, value: str) -> MessengerThreadId: + """Resolve a value to a ``MessengerThreadId``. + + Accepts both encoded thread IDs (``messenger:PSID``) and bare PSIDs, + mirroring upstream's ``resolveThreadId`` helper. + """ + if value.startswith("messenger:"): + return self.decode_thread_id(value) + return MessengerThreadId(recipient_id=value) + + # ========================================================================= + # Parsing + # ========================================================================= + + def parse_message(self, raw: MessengerRawMessage) -> Message: + """Parse a raw messaging event into a normalized ``Message``.""" + sender = raw.get("sender") or {} + message_data = raw.get("message") or {} + # For echoes (bot-sent messages echoed back) ``sender.id`` is the Page + # ID and ``recipient.id`` is the user's PSID — the reverse of a normal + # inbound event. Thread off the user PSID so the message threads under + # the same conversation ``_handle_echo`` uses. + if message_data.get("is_echo"): + recipient = raw.get("recipient") or {} + thread_id = self.encode_thread_id(MessengerThreadId(recipient_id=recipient.get("id", ""))) + else: + thread_id = self.encode_thread_id(MessengerThreadId(recipient_id=sender.get("id", ""))) + message = self._parse_messenger_message(raw, thread_id) + self._cache_message(message) + return message + + def render_formatted(self, content: FormattedContent) -> str: + """Render formatted AST content back to Messenger text.""" + return self._format_converter.from_ast(content) + + def _parse_messenger_message( + self, + event: MessengerMessagingEvent, + thread_id: str, + ) -> Message: + message = event.get("message") or {} + postback = event.get("postback") or {} + sender = event.get("sender") or {} + text = message.get("text") or postback.get("title") or "" + is_echo = bool(message.get("is_echo")) + is_me = is_echo or (sender.get("id") == self._bot_user_id and self._bot_user_id is not None) + + mid = message.get("mid") + timestamp = event.get("timestamp", 0) + msg_id = mid if mid else f"event:{timestamp}" + + return Message( + id=msg_id, + thread_id=thread_id, + text=text, + formatted=self._format_converter.to_ast(text), + raw=event, + author=Author( + user_id=sender.get("id", ""), + user_name=sender.get("id", ""), + full_name=sender.get("id", ""), + is_bot=is_me, + is_me=is_me, + ), + metadata=MessageMetadata( + date_sent=datetime.fromtimestamp(timestamp / 1000.0, tz=timezone.utc), + edited=False, + ), + attachments=self._extract_attachments(event), + is_mention=True, + ) + + def _extract_attachments(self, event: MessengerMessagingEvent) -> list[Attachment]: + message = event.get("message") or {} + attachments = message.get("attachments") or [] + result: list[Attachment] = [] + for attachment in attachments: + payload = attachment.get("payload") or {} + url = payload.get("url") + if not url: + continue + result.append( + Attachment( + type=self._map_attachment_type(attachment.get("type", "")), + url=url, + fetch_data=self._make_attachment_downloader(url), + # Persist the download URL so the closure can be rebuilt + # by ``rehydrate_attachment`` after the message survives + # a queue/debounce/burst JSON roundtrip (which drops the + # ``fetch_data`` closure). Messenger payload URLs are + # signature-gated by Meta and require no auth header, so + # the URL alone is sufficient to rebuild the closure. + fetch_metadata={"url": url}, + ) + ) + return result + + def _make_attachment_downloader(self, url: str) -> Any: + """Build a closure that downloads ``url`` lazily. + + Kept as a named helper so the ``url`` capture isn't subject to + late-binding when multiple attachments are extracted from a single + event. + """ + + async def _download() -> bytes: + return await self._download_attachment(url) + + return _download + + def rehydrate_attachment(self, attachment: Attachment) -> Attachment: + """Reconstruct ``fetch_data`` on a deserialized Messenger attachment. + + Called by :class:`~chat_sdk.chat.Chat` during message rehydration in + the queue/debounce/burst concurrency paths, where the original + ``fetch_data`` closure was dropped during JSON serialization. Reads + the download URL from ``attachment.fetch_metadata`` (populated by + :meth:`_extract_attachments`) and rebuilds the lazy downloader that + reuses the shared aiohttp session. Returns the attachment unchanged + when no URL is present — matching the upstream documented "leave + unchanged when no hook" degraded-mode behavior. + + Mirrors :meth:`WhatsAppAdapter.rehydrate_attachment` exactly (both + platforms are Meta-family and share the same queue-mode failure + shape). Like the original ``_download_attachment``, the rebuilt + closure attaches no auth headers — Messenger payload URLs are + signature-gated by Meta and do not require Bearer tokens. + """ + meta = attachment.fetch_metadata if attachment.fetch_metadata is not None else {} + url = meta.get("url") + if not url: + return attachment + return Attachment( + type=attachment.type, + url=attachment.url, + name=attachment.name, + mime_type=attachment.mime_type, + size=attachment.size, + width=attachment.width, + height=attachment.height, + data=attachment.data, + fetch_data=self._make_attachment_downloader(url), + fetch_metadata=attachment.fetch_metadata, + ) + + @staticmethod + def _map_attachment_type(fb_type: str) -> str: + """Map a Messenger attachment type to the SDK ``Attachment.type`` enum.""" + if fb_type == "image": + return "image" + if fb_type == "video": + return "video" + if fb_type == "audio": + return "audio" + return "file" + + async def _download_attachment(self, url: str) -> bytes: + """Download an attachment payload URL. Wraps errors in ``NetworkError``.""" + try: + session = await self._get_http_session() + async with session.get(url) as response: + if response.status != 200: + raise NetworkError( + "messenger", + f"Failed to download Messenger attachment: {response.status}", + ) + return await response.read() + except NetworkError: + raise + except Exception as error: + raise NetworkError( + "messenger", + "Failed to download Messenger attachment", + original_error=error if isinstance(error, Exception) else None, + ) from error + + # ========================================================================= + # User profile (with cache) + # ========================================================================= + + async def _fetch_user_profile(self, user_id: str) -> MessengerUserProfile: + cached = self._user_profile_cache.get(user_id) + # ``is not None`` (not truthy): an empty-dict ``{}`` cache entry is + # falsy and would otherwise trigger a re-fetch on every call. + if cached is not None: + return cached + + try: + profile = await self._graph_api_fetch( + user_id, + method="GET", + query_params={"fields": "first_name,last_name,profile_pic"}, + ) + if not isinstance(profile, dict): + return {"id": user_id} + self._user_profile_cache[user_id] = profile + return cast(MessengerUserProfile, profile) + except Exception: + # On any error, fall back to a minimal profile carrying just the + # user ID. Matches upstream's silent fallback. + return {"id": user_id} + + @staticmethod + def _profile_display_name(profile: MessengerUserProfile) -> str: + parts = [p for p in (profile.get("first_name"), profile.get("last_name")) if p] + if parts: + return " ".join(parts) + return profile.get("id", "") + + # ========================================================================= + # Cache / pagination + # ========================================================================= + + def _cache_message(self, message: Message) -> None: + existing = self._message_cache.get(message.thread_id, []) + for i, item in enumerate(existing): + if item.id == message.id: + existing[i] = message + break + else: + existing.append(message) + existing.sort(key=self._sort_key) + self._message_cache[message.thread_id] = existing + + @staticmethod + def _sort_key(message: Message) -> tuple[float, int]: + """Sort by ``(date_sent, sequence_suffix)``. + + Messages with the same timestamp but a ``:N`` sequence suffix on the + ID are ordered by N. Mirrors upstream's ``compareMessages``. + """ + date_value = message.metadata.date_sent.timestamp() if message.metadata else 0.0 + seq = 0 + if _MESSAGE_SEQUENCE_SUFFIX in message.id: + tail = message.id.rsplit(_MESSAGE_SEQUENCE_SUFFIX, 1)[1] + if tail.isdigit(): + seq = int(tail) + return (date_value, seq) + + @staticmethod + def _paginate_messages(messages: list[Message], options: FetchOptions) -> FetchResult: + limit = max(1, min(options.limit if options.limit is not None else 50, 100)) + direction = options.direction or "backward" + + if not messages: + return FetchResult(messages=[]) + + index_by_id = {m.id: i for i, m in enumerate(messages)} + + if direction == "backward": + end = index_by_id[options.cursor] if options.cursor and options.cursor in index_by_id else len(messages) + start = max(0, end - limit) + page = messages[start:end] + return FetchResult( + messages=page, + next_cursor=(page[0].id if start > 0 and page else None), + ) + + # forward + start = index_by_id[options.cursor] + 1 if options.cursor and options.cursor in index_by_id else 0 + end = min(len(messages), start + limit) + page = messages[start:end] + return FetchResult( + messages=page, + next_cursor=(page[-1].id if end < len(messages) and page else None), + ) + + # ========================================================================= + # Send-API helpers + # ========================================================================= + + @staticmethod + def _truncate_message(text: str) -> str: + if len(text) <= MESSENGER_MESSAGE_LIMIT: + return text + return f"{text[: MESSENGER_MESSAGE_LIMIT - 3]}..." + + async def _graph_api_fetch( + self, + endpoint: str, + *, + method: str, + body: dict[str, Any] | None = None, + query_params: dict[str, str] | None = None, + ) -> Any: + """Call the Meta Graph API with the page access token. + + Wraps the call in standardized adapter errors. Mirrors the upstream + ``graphApiFetch`` helper's surface (URL building, query params, JSON + parse-then-status check, error-code mapping). + """ + session = await self._get_http_session() + url = f"{self._graph_api_url}/{endpoint}" + params: dict[str, str] = {"access_token": self._page_access_token} + if query_params: + params.update(query_params) + + try: + if method == "GET": + response_ctx = session.get(url, params=params) + else: + response_ctx = session.post( + url, + params=params, + headers={"Content-Type": "application/json"}, + json=body, + ) + + async with response_ctx as response: + status = response.status + try: + data = await response.json(content_type=None) + except Exception as error: + raise NetworkError( + "messenger", + f"Failed to parse Messenger API response for {endpoint}", + ) from error + + if status < 200 or status >= 300: + self._throw_graph_api_error(endpoint, status, data or {}) + + return data + except NetworkError: + raise + except AdapterRateLimitError: + raise + except AuthenticationError: + raise + except ResourceNotFoundError: + raise + except ValidationError: + raise + except Exception as error: + raise NetworkError( + "messenger", + f"Network error calling Messenger Graph API {endpoint}", + original_error=error if isinstance(error, Exception) else None, + ) from error + + @staticmethod + def _throw_graph_api_error( + endpoint: str, + status: int, + data: dict[str, Any], + ) -> None: + """Translate a non-2xx Graph API response to a typed adapter error.""" + error = data.get("error") if isinstance(data, dict) else None + if not isinstance(error, dict): + error = {} + message = error.get("message") or f"Messenger API {endpoint} failed" + code = error.get("code") if error.get("code") is not None else status + + # Rate limiting: HTTP 429 or known Meta rate-limit codes. + if status == 429 or code in (4, 32, 613): + raise AdapterRateLimitError("messenger") + # Auth: HTTP 401 or Meta auth code 190. + if status == 401 or code == 190: + raise AuthenticationError("messenger", message) + # Permission: HTTP 403 or Meta permission codes 10 / 200. + if status == 403 or code in (10, 200): + raise ValidationError("messenger", message) + # Not found: HTTP 404. + if status == 404: + raise ResourceNotFoundError("messenger", endpoint) + raise NetworkError( + "messenger", + f"{message} (status {status}, code {code})", + ) + + # ========================================================================= + # Request helpers (framework-agnostic) + # ========================================================================= + + @staticmethod + async def _get_request_body(request: Any) -> bytes: + """Extract the raw request body as bytes (sync or async). + + Returned as raw bytes so signature verification operates on the exact + wire bytes Meta signed. Any encode/decode round-trip risks replacement + characters or altered code points and breaks HMAC parity. + """ + body = getattr(request, "body", None) + if body is not None: + if callable(body): + result = body() + body = await result if inspect.isawaitable(result) else result + if isinstance(body, (bytes, bytearray)): + return bytes(body) + if isinstance(body, str): + return body.encode("utf-8") + text_attr = getattr(request, "text", None) + if text_attr is not None: + if callable(text_attr): + result = text_attr() + text_attr = await result if inspect.isawaitable(result) else result + if isinstance(text_attr, (bytes, bytearray)): + return bytes(text_attr) + if isinstance(text_attr, str): + return text_attr.encode("utf-8") + return b"" + + @staticmethod + def _get_header(request: Any, name: str) -> str | None: + """Get a header value from a request object (case-insensitive).""" + if hasattr(request, "headers"): + headers = request.headers + if isinstance(headers, dict): + for k, v in headers.items(): + if k.lower() == name.lower(): + return v + return None + return headers.get(name) + return None + + @staticmethod + def _make_response(body: str, status: int) -> dict[str, Any]: + """Create a framework-agnostic response dict.""" + return {"body": body, "status": status} + + +# ============================================================================= +# Factory +# ============================================================================= + + +def create_messenger_adapter( + *, + app_secret: str | None = None, + page_access_token: str | None = None, + verify_token: str | None = None, + api_version: str | None = None, + logger: Logger | None = None, + user_name: str | None = None, +) -> MessengerAdapter: + """Factory for ``MessengerAdapter`` with env-var fallbacks. + + Q1 (see #110): init-failure behavior. We match the upstream contract by + raising ``ValidationError`` at construction time for each missing required + credential. This improves on the sibling ``WhatsAppAdapter``, whose + constructor does no validation (direct misconstruction raises ``TypeError``, + not ``ValidationError``) -- Messenger raises a descriptive ``ValidationError`` + from both the factory and the constructor. It surfaces config errors loudly + during startup rather than at first webhook call. + """ + _logger = logger or ConsoleLogger("info").child("messenger") + + _app_secret = app_secret or os.environ.get(ENV_APP_SECRET) + if not _app_secret: + raise ValidationError( + "messenger", + f"appSecret is required. Set {ENV_APP_SECRET} or provide it in config.", + ) + + _page_access_token = page_access_token or os.environ.get(ENV_PAGE_ACCESS_TOKEN) + if not _page_access_token: + raise ValidationError( + "messenger", + f"pageAccessToken is required. Set {ENV_PAGE_ACCESS_TOKEN} or provide it in config.", + ) + + _verify_token = verify_token or os.environ.get(ENV_VERIFY_TOKEN) + if not _verify_token: + raise ValidationError( + "messenger", + f"verifyToken is required. Set {ENV_VERIFY_TOKEN} or provide it in config.", + ) + + return MessengerAdapter( + MessengerAdapterConfig( + app_secret=_app_secret, + page_access_token=_page_access_token, + verify_token=_verify_token, + api_version=api_version, + logger=_logger, + user_name=user_name, + ) + ) diff --git a/tests/test_messenger_api.py b/tests/test_messenger_api.py new file mode 100644 index 0000000..97e7c31 --- /dev/null +++ b/tests/test_messenger_api.py @@ -0,0 +1,989 @@ +"""Tests for the Messenger adapter — Graph API send & stream paths. + +Covers ``post_message`` (text / markdown / AST / generic-template card / +button-template card / text fallback), ``stream`` accumulation, +``start_typing``, ``edit_message`` / ``delete_message`` / +``add_reaction`` / ``remove_reaction`` unsupported paths, message +truncation, message caching after send, and Graph API error mapping +(rate limit / auth / not-found / network). + +Stubs the adapter's private ``_graph_api_fetch`` helper so we never hit +the network, mirroring the WhatsApp ``test_whatsapp_api.py`` pattern. + +Pairs with ``tests/test_messenger_webhook.py``. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from chat_sdk.adapters.messenger.adapter import ( + MESSENGER_MESSAGE_LIMIT, + MessengerAdapter, +) +from chat_sdk.adapters.messenger.types import MessengerAdapterConfig, MessengerThreadId +from chat_sdk.logger import ConsoleLogger +from chat_sdk.shared.errors import ( + AdapterRateLimitError, + AuthenticationError, + NetworkError, + ResourceNotFoundError, + ValidationError, +) +from chat_sdk.types import MarkdownTextChunk, StreamChunk + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +THREAD_ID = "messenger:USER_123" +RECIPIENT_ID = "USER_123" + + +def _make_adapter(**overrides: Any) -> MessengerAdapter: + defaults: dict[str, Any] = { + "app_secret": "test-app-secret", + "page_access_token": "test-page-token", + "verify_token": "test-verify-token", + "user_name": "test-bot", + "logger": ConsoleLogger("error"), + } + defaults.update(overrides) + return MessengerAdapter(MessengerAdapterConfig(**defaults)) + + +def _send_api_response(message_id: str = "mid.sent") -> dict[str, Any]: + return {"recipient_id": RECIPIENT_ID, "message_id": message_id} + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + + +class TestInitialize: + """``initialize`` fetches Page identity, but failure is non-fatal.""" + + @pytest.mark.asyncio + async def test_initialize_sets_bot_id_and_name(self) -> None: + adapter = _make_adapter() + # Drop explicit user_name so /me name is used. + adapter._has_explicit_user_name = False + adapter._user_name = "bot" + adapter._graph_api_fetch = AsyncMock(return_value={"id": "PAGE_456", "name": "My Cool Page"}) + + chat = MagicMock() + chat.get_user_name.return_value = "TestBot" + await adapter.initialize(chat) + + assert adapter.bot_user_id == "PAGE_456" + assert adapter.user_name == "My Cool Page" + + @pytest.mark.asyncio + async def test_initialize_preserves_explicit_user_name(self) -> None: + adapter = _make_adapter(user_name="CustomBot") + adapter._graph_api_fetch = AsyncMock(return_value={"id": "PAGE_456", "name": "Page Name"}) + + chat = MagicMock() + chat.get_user_name.return_value = "ChatName" + await adapter.initialize(chat) + + # Explicit user_name wins over both chat.get_user_name() and /me name. + assert adapter.user_name == "CustomBot" + + @pytest.mark.asyncio + async def test_initialize_uses_chat_user_name_when_no_explicit(self) -> None: + adapter = _make_adapter() + adapter._has_explicit_user_name = False + adapter._user_name = "bot" + # /me fails — falls back to chat.get_user_name(). + adapter._graph_api_fetch = AsyncMock(side_effect=RuntimeError("API down")) + + chat = MagicMock() + chat.get_user_name.return_value = "TestBot" + await adapter.initialize(chat) + + assert adapter.bot_user_id is None + assert adapter.user_name == "TestBot" + + @pytest.mark.asyncio + async def test_initialize_continues_when_me_fails(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(side_effect=RuntimeError("API down")) + chat = MagicMock() + chat.get_user_name.return_value = "Bot" + # Should not raise. + await adapter.initialize(chat) + assert adapter.bot_user_id is None + + +# --------------------------------------------------------------------------- +# post_message — text +# --------------------------------------------------------------------------- + + +class TestPostMessageText: + """``post_message`` sends a single text payload via the Send API.""" + + @pytest.mark.asyncio + async def test_post_plain_string(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response("mid.s")) + result = await adapter.post_message(THREAD_ID, "Hello!") + + assert result.id == "mid.s" + assert result.thread_id == THREAD_ID + endpoint, kwargs = adapter._graph_api_fetch.call_args.args, adapter._graph_api_fetch.call_args.kwargs + assert endpoint[0] == "me/messages" + assert kwargs["method"] == "POST" + body = kwargs["body"] + assert body["recipient"] == {"id": RECIPIENT_ID} + assert body["message"]["text"] == "Hello!" + assert body["messaging_type"] == "RESPONSE" + + @pytest.mark.asyncio + async def test_post_markdown(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response("mid.md")) + await adapter.post_message(THREAD_ID, {"markdown": "**bold** and *italic*"}) + body = adapter._graph_api_fetch.call_args.kwargs["body"] + # Messenger doesn't render markdown — text contains the source. + assert "bold" in body["message"]["text"] + assert "italic" in body["message"]["text"] + + @pytest.mark.asyncio + async def test_post_ast(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response("mid.ast")) + await adapter.post_message( + THREAD_ID, + { + "ast": { + "type": "root", + "children": [ + {"type": "paragraph", "children": [{"type": "text", "value": "ast content"}]}, + ], + } + }, + ) + body = adapter._graph_api_fetch.call_args.kwargs["body"] + assert "ast content" in body["message"]["text"] + + @pytest.mark.asyncio + async def test_rejects_empty_message(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response()) + with pytest.raises(ValidationError): + await adapter.post_message(THREAD_ID, " ") + adapter._graph_api_fetch.assert_not_called() + + @pytest.mark.asyncio + async def test_exact_2000_chars_unchanged(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response()) + text = "x" * MESSENGER_MESSAGE_LIMIT + await adapter.post_message(THREAD_ID, text) + body = adapter._graph_api_fetch.call_args.kwargs["body"] + assert body["message"]["text"] == text + assert len(body["message"]["text"]) == MESSENGER_MESSAGE_LIMIT + + @pytest.mark.asyncio + async def test_2001_chars_truncated_with_ellipsis(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response()) + text = "y" * (MESSENGER_MESSAGE_LIMIT + 1) + await adapter.post_message(THREAD_ID, text) + body = adapter._graph_api_fetch.call_args.kwargs["body"] + assert len(body["message"]["text"]) == MESSENGER_MESSAGE_LIMIT + assert body["message"]["text"].endswith("...") + + @pytest.mark.asyncio + async def test_long_text_truncated(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response()) + await adapter.post_message(THREAD_ID, "a" * 3000) + body = adapter._graph_api_fetch.call_args.kwargs["body"] + assert len(body["message"]["text"]) <= MESSENGER_MESSAGE_LIMIT + assert body["message"]["text"].endswith("...") + + @pytest.mark.asyncio + async def test_sent_message_is_cached(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response("mid.cached")) + await adapter.post_message(THREAD_ID, "cached msg") + fetched = await adapter.fetch_message(THREAD_ID, "mid.cached") + assert fetched is not None + assert "cached msg" in fetched.text + + @pytest.mark.asyncio + async def test_resolves_bare_psid(self) -> None: + """A non-prefixed value is treated as a raw PSID.""" + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response("mid.raw")) + result = await adapter.post_message("USER_123", "hi") + assert result.id == "mid.raw" + + +# --------------------------------------------------------------------------- +# post_message — card templates +# --------------------------------------------------------------------------- + + +class TestPostMessageCard: + """``post_message`` routes cards to generic / button templates or text.""" + + @pytest.mark.asyncio + async def test_generic_template_for_card_with_title(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response("mid.tmpl")) + await adapter.post_message( + THREAD_ID, + { + "type": "card", + "title": "Welcome", + "children": [ + {"type": "text", "content": "Hello!"}, + { + "type": "actions", + "children": [ + {"type": "button", "id": "start", "label": "Start"}, + {"type": "button", "id": "help", "label": "Help"}, + ], + }, + ], + }, + ) + body = adapter._graph_api_fetch.call_args.kwargs["body"] + attachment = body["message"]["attachment"] + assert attachment["type"] == "template" + assert attachment["payload"]["template_type"] == "generic" + assert len(attachment["payload"]["elements"]) == 1 + assert attachment["payload"]["elements"][0]["title"] == "Welcome" + assert len(attachment["payload"]["elements"][0]["buttons"]) == 2 + + @pytest.mark.asyncio + async def test_button_template_for_titleless_card(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response("mid.btn")) + await adapter.post_message( + THREAD_ID, + { + "type": "card", + "children": [ + {"type": "text", "content": "Please choose:"}, + { + "type": "actions", + "children": [{"type": "button", "id": "opt1", "label": "Option 1"}], + }, + ], + }, + ) + body = adapter._graph_api_fetch.call_args.kwargs["body"] + payload = body["message"]["attachment"]["payload"] + assert payload["template_type"] == "button" + assert payload["text"] == "Please choose:" + + @pytest.mark.asyncio + async def test_text_fallback_for_unsupported_card(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response("mid.txt")) + await adapter.post_message( + THREAD_ID, + { + "type": "card", + "title": "With Table", + "children": [ + {"type": "table", "headers": ["A", "B"], "rows": [["1", "2"]]}, + ], + }, + ) + body = adapter._graph_api_fetch.call_args.kwargs["body"] + # Falls back to text — no attachment field. + assert "attachment" not in body["message"] + assert "With Table" in body["message"]["text"] + + +# --------------------------------------------------------------------------- +# stream +# --------------------------------------------------------------------------- + + +class TestStream: + """``stream`` buffers chunks and posts once.""" + + @pytest.mark.asyncio + async def test_buffers_str_chunks(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response("mid.s")) + + async def _chunks() -> AsyncIterator[str | StreamChunk]: + yield "Hello" + yield " " + yield "world" + + result = await adapter.stream(THREAD_ID, _chunks()) + assert result.id == "mid.s" + assert adapter._graph_api_fetch.call_count == 1 + body = adapter._graph_api_fetch.call_args.kwargs["body"] + assert body["message"]["text"] == "Hello world" + + @pytest.mark.asyncio + async def test_buffers_markdown_text_chunks(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response()) + + async def _chunks() -> AsyncIterator[str | StreamChunk]: + yield MarkdownTextChunk(text="Structured ") + yield "plain " + yield MarkdownTextChunk(text="content") + + await adapter.stream(THREAD_ID, _chunks()) + body = adapter._graph_api_fetch.call_args.kwargs["body"] + assert body["message"]["text"] == "Structured plain content" + + +# --------------------------------------------------------------------------- +# Typing indicator +# --------------------------------------------------------------------------- + + +class TestStartTyping: + @pytest.mark.asyncio + async def test_sends_typing_on(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value={}) + await adapter.start_typing(THREAD_ID) + kwargs = adapter._graph_api_fetch.call_args.kwargs + assert kwargs["body"]["sender_action"] == "typing_on" + assert kwargs["body"]["recipient"]["id"] == RECIPIENT_ID + + +# --------------------------------------------------------------------------- +# Unsupported operations +# --------------------------------------------------------------------------- + + +class TestUnsupportedOperations: + """Operations that Messenger doesn't support — must raise ValidationError.""" + + @pytest.mark.asyncio + async def test_edit_message_raises(self) -> None: + adapter = _make_adapter() + with pytest.raises(ValidationError): + await adapter.edit_message(THREAD_ID, "mid.1", "new text") + + @pytest.mark.asyncio + async def test_delete_message_raises(self) -> None: + adapter = _make_adapter() + with pytest.raises(ValidationError): + await adapter.delete_message(THREAD_ID, "mid.1") + + @pytest.mark.asyncio + async def test_add_reaction_raises(self) -> None: + adapter = _make_adapter() + with pytest.raises(ValidationError): + await adapter.add_reaction(THREAD_ID, "mid.1", "thumbsup") + + @pytest.mark.asyncio + async def test_remove_reaction_raises(self) -> None: + adapter = _make_adapter() + with pytest.raises(ValidationError): + await adapter.remove_reaction(THREAD_ID, "mid.1", "thumbsup") + + +# --------------------------------------------------------------------------- +# Thread / channel info +# --------------------------------------------------------------------------- + + +class TestFetchThreadAndChannel: + """``fetch_thread`` / ``fetch_channel_info`` use the Graph user profile.""" + + @pytest.mark.asyncio + async def test_thread_with_full_name(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value={"id": "USER_123", "first_name": "John", "last_name": "Doe"}) + thread = await adapter.fetch_thread(THREAD_ID) + assert thread.channel_name == "John Doe" + assert thread.is_dm is True + + @pytest.mark.asyncio + async def test_channel_info_with_full_name(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock( + return_value={"id": "USER_123", "first_name": "Jane", "last_name": "Smith"} + ) + info = await adapter.fetch_channel_info(RECIPIENT_ID) + assert info.name == "Jane Smith" + assert info.is_dm is True + + @pytest.mark.asyncio + async def test_falls_back_to_user_id_on_profile_error(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(side_effect=RuntimeError("err")) + info = await adapter.fetch_channel_info(RECIPIENT_ID) + assert info.name == RECIPIENT_ID + + @pytest.mark.asyncio + async def test_first_name_only(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value={"id": "USER_123", "first_name": "Alice"}) + info = await adapter.fetch_thread(THREAD_ID) + assert info.channel_name == "Alice" + + @pytest.mark.asyncio + async def test_last_name_only(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value={"id": "USER_123", "last_name": "Smith"}) + info = await adapter.fetch_thread(THREAD_ID) + assert info.channel_name == "Smith" + + @pytest.mark.asyncio + async def test_caches_user_profile(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value={"id": "USER_123", "first_name": "John"}) + await adapter.fetch_thread(THREAD_ID) + await adapter.fetch_thread(THREAD_ID) + # Second call hits the cache — no extra API call. + assert adapter._graph_api_fetch.call_count == 1 + + @pytest.mark.asyncio + async def test_empty_dict_cache_entry_is_a_hit_not_a_miss(self) -> None: + """Regression: a cached ``{}`` profile must NOT trigger a re-fetch. + + The old ``if cached:`` treated ``{}`` (falsy) as a miss and called the + Graph API every time. The fix ``if cached is not None`` honors the + cache entry regardless of dict contents. Without the fix this test + would observe ``call_count == 1`` (the re-fetch). + """ + adapter = _make_adapter() + adapter._user_profile_cache[RECIPIENT_ID] = {} # pre-populated empty hit + adapter._graph_api_fetch = AsyncMock(return_value={"id": RECIPIENT_ID, "first_name": "Should-Not-Be-Called"}) + + profile = await adapter._fetch_user_profile(RECIPIENT_ID) + + # Cache hit returns the empty dict as-is, no Graph API roundtrip. + assert profile == {} + assert adapter._graph_api_fetch.call_count == 0 + + @pytest.mark.asyncio + async def test_non_dict_profile_response_falls_back(self) -> None: + """A successful Graph API call that returns a non-mapping must not poison the cache. + + ``_graph_api_fetch`` is typed loosely and could in principle yield + ``None`` or a list (e.g. an API shape change, a stubbed test, a + proxy returning an array). Without the ``isinstance(profile, dict)`` + guard, ``_profile_display_name`` then calls ``.get`` on whatever + came back and raises ``AttributeError``, and the next call hits the + same poisoned cache entry. Verify (a) the minimal ``{"id": uid}`` + fallback is returned, and (b) the cache stays empty. + """ + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=None) + info = await adapter.fetch_thread(THREAD_ID) + assert info.channel_name == RECIPIENT_ID + assert RECIPIENT_ID not in adapter._user_profile_cache + + # Same again for a list, to pin the dict-only contract. + adapter._graph_api_fetch = AsyncMock(return_value=[{"id": RECIPIENT_ID}]) + info = await adapter.fetch_thread(THREAD_ID) + assert info.channel_name == RECIPIENT_ID + assert RECIPIENT_ID not in adapter._user_profile_cache + + +# --------------------------------------------------------------------------- +# Message fetching / pagination +# --------------------------------------------------------------------------- + + +def _seed_messages(adapter: MessengerAdapter, count: int) -> None: + for i in range(1, count + 1): + adapter.parse_message( + { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000 + i * 1000, + "message": {"mid": f"mid.{i}", "text": f"message {i}"}, + } + ) + + +class TestFetchMessages: + """Local message cache backs ``fetch_messages`` / ``fetch_message``.""" + + @pytest.mark.asyncio + async def test_empty_unknown_thread(self) -> None: + adapter = _make_adapter() + result = await adapter.fetch_messages("messenger:UNKNOWN") + assert result.messages == [] + + @pytest.mark.asyncio + async def test_backward_default(self) -> None: + from chat_sdk.types import FetchOptions + + adapter = _make_adapter() + _seed_messages(adapter, 5) + result = await adapter.fetch_messages(THREAD_ID, FetchOptions(limit=3)) + assert [m.id for m in result.messages] == ["mid.3", "mid.4", "mid.5"] + assert result.next_cursor == "mid.3" + + @pytest.mark.asyncio + async def test_backward_with_cursor(self) -> None: + from chat_sdk.types import FetchOptions + + adapter = _make_adapter() + _seed_messages(adapter, 5) + result = await adapter.fetch_messages( + THREAD_ID, + FetchOptions(limit=2, cursor="mid.3", direction="backward"), + ) + assert [m.id for m in result.messages] == ["mid.1", "mid.2"] + + @pytest.mark.asyncio + async def test_forward(self) -> None: + from chat_sdk.types import FetchOptions + + adapter = _make_adapter() + _seed_messages(adapter, 5) + result = await adapter.fetch_messages( + THREAD_ID, + FetchOptions(limit=2, direction="forward"), + ) + assert [m.id for m in result.messages] == ["mid.1", "mid.2"] + assert result.next_cursor == "mid.2" + + @pytest.mark.asyncio + async def test_forward_with_cursor(self) -> None: + from chat_sdk.types import FetchOptions + + adapter = _make_adapter() + _seed_messages(adapter, 5) + result = await adapter.fetch_messages( + THREAD_ID, + FetchOptions(limit=2, cursor="mid.2", direction="forward"), + ) + assert [m.id for m in result.messages] == ["mid.3", "mid.4"] + + @pytest.mark.asyncio + async def test_no_next_cursor_when_exhausted(self) -> None: + from chat_sdk.types import FetchOptions + + adapter = _make_adapter() + _seed_messages(adapter, 5) + result = await adapter.fetch_messages(THREAD_ID, FetchOptions(limit=100)) + assert len(result.messages) == 5 + assert result.next_cursor is None + + @pytest.mark.asyncio + async def test_clamps_negative_limit(self) -> None: + from chat_sdk.types import FetchOptions + + adapter = _make_adapter() + _seed_messages(adapter, 5) + result = await adapter.fetch_messages(THREAD_ID, FetchOptions(limit=-10)) + assert len(result.messages) == 1 + + @pytest.mark.asyncio + async def test_clamps_high_limit(self) -> None: + from chat_sdk.types import FetchOptions + + adapter = _make_adapter() + _seed_messages(adapter, 5) + result = await adapter.fetch_messages(THREAD_ID, FetchOptions(limit=500)) + assert len(result.messages) == 5 + + @pytest.mark.asyncio + async def test_explicit_zero_limit_is_not_swallowed_to_default(self) -> None: + """Regression: ``FetchOptions(limit=0)`` must not silently become 50. + + The old ``limit = options.limit or 50`` treated ``0`` as falsy and + substituted the default page size — a caller asking for zero messages + got fifty. Switching to ``is not None`` preserves the explicit value + (then ``max(1, ...)`` clamps to 1, matching ``limit=-N`` behavior). + Without the fix, with 5 messages seeded, this test would observe + ``len(result.messages) == 5`` (all of them, since 50 > 5). + """ + from chat_sdk.types import FetchOptions + + adapter = _make_adapter() + _seed_messages(adapter, 5) + result = await adapter.fetch_messages(THREAD_ID, FetchOptions(limit=0)) + # New behavior: clamped to 1 via ``max(1, min(0, 100))``, NOT 50. + assert len(result.messages) == 1 + + @pytest.mark.asyncio + async def test_unknown_cursor_backward(self) -> None: + from chat_sdk.types import FetchOptions + + adapter = _make_adapter() + _seed_messages(adapter, 3) + result = await adapter.fetch_messages( + THREAD_ID, + FetchOptions(cursor="mid.nonexistent", direction="backward", limit=2), + ) + # Falls back to "from end". + assert [m.id for m in result.messages] == ["mid.2", "mid.3"] + + @pytest.mark.asyncio + async def test_unknown_cursor_forward(self) -> None: + from chat_sdk.types import FetchOptions + + adapter = _make_adapter() + _seed_messages(adapter, 3) + result = await adapter.fetch_messages( + THREAD_ID, + FetchOptions(cursor="mid.nonexistent", direction="forward", limit=2), + ) + # Falls back to "from start". + assert [m.id for m in result.messages] == ["mid.1", "mid.2"] + + @pytest.mark.asyncio + async def test_fetch_single_unknown_message_returns_none(self) -> None: + adapter = _make_adapter() + result = await adapter.fetch_message(THREAD_ID, "mid.nope") + assert result is None + + @pytest.mark.asyncio + async def test_sort_by_timestamp_then_sequence(self) -> None: + """Equal timestamps order by the ``:N`` suffix on the mid.""" + adapter = _make_adapter() + adapter.parse_message( + { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "message": {"mid": "mid.abc:2", "text": "second"}, + } + ) + adapter.parse_message( + { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "message": {"mid": "mid.abc:1", "text": "first"}, + } + ) + result = await adapter.fetch_messages(THREAD_ID) + assert result.messages[0].text == "first" + assert result.messages[1].text == "second" + + @pytest.mark.asyncio + async def test_reparsing_same_id_updates_cache(self) -> None: + adapter = _make_adapter() + event1 = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "message": {"mid": "mid.dup", "text": "first"}, + } + event2 = dict(event1) + event2["message"] = {"mid": "mid.dup", "text": "updated"} + adapter.parse_message(event1) + updated = adapter.parse_message(event2) + assert updated.text == "updated" + + +# --------------------------------------------------------------------------- +# parseMessage +# --------------------------------------------------------------------------- + + +class TestParseMessage: + """``parse_message`` produces a normalized ``Message`` from raw events.""" + + def test_basic_message(self) -> None: + adapter = _make_adapter() + event = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "message": {"mid": "mid.abc123", "text": "hello"}, + } + parsed = adapter.parse_message(event) + assert parsed.text == "hello" + assert parsed.thread_id == "messenger:USER_123" + assert parsed.id == "mid.abc123" + + def test_is_mention_true(self) -> None: + adapter = _make_adapter() + event = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "message": {"mid": "mid.x", "text": "hi"}, + } + parsed = adapter.parse_message(event) + # All inbound Messenger messages are 1:1 DMs — always a mention. + assert parsed.is_mention is True + + def test_echo_marks_as_me_and_bot(self) -> None: + adapter = _make_adapter() + adapter._bot_user_id = "PAGE_456" + event = { + "sender": {"id": "PAGE_456"}, + "recipient": {"id": "USER_123"}, + "timestamp": 1735689600000, + "message": {"mid": "mid.echo", "text": "bot says", "is_echo": True}, + } + parsed = adapter.parse_message(event) + assert parsed.author.is_me is True + assert parsed.author.is_bot is True + + def test_echo_threads_by_recipient_psid(self) -> None: + """Echo events flip sender/recipient: ``sender.id`` is the Page ID and + ``recipient.id`` is the user's PSID. ``parse_message`` must thread the + echo off the user PSID so it lands in the same conversation as the + inbound user messages (matching ``_handle_echo``), not the page id. + """ + adapter = _make_adapter() + adapter._bot_user_id = "PAGE_456" + event = { + "sender": {"id": "PAGE_456"}, + "recipient": {"id": "USER_123"}, + "timestamp": 1735689600000, + "message": {"mid": "mid.echo", "text": "bot says", "is_echo": True}, + } + parsed = adapter.parse_message(event) + # Threaded under the user's PSID, NOT the page id. + assert parsed.thread_id == "messenger:USER_123" + assert parsed.thread_id != "messenger:PAGE_456" + # Must match what _handle_echo derives for the same event. + expected = adapter.encode_thread_id(MessengerThreadId(recipient_id="USER_123")) + assert parsed.thread_id == expected + + def test_non_echo_threads_by_sender_psid(self) -> None: + """Non-echo inbound messages have ``sender.id`` == user PSID and must + keep threading off the sender — guards against over-applying the echo + fix to normal inbound events. + """ + adapter = _make_adapter() + event = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "message": {"mid": "mid.abc", "text": "hi"}, + } + parsed = adapter.parse_message(event) + assert parsed.thread_id == "messenger:USER_123" + + def test_postback_uses_title_as_text(self) -> None: + adapter = _make_adapter() + event = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "postback": {"title": "Get Started", "payload": "START"}, + } + parsed = adapter.parse_message(event) + assert parsed.id == "event:1735689600000" + assert parsed.text == "Get Started" + + def test_render_formatted_returns_text(self) -> None: + adapter = _make_adapter() + out = adapter.render_formatted( + { + "type": "root", + "children": [ + {"type": "paragraph", "children": [{"type": "text", "value": "hello world"}]}, + ], + } + ) + assert "hello world" in out + + +# --------------------------------------------------------------------------- +# Graph API error mapping +# --------------------------------------------------------------------------- + + +class _FakeResponse: + """Async-context-manager response stub for aiohttp-compatible callers.""" + + def __init__(self, status: int, json_data: Any) -> None: + self.status = status + self._json_data = json_data + + async def json(self, content_type: Any = None) -> Any: + return self._json_data + + async def __aenter__(self) -> _FakeResponse: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + +class _FakeSession: + """Minimal aiohttp-like session stub. Records the last GET/POST call.""" + + def __init__(self, response: _FakeResponse | Exception) -> None: + self._response = response + self.closed = False + self.last_method: str | None = None + + def get(self, url: str, **kwargs: Any) -> Any: + self.last_method = "GET" + if isinstance(self._response, Exception): + raise self._response + return self._response + + def post(self, url: str, **kwargs: Any) -> Any: + self.last_method = "POST" + if isinstance(self._response, Exception): + raise self._response + return self._response + + async def close(self) -> None: + self.closed = True + + +@pytest.mark.asyncio +async def _call_with_response(status: int, json_data: Any) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(_FakeResponse(status, json_data)) + await adapter.start_typing(THREAD_ID) + + +class TestGraphApiErrors: + """``_graph_api_fetch`` maps Meta error codes to typed adapter errors.""" + + @pytest.mark.asyncio + async def test_rate_limit_on_429(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(_FakeResponse(429, {"error": {"message": "Rate limited"}})) + with pytest.raises(AdapterRateLimitError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_rate_limit_on_code_4(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(_FakeResponse(400, {"error": {"message": "Too many calls", "code": 4}})) + with pytest.raises(AdapterRateLimitError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_rate_limit_on_code_32(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(_FakeResponse(400, {"error": {"message": "Page rate limit", "code": 32}})) + with pytest.raises(AdapterRateLimitError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_rate_limit_on_code_613(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession( + _FakeResponse(400, {"error": {"message": "Custom rate limit", "code": 613}}) + ) + with pytest.raises(AdapterRateLimitError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_auth_error_on_401(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(_FakeResponse(401, {"error": {"message": "Invalid token", "code": 190}})) + with pytest.raises(AuthenticationError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_auth_error_on_code_190(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(_FakeResponse(400, {"error": {"message": "Token expired", "code": 190}})) + with pytest.raises(AuthenticationError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_validation_on_403(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession( + _FakeResponse(403, {"error": {"message": "Permission denied", "code": 10}}) + ) + with pytest.raises(ValidationError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_validation_on_code_200(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession( + _FakeResponse(400, {"error": {"message": "Requires permission", "code": 200}}) + ) + with pytest.raises(ValidationError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_not_found_on_404(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(_FakeResponse(404, {"error": {"message": "Not found"}})) + with pytest.raises(ResourceNotFoundError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_network_on_generic_5xx(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(_FakeResponse(500, {"error": {"message": "Internal error", "code": 2}})) + with pytest.raises(NetworkError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_network_on_session_exception(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(RuntimeError("DNS failure")) + with pytest.raises(NetworkError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_network_on_unparseable_response(self) -> None: + class _BadResp(_FakeResponse): + async def json(self, content_type: Any = None) -> Any: + raise ValueError("not json") + + adapter = _make_adapter() + adapter._http_session = _FakeSession(_BadResp(200, None)) + with pytest.raises(NetworkError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_fallback_message_when_no_error_message(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(_FakeResponse(500, {"error": {"code": 999}})) + with pytest.raises(NetworkError, match="Messenger API"): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_uses_status_as_code_when_missing(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(_FakeResponse(500, {"error": {"message": "Something failed"}})) + with pytest.raises(NetworkError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_no_error_object(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(_FakeResponse(500, {})) + with pytest.raises(NetworkError): + await adapter.start_typing(THREAD_ID) + + +# --------------------------------------------------------------------------- +# Disconnect +# --------------------------------------------------------------------------- + + +class TestDisconnect: + @pytest.mark.asyncio + async def test_disconnect_closes_session(self) -> None: + adapter = _make_adapter() + session = _FakeSession(_FakeResponse(200, {})) + adapter._http_session = session + await adapter.disconnect() + assert session.closed is True + assert adapter._http_session is None + + @pytest.mark.asyncio + async def test_disconnect_when_no_session(self) -> None: + adapter = _make_adapter() + # Should not raise. + await adapter.disconnect() diff --git a/tests/test_messenger_webhook.py b/tests/test_messenger_webhook.py new file mode 100644 index 0000000..0f874cd --- /dev/null +++ b/tests/test_messenger_webhook.py @@ -0,0 +1,1105 @@ +"""Tests for the Messenger adapter — webhook routing & signature verification. + +Mirrors the upstream ``packages/adapter-messenger/src/index.test.ts`` suite +focused on the webhook surface: GET verification challenge, POST event +dispatch, X-Hub-Signature-256 verification (valid / invalid / missing / +replay), payload parsing, and event-type routing (messages, echoes, +postbacks, reactions, delivery / read confirmations, mixed batches). + +Pairs with ``tests/test_messenger_api.py`` (Graph API send/stream/error +mapping) — same split used for Telegram and WhatsApp. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import os +from dataclasses import dataclass +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from chat_sdk.adapters.messenger.adapter import ( + MessengerAdapter, + create_messenger_adapter, +) +from chat_sdk.adapters.messenger.types import ( + ENV_APP_SECRET, + ENV_PAGE_ACCESS_TOKEN, + ENV_VERIFY_TOKEN, + MessengerAdapterConfig, +) +from chat_sdk.logger import ConsoleLogger +from chat_sdk.shared.errors import ValidationError + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +APP_SECRET = "test-app-secret" +PAGE_TOKEN = "test-page-token" +VERIFY_TOKEN = "test-verify-token" + + +def _make_adapter(**overrides: Any) -> MessengerAdapter: + """Create a ``MessengerAdapter`` with minimal valid config.""" + config_kwargs: dict[str, Any] = { + "app_secret": APP_SECRET, + "page_access_token": PAGE_TOKEN, + "verify_token": VERIFY_TOKEN, + "user_name": "test-bot", + "logger": ConsoleLogger("error"), + } + config_kwargs.update(overrides) + return MessengerAdapter(MessengerAdapterConfig(**config_kwargs)) + + +def _make_chat() -> MagicMock: + """Build a ChatInstance-shaped mock with the methods adapters use.""" + chat = MagicMock() + chat.get_user_name.return_value = "TestBot" + chat.process_message = MagicMock() + chat.process_action = MagicMock() + chat.process_reaction = MagicMock() + return chat + + +@dataclass +class _FakeRequest: + """Minimal request-like object accepted by ``handle_webhook``. + + Mirrors the framework-agnostic duck-typed shape used by the WhatsApp / + Telegram adapters (``url``, ``method``, ``headers``, awaitable ``text``). + """ + + url: str + method: str + _body: str + headers: dict[str, str] + + async def text(self) -> str: # noqa: D102 — async body getter + return self._body + + +def _sign(body: bytes | str, secret: str = APP_SECRET) -> str: + """Compute the X-Hub-Signature-256 header value for ``body``.""" + body_bytes = body if isinstance(body, bytes) else body.encode("utf-8") + return "sha256=" + hmac.new(secret.encode("utf-8"), body_bytes, hashlib.sha256).hexdigest() + + +def _sample_event(**overrides: Any) -> dict[str, Any]: + """Build a representative inbound Messenger messaging event.""" + base: dict[str, Any] = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "message": {"mid": "mid.abc123", "text": "hello"}, + } + base.update(overrides) + return base + + +def _webhook_payload(events: list[dict[str, Any]]) -> dict[str, Any]: + return { + "object": "page", + "entry": [ + { + "id": "PAGE_456", + "time": 1735689600000, + "messaging": events, + } + ], + } + + +def _post_request(payload: dict[str, Any], *, sign: bool = True, signature: str | None = None) -> _FakeRequest: + body = json.dumps(payload) + headers: dict[str, str] = {"content-type": "application/json"} + if signature is not None: + headers["x-hub-signature-256"] = signature + elif sign: + headers["x-hub-signature-256"] = _sign(body) + return _FakeRequest(url="https://example.com/webhook", method="POST", _body=body, headers=headers) + + +# --------------------------------------------------------------------------- +# Factory and env-fallback behavior (Q1) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def _clear_messenger_env() -> Any: + """Save and clear FACEBOOK_* env vars for the duration of a test.""" + saved: dict[str, str | None] = { + k: os.environ.pop(k, None) for k in (ENV_APP_SECRET, ENV_PAGE_ACCESS_TOKEN, ENV_VERIFY_TOKEN) + } + yield + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +class TestFactory: + """``create_messenger_adapter`` env fallbacks and Q1 init failure path.""" + + def test_with_explicit_params(self, _clear_messenger_env: Any) -> None: + adapter = create_messenger_adapter( + app_secret="s", + page_access_token="t", + verify_token="v", + user_name="mybot", + ) + assert adapter.name == "messenger" + assert adapter.user_name == "mybot" + + def test_uses_env_vars_when_omitted(self, _clear_messenger_env: Any) -> None: + os.environ[ENV_APP_SECRET] = "secret" + os.environ[ENV_PAGE_ACCESS_TOKEN] = "token" + os.environ[ENV_VERIFY_TOKEN] = "verify" + adapter = create_messenger_adapter() + assert isinstance(adapter, MessengerAdapter) + assert adapter.name == "messenger" + + def test_missing_app_secret_raises(self, _clear_messenger_env: Any) -> None: + os.environ[ENV_PAGE_ACCESS_TOKEN] = "t" + os.environ[ENV_VERIFY_TOKEN] = "v" + with pytest.raises(ValidationError, match="appSecret"): + create_messenger_adapter() + + def test_missing_page_access_token_raises(self, _clear_messenger_env: Any) -> None: + os.environ[ENV_APP_SECRET] = "s" + os.environ[ENV_VERIFY_TOKEN] = "v" + with pytest.raises(ValidationError, match="pageAccessToken"): + create_messenger_adapter() + + def test_missing_verify_token_raises(self, _clear_messenger_env: Any) -> None: + os.environ[ENV_APP_SECRET] = "s" + os.environ[ENV_PAGE_ACCESS_TOKEN] = "t" + with pytest.raises(ValidationError, match="verifyToken"): + create_messenger_adapter() + + def test_constructor_also_raises_on_missing_credentials(self, _clear_messenger_env: Any) -> None: + """Q1: constructing the adapter directly with missing creds also fails. + + The constructor goes through the same ``resolved_*`` fallback chain as + the factory, matching the WhatsApp adapter's behavior and surfacing + config errors loudly at startup rather than at first webhook call. + """ + with pytest.raises(ValidationError, match="appSecret"): + MessengerAdapter( + MessengerAdapterConfig( + page_access_token="t", + verify_token="v", + logger=ConsoleLogger("error"), + ) + ) + + def test_explicit_empty_user_name_is_respected(self, _clear_messenger_env: Any) -> None: + """Regression: ``user_name=""`` must be treated as an explicit choice. + + The old ``config.user_name or "bot"`` (paired with + ``bool(config.user_name)``) silently replaced an explicit empty + string with the ``"bot"`` default and left ``_has_explicit_user_name`` + ``False`` — so ``initialize()`` would then overwrite it from + ``chat.get_user_name()`` / ``/me``. Switching both sites to + ``is not None`` honors the explicit empty string. + + Without the fix this test would observe ``_user_name == "bot"`` and + ``_has_explicit_user_name is False``. + """ + adapter = MessengerAdapter( + MessengerAdapterConfig( + app_secret=APP_SECRET, + page_access_token=PAGE_TOKEN, + verify_token=VERIFY_TOKEN, + user_name="", + logger=ConsoleLogger("error"), + ) + ) + assert adapter._user_name == "" + assert adapter._has_explicit_user_name is True + + +# --------------------------------------------------------------------------- +# Thread ID encoding +# --------------------------------------------------------------------------- + + +class TestThreadId: + """Encode / decode of Messenger thread IDs.""" + + def test_encode(self) -> None: + from chat_sdk.adapters.messenger.types import MessengerThreadId + + adapter = _make_adapter() + assert adapter.encode_thread_id(MessengerThreadId(recipient_id="USER_123")) == "messenger:USER_123" + + def test_decode(self) -> None: + adapter = _make_adapter() + decoded = adapter.decode_thread_id("messenger:USER_123") + assert decoded.recipient_id == "USER_123" + + def test_decode_rejects_invalid_prefix(self) -> None: + adapter = _make_adapter() + with pytest.raises(ValidationError): + adapter.decode_thread_id("invalid") + + def test_decode_rejects_empty_recipient(self) -> None: + adapter = _make_adapter() + with pytest.raises(ValidationError): + adapter.decode_thread_id("messenger:") + + def test_decode_rejects_extra_colons(self) -> None: + adapter = _make_adapter() + with pytest.raises(ValidationError): + adapter.decode_thread_id("messenger:foo:bar") + + def test_decode_rejects_empty(self) -> None: + adapter = _make_adapter() + with pytest.raises(ValidationError): + adapter.decode_thread_id("") + + def test_decode_rejects_wrong_platform(self) -> None: + adapter = _make_adapter() + with pytest.raises(ValidationError): + adapter.decode_thread_id("slack:C123:ts") + + def test_is_dm_always_true(self) -> None: + adapter = _make_adapter() + assert adapter.is_dm("messenger:anything") is True + + def test_channel_id_equals_thread_id(self) -> None: + adapter = _make_adapter() + assert adapter.channel_id_from_thread_id("messenger:USER_123") == "messenger:USER_123" + + @pytest.mark.asyncio + async def test_open_dm_returns_encoded(self) -> None: + adapter = _make_adapter() + tid = await adapter.open_dm("USER_999") + assert tid == "messenger:USER_999" + + +# --------------------------------------------------------------------------- +# GET verification challenge (Q3-adjacent — token equality check) +# --------------------------------------------------------------------------- + + +class TestGetVerification: + """Webhook subscription verification (``hub.mode=subscribe``).""" + + @pytest.mark.asyncio + async def test_valid_verification_request(self) -> None: + adapter = _make_adapter() + request = _FakeRequest( + url=f"https://example.com/webhook?hub.mode=subscribe&hub.verify_token={VERIFY_TOKEN}&hub.challenge=CHALLENGE_VALUE", + method="GET", + _body="", + headers={}, + ) + response = await adapter.handle_webhook(request) + assert response["status"] == 200 + assert response["body"] == "CHALLENGE_VALUE" + + @pytest.mark.asyncio + async def test_invalid_verify_token_rejected(self) -> None: + adapter = _make_adapter() + request = _FakeRequest( + url="https://example.com/webhook?hub.mode=subscribe&hub.verify_token=wrong&hub.challenge=CHALLENGE", + method="GET", + _body="", + headers={}, + ) + response = await adapter.handle_webhook(request) + assert response["status"] == 403 + + @pytest.mark.asyncio + async def test_missing_challenge_yields_empty_body(self) -> None: + adapter = _make_adapter() + request = _FakeRequest( + url=f"https://example.com/webhook?hub.mode=subscribe&hub.verify_token={VERIFY_TOKEN}", + method="GET", + _body="", + headers={}, + ) + response = await adapter.handle_webhook(request) + assert response["status"] == 200 + assert response["body"] == "" + + @pytest.mark.asyncio + async def test_wrong_mode_rejected(self) -> None: + adapter = _make_adapter() + request = _FakeRequest( + url=f"https://example.com/webhook?hub.mode=unsubscribe&hub.verify_token={VERIFY_TOKEN}&hub.challenge=CHALLENGE", + method="GET", + _body="", + headers={}, + ) + response = await adapter.handle_webhook(request) + assert response["status"] == 403 + + +# --------------------------------------------------------------------------- +# Signature verification (Q3) +# --------------------------------------------------------------------------- + + +class TestVerifySignature: + """Unit tests for ``_verify_signature``. + + Q3: upstream pins X-Hub-Signature-256 with HMAC-SHA256 over the raw body + using the App Secret. We mirror that exactly; a swappable verifier (like + Slack's ``webhook_verifier``) would diverge from Meta's protocol and + isn't justified for the single-secret Meta integration. + """ + + def test_valid_signature_accepted(self) -> None: + adapter = _make_adapter(app_secret="my-secret") + body = b'{"test": true}' + sig = "sha256=" + hmac.new(b"my-secret", body, hashlib.sha256).hexdigest() + assert adapter._verify_signature(body, sig) is True + + def test_uppercase_hex_signature_accepted(self) -> None: + """An uppercase-hex signature must still verify. + + ``hexdigest()`` is lowercase, but Node's ``Buffer.from(hex)`` is + case-insensitive, so upstream accepts an uppercase-hex signature. + We normalize the header hash to lowercase before the constant-time + compare; without that, this exact-but-uppercased signature would be + rejected. Pins parity with upstream's behavior. + """ + adapter = _make_adapter(app_secret="my-secret") + body = b'{"test": true}' + sig = "sha256=" + hmac.new(b"my-secret", body, hashlib.sha256).hexdigest().upper() + assert adapter._verify_signature(body, sig) is True + + def test_invalid_signature_rejected(self) -> None: + adapter = _make_adapter(app_secret="my-secret") + assert adapter._verify_signature(b'{"a":1}', "sha256=deadbeef") is False + + def test_missing_signature_rejected(self) -> None: + adapter = _make_adapter() + assert adapter._verify_signature(b"body", None) is False + + def test_empty_signature_rejected(self) -> None: + adapter = _make_adapter() + assert adapter._verify_signature(b"body", "") is False + + def test_wrong_algo_rejected(self) -> None: + adapter = _make_adapter() + assert adapter._verify_signature(b"body", "sha1=abcdef") is False + + def test_missing_hash_after_algo_rejected(self) -> None: + adapter = _make_adapter() + assert adapter._verify_signature(b"body", "sha256=") is False + + def test_signature_without_equals_rejected(self) -> None: + adapter = _make_adapter() + # No "=" separator at all is malformed. + assert adapter._verify_signature(b"body", "abc123") is False + + def test_signature_with_wrong_secret_rejected(self) -> None: + """A signature computed with the wrong key must not validate. + + Equivalent of a "replay with attacker's secret" / forgery attempt — + even with a valid-looking sha256 hex shape, mismatching HMAC fails. + """ + adapter = _make_adapter(app_secret="real-secret") + body = b'{"x":1}' + forged = "sha256=" + hmac.new(b"other-secret", body, hashlib.sha256).hexdigest() + assert adapter._verify_signature(body, forged) is False + + def test_replay_with_modified_body_rejected(self) -> None: + """Capturing a signature and replaying it with a mutated body fails. + + Models the classic "attacker replays old signature against new body" + scenario — HMAC binds the signature to the exact bytes that were + signed, so any change to the body invalidates the signature. + """ + adapter = _make_adapter() + original_body = json.dumps(_webhook_payload([_sample_event()])).encode("utf-8") + sig = _sign(original_body) + # Same signature, body mutated (e.g. attacker injects another event). + mutated_body = json.dumps(_webhook_payload([_sample_event(), _sample_event()])).encode("utf-8") + assert adapter._verify_signature(mutated_body, sig) is False + + def test_verifies_raw_bytes_without_encoding_roundtrip(self) -> None: + """Signature must be checked against the exact wire bytes. + + Meta signs the raw HTTP request body. If the adapter re-encodes the + body (decode-to-str then encode-back-to-bytes) before HMAC, any byte + sequence that's not valid UTF-8 gets replaced with U+FFFD and the + computed HMAC diverges from Meta's — legitimate webhooks would fail + verification. This regression pins the contract: feed a body whose + bytes don't round-trip through UTF-8 cleanly, and verification still + succeeds when the signature was computed over the original bytes. + """ + adapter = _make_adapter(app_secret="my-secret") + # Lone continuation byte (0x80) — invalid UTF-8. A decode+re-encode + # round-trip via "utf-8" with errors="replace" would mutate this to + # the 3-byte U+FFFD sequence, breaking HMAC parity. + body = b'{"data":"\x80\xff"}' + assert body.decode("utf-8", errors="replace").encode("utf-8") != body + sig = "sha256=" + hmac.new(b"my-secret", body, hashlib.sha256).hexdigest() + assert adapter._verify_signature(body, sig) is True + + +class TestGetRequestBody: + """Pin that ``_get_request_body`` returns raw bytes. + + The function feeds ``_verify_signature``; switching its return type + would silently re-introduce the decode/re-encode hazard. These tests + cover the four frameworks-shaped inputs the helper supports + (bytes/str ``body`` attribute, bytes/str ``text`` attribute, async or + sync callables) and pin the bytes contract on each path. + """ + + @pytest.mark.asyncio + async def test_bytes_body_attribute_returned_unchanged(self) -> None: + adapter = _make_adapter() + raw = b'{"x":"\x80\xff"}' + + class Req: + body = raw + + result = await adapter._get_request_body(Req()) + assert result == raw + assert isinstance(result, bytes) + + @pytest.mark.asyncio + async def test_str_body_attribute_encoded_utf8(self) -> None: + adapter = _make_adapter() + + class Req: + body = '{"hello": "world"}' + + result = await adapter._get_request_body(Req()) + assert result == b'{"hello": "world"}' + assert isinstance(result, bytes) + + @pytest.mark.asyncio + async def test_async_callable_body_returns_bytes(self) -> None: + """Mirrors Starlette/FastAPI: ``await request.body()`` returns bytes.""" + adapter = _make_adapter() + raw = b'{"async": true}' + + async def body() -> bytes: + return raw + + class Req: + pass + + req = Req() + req.body = body # type: ignore[attr-defined] + result = await adapter._get_request_body(req) + assert result == raw + assert isinstance(result, bytes) + + @pytest.mark.asyncio + async def test_text_attribute_fallback_returns_bytes(self) -> None: + """aiohttp-style: ``await request.text()`` returns str — encode it.""" + adapter = _make_adapter() + + async def text() -> str: + return '{"text": "ok"}' + + class Req: + pass + + req = Req() + req.text = text # type: ignore[attr-defined] + result = await adapter._get_request_body(req) + assert result == b'{"text": "ok"}' + assert isinstance(result, bytes) + + @pytest.mark.asyncio + async def test_missing_body_returns_empty_bytes(self) -> None: + adapter = _make_adapter() + + class Req: + pass + + result = await adapter._get_request_body(Req()) + assert result == b"" + assert isinstance(result, bytes) + + +# --------------------------------------------------------------------------- +# POST webhook — payload validation & signature gate +# --------------------------------------------------------------------------- + + +class TestWebhookPostSignatureGate: + """End-to-end signature gating in ``handle_webhook`` (POST).""" + + @pytest.mark.asyncio + async def test_missing_signature_header_rejected(self) -> None: + adapter = _make_adapter() + request = _post_request(_webhook_payload([_sample_event()]), sign=False) + response = await adapter.handle_webhook(request) + assert response["status"] == 403 + + @pytest.mark.asyncio + async def test_wrong_algo_rejected(self) -> None: + adapter = _make_adapter() + request = _post_request(_webhook_payload([_sample_event()]), signature="sha1=abc123") + response = await adapter.handle_webhook(request) + assert response["status"] == 403 + + @pytest.mark.asyncio + async def test_missing_hash_rejected(self) -> None: + adapter = _make_adapter() + request = _post_request(_webhook_payload([_sample_event()]), signature="sha256=") + response = await adapter.handle_webhook(request) + assert response["status"] == 403 + + @pytest.mark.asyncio + async def test_bad_hex_rejected(self) -> None: + adapter = _make_adapter() + request = _post_request(_webhook_payload([_sample_event()]), signature="sha256=not-valid-hex") + response = await adapter.handle_webhook(request) + assert response["status"] == 403 + + @pytest.mark.asyncio + async def test_valid_signature_returns_event_received(self) -> None: + adapter = _make_adapter() + adapter._chat = _make_chat() # bypass full init + request = _post_request(_webhook_payload([_sample_event()])) + response = await adapter.handle_webhook(request) + assert response["status"] == 200 + assert response["body"] == "EVENT_RECEIVED" + + +class TestWebhookPostPayloadValidation: + """Payload-shape validation after signature passes.""" + + @pytest.mark.asyncio + async def test_invalid_json_returns_400(self) -> None: + adapter = _make_adapter() + body = "not valid json{{{" + request = _FakeRequest( + url="https://example.com/webhook", + method="POST", + _body=body, + headers={"content-type": "application/json", "x-hub-signature-256": _sign(body)}, + ) + response = await adapter.handle_webhook(request) + assert response["status"] == 400 + + @pytest.mark.asyncio + async def test_non_page_object_returns_404(self) -> None: + adapter = _make_adapter() + request = _post_request({"object": "user", "entry": []}) + response = await adapter.handle_webhook(request) + assert response["status"] == 404 + + @pytest.mark.asyncio + async def test_uninitialized_chat_returns_200_and_warns(self) -> None: + """Match upstream: webhooks that arrive before ``initialize`` ack 200. + + Returning non-200 would cause Meta to retry — and the only thing the + adapter knows at this point is that it's not ready to dispatch, not + that the payload is bad. Acking matches upstream. + """ + adapter = _make_adapter() + request = _post_request(_webhook_payload([_sample_event()])) + response = await adapter.handle_webhook(request) + assert response["status"] == 200 + assert response["body"] == "EVENT_RECEIVED" + + +# --------------------------------------------------------------------------- +# Event routing +# --------------------------------------------------------------------------- + + +class TestWebhookMessageRouting: + """Routing of inbound message events to ``chat.process_message``.""" + + @pytest.mark.asyncio + async def test_incoming_message_dispatched(self) -> None: + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + request = _post_request(_webhook_payload([_sample_event()])) + response = await adapter.handle_webhook(request) + assert response["status"] == 200 + assert chat.process_message.call_count == 1 + call_args = chat.process_message.call_args + # Signature: (adapter, thread_id, message, options) + assert call_args.args[1] == "messenger:USER_123" + + @pytest.mark.asyncio + async def test_echo_message_not_dispatched(self) -> None: + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + event = _sample_event(message={"mid": "mid.echo", "text": "bot", "is_echo": True}) + request = _post_request(_webhook_payload([event])) + await adapter.handle_webhook(request) + assert chat.process_message.call_count == 0 + + @pytest.mark.asyncio + async def test_echo_message_cached_for_history(self) -> None: + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + event = _sample_event( + sender={"id": "PAGE_456"}, + recipient={"id": "USER_123"}, + message={"mid": "mid.echo1", "text": "bot reply", "is_echo": True}, + ) + request = _post_request(_webhook_payload([event])) + await adapter.handle_webhook(request) + cached = await adapter.fetch_message("messenger:USER_123", "mid.echo1") + assert cached is not None + assert cached.text == "bot reply" + + @pytest.mark.asyncio + async def test_multiple_messages_in_entry(self) -> None: + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + payload = _webhook_payload( + [ + _sample_event(message={"mid": "mid.1", "text": "first"}), + _sample_event(message={"mid": "mid.2", "text": "second"}), + _sample_event(message={"mid": "mid.3", "text": "third"}), + ] + ) + await adapter.handle_webhook(_post_request(payload)) + assert chat.process_message.call_count == 3 + + @pytest.mark.asyncio + async def test_multiple_entries_in_payload(self) -> None: + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + payload = { + "object": "page", + "entry": [ + { + "id": "PAGE_456", + "time": 1735689600000, + "messaging": [_sample_event(message={"mid": "mid.a", "text": "from 1"})], + }, + { + "id": "PAGE_456", + "time": 1735689601000, + "messaging": [_sample_event(message={"mid": "mid.b", "text": "from 2"})], + }, + ], + } + await adapter.handle_webhook(_post_request(payload)) + assert chat.process_message.call_count == 2 + + @pytest.mark.asyncio + async def test_delivery_confirmation_no_error(self) -> None: + adapter = _make_adapter() + adapter._chat = _make_chat() + event = _sample_event(message=None, delivery={"watermark": 1735689600000, "mids": ["mid.abc"]}) + # The TypedDict allows None for missing keys; remove ``message`` key. + event.pop("message") + response = await adapter.handle_webhook(_post_request(_webhook_payload([event]))) + assert response["status"] == 200 + + @pytest.mark.asyncio + async def test_read_confirmation_no_error(self) -> None: + adapter = _make_adapter() + adapter._chat = _make_chat() + event = _sample_event(read={"watermark": 1735689600000}) + event.pop("message") + response = await adapter.handle_webhook(_post_request(_webhook_payload([event]))) + assert response["status"] == 200 + + @pytest.mark.asyncio + async def test_mixed_event_types(self) -> None: + """Single payload with message + reaction + delivery + read.""" + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + payload = _webhook_payload( + [ + _sample_event(message={"mid": "mid.msg", "text": "hi"}), + { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "reaction": { + "mid": "mid.msg", + "action": "react", + "emoji": "❤", + "reaction": "love", + }, + }, + { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "delivery": {"watermark": 1735689600000, "mids": ["mid.msg"]}, + }, + { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "read": {"watermark": 1735689600000}, + }, + ] + ) + response = await adapter.handle_webhook(_post_request(payload)) + assert response["status"] == 200 + assert chat.process_message.call_count == 1 + assert chat.process_reaction.call_count == 1 + + +class TestPostbackRouting: + """Postback events dispatch via ``chat.process_action``.""" + + @pytest.mark.asyncio + async def test_postback_dispatched(self) -> None: + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + event = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "postback": {"title": "Get Started", "payload": "GET_STARTED"}, + } + await adapter.handle_webhook(_post_request(_webhook_payload([event]))) + assert chat.process_action.call_count == 1 + + @pytest.mark.asyncio + async def test_postback_mid_used_when_present(self) -> None: + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + event = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "postback": {"title": "Menu", "payload": "MENU_1", "mid": "mid.postback1"}, + } + await adapter.handle_webhook(_post_request(_webhook_payload([event]))) + action = chat.process_action.call_args.args[0] + assert action.message_id == "mid.postback1" + assert action.action_id == "MENU_1" + # Q2 (PR 1): callback-data passthrough — when no chat: prefix, both + # action_id and value come from the raw payload. + assert action.value == "MENU_1" + + @pytest.mark.asyncio + async def test_postback_falls_back_to_timestamp_id(self) -> None: + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + event = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689999000, + "postback": {"title": "Get Started", "payload": "GET_STARTED"}, + } + await adapter.handle_webhook(_post_request(_webhook_payload([event]))) + action = chat.process_action.call_args.args[0] + assert action.message_id == "postback:1735689999000" + + @pytest.mark.asyncio + async def test_postback_with_chat_prefixed_payload(self) -> None: + """Postbacks with the ``chat:`` prefix decode into (action, value).""" + from chat_sdk.adapters.messenger.cards import encode_messenger_callback_data + + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + encoded = encode_messenger_callback_data("approve", "yes") + event = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "postback": {"title": "Approve", "payload": encoded, "mid": "mid.p"}, + } + await adapter.handle_webhook(_post_request(_webhook_payload([event]))) + action = chat.process_action.call_args.args[0] + assert action.action_id == "approve" + assert action.value == "yes" + + +class TestReactionRouting: + """Reaction events dispatch via ``chat.process_reaction``.""" + + @pytest.mark.asyncio + async def test_react_event(self) -> None: + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + event = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "reaction": { + "mid": "m_reacted", + "action": "react", + "emoji": "❤", + "reaction": "love", + }, + } + await adapter.handle_webhook(_post_request(_webhook_payload([event]))) + reaction = chat.process_reaction.call_args.args[0] + assert reaction.message_id == "m_reacted" + assert reaction.raw_emoji == "❤" + assert reaction.added is True + + @pytest.mark.asyncio + async def test_unreact_event(self) -> None: + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + event = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "reaction": { + "mid": "m_reacted", + "action": "unreact", + "emoji": "❤", + "reaction": "love", + }, + } + await adapter.handle_webhook(_post_request(_webhook_payload([event]))) + reaction = chat.process_reaction.call_args.args[0] + assert reaction.added is False + + +# --------------------------------------------------------------------------- +# Attachment parsing (no network) +# --------------------------------------------------------------------------- + + +class TestAttachmentParsing: + """``parse_message`` extracts attachments from inbound events.""" + + def test_extracts_image_video_audio_file_fallback(self) -> None: + adapter = _make_adapter() + event = _sample_event( + message={ + "mid": "mid.attach", + "text": "check", + "attachments": [ + {"type": "image", "payload": {"url": "https://example.com/img.jpg"}}, + {"type": "video", "payload": {"url": "https://example.com/vid.mp4"}}, + {"type": "audio", "payload": {"url": "https://example.com/aud.mp3"}}, + {"type": "file", "payload": {"url": "https://example.com/doc.pdf"}}, + {"type": "fallback", "payload": {"url": "https://example.com/fb"}}, + ], + } + ) + parsed = adapter.parse_message(event) + assert len(parsed.attachments) == 5 + assert [a.type for a in parsed.attachments] == ["image", "video", "audio", "file", "file"] + + def test_skips_attachments_without_url(self) -> None: + adapter = _make_adapter() + event = _sample_event( + message={ + "mid": "mid.nourl", + "text": "sticker", + "attachments": [ + {"type": "image", "payload": {"sticker_id": 123}}, + {"type": "image"}, + ], + } + ) + parsed = adapter.parse_message(event) + assert parsed.attachments == [] + + def test_location_attachment_mapped_to_file(self) -> None: + adapter = _make_adapter() + event = _sample_event( + message={ + "mid": "mid.loc", + "text": "location", + "attachments": [ + {"type": "location", "payload": {"url": "https://maps.example.com/x"}}, + ], + } + ) + parsed = adapter.parse_message(event) + assert len(parsed.attachments) == 1 + assert parsed.attachments[0].type == "file" + + def test_no_attachments_field(self) -> None: + adapter = _make_adapter() + event = _sample_event(message={"mid": "mid.no", "text": "plain"}) + parsed = adapter.parse_message(event) + assert parsed.attachments == [] + + def test_attachment_has_fetch_data_callable(self) -> None: + adapter = _make_adapter() + event = _sample_event( + message={ + "mid": "mid.fd", + "text": "x", + "attachments": [{"type": "image", "payload": {"url": "https://example.com/img.jpg"}}], + } + ) + parsed = adapter.parse_message(event) + assert parsed.attachments[0].fetch_data is not None + assert callable(parsed.attachments[0].fetch_data) + + @pytest.mark.asyncio + async def test_attachment_download_uses_session(self) -> None: + """``fetch_data`` reads bytes from the shared aiohttp session.""" + adapter = _make_adapter() + event = _sample_event( + message={ + "mid": "mid.dl", + "text": "x", + "attachments": [{"type": "image", "payload": {"url": "https://example.com/img.jpg"}}], + } + ) + parsed = adapter.parse_message(event) + + # Patch the shared session with a minimal context-manager mock. + class _Resp: + status = 200 + + async def read(self) -> bytes: + return b"image-bytes" + + async def __aenter__(self) -> _Resp: + return self + + async def __aexit__(self, *_: object) -> None: + pass + + # aiohttp's session.get(url) is a sync call returning an + # async-context-manager request handle. Wire side_effect (instead of + # assigning a fresh mock to .get) so the call returns _Resp directly. + def _session_get(url: str, **_kw: object) -> _Resp: + return _Resp() + + mock_session = MagicMock() + mock_session.closed = False + mock_session.get.side_effect = _session_get + adapter._http_session = mock_session + + result = await parsed.attachments[0].fetch_data() + assert result == b"image-bytes" + + +# --------------------------------------------------------------------------- +# Attachment rehydration (queue/debounce/burst concurrency) +# --------------------------------------------------------------------------- + + +class TestRehydrateAttachment: + """``MessengerAdapter.rehydrate_attachment`` rebuilds dropped closures. + + Ports the WhatsApp adapter's ``rehydrate_attachment`` hook to Messenger + so that messages bearing attachments survive the queue/debounce/burst + concurrency paths (Codex P2 finding). Without the hook, ``fetch_data`` + is ``None`` after dequeue and downstream handlers cannot download bytes. + """ + + def test_extraction_populates_fetch_metadata_with_url(self) -> None: + """``_extract_attachments`` stores the download URL on ``fetch_metadata``.""" + adapter = _make_adapter() + event = _sample_event( + message={ + "mid": "mid.meta", + "text": "x", + "attachments": [ + {"type": "image", "payload": {"url": "https://scontent.example.com/img.jpg"}}, + ], + } + ) + parsed = adapter.parse_message(event) + assert parsed.attachments[0].fetch_metadata == {"url": "https://scontent.example.com/img.jpg"} + + @pytest.mark.asyncio + async def test_rehydrate_rebuilds_fetch_data_after_queue_roundtrip(self) -> None: + """Simulate the queue/serialize cycle and confirm rehydration restores downloads. + + This is the load-bearing test: it would FAIL if ``rehydrate_attachment`` + is left as the protocol default (no-op) because ``fetch_data`` would + stay ``None`` and the ``await`` would raise ``TypeError``. + """ + adapter = _make_adapter() + event = _sample_event( + message={ + "mid": "mid.rehy", + "text": "x", + "attachments": [ + {"type": "image", "payload": {"url": "https://scontent.example.com/img.jpg"}}, + ], + } + ) + parsed = adapter.parse_message(event) + original = parsed.attachments[0] + + # Simulate the JSON roundtrip: ``fetch_data`` closure is dropped, + # ``fetch_metadata`` survives (it serializes as a plain dict). + original.fetch_data = None + + rehydrated = adapter.rehydrate_attachment(original) + assert rehydrated.fetch_data is not None + assert callable(rehydrated.fetch_data) + # Preserves the metadata so a second roundtrip would still work. + assert rehydrated.fetch_metadata == {"url": "https://scontent.example.com/img.jpg"} + + # Wire a fake session to capture the URL the rebuilt closure hits. + captured_urls: list[str] = [] + + class _Resp: + status = 200 + + async def read(self) -> bytes: + return b"rehydrated-bytes" + + async def __aenter__(self) -> _Resp: + return self + + async def __aexit__(self, *_: object) -> None: + pass + + def _session_get(url: str, **_kw: object) -> _Resp: + captured_urls.append(url) + return _Resp() + + mock_session = MagicMock() + mock_session.closed = False + mock_session.get.side_effect = _session_get + adapter._http_session = mock_session + + data = await rehydrated.fetch_data() + assert data == b"rehydrated-bytes" + assert captured_urls == ["https://scontent.example.com/img.jpg"] + + def test_rehydrate_no_metadata_returns_unchanged(self) -> None: + """Degraded mode: attachment without ``fetch_metadata`` is returned as-is.""" + adapter = _make_adapter() + from chat_sdk.types import Attachment + + bare = Attachment(type="image", url="https://example.com/x.jpg") + # fetch_metadata is None by default — adapter cannot rebuild the closure. + result = adapter.rehydrate_attachment(bare) + assert result is bare + assert result.fetch_data is None + + def test_rehydrate_metadata_missing_url_returns_unchanged(self) -> None: + """Degraded mode: ``fetch_metadata`` without the ``url`` key is a no-op.""" + adapter = _make_adapter() + from chat_sdk.types import Attachment + + bare = Attachment( + type="image", + url="https://example.com/x.jpg", + fetch_metadata={"unrelated": "value"}, + ) + result = adapter.rehydrate_attachment(bare) + assert result is bare + assert result.fetch_data is None From 5b3b48734e29d9a66e0fcfe182c2ee02316166b7 Mon Sep 17 00:00:00 2001 From: patrick-chinchill Date: Fri, 29 May 2026 19:47:14 -0700 Subject: [PATCH 02/14] feat(ai): add create_chat_tools tool factory (PR 2 of 3) (#122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ai): add create_chat_tools tool factory (PR 2 of 3) (vercel/chat#492) Port of `packages/chat/src/ai/tools.ts` (plus the supporting `tools/{channels,messages,reactions,threads,users}.ts` and `types.ts`) introduced by vercel/chat#492. Builds on top of #116, which moved `chat_sdk.ai` from a single module to a package so `tools.py` has a home. `create_chat_tools(chat, preset=, require_approval=, overrides=)` returns a mapping of tool-name -> `ChatTool` dataclass holding a description, JSON-Schema-shaped `input_schema`, an async `execute` callable, and the `needs_approval` flag. Three presets (`reader` / `messenger` / `moderator`) and per-tool overrides match the upstream API surface verbatim. Individual factories (`post_message`, `add_reaction`, ...) are also exported so callers can cherry-pick. PR 3 will wire these tools into the existing handler paths; this PR adds the surface only. Validation: uv run ruff check src/ tests/ -> clean uv run ruff format --check src/ tests/ -> clean uv run python scripts/audit_test_quality.py -> 0 hard failures uv run pytest tests/ -q -> 4081 passed, 3 skipped Refs #98, #109. * fix(ai/tools): wrap ChatNotImplementedError + tighten preset check (gemini review) Four small follow-ups to PR 2 of the chat-ai port from Gemini's review: - Import `ChatNotImplementedError` alongside `ChatError`. - Wrap `fetch_channel_messages` invocation in `try/except ChatNotImplementedError` and re-raise as `ChatError` (preserves cause). The early `getattr(adapter, "fetch_channel_messages", None) is None` branch only catches missing attributes; `BaseAdapter` exposes the attribute as a stub that itself raises `ChatNotImplementedError`, so without this wrap the tool surfaces a different exception type than callers expect. Matches the `Chat.get_user` pattern. - Same wrap for `list_threads`. - `_resolve_preset_tools` now distinguishes a single `str` preset from any iterable of presets via `isinstance(preset, str)` rather than `isinstance(preset, list)`, so tuples/sets/Sequences work correctly. Two new regression tests (`test_fetch_channel_messages_wraps_not_implemented`, `test_list_threads_wraps_not_implemented`) drive an `AsyncMock` whose `side_effect` is `ChatNotImplementedError`, assert the surfaced exception is `ChatError` with the expected message, and pin the cause chain (`__cause__ is ChatNotImplementedError`). https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj * fix(ai/tools): isolate per-tool input schemas to prevent mutation bleed (review) * fix(ai/tools): pass list_threads options as keyword (codex P2) `MockAdapter.list_threads` is declared as `list_threads(self, channel_id, **kwargs)`, so the tool's positional `list_method(channel_id, ListThreadsOptions(...))` raised `TypeError` for any consumer wiring `create_chat_tools` into MockAdapter (the SDK's own mock). Production adapters accept the kwarg form just as readily as the positional form, so the change is universally safe. The existing `test_list_threads_projects_summaries` used `AsyncMock(return_value=...)` which masked the TypeError by replacing the real `MockAdapter.list_threads` entirely. New `test_list_threads_uses_keyword_options` exercises the real MockAdapter and asserts the default empty result — fails on the old positional call with `TypeError`, passes after. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj * style(ai/tools): apply is-not-None idiom + hoist core import (audit nits) --------- Co-authored-by: Claude (cherry picked from commit bf66edb6da99443a83f00190ada210883dc9391d) https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 --- src/chat_sdk/ai/__init__.py | 67 ++- src/chat_sdk/ai/tools.py | 1133 +++++++++++++++++++++++++++++++++++ tests/test_ai_tools.py | 815 +++++++++++++++++++++++++ 3 files changed, 2011 insertions(+), 4 deletions(-) create mode 100644 src/chat_sdk/ai/tools.py create mode 100644 tests/test_ai_tools.py diff --git a/src/chat_sdk/ai/__init__.py b/src/chat_sdk/ai/__init__.py index d5a874d..94d4a3a 100644 --- a/src/chat_sdk/ai/__init__.py +++ b/src/chat_sdk/ai/__init__.py @@ -1,11 +1,12 @@ """AI SDK integration for the chat SDK. Python port of the ``chat/ai`` subpath. Mirrors the upstream structure where -``ai.ts`` was split into ``ai/messages.ts`` (and, in later PRs, ``ai/tools.ts``) -to make room for tool factories. +``ai.ts`` was split into ``ai/messages.ts`` and ``ai/tools.ts`` (plus the +``ai/tools/*`` helpers) to make room for the tool factory surface. -Re-exports everything the former ``chat_sdk.ai`` module exposed so existing -imports such as ``from chat_sdk.ai import to_ai_messages`` keep working. +Re-exports the message-conversion helpers and the tool factory + supporting +types so callers can do ``from chat_sdk.ai import to_ai_messages, +create_chat_tools`` regardless of how upstream splits the source files. """ from __future__ import annotations @@ -22,6 +23,36 @@ ToAiMessagesOptions, to_ai_messages, ) +from chat_sdk.ai.tools import ( + ApprovalConfig, + ChatBinding, + ChatTool, + ChatToolName, + ChatToolPreset, + ChatTools, + ChatToolsOptions, + ChatWriteToolName, + ToolOptions, + ToolOverrides, + add_reaction, + create_chat_tools, + delete_message, + edit_message, + fetch_channel_messages, + fetch_messages, + fetch_thread, + get_channel_info, + get_thread_participants, + get_user, + list_threads, + post_channel_message, + post_message, + remove_reaction, + send_direct_message, + start_typing, + subscribe_thread, + unsubscribe_thread, +) __all__ = [ "TEXT_MIME_PREFIXES", @@ -32,6 +63,34 @@ "AiMessagePart", "AiTextPart", "AiUserMessage", + "ApprovalConfig", + "ChatBinding", + "ChatTool", + "ChatToolName", + "ChatToolPreset", + "ChatTools", + "ChatToolsOptions", + "ChatWriteToolName", "ToAiMessagesOptions", + "ToolOptions", + "ToolOverrides", + "add_reaction", + "create_chat_tools", + "delete_message", + "edit_message", + "fetch_channel_messages", + "fetch_messages", + "fetch_thread", + "get_channel_info", + "get_thread_participants", + "get_user", + "list_threads", + "post_channel_message", + "post_message", + "remove_reaction", + "send_direct_message", + "start_typing", + "subscribe_thread", + "unsubscribe_thread", "to_ai_messages", ] diff --git a/src/chat_sdk/ai/tools.py b/src/chat_sdk/ai/tools.py new file mode 100644 index 0000000..ea84d36 --- /dev/null +++ b/src/chat_sdk/ai/tools.py @@ -0,0 +1,1133 @@ +"""Tool factory for exposing Chat SDK operations to AI agents. + +Python port of ``packages/chat/src/ai/tools.ts`` (and the supporting +``tools/{channels,messages,reactions,threads,users}.ts`` / ``types.ts`` +files) introduced by `vercel/chat#492`_. + +The TS implementation builds on the Vercel AI SDK's :func:`tool` helper and +``zod`` schemas. Python has no direct equivalent: there's no canonical AI +agent runtime in the standard library, and adding ``pydantic`` (or any other +runtime) would couple ``chat_sdk`` to a third-party schema validator. To +keep the surface framework-agnostic — and faithful to the upstream contract +that a tool is "a description + an input schema + an ``execute`` callable" — +each factory returns a plain :class:`ChatTool` dataclass holding: + +* ``description`` — the natural-language description shown to the model. +* ``input_schema`` — a JSON-Schema-shaped :class:`dict` describing the tool + inputs. Consumers that bind these tools into the Vercel AI SDK (via the + ``@ai-sdk/python``-style bridge) or any other agent runtime can feed this + dict directly to their schema layer. +* ``needs_approval`` — mirrors upstream's ``needsApproval`` flag for + human-in-the-loop write tools. Falsy for read-only tools, ``True`` by + default for writes. +* ``execute`` — an ``async`` callable taking a ``dict`` of validated + arguments and returning the tool result. + +The factory entry point :func:`create_chat_tools` mirrors upstream's +``createChatTools`` exactly: presets, ``require_approval`` config (bool or +per-tool mapping), per-tool ``overrides``, and the same set of protected +core fields that overrides cannot replace. + +.. _vercel/chat#492: https://github.com/vercel/chat/pull/492 +""" + +from __future__ import annotations + +import copy +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field, replace +from typing import Any, Literal + +from chat_sdk.chat import Chat +from chat_sdk.errors import ChatError, ChatNotImplementedError +from chat_sdk.types import ( + Author, + FetchOptions, + ListThreadsOptions, + Message, + PostableMarkdown, + PostableRaw, +) + +# --------------------------------------------------------------------------- +# Tool dataclass +# --------------------------------------------------------------------------- + + +@dataclass +class ChatTool: + """A Chat SDK tool exposed to an AI agent. + + Mirrors the runtime shape produced by upstream's ``tool({...})`` helper: + a ``description`` + an ``input_schema`` (JSON-Schema-shaped) + an + ``execute`` coroutine. ``needs_approval`` is ``True`` for write tools so + callers can gate execution behind human approval (matching upstream's + ``needsApproval`` flag). + + Additional upstream fields exposed via the ``overrides`` config — + ``title``, ``input_examples``, ``metadata``, ``provider_options``, + ``strict``, ``to_model_output``, ``on_input_available``, + ``on_input_delta``, ``on_input_start`` — are stored in :attr:`extras` + as a free-form dict so callers can forward them to whatever agent + runtime they bind these tools into. + """ + + description: str + input_schema: dict[str, Any] + execute: Callable[[dict[str, Any]], Awaitable[Any]] + needs_approval: bool | None = None + extras: dict[str, Any] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Aliases mirroring upstream type names +# --------------------------------------------------------------------------- + +#: Alias for the ``Chat`` instance threaded through every tool factory. +#: Upstream types this as ``Chat``; in Python ``Chat`` is already +#: untyped at the adapter level so a plain alias is enough. +ChatBinding = Chat + + +@dataclass +class ToolOptions: + """Common options for write tools that may require approval before executing. + + Mirrors upstream's ``ToolOptions`` interface. + """ + + needs_approval: bool = True + + +#: Partial overrides for a single tool. Mirrors upstream's ``ToolOverrides``, +#: but ``input_schema``/``execute``/``output_schema`` etc. are filtered out +#: at apply-time (see :data:`_PROTECTED_TOOL_FIELDS`) so semantics stay +#: stable. +ToolOverrides = dict[str, Any] + + +# --------------------------------------------------------------------------- +# Tool names +# --------------------------------------------------------------------------- + +#: Every tool name produced by :func:`create_chat_tools`. Kept as a +#: ``Literal`` alias instead of an enum so the mapping types below can be +#: expressed naturally. +ChatToolName = Literal[ + "fetchMessages", + "fetchChannelMessages", + "fetchThread", + "listThreads", + "getThreadParticipants", + "getChannelInfo", + "getUser", + "startTyping", + "postMessage", + "postChannelMessage", + "sendDirectMessage", + "editMessage", + "deleteMessage", + "addReaction", + "removeReaction", + "subscribeThread", + "unsubscribeThread", +] + +#: Names of every tool that mutates platform state. These default to +#: ``needs_approval=True`` and can be toggled via ``require_approval`` on +#: :func:`create_chat_tools`. +ChatWriteToolName = Literal[ + "postMessage", + "postChannelMessage", + "sendDirectMessage", + "editMessage", + "deleteMessage", + "addReaction", + "removeReaction", + "subscribeThread", + "unsubscribeThread", +] + +#: Whether write operations require user approval. +#: +#: - ``True`` — every write tool needs approval (default) +#: - ``False`` — no write tool needs approval +#: - ``dict`` — per-tool override; unspecified write tools default to +#: ``True`` +ApprovalConfig = bool | dict[str, bool] + +#: Predefined tool presets for common chat-agent use cases. +#: +#: - ``"reader"`` — read-only: fetch threads, messages, channel info, +#: users +#: - ``"messenger"`` — basic posting: post in thread/channel, DM, react, +#: typing +#: - ``"moderator"`` — full management: read + write + edit/delete + +#: subscriptions +ChatToolPreset = Literal["reader", "messenger", "moderator"] + + +_PRESET_TOOLS: dict[str, list[str]] = { + "reader": [ + "fetchMessages", + "fetchChannelMessages", + "fetchThread", + "listThreads", + "getThreadParticipants", + "getChannelInfo", + "getUser", + ], + "messenger": [ + "fetchMessages", + "fetchThread", + "getChannelInfo", + "getUser", + "postMessage", + "postChannelMessage", + "sendDirectMessage", + "addReaction", + "removeReaction", + "startTyping", + ], + "moderator": [ + "fetchMessages", + "fetchChannelMessages", + "fetchThread", + "listThreads", + "getThreadParticipants", + "getChannelInfo", + "getUser", + "postMessage", + "postChannelMessage", + "sendDirectMessage", + "editMessage", + "deleteMessage", + "addReaction", + "removeReaction", + "subscribeThread", + "unsubscribeThread", + "startTyping", + ], +} + + +# Fields that overrides cannot replace. Mirrors upstream's +# ``PROTECTED_TOOL_FIELDS``. ``args``/``id``/``output_schema``/``type``/ +# ``supports_deferred_results`` come from upstream's AI SDK shape; they're +# included verbatim so consumers porting upstream ``overrides`` dicts get +# the same protection. +_PROTECTED_TOOL_FIELDS: frozenset[str] = frozenset( + { + "args", + "execute", + "id", + "input_schema", + "inputSchema", + "output_schema", + "outputSchema", + "supports_deferred_results", + "supportsDeferredResults", + "type", + } +) + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _project_message(message: Message) -> dict[str, Any]: + """Flatten a :class:`~chat_sdk.types.Message` for model consumption. + + Mirrors upstream's ``projectMessage`` helper. Field names use camelCase + so that JSON dumps of the returned dicts match the upstream wire shape; + Python callers who want snake_case can rename downstream. + """ + return { + "id": message.id, + "threadId": message.thread_id, + "text": message.text, + "author": { + "userId": message.author.user_id, + "userName": message.author.user_name, + "fullName": message.author.full_name, + "isBot": message.author.is_bot, + "isMe": message.author.is_me, + }, + "dateSent": (message.metadata.date_sent.isoformat() if message.metadata.date_sent else None), + "edited": message.metadata.edited, + "isMention": getattr(message, "is_mention", False), + "attachments": [ + { + "type": att.type, + "name": att.name, + "mimeType": att.mime_type, + "url": att.url, + } + for att in (message.attachments if message.attachments is not None else []) + ], + } + + +def _project_author(author: Author) -> dict[str, Any]: + return { + "userId": author.user_id, + "userName": author.user_name, + "fullName": author.full_name, + "isBot": author.is_bot, + } + + +# ``message`` arg shape — one of: plain string, ``{"markdown": "..."}``, +# ``{"raw": "..."}``. Mirrors upstream's ``POSTABLE_INPUT`` union. +_POSTABLE_INPUT_SCHEMA: dict[str, Any] = { + "oneOf": [ + {"type": "string", "description": "Plain text body"}, + { + "type": "object", + "properties": {"markdown": {"type": "string"}}, + "required": ["markdown"], + "additionalProperties": False, + "description": "Markdown body, converted to the platform's native format", + }, + { + "type": "object", + "properties": {"raw": {"type": "string"}}, + "required": ["raw"], + "additionalProperties": False, + "description": "Raw body, passed through to the platform untouched", + }, + ], + "description": "Message body", +} + + +def _to_postable(message: Any) -> Any: + """Translate a tool-input ``message`` value to the SDK's postable form. + + Accepts a plain string, ``{"markdown": "..."}``, or ``{"raw": "..."}``. + The string and ``{"raw": ...}`` cases pass through unchanged so the + adapter can decide how to handle them; ``{"markdown": ...}`` is wrapped + in a :class:`~chat_sdk.types.PostableMarkdown` so the SDK's markdown + rendering path runs. + """ + if isinstance(message, str): + return message + if isinstance(message, dict): + if "markdown" in message: + return PostableMarkdown(markdown=message["markdown"]) + if "raw" in message: + return PostableRaw(raw=message["raw"]) + # Fall through — pass anything else through unchanged so adapters that + # accept their own postable shapes (e.g. a card) still work. + return message + + +# --------------------------------------------------------------------------- +# channels.ts +# --------------------------------------------------------------------------- + + +def get_channel_info(chat: ChatBinding) -> ChatTool: + """Fetch metadata for a channel (name, member count, DM status, etc.).""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + channel = chat.channel(args["channelId"]) + info = await channel.fetch_metadata() + return { + "id": info.id, + "name": info.name, + "isDM": info.is_dm if info.is_dm is not None else False, + "memberCount": info.member_count, + "channelVisibility": info.channel_visibility, + } + + return ChatTool( + description=( + "Fetch metadata for a channel: name, member count, DM status, " + "visibility, etc. Use to identify a channel before posting." + ), + input_schema={ + "type": "object", + "properties": { + "channelId": { + "type": "string", + "description": "Full channel id including adapter prefix", + }, + }, + "required": ["channelId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +# --------------------------------------------------------------------------- +# messages.ts +# --------------------------------------------------------------------------- + + +def post_message(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Post a message inside an existing thread.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + sent = await thread.post(_to_postable(args["message"])) + return {"messageId": sent.id, "threadId": sent.thread_id} + + return ChatTool( + description=( + "Post a message inside an existing thread. Use this to reply within a " + "conversation the bot already has context for. The threadId is the " + "full id (e.g. 'slack:C123:1234567890.123456')." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": { + "type": "string", + "description": "Full thread id including adapter prefix", + }, + "message": copy.deepcopy(_POSTABLE_INPUT_SCHEMA), + }, + "required": ["threadId", "message"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +def post_channel_message(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Post a top-level channel message (not threaded under another message).""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + channel = chat.channel(args["channelId"]) + sent = await channel.post(_to_postable(args["message"])) + return {"messageId": sent.id, "threadId": sent.thread_id} + + return ChatTool( + description=( + "Post a top-level message to a channel (not threaded under an existing " + "message). The channelId is the full id (e.g. 'slack:C123ABC')." + ), + input_schema={ + "type": "object", + "properties": { + "channelId": { + "type": "string", + "description": "Full channel id including adapter prefix", + }, + "message": copy.deepcopy(_POSTABLE_INPUT_SCHEMA), + }, + "required": ["channelId", "message"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +def send_direct_message(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Open (or reuse) a 1:1 DM with a user and post in it.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + dm = await chat.open_dm(args["userId"]) + sent = await dm.post(_to_postable(args["message"])) + return {"messageId": sent.id, "threadId": sent.thread_id} + + return ChatTool( + description=( + "Open (or reuse) a 1:1 direct-message conversation with a user and post " + "a message in it. The userId format is platform-specific (e.g. 'U123456' " + "for Slack, 'users/123' for Google Chat)." + ), + input_schema={ + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "Platform-specific user id; the adapter is auto-detected", + }, + "message": copy.deepcopy(_POSTABLE_INPUT_SCHEMA), + }, + "required": ["userId", "message"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +def edit_message(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Edit a previously posted message in a thread.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + result = await thread.adapter.edit_message( + args["threadId"], + args["messageId"], + _to_postable(args["message"]), + ) + return {"messageId": result.id, "threadId": result.thread_id} + + return ChatTool( + description=( + "Edit a previously posted message in a thread. Replaces the existing " + "message body. Only messages the bot itself authored can be edited on " + "most platforms." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + "messageId": { + "type": "string", + "description": "Platform-specific message id of the message to edit", + }, + "message": copy.deepcopy(_POSTABLE_INPUT_SCHEMA), + }, + "required": ["threadId", "messageId", "message"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +def delete_message(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Delete a message from a thread.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + await thread.adapter.delete_message(args["threadId"], args["messageId"]) + return { + "deleted": True, + "messageId": args["messageId"], + "threadId": args["threadId"], + } + + return ChatTool( + description=( + "Delete a message from a thread. Only messages the bot itself authored can be deleted on most platforms." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + "messageId": { + "type": "string", + "description": "Platform-specific message id of the message to delete", + }, + }, + "required": ["threadId", "messageId"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +# --------------------------------------------------------------------------- +# reactions.ts +# --------------------------------------------------------------------------- + + +def add_reaction(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Add an emoji reaction to a specific message.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + await thread.adapter.add_reaction(args["threadId"], args["messageId"], args["emoji"]) + return { + "added": True, + "emoji": args["emoji"], + "messageId": args["messageId"], + "threadId": args["threadId"], + } + + return ChatTool( + description=( + "Add an emoji reaction to a specific message. Use a well-known emoji " + "name (e.g. 'thumbs_up', 'heart', 'check') or a platform-native shorthand." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + "messageId": { + "type": "string", + "description": "Platform-specific message id to react to", + }, + "emoji": { + "type": "string", + "description": ("Emoji name or platform shortcode (e.g. 'thumbs_up', 'white_check_mark')"), + }, + }, + "required": ["threadId", "messageId", "emoji"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +def remove_reaction(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Remove an emoji reaction the bot previously added.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + await thread.adapter.remove_reaction(args["threadId"], args["messageId"], args["emoji"]) + return { + "removed": True, + "emoji": args["emoji"], + "messageId": args["messageId"], + "threadId": args["threadId"], + } + + return ChatTool( + description="Remove an emoji reaction the bot previously added to a message.", + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + "messageId": { + "type": "string", + "description": "Platform-specific message id to remove the reaction from", + }, + "emoji": { + "type": "string", + "description": "Emoji name or platform shortcode previously added by the bot", + }, + }, + "required": ["threadId", "messageId", "emoji"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +# --------------------------------------------------------------------------- +# threads.ts +# --------------------------------------------------------------------------- + + +_FETCH_DIRECTION_SCHEMA: dict[str, Any] = { + "type": "string", + "enum": ["forward", "backward"], + "default": "backward", +} + + +def fetch_messages(chat: ChatBinding) -> ChatTool: + """Fetch recent messages from a thread, oldest-first within the page.""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + limit = args.get("limit", 20) + cursor = args.get("cursor") + direction = args.get("direction", "backward") + result = await thread.adapter.fetch_messages( + args["threadId"], + FetchOptions(limit=limit, cursor=cursor, direction=direction), + ) + return { + "messages": [_project_message(m) for m in result.messages], + "nextCursor": result.next_cursor, + } + + return ChatTool( + description=( + "Fetch recent messages from a thread, ordered chronologically (oldest " + "first within the page). Use to read the conversation before responding." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20, + "description": "Maximum number of messages to fetch", + }, + "cursor": { + "type": "string", + "description": "Pagination cursor from a previous fetchMessages call", + }, + "direction": { + **copy.deepcopy(_FETCH_DIRECTION_SCHEMA), + "description": ( + "'backward' (default) returns the most recent messages; 'forward' iterates from the oldest" + ), + }, + }, + "required": ["threadId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +def fetch_channel_messages(chat: ChatBinding) -> ChatTool: + """Fetch top-level messages in a channel (not thread replies).""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + channel_id: str = args["channelId"] + adapter_name = channel_id.split(":")[0] if ":" in channel_id else "" + adapter = chat.get_adapter(adapter_name) if adapter_name else None + fetch_method = getattr(adapter, "fetch_channel_messages", None) if adapter is not None else None + if fetch_method is None: + raise ChatError(f'Adapter "{adapter_name}" does not support fetching channel messages') + + limit = args.get("limit", 20) + cursor = args.get("cursor") + direction = args.get("direction", "backward") + try: + result = await fetch_method( + channel_id, + FetchOptions(limit=limit, cursor=cursor, direction=direction), + ) + except ChatNotImplementedError as exc: + raise ChatError(f'Adapter "{adapter_name}" does not support fetching channel messages') from exc + return { + "messages": [_project_message(m) for m in result.messages], + "nextCursor": result.next_cursor, + } + + return ChatTool( + description=( + "Fetch top-level messages in a channel (not thread replies). Returns " + "messages in chronological order within the page." + ), + input_schema={ + "type": "object", + "properties": { + "channelId": {"type": "string", "description": "Full channel id"}, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20, + }, + "cursor": {"type": "string"}, + "direction": copy.deepcopy(_FETCH_DIRECTION_SCHEMA), + }, + "required": ["channelId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +def fetch_thread(chat: ChatBinding) -> ChatTool: + """Fetch metadata about a thread.""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + info = await thread.adapter.fetch_thread(args["threadId"]) + return { + "id": info.id, + "channelId": info.channel_id, + "channelName": info.channel_name, + "channelVisibility": info.channel_visibility, + "isDM": info.is_dm if info.is_dm is not None else False, + } + + return ChatTool( + description=("Fetch metadata about a thread (channel id, channel name, visibility, DM status, etc)."), + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + }, + "required": ["threadId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +def list_threads(chat: ChatBinding) -> ChatTool: + """List recent threads in a channel.""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + channel_id: str = args["channelId"] + adapter_name = channel_id.split(":")[0] if ":" in channel_id else "" + adapter = chat.get_adapter(adapter_name) if adapter_name else None + list_method = getattr(adapter, "list_threads", None) if adapter is not None else None + if list_method is None: + raise ChatError(f'Adapter "{adapter_name}" does not support listing threads') + + limit = args.get("limit", 20) + cursor = args.get("cursor") + try: + result = await list_method(channel_id, options=ListThreadsOptions(limit=limit, cursor=cursor)) + except ChatNotImplementedError as exc: + raise ChatError(f'Adapter "{adapter_name}" does not support listing threads') from exc + return { + "threads": [ + { + "id": t.id, + "replyCount": t.reply_count, + "lastReplyAt": t.last_reply_at.isoformat() if t.last_reply_at else None, + "rootMessage": _project_message(t.root_message), + } + for t in result.threads + ], + "nextCursor": result.next_cursor, + } + + return ChatTool( + description=( + "List recent threads in a channel. Returns lightweight summaries with the root message of each thread." + ), + input_schema={ + "type": "object", + "properties": { + "channelId": {"type": "string", "description": "Full channel id"}, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20, + }, + "cursor": {"type": "string"}, + }, + "required": ["channelId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +def get_thread_participants(chat: ChatBinding) -> ChatTool: + """Return the unique non-bot participants in a thread.""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + participants = await thread.get_participants() + return {"participants": [_project_author(p) for p in participants]} + + return ChatTool( + description=( + "Return the unique non-bot participants in a thread. Useful for " + "deciding whether to subscribe (1:1) or stay quiet (group)." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + }, + "required": ["threadId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +def subscribe_thread(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Subscribe to all future messages in a thread.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + await thread.subscribe() + return {"subscribed": True, "threadId": args["threadId"]} + + return ChatTool( + description=( + "Subscribe to all future messages in a thread. After subscribing, the " + "bot will receive every message in this thread (not just @mentions)." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": { + "type": "string", + "description": "Full thread id to subscribe to", + }, + }, + "required": ["threadId"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +def unsubscribe_thread(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Unsubscribe from a thread.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + await thread.unsubscribe() + return {"subscribed": False, "threadId": args["threadId"]} + + return ChatTool( + description=("Unsubscribe from a thread. The bot will stop receiving non-mention messages in this thread."), + input_schema={ + "type": "object", + "properties": { + "threadId": { + "type": "string", + "description": "Full thread id to unsubscribe from", + }, + }, + "required": ["threadId"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +def start_typing(chat: ChatBinding) -> ChatTool: + """Show a typing indicator in a thread.""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + await thread.start_typing(args.get("status")) + return {"typing": True, "threadId": args["threadId"]} + + return ChatTool( + description=( + "Show a typing indicator in a thread. Use this when starting a " + "long-running operation so users know the bot is working." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + "status": { + "type": "string", + "description": ("Optional human-readable status (some platforms display this, others ignore it)"), + }, + }, + "required": ["threadId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +# --------------------------------------------------------------------------- +# users.ts +# --------------------------------------------------------------------------- + + +def get_user(chat: ChatBinding) -> ChatTool: + """Look up profile information about a user by platform-specific id.""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any] | None: + user = await chat.get_user(args["userId"]) + if not user: + return None + return { + "userId": user.user_id, + "userName": user.user_name, + "fullName": user.full_name, + "email": user.email, + "isBot": user.is_bot, + "avatarUrl": user.avatar_url, + } + + return ChatTool( + description=( + "Look up profile information about a user by their platform-specific id " + "(e.g. 'U123456' for Slack, '29:...' for Teams, 'users/123' for Google " + "Chat). Returns null if the user is unknown." + ), + input_schema={ + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "Platform-specific user id; the adapter is auto-detected", + }, + }, + "required": ["userId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +# --------------------------------------------------------------------------- +# Orchestrator: createChatTools +# --------------------------------------------------------------------------- + + +def _resolve_approval(tool_name: str, config: ApprovalConfig) -> bool: + if isinstance(config, bool): + return config + return config.get(tool_name, True) + + +def _resolve_preset_tools(preset: ChatToolPreset | list[ChatToolPreset]) -> set[str]: + presets: list[str] = [preset] if isinstance(preset, str) else list(preset) + tools: set[str] = set() + for p in presets: + if p not in _PRESET_TOOLS: + raise ChatError(f'Unknown preset: "{p}"') + for t in _PRESET_TOOLS[p]: + tools.add(t) + return tools + + +def _apply_overrides(tool: ChatTool, overrides: ToolOverrides | None) -> ChatTool: + """Apply tool overrides while blocking attempts to replace core fields. + + Core fields — ``execute``, ``input_schema``, etc. — are filtered out so + that overrides can never break tool semantics. ``description`` and + ``needs_approval`` are first-class fields on :class:`ChatTool` and are + applied directly; everything else is stashed in :attr:`ChatTool.extras` + for downstream agent runtimes to pick up. + """ + if not overrides: + return tool + + safe = {k: v for k, v in overrides.items() if k not in _PROTECTED_TOOL_FIELDS} + + description = safe.pop("description", tool.description) + needs_approval = safe.pop("needs_approval", safe.pop("needsApproval", tool.needs_approval)) + + extras = {**tool.extras, **safe} + return replace( + tool, + description=description, + needs_approval=needs_approval, + extras=extras, + ) + + +@dataclass +class ChatToolsOptions: + """Options for :func:`create_chat_tools`. Mirrors upstream's ``ChatToolsOptions``.""" + + chat: ChatBinding + overrides: dict[str, ToolOverrides] | None = None + preset: ChatToolPreset | list[ChatToolPreset] | None = None + require_approval: ApprovalConfig = True + + +def create_chat_tools( + chat: ChatBinding | None = None, + *, + preset: ChatToolPreset | list[ChatToolPreset] | None = None, + require_approval: ApprovalConfig = True, + overrides: dict[str, ToolOverrides] | None = None, +) -> dict[str, ChatTool]: + """Create a set of Chat SDK tools for an AI agent. + + Mirrors upstream's ``createChatTools`` from ``chat/ai``: returns a dict + keyed by ``ChatToolName`` (camelCase, matching upstream so consumers + that bind to AI runtimes get the same tool ids). + + Each entry is built lazily so a preset filter skips both the + ``approval`` lookup and the underlying tool construction for tools the + agent will never see — same optimization as upstream. + + Parameters mirror upstream 1:1: + + chat: + The :class:`~chat_sdk.chat.Chat` instance the tools dispatch + operations against. **Required.** + preset: + Optional preset or list of presets to scope the returned toolset. + Omit (or pass ``None``) to get every tool. + require_approval: + ``True`` (default) to require human approval for every write tool; + ``False`` to disable approval globally; or a per-tool ``dict`` + where unspecified write tools default to ``True``. + overrides: + Per-tool overrides. Mirrors upstream's behaviour: core fields + cannot be overridden (see :data:`_PROTECTED_TOOL_FIELDS`). + """ + if chat is None: + raise ChatError( + "createChatTools requires a `chat` instance. Pass your `Chat({ ... })` instance as the `chat` option." + ) + + allowed: set[str] | None = _resolve_preset_tools(preset) if preset is not None else None + + def _approval(name: str) -> ToolOptions: + return ToolOptions(needs_approval=_resolve_approval(name, require_approval)) + + factories: dict[str, Callable[[], ChatTool]] = { + "fetchMessages": lambda: fetch_messages(chat), + "fetchChannelMessages": lambda: fetch_channel_messages(chat), + "fetchThread": lambda: fetch_thread(chat), + "listThreads": lambda: list_threads(chat), + "getThreadParticipants": lambda: get_thread_participants(chat), + "getChannelInfo": lambda: get_channel_info(chat), + "getUser": lambda: get_user(chat), + "startTyping": lambda: start_typing(chat), + "postMessage": lambda: post_message(chat, _approval("postMessage")), + "postChannelMessage": lambda: post_channel_message(chat, _approval("postChannelMessage")), + "sendDirectMessage": lambda: send_direct_message(chat, _approval("sendDirectMessage")), + "editMessage": lambda: edit_message(chat, _approval("editMessage")), + "deleteMessage": lambda: delete_message(chat, _approval("deleteMessage")), + "addReaction": lambda: add_reaction(chat, _approval("addReaction")), + "removeReaction": lambda: remove_reaction(chat, _approval("removeReaction")), + "subscribeThread": lambda: subscribe_thread(chat, _approval("subscribeThread")), + "unsubscribeThread": lambda: unsubscribe_thread(chat, _approval("unsubscribeThread")), + } + + result: dict[str, ChatTool] = {} + overrides_map = overrides if overrides is not None else {} + for name, build in factories.items(): + if allowed is not None and name not in allowed: + continue + built = build() + result[name] = _apply_overrides(built, overrides_map.get(name)) + return result + + +#: Alias matching upstream's ``ChatTools`` — the shape returned by +#: :func:`create_chat_tools`. Kept as an alias rather than a distinct type +#: so consumers can treat the result as a plain dict. +ChatTools = dict[str, ChatTool] + + +__all__ = [ + "ApprovalConfig", + "ChatBinding", + "ChatTool", + "ChatToolName", + "ChatToolPreset", + "ChatTools", + "ChatToolsOptions", + "ChatWriteToolName", + "ToolOptions", + "ToolOverrides", + "add_reaction", + "create_chat_tools", + "delete_message", + "edit_message", + "fetch_channel_messages", + "fetch_messages", + "fetch_thread", + "get_channel_info", + "get_thread_participants", + "get_user", + "list_threads", + "post_channel_message", + "post_message", + "remove_reaction", + "send_direct_message", + "start_typing", + "subscribe_thread", + "unsubscribe_thread", +] diff --git a/tests/test_ai_tools.py b/tests/test_ai_tools.py new file mode 100644 index 0000000..3bb5333 --- /dev/null +++ b/tests/test_ai_tools.py @@ -0,0 +1,815 @@ +"""Tests for ``chat_sdk.ai.tools``. + +Mirrors the upstream Vitest suite in +``packages/chat/src/ai/index.test.ts`` (vercel/chat#492). Each test is +load-bearing — exercising a specific contract of either the +``create_chat_tools`` orchestrator (presets, approval config, override +filtering) or a specific tool factory's ``execute`` path. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from chat_sdk import Chat +from chat_sdk.ai import ( + ChatTool, + create_chat_tools, +) +from chat_sdk.errors import ChatError, ChatNotImplementedError +from chat_sdk.shared.mock_adapter import ( + MockAdapter, + MockStateAdapter, + create_mock_adapter, + create_mock_state, + create_test_message, + mock_logger, +) +from chat_sdk.types import ( + ChannelInfo, + FetchResult, + ListThreadsResult, + PostableMarkdown, + PostableRaw, + ThreadInfo, + ThreadSummary, + UserInfo, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@dataclass +class _Harness: + chat: Chat + adapter: MockAdapter + state: MockStateAdapter + + +@pytest.fixture +async def harness() -> _Harness: + adapter = create_mock_adapter("slack") + state = create_mock_state() + chat = Chat( + user_name="testbot", + adapters={"slack": adapter}, + state=state, + logger=mock_logger, + ) + return _Harness(chat=chat, adapter=adapter, state=state) + + +# --------------------------------------------------------------------------- +# Orchestrator: createChatTools +# --------------------------------------------------------------------------- + + +class TestCreateChatToolsShape: + """Tests for the ``create_chat_tools`` return shape, presets, and validation.""" + + async def test_returns_full_toolset_when_no_preset_supplied(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + assert sorted(tools.keys()) == sorted( + [ + "addReaction", + "deleteMessage", + "editMessage", + "fetchChannelMessages", + "fetchMessages", + "fetchThread", + "getChannelInfo", + "getThreadParticipants", + "getUser", + "listThreads", + "postChannelMessage", + "postMessage", + "removeReaction", + "sendDirectMessage", + "startTyping", + "subscribeThread", + "unsubscribeThread", + ] + ) + + async def test_requires_a_chat_instance(self): + with pytest.raises(ChatError, match="requires a `chat` instance"): + create_chat_tools(chat=None) + + async def test_scopes_tools_to_single_preset(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, preset="reader") + names = sorted(tools.keys()) + assert names == sorted( + [ + "fetchChannelMessages", + "fetchMessages", + "fetchThread", + "getChannelInfo", + "getThreadParticipants", + "getUser", + "listThreads", + ] + ) + # No write tools at all + assert "postMessage" not in names + assert "deleteMessage" not in names + + async def test_composes_multiple_presets(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, preset=["reader", "messenger"]) + names = set(tools.keys()) + assert "postMessage" in names + assert "fetchMessages" in names + assert "listThreads" in names + # Neither preset includes deleteMessage / editMessage + assert "deleteMessage" not in names + assert "editMessage" not in names + + async def test_rejects_unknown_preset_name(self, harness: _Harness): + with pytest.raises(ChatError, match="Unknown preset"): + create_chat_tools(chat=harness.chat, preset="superuser") # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# Approval semantics +# --------------------------------------------------------------------------- + + +class TestRequireApproval: + """Tests for the ``require_approval`` config (bool + per-tool mapping).""" + + async def test_every_write_tool_defaults_to_needs_approval_true(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + write_tools = [ + "postMessage", + "postChannelMessage", + "sendDirectMessage", + "editMessage", + "deleteMessage", + "addReaction", + "removeReaction", + "subscribeThread", + "unsubscribeThread", + ] + for name in write_tools: + assert tools[name].needs_approval is True, name + + async def test_read_only_tools_never_gate_on_approval(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + read_tools = [ + "fetchMessages", + "fetchChannelMessages", + "fetchThread", + "listThreads", + "getThreadParticipants", + "getChannelInfo", + "getUser", + # Typing indicator is harmless and never gated + "startTyping", + ] + for name in read_tools: + assert tools[name].needs_approval is None, name + + async def test_require_approval_false_disables_all(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + write_tools = [ + "postMessage", + "postChannelMessage", + "sendDirectMessage", + "editMessage", + "deleteMessage", + "addReaction", + "removeReaction", + "subscribeThread", + "unsubscribeThread", + ] + for name in write_tools: + assert tools[name].needs_approval is False, name + + async def test_per_tool_approval_overrides(self, harness: _Harness): + tools = create_chat_tools( + chat=harness.chat, + require_approval={ + "postMessage": False, + "deleteMessage": True, + "subscribeThread": False, + }, + ) + assert tools["postMessage"].needs_approval is False + assert tools["deleteMessage"].needs_approval is True + assert tools["subscribeThread"].needs_approval is False + # Unspecified write tools fall back to True + assert tools["editMessage"].needs_approval is True + assert tools["unsubscribeThread"].needs_approval is True + + +# --------------------------------------------------------------------------- +# Override semantics +# --------------------------------------------------------------------------- + + +class TestOverrides: + """Tests for per-tool overrides (descriptions, extras, protected fields).""" + + async def test_applies_overrides_without_breaking_execution(self, harness: _Harness): + tools = create_chat_tools( + chat=harness.chat, + overrides={ + "postMessage": { + "description": "Reply in the active support thread", + "needs_approval": False, + }, + }, + ) + assert tools["postMessage"].description == "Reply in the active support thread" + assert tools["postMessage"].needs_approval is False + + async def test_overrides_cannot_replace_core_tool_fields(self, harness: _Harness): + # Stash sentinels; if any of these leak through, the tool can't run. + hijack_execute = AsyncMock(return_value={"hijacked": True}) + hijack_input_schema: dict[str, Any] = {"sentinel": "input"} + hijack_output_schema: dict[str, Any] = {"sentinel": "output"} + input_examples = [ + {"input": {"threadId": "slack:C123:1234.5678", "message": "hello"}}, + ] + metadata = {"source": "chat-sdk"} + + tools = create_chat_tools( + chat=harness.chat, + require_approval=False, + overrides={ + "postMessage": { + "args": {"name": "custom"}, + "description": "Reply in the active support thread", + "execute": hijack_execute, + "id": "openai.custom", + "input_examples": input_examples, + "input_schema": hijack_input_schema, + "metadata": metadata, + "output_schema": hijack_output_schema, + "supports_deferred_results": True, + "type": "provider", + }, + }, + ) + tool = tools["postMessage"] + + # Description does come from overrides... + assert tool.description == "Reply in the active support thread" + # ...but the protected fields are filtered out so the real tool is intact. + assert tool.execute is not hijack_execute + assert tool.input_schema is not hijack_input_schema + # `args`, `id`, `output_schema`, `supports_deferred_results`, and `type` + # are protected fields — they never make it into `extras`. + for protected in ( + "args", + "id", + "output_schema", + "supports_deferred_results", + "type", + ): + assert protected not in tool.extras, protected + # Non-protected fields pass through to `extras` for the agent runtime. + assert tool.extras["input_examples"] == input_examples + assert tool.extras["metadata"] == metadata + + # The real execute still dispatches to the adapter. + result = await tool.execute({"threadId": "slack:C123:1234.5678", "message": "hello"}) + hijack_execute.assert_not_awaited() + assert harness.adapter._post_calls == [("slack:C123:1234.5678", "hello")] + assert result == {"messageId": "msg-1", "threadId": "slack:C123:1234.5678"} + + +# --------------------------------------------------------------------------- +# Tool execute() paths +# --------------------------------------------------------------------------- + + +class TestExecutePaths: + """Each tool's ``execute()`` dispatches through to the right adapter call.""" + + async def test_post_message_dispatches_via_post_message(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + result = await tools["postMessage"].execute( + {"threadId": "slack:C123:1234.5678", "message": "hello"}, + ) + assert harness.adapter._post_calls == [("slack:C123:1234.5678", "hello")] + assert result["messageId"] == "msg-1" + + async def test_post_message_forwards_raw_postable_unchanged(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + await tools["postMessage"].execute( + { + "threadId": "slack:C123:1234.5678", + "message": {"raw": "..."}, + }, + ) + # The raw body must reach the adapter as a PostableRaw (not flattened to str). + assert len(harness.adapter._post_calls) == 1 + thread_id, sent = harness.adapter._post_calls[0] + assert thread_id == "slack:C123:1234.5678" + assert isinstance(sent, PostableRaw) + assert sent.raw == "..." + + async def test_post_channel_message_dispatches_with_markdown(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + result = await tools["postChannelMessage"].execute( + {"channelId": "slack:C123", "message": {"markdown": "**hi**"}}, + ) + # ChannelImpl uses post_channel_message on adapters that support it + # (which MockAdapter does), so this must produce a SentMessage. + assert result["messageId"] == "msg-1" + assert result["threadId"] == "slack:C123" + + async def test_send_direct_message_opens_dm_then_posts(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + await tools["sendDirectMessage"].execute( + {"userId": "U123456", "message": "ping"}, + ) + # MockAdapter.open_dm produces `slack:DU123456:` — the DM thread id. + assert harness.adapter._post_calls == [("slack:DU123456:", "ping")] + + async def test_add_reaction_dispatches_via_adapter(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + result = await tools["addReaction"].execute( + { + "threadId": "slack:C123:1234.5678", + "messageId": "msg-1", + "emoji": "thumbs_up", + }, + ) + assert harness.adapter._add_reaction_calls == [ + ("slack:C123:1234.5678", "msg-1", "thumbs_up"), + ] + assert result["added"] is True + assert result["emoji"] == "thumbs_up" + + async def test_remove_reaction_dispatches_via_adapter(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + result = await tools["removeReaction"].execute( + { + "threadId": "slack:C123:1234.5678", + "messageId": "msg-1", + "emoji": "thumbs_up", + }, + ) + assert harness.adapter._remove_reaction_calls == [ + ("slack:C123:1234.5678", "msg-1", "thumbs_up"), + ] + assert result == { + "removed": True, + "emoji": "thumbs_up", + "messageId": "msg-1", + "threadId": "slack:C123:1234.5678", + } + + async def test_delete_message_dispatches_via_adapter(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + result = await tools["deleteMessage"].execute( + {"threadId": "slack:C123:1234.5678", "messageId": "msg-1"}, + ) + assert harness.adapter._delete_calls == [("slack:C123:1234.5678", "msg-1")] + assert result == { + "deleted": True, + "messageId": "msg-1", + "threadId": "slack:C123:1234.5678", + } + + async def test_edit_message_dispatches_with_markdown(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + result = await tools["editMessage"].execute( + { + "threadId": "slack:C123:1234.5678", + "messageId": "msg-1", + "message": {"markdown": "**updated**"}, + }, + ) + assert len(harness.adapter._edit_calls) == 1 + thread_id, msg_id, postable = harness.adapter._edit_calls[0] + assert thread_id == "slack:C123:1234.5678" + assert msg_id == "msg-1" + assert isinstance(postable, PostableMarkdown) + assert postable.markdown == "**updated**" + assert result["messageId"] == "msg-1" + + async def test_subscribe_thread_persists_subscription(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + await tools["subscribeThread"].execute({"threadId": "slack:C123:1234.5678"}) + assert await harness.state.is_subscribed("slack:C123:1234.5678") is True + + async def test_unsubscribe_thread_clears_subscription(self, harness: _Harness): + # Seed the state so we can prove unsubscribe clears it. + await harness.state.subscribe("slack:C123:1234.5678") + assert await harness.state.is_subscribed("slack:C123:1234.5678") is True + + tools = create_chat_tools(chat=harness.chat, require_approval=False) + result = await tools["unsubscribeThread"].execute( + {"threadId": "slack:C123:1234.5678"}, + ) + assert await harness.state.is_subscribed("slack:C123:1234.5678") is False + assert result == {"subscribed": False, "threadId": "slack:C123:1234.5678"} + + async def test_start_typing_dispatches_via_adapter(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + await tools["startTyping"].execute( + {"threadId": "slack:C123:1234.5678", "status": "Searching..."}, + ) + assert harness.adapter._start_typing_calls == [ + ("slack:C123:1234.5678", "Searching..."), + ] + + async def test_fetch_messages_projects_model_friendly_shape(self, harness: _Harness): + stub_message = create_test_message("m1", "hello") + harness.adapter.fetch_messages = AsyncMock( # type: ignore[method-assign] + return_value=FetchResult(messages=[stub_message], next_cursor=None), + ) + tools = create_chat_tools(chat=harness.chat) + result = await tools["fetchMessages"].execute( + {"threadId": "slack:C123:1234.5678", "limit": 5, "direction": "backward"}, + ) + assert len(result["messages"]) == 1 + assert result["messages"][0]["id"] == "m1" + assert result["messages"][0]["text"] == "hello" + # Author is flattened into camelCase keys that match the wire shape. + assert result["messages"][0]["author"]["userName"] == "testuser" + + async def test_get_channel_info_returns_flattened_metadata(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + result = await tools["getChannelInfo"].execute({"channelId": "slack:C123"}) + assert result == { + "id": "slack:C123", + "name": "#slack:C123", + "isDM": False, + "memberCount": None, + "channelVisibility": None, + } + + async def test_fetch_channel_messages_dispatches_and_projects(self, harness: _Harness): + stub_message = create_test_message("m1", "channel hello") + harness.adapter.fetch_channel_messages = AsyncMock( # type: ignore[method-assign] + return_value=FetchResult(messages=[stub_message], next_cursor="next"), + ) + tools = create_chat_tools(chat=harness.chat) + result = await tools["fetchChannelMessages"].execute( + {"channelId": "slack:C123", "limit": 5, "direction": "backward"}, + ) + harness.adapter.fetch_channel_messages.assert_awaited_once() + call_args = harness.adapter.fetch_channel_messages.await_args + assert call_args.args[0] == "slack:C123" + # The FetchOptions are forwarded verbatim from the tool's inputs. + opts = call_args.args[1] + assert opts.limit == 5 + assert opts.cursor is None + assert opts.direction == "backward" + + assert len(result["messages"]) == 1 + assert result["messages"][0]["id"] == "m1" + assert result["messages"][0]["text"] == "channel hello" + assert result["nextCursor"] == "next" + + async def test_fetch_channel_messages_raises_when_adapter_unsupported(self, harness: _Harness): + # Remove the adapter's fetch_channel_messages so the tool path's + # "does not support" branch fires. + harness.adapter.fetch_channel_messages = None # type: ignore[method-assign,assignment] + tools = create_chat_tools(chat=harness.chat) + with pytest.raises(ChatError, match="does not support fetching channel messages"): + await tools["fetchChannelMessages"].execute({"channelId": "slack:C123"}) + + async def test_fetch_channel_messages_wraps_not_implemented(self, harness: _Harness): + # BaseAdapter's default stub for optional methods raises + # ChatNotImplementedError. The tool must wrap that into ChatError so + # callers see one consistent failure mode, preserving the cause chain. + harness.adapter.fetch_channel_messages = AsyncMock( # type: ignore[method-assign] + side_effect=ChatNotImplementedError("slack", "fetch_channel_messages"), + ) + tools = create_chat_tools(chat=harness.chat) + with pytest.raises(ChatError, match="does not support fetching channel messages") as exc_info: + await tools["fetchChannelMessages"].execute({"channelId": "slack:C123"}) + assert isinstance(exc_info.value.__cause__, ChatNotImplementedError) + + async def test_fetch_thread_returns_flattened_thread_info(self, harness: _Harness): + harness.adapter.fetch_thread = AsyncMock( # type: ignore[method-assign] + return_value=ThreadInfo( + id="slack:C123:1234.5678", + channel_id="C123", + channel_name="#general", + channel_visibility="public", + is_dm=False, + metadata={}, + ), + ) + tools = create_chat_tools(chat=harness.chat) + result = await tools["fetchThread"].execute({"threadId": "slack:C123:1234.5678"}) + assert result == { + "id": "slack:C123:1234.5678", + "channelId": "C123", + "channelName": "#general", + "channelVisibility": "public", + "isDM": False, + } + + async def test_list_threads_projects_summaries(self, harness: _Harness): + root_message = create_test_message("m1", "root") + from datetime import datetime, timezone + + last_reply = datetime(2026, 3, 2, tzinfo=timezone.utc) + harness.adapter.list_threads = AsyncMock( # type: ignore[method-assign] + return_value=ListThreadsResult( + threads=[ + ThreadSummary( + id="slack:C123:1234.5678", + reply_count=4, + last_reply_at=last_reply, + root_message=root_message, + ), + ], + next_cursor=None, + ), + ) + tools = create_chat_tools(chat=harness.chat) + result = await tools["listThreads"].execute( + {"channelId": "slack:C123", "limit": 10}, + ) + assert len(result["threads"]) == 1 + summary = result["threads"][0] + assert summary["id"] == "slack:C123:1234.5678" + assert summary["replyCount"] == 4 + assert summary["lastReplyAt"] == last_reply.isoformat() + assert summary["rootMessage"]["id"] == "m1" + assert summary["rootMessage"]["text"] == "root" + + async def test_list_threads_uses_keyword_options(self, harness: _Harness): + """Pin that the tool passes ``options`` as a keyword to ``list_threads``. + + ``MockAdapter.list_threads`` (and any adapter using a ``**kwargs`` + signature) rejects a second positional arg with ``TypeError`` — the + tool must use the keyword form. This exercises the **real** + ``MockAdapter.list_threads`` (no ``AsyncMock`` override) so a + regression to positional args trips immediately at runtime, not just + in the mock-adapter ergonomics. + """ + tools = create_chat_tools(chat=harness.chat) + result = await tools["listThreads"].execute({"channelId": "slack:C123"}) + assert result == {"threads": [], "nextCursor": None} + + async def test_list_threads_raises_when_adapter_unsupported(self, harness: _Harness): + harness.adapter.list_threads = None # type: ignore[method-assign,assignment] + tools = create_chat_tools(chat=harness.chat) + with pytest.raises(ChatError, match="does not support listing threads"): + await tools["listThreads"].execute({"channelId": "slack:C123"}) + + async def test_list_threads_wraps_not_implemented(self, harness: _Harness): + harness.adapter.list_threads = AsyncMock( # type: ignore[method-assign] + side_effect=ChatNotImplementedError("slack", "list_threads"), + ) + tools = create_chat_tools(chat=harness.chat) + with pytest.raises(ChatError, match="does not support listing threads") as exc_info: + await tools["listThreads"].execute({"channelId": "slack:C123"}) + assert isinstance(exc_info.value.__cause__, ChatNotImplementedError) + + async def test_get_thread_participants_delegates_to_thread(self, harness: _Harness): + # Stub `chat.thread(...)` directly so we don't drag in the cursor + # pagination / current-message machinery just to test the projection. + from chat_sdk.types import Author as AuthorType + + participants_stub = [ + AuthorType( + user_id="UALICE1", + user_name="alice", + full_name="Alice", + is_bot=False, + is_me=False, + ), + AuthorType( + user_id="UBOB1", + user_name="bob", + full_name="Bob", + is_bot=False, + is_me=False, + ), + ] + + class _FakeThread: + async def get_participants(self) -> list[AuthorType]: + return participants_stub + + original_thread = harness.chat.thread + harness.chat.thread = lambda thread_id, **kwargs: _FakeThread() # type: ignore[assignment] + try: + tools = create_chat_tools(chat=harness.chat) + result = await tools["getThreadParticipants"].execute( + {"threadId": "slack:C123:1234.5678"}, + ) + finally: + harness.chat.thread = original_thread # type: ignore[assignment] + + assert result == { + "participants": [ + {"userId": "UALICE1", "userName": "alice", "fullName": "Alice", "isBot": False}, + {"userId": "UBOB1", "userName": "bob", "fullName": "Bob", "isBot": False}, + ], + } + + async def test_get_user_projects_user_info_when_found(self, harness: _Harness): + harness.adapter.get_user = AsyncMock( # type: ignore[method-assign] + return_value=UserInfo( + user_id="U123456", + user_name="alice", + full_name="Alice Doe", + email="alice@example.com", + is_bot=False, + avatar_url="https://example.com/a.png", + ), + ) + tools = create_chat_tools(chat=harness.chat) + result = await tools["getUser"].execute({"userId": "U123456"}) + assert result == { + "userId": "U123456", + "userName": "alice", + "fullName": "Alice Doe", + "email": "alice@example.com", + "isBot": False, + "avatarUrl": "https://example.com/a.png", + } + + async def test_get_user_returns_none_when_adapter_returns_none(self, harness: _Harness): + harness.adapter.get_user = AsyncMock(return_value=None) # type: ignore[method-assign] + tools = create_chat_tools(chat=harness.chat) + result = await tools["getUser"].execute({"userId": "UMISSING"}) + assert result is None + + +# --------------------------------------------------------------------------- +# Schema sanity checks +# --------------------------------------------------------------------------- + + +class TestInputSchemas: + """Schemas are part of the public contract — break them, break agent runtimes.""" + + async def test_every_tool_declares_an_input_schema_with_a_description(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + for name, tool in tools.items(): + assert isinstance(tool, ChatTool), name + assert tool.description, f"{name} is missing a description" + assert tool.input_schema.get("type") == "object", name + assert "properties" in tool.input_schema, name + + async def test_postable_input_schema_is_a_oneof_union(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + message_schema = tools["postMessage"].input_schema["properties"]["message"] + # The union must include all three branches: string, markdown, raw. + assert "oneOf" in message_schema + kinds: list[Any] = [] + for branch in message_schema["oneOf"]: + if branch.get("type") == "string": + kinds.append("string") + elif "properties" in branch and "markdown" in branch["properties"]: + kinds.append("markdown") + elif "properties" in branch and "raw" in branch["properties"]: + kinds.append("raw") + assert sorted(kinds) == ["markdown", "raw", "string"] + + +# --------------------------------------------------------------------------- +# Re-exports +# --------------------------------------------------------------------------- + + +class TestReexports: + """The ``chat_sdk.ai`` package re-exports the tool factory surface.""" + + async def test_can_import_individual_factory(self, harness: _Harness): + # If individual tool factories aren't exported, downstream code that + # cherry-picks (``from chat_sdk.ai import post_message``) breaks. + from chat_sdk.ai import add_reaction, post_message + + tool = post_message(harness.chat) + assert isinstance(tool, ChatTool) + assert tool.needs_approval is True + # The needs_approval default propagates through the factory's ToolOptions. + + from chat_sdk.ai.tools import ToolOptions + + relaxed = add_reaction(harness.chat, ToolOptions(needs_approval=False)) + assert relaxed.needs_approval is False + + async def test_messages_helpers_still_importable_from_ai(self): + # PR 1 of the chat/ai port moved to_ai_messages here; if the new + # tool exports clobber that, the re-export goes silently stale. + from chat_sdk.ai import to_ai_messages + + assert callable(to_ai_messages) + + +# --------------------------------------------------------------------------- +# Approval gating quirks +# --------------------------------------------------------------------------- + + +class TestApprovalEdgeCases: + """Edge cases for the approval mapping that aren't covered above.""" + + async def test_partial_mapping_falls_back_to_true_for_unspecified_writes(self, harness: _Harness): + # Only override one tool — every other write tool should keep the + # default needs_approval=True. A regression that flips the default + # to False would silently let untrusted models post messages. + tools = create_chat_tools( + chat=harness.chat, + require_approval={"postMessage": False}, + ) + assert tools["postMessage"].needs_approval is False + # Every other write tool still needs approval. + for name in ( + "postChannelMessage", + "sendDirectMessage", + "editMessage", + "deleteMessage", + "addReaction", + "removeReaction", + "subscribeThread", + "unsubscribeThread", + ): + assert tools[name].needs_approval is True, name + + +# --------------------------------------------------------------------------- +# Channel info edge cases (covers ChannelInfo.is_dm branching) +# --------------------------------------------------------------------------- + + +class TestChannelInfoEdgeCases: + async def test_get_channel_info_defaults_is_dm_false_when_adapter_returns_none(self, harness: _Harness): + # ChannelInfo.is_dm is Optional; the tool must coerce None → False + # so the model sees a plain boolean instead of a missing field. + harness.adapter.fetch_channel_info = AsyncMock( # type: ignore[method-assign] + return_value=ChannelInfo(id="slack:C999", name=None, is_dm=None, metadata={}), + ) + tools = create_chat_tools(chat=harness.chat) + result = await tools["getChannelInfo"].execute({"channelId": "slack:C999"}) + assert result["isDM"] is False + assert result["name"] is None + + +# --------------------------------------------------------------------------- +# Schema isolation (review): tools must not share mutable nested schema dicts +# --------------------------------------------------------------------------- + + +class TestSchemaIsolation: + """Each tool must own a fully independent ``input_schema``. + + The factories build several tools from the same shared schema source + (the postable-message body, the fetch ``direction`` enum). If two tools + embedded the *same* nested dict object, a downstream consumer mutating + one tool's schema in place would silently corrupt its siblings. These + tests deep-mutate one tool's schema and assert the sibling is untouched, + pinning the per-tool deep-copy guarantee. + """ + + async def test_postable_message_schema_not_shared_between_tools(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + post_msg = tools["postMessage"] + post_channel = tools["postChannelMessage"] + + a = post_msg.input_schema["properties"]["message"] + b = post_channel.input_schema["properties"]["message"] + # Distinct top-level objects and distinct nested objects. + assert a is not b + assert a["oneOf"] is not b["oneOf"] + assert a["oneOf"][1] is not b["oneOf"][1] + + # Deep-mutate one tool's nested schema; the sibling must be unaffected. + a["oneOf"][1]["properties"]["markdown"]["description"] = "MUTATED" + a["oneOf"].append({"type": "null"}) + assert "description" not in b["oneOf"][1]["properties"]["markdown"] + assert len(b["oneOf"]) == 3 + + # A freshly built toolset must also be pristine (no module-level bleed). + fresh = create_chat_tools(chat=harness.chat) + fresh_msg = fresh["postMessage"].input_schema["properties"]["message"] + assert "description" not in fresh_msg["oneOf"][1]["properties"]["markdown"] + assert len(fresh_msg["oneOf"]) == 3 + + async def test_fetch_direction_schema_not_shared_between_tools(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + fetch_msgs = tools["fetchMessages"] + fetch_channel = tools["fetchChannelMessages"] + + a = fetch_msgs.input_schema["properties"]["direction"] + b = fetch_channel.input_schema["properties"]["direction"] + assert a is not b + # The enum list is a nested mutable object that must not be shared. + assert a["enum"] is not b["enum"] + + # Deep-mutate one tool's direction enum; sibling must be unaffected. + a["enum"].append("sideways") + assert b["enum"] == ["forward", "backward"] + + fresh = create_chat_tools(chat=harness.chat) + fresh_dir = fresh["fetchMessages"].input_schema["properties"]["direction"] + assert fresh_dir["enum"] == ["forward", "backward"] From 2b1a9a67914dcf25107731a3f34424ee3ff31230 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 20:52:54 +0000 Subject: [PATCH 03/14] feat(chat): process_message returns the handler task + 4.29 fidelity alignment Ports the core slice of vercel/chat#444 (processMessage returns the inner task as Promise): process_message now returns the eagerly-started asyncio.Task so streaming callers can await full handler completion and observe handler exceptions; wait_until keeps swallowed-error semantics. Fidelity alignment for the chat@4.29.0 pin: - MAPPING follows upstream's ai.test.ts split into ai/messages.test.ts + ai/index.test.ts (vercel/chat#492) - tests/test_ai_tools.py: 14 tests renamed to converter-exact names so the strict checker exact-matches the upstream it() titles - new faithful ports in tests/test_chat_faithful.py: the #444 awaitable test, plus the #459/#495 queue/burst subject-rehydration tests, skipif- gated on BaseAdapter.fetch_subject so they activate when PR #131 lands https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 --- scripts/verify_test_fidelity.py | 4 +- src/chat_sdk/chat.py | 11 ++- tests/test_ai_tools.py | 30 +++--- tests/test_chat_faithful.py | 166 ++++++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 17 deletions(-) diff --git a/scripts/verify_test_fidelity.py b/scripts/verify_test_fidelity.py index e559019..f65d152 100644 --- a/scripts/verify_test_fidelity.py +++ b/scripts/verify_test_fidelity.py @@ -45,7 +45,9 @@ "packages/chat/src/markdown.test.ts": "tests/test_markdown_faithful.py", "packages/chat/src/streaming-markdown.test.ts": "tests/test_streaming_markdown.py", "packages/chat/src/serialization.test.ts": "tests/test_serialization.py", - "packages/chat/src/ai.test.ts": "tests/test_ai_messages.py", + # chat@4.29.0 moved ai.test.ts into ai/ and split it (vercel/chat#492) + "packages/chat/src/ai/messages.test.ts": "tests/test_ai_messages.py", + "packages/chat/src/ai/index.test.ts": "tests/test_ai_tools.py", "packages/chat/src/from-full-stream.test.ts": "tests/test_from_full_stream.py", } diff --git a/src/chat_sdk/chat.py b/src/chat_sdk/chat.py index 3153308..7b3e57c 100644 --- a/src/chat_sdk/chat.py +++ b/src/chat_sdk/chat.py @@ -909,10 +909,16 @@ def process_message( thread_id: str, message_or_factory: Message | Callable[[], Awaitable[Message]], options: WebhookOptions | None = None, - ) -> None: + ) -> asyncio.Task[None] | None: """Process an incoming message from an adapter. - Handles waitUntil registration and error catching. + Handles waitUntil registration and error catching. Returns the + handler task (``None`` only when no event loop is running) so + streaming callers can await full handler completion and observe + handler exceptions; fire-and-forget webhook callers may ignore it. + ``wait_until`` keeps the existing swallowed-error semantics — + platforms shouldn't retry on handler bugs. (Core slice of + vercel/chat#444; the @chat-adapter/web package itself is not ported.) """ async def _task() -> None: @@ -932,6 +938,7 @@ async def _task() -> None: ) if options and options.wait_until: options.wait_until(task) + return task def process_reaction( self, diff --git a/tests/test_ai_tools.py b/tests/test_ai_tools.py index 3bb5333..cce9a6c 100644 --- a/tests/test_ai_tools.py +++ b/tests/test_ai_tools.py @@ -174,7 +174,7 @@ async def test_read_only_tools_never_gate_on_approval(self, harness: _Harness): for name in read_tools: assert tools[name].needs_approval is None, name - async def test_require_approval_false_disables_all(self, harness: _Harness): + async def test_disables_approval_on_every_write_tool_when_requireapproval_is_false(self, harness: _Harness): tools = create_chat_tools(chat=harness.chat, require_approval=False) write_tools = [ "postMessage", @@ -292,7 +292,7 @@ async def test_overrides_cannot_replace_core_tool_fields(self, harness: _Harness class TestExecutePaths: """Each tool's ``execute()`` dispatches through to the right adapter call.""" - async def test_post_message_dispatches_via_post_message(self, harness: _Harness): + async def test_postmessage_dispatches_via_the_adapters_postmessage(self, harness: _Harness): tools = create_chat_tools(chat=harness.chat, require_approval=False) result = await tools["postMessage"].execute( {"threadId": "slack:C123:1234.5678", "message": "hello"}, @@ -315,7 +315,7 @@ async def test_post_message_forwards_raw_postable_unchanged(self, harness: _Harn assert isinstance(sent, PostableRaw) assert sent.raw == "..." - async def test_post_channel_message_dispatches_with_markdown(self, harness: _Harness): + async def test_postchannelmessage_dispatches_via_the_adapters_postchannelmessage(self, harness: _Harness): tools = create_chat_tools(chat=harness.chat, require_approval=False) result = await tools["postChannelMessage"].execute( {"channelId": "slack:C123", "message": {"markdown": "**hi**"}}, @@ -333,7 +333,7 @@ async def test_send_direct_message_opens_dm_then_posts(self, harness: _Harness): # MockAdapter.open_dm produces `slack:DU123456:` — the DM thread id. assert harness.adapter._post_calls == [("slack:DU123456:", "ping")] - async def test_add_reaction_dispatches_via_adapter(self, harness: _Harness): + async def test_addreaction_dispatches_via_the_adapters_addreaction(self, harness: _Harness): tools = create_chat_tools(chat=harness.chat, require_approval=False) result = await tools["addReaction"].execute( { @@ -348,7 +348,7 @@ async def test_add_reaction_dispatches_via_adapter(self, harness: _Harness): assert result["added"] is True assert result["emoji"] == "thumbs_up" - async def test_remove_reaction_dispatches_via_adapter(self, harness: _Harness): + async def test_removereaction_dispatches_via_the_adapters_removereaction(self, harness: _Harness): tools = create_chat_tools(chat=harness.chat, require_approval=False) result = await tools["removeReaction"].execute( { @@ -367,7 +367,7 @@ async def test_remove_reaction_dispatches_via_adapter(self, harness: _Harness): "threadId": "slack:C123:1234.5678", } - async def test_delete_message_dispatches_via_adapter(self, harness: _Harness): + async def test_deletemessage_dispatches_via_the_adapters_deletemessage(self, harness: _Harness): tools = create_chat_tools(chat=harness.chat, require_approval=False) result = await tools["deleteMessage"].execute( {"threadId": "slack:C123:1234.5678", "messageId": "msg-1"}, @@ -379,7 +379,7 @@ async def test_delete_message_dispatches_via_adapter(self, harness: _Harness): "threadId": "slack:C123:1234.5678", } - async def test_edit_message_dispatches_with_markdown(self, harness: _Harness): + async def test_editmessage_dispatches_via_the_adapters_editmessage(self, harness: _Harness): tools = create_chat_tools(chat=harness.chat, require_approval=False) result = await tools["editMessage"].execute( { @@ -413,7 +413,7 @@ async def test_unsubscribe_thread_clears_subscription(self, harness: _Harness): assert await harness.state.is_subscribed("slack:C123:1234.5678") is False assert result == {"subscribed": False, "threadId": "slack:C123:1234.5678"} - async def test_start_typing_dispatches_via_adapter(self, harness: _Harness): + async def test_starttyping_dispatches_via_the_adapters_starttyping(self, harness: _Harness): tools = create_chat_tools(chat=harness.chat) await tools["startTyping"].execute( {"threadId": "slack:C123:1234.5678", "status": "Searching..."}, @@ -448,7 +448,7 @@ async def test_get_channel_info_returns_flattened_metadata(self, harness: _Harne "channelVisibility": None, } - async def test_fetch_channel_messages_dispatches_and_projects(self, harness: _Harness): + async def test_fetchchannelmessages_dispatches_via_the_adapter_and_projects_messages(self, harness: _Harness): stub_message = create_test_message("m1", "channel hello") harness.adapter.fetch_channel_messages = AsyncMock( # type: ignore[method-assign] return_value=FetchResult(messages=[stub_message], next_cursor="next"), @@ -471,7 +471,7 @@ async def test_fetch_channel_messages_dispatches_and_projects(self, harness: _Ha assert result["messages"][0]["text"] == "channel hello" assert result["nextCursor"] == "next" - async def test_fetch_channel_messages_raises_when_adapter_unsupported(self, harness: _Harness): + async def test_fetchchannelmessages_throws_when_the_adapter_does_not_support_it(self, harness: _Harness): # Remove the adapter's fetch_channel_messages so the tool path's # "does not support" branch fires. harness.adapter.fetch_channel_messages = None # type: ignore[method-assign,assignment] @@ -512,7 +512,7 @@ async def test_fetch_thread_returns_flattened_thread_info(self, harness: _Harnes "isDM": False, } - async def test_list_threads_projects_summaries(self, harness: _Harness): + async def test_listthreads_projects_threadsummary_entries(self, harness: _Harness): root_message = create_test_message("m1", "root") from datetime import datetime, timezone @@ -556,7 +556,7 @@ async def test_list_threads_uses_keyword_options(self, harness: _Harness): result = await tools["listThreads"].execute({"channelId": "slack:C123"}) assert result == {"threads": [], "nextCursor": None} - async def test_list_threads_raises_when_adapter_unsupported(self, harness: _Harness): + async def test_listthreads_throws_when_the_adapter_does_not_support_it(self, harness: _Harness): harness.adapter.list_threads = None # type: ignore[method-assign,assignment] tools = create_chat_tools(chat=harness.chat) with pytest.raises(ChatError, match="does not support listing threads"): @@ -571,7 +571,9 @@ async def test_list_threads_wraps_not_implemented(self, harness: _Harness): await tools["listThreads"].execute({"channelId": "slack:C123"}) assert isinstance(exc_info.value.__cause__, ChatNotImplementedError) - async def test_get_thread_participants_delegates_to_thread(self, harness: _Harness): + async def test_getthreadparticipants_delegates_to_threadgetparticipants_and_projects_authors( + self, harness: _Harness + ): # Stub `chat.thread(...)` directly so we don't drag in the cursor # pagination / current-message machinery just to test the projection. from chat_sdk.types import Author as AuthorType @@ -614,7 +616,7 @@ async def get_participants(self) -> list[AuthorType]: ], } - async def test_get_user_projects_user_info_when_found(self, harness: _Harness): + async def test_getuser_projects_userinfo_when_the_adapter_resolves_a_user(self, harness: _Harness): harness.adapter.get_user = AsyncMock( # type: ignore[method-assign] return_value=UserInfo( user_id="U123456", diff --git a/tests/test_chat_faithful.py b/tests/test_chat_faithful.py index bb5c491..56e5fed 100644 --- a/tests/test_chat_faithful.py +++ b/tests/test_chat_faithful.py @@ -3889,3 +3889,169 @@ async def test_should_not_cache_incoming_messages_when_adapter_does_not_set_pers history_keys = [k for k in state.cache if k.startswith("msg-history:")] assert len(history_keys) == 0 + + +# ============================================================================ +# 21. processMessage return value (core slice of vercel/chat#444) +# ============================================================================ + + +class TestProcessMessageAwaitable: + """Faithful port of the TS root-describe test added by vercel/chat#444. + + Only the core ``processMessage`` slice of #444 applies to the Python + port — the ``@chat-adapter/web`` package itself is browser-only and not + ported (see docs/UPSTREAM_SYNC.md). + """ + + # TS: "should return an awaitable Promise from processMessage" + async def test_should_return_an_awaitable_promise_from_processmessage(self): + chat, adapter, _state = await _init_chat() + + handler_started = asyncio.Event() + release_handler = asyncio.Event() + + @chat.on_mention + async def handler(thread, message, context=None): + handler_started.set() + await release_handler.wait() + + message = create_test_message("msg-await-1", "Hey @slack-bot ping") + task = chat.process_message(adapter, "slack:C123:await.1", message) + + assert isinstance(task, asyncio.Task) + + # Yield so the handler kicks off but cannot complete until released. + await asyncio.wait_for(handler_started.wait(), timeout=1) + assert not task.done() + + release_handler.set() + await asyncio.wait_for(task, timeout=1) + assert task.done() + + +# ============================================================================ +# 22. queue/burst subject rehydration after a JSON roundtrip (vercel/chat#459 +# + #495). Gated on message.subject (PR #131): the skipif below evaluates +# BaseAdapter.fetch_subject at collection time, so these activate +# automatically once #131 merges — no manual unskip step. +# ============================================================================ + + +def _wrap_enqueue_with_json_roundtrip(state: MockStateAdapter) -> None: + """Make ``state.enqueue`` JSON-roundtrip entries like a persistent backend. + + TS does ``JSON.parse(JSON.stringify(entry))``; the Python equivalent of + what Redis/Postgres do to a queued message is ``Message.to_json()`` → + JSON → plain dict (``Chat._rehydrate_message`` upgrades it on dequeue). + """ + import json + + real_enqueue = state.enqueue + + async def wrapped(thread_id: str, entry: QueueEntry, max_size: int) -> int: + plain = json.loads(json.dumps(entry.message.to_json())) + roundtripped = QueueEntry( + message=plain, # type: ignore[arg-type] + enqueued_at=entry.enqueued_at, + expires_at=entry.expires_at, + ) + return await real_enqueue(thread_id, roundtripped, max_size) + + state.enqueue = wrapped # type: ignore[method-assign] + + +def _base_adapter_has_fetch_subject() -> bool: + from chat_sdk.types import BaseAdapter + + return hasattr(BaseAdapter, "fetch_subject") + + +requires_fetch_subject = pytest.mark.skipif( + not _base_adapter_has_fetch_subject(), + reason="message.subject infrastructure lands with PR #131; this fidelity " + "port auto-unskips once BaseAdapter.fetch_subject exists", +) + + +class TestConcurrencyQueueSubjectRehydration: + # TS: "should wire adapter on queued message so fetchSubject works after JSON roundtrip" + @requires_fetch_subject + async def test_should_wire_adapter_on_queued_message_so_fetchsubject_works_after_json_roundtrip( + self, + ): + from unittest.mock import AsyncMock + + state = create_mock_state() + _wrap_enqueue_with_json_roundtrip(state) + adapter = create_mock_adapter("slack") + mock_subject = {"type": "issue", "id": "ENG-1", "title": "Queue test", "raw": {}} + adapter.fetch_subject = AsyncMock(return_value=mock_subject) # type: ignore[attr-defined] + + chat, _, _ = await _init_chat(adapter=adapter, state=state, concurrency="queue") + + received_subject: Any = "not-called" + + @chat.on_mention + async def handler(thread, message, context=None): + nonlocal received_subject + received_subject = await message.subject + + await state.acquire_lock("slack:C123:1234.5678", 30000) + msg = create_test_message("msg-sub-q", "Hey @slack-bot test") + await chat.handle_incoming_message(adapter, "slack:C123:1234.5678", msg) + + await state.force_release_lock("slack:C123:1234.5678") + trigger = create_test_message("msg-sub-trigger", "Hey @slack-bot go") + await chat.handle_incoming_message(adapter, "slack:C123:1234.5678", trigger) + + assert received_subject == mock_subject + assert adapter.fetch_subject.await_count == 2 # type: ignore[attr-defined] + + +class TestConcurrencyBurstRehydration: + # TS: "should rehydrate queued messages after JSON roundtrip" + @requires_fetch_subject + async def test_should_rehydrate_queued_messages_after_json_roundtrip(self): + from unittest.mock import AsyncMock + + state = create_mock_state() + _wrap_enqueue_with_json_roundtrip(state) + adapter = create_mock_adapter("slack") + mock_subject = {"type": "issue", "id": "ENG-414", "title": "Burst test", "raw": {}} + adapter.fetch_subject = AsyncMock(return_value=mock_subject) # type: ignore[attr-defined] + + chat, _, _ = await _init_chat( + adapter=adapter, + state=state, + concurrency=ConcurrencyConfig(strategy="burst", debounce_ms=80), + ) + + received_subject: Any = "not-called" + received_skipped_subject: Any = "not-called" + + @chat.on_mention + async def handler(thread, message, context=None): + nonlocal received_subject, received_skipped_subject + received_subject = await message.subject + if context is not None and context.skipped: + received_skipped_subject = await context.skipped[0].subject + + task = asyncio.create_task( + chat.handle_incoming_message( + adapter, + "slack:C123:1234.5678", + create_test_message("msg-burst-json-1", "Hey @slack-bot one"), + ) + ) + await asyncio.sleep(0.005) + await chat.handle_incoming_message( + adapter, + "slack:C123:1234.5678", + create_test_message("msg-burst-json-2", "Hey @slack-bot two"), + ) + await task + + assert received_subject == mock_subject + assert received_skipped_subject == mock_subject + assert adapter.fetch_subject.await_count == 2 # type: ignore[attr-defined] From 80508ff06c3099778aa661a34aa7a25cd56f23a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 20:52:54 +0000 Subject: [PATCH 04/14] =?UTF-8?q?release:=200.4.29=20=E2=80=94=20version?= =?UTF-8?q?=20cut,=20CHANGELOG=20consolidation,=20fidelity=20re-pin,=20ai?= =?UTF-8?q?=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pyproject 0.4.29a2 -> 0.4.29; README status + 9-platform claims - CHANGELOG: consolidated 0.4.29 release entry (0.4.29a3 notes folded in) + backfilled 0.4.27.1 section - lint.yml upstream clone re-pinned chat@4.26.0 -> chat@4.29.0 - docs/UPSTREAM_SYNC.md: version-table rows for 0.4.27.1/0.4.29; Known Non-Parity rows for GitHub octokit / Linear linear_client getters (no Python SDK object to expose), @chat-adapter/tests kit, and the Teams microsoft-teams-apps migration deferral to 0.4.30 (issue #93) - CLAUDE.md: pin references moved to chat@4.29.0 - examples/ai_tools_example.py + README AI section (chat/ai PR 3 of 3, design #109) - 0.4.30 wave tracking issue: #135 https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 --- .github/workflows/lint.yml | 2 +- CHANGELOG.md | 55 ++++++++++++++++++-- CLAUDE.md | 25 ++++----- README.md | 25 +++++++-- docs/UPSTREAM_SYNC.md | 15 ++++-- examples/ai_tools_example.py | 98 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 7 files changed, 194 insertions(+), 28 deletions(-) create mode 100644 examples/ai_tools_example.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f490d45..06fef1c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -60,7 +60,7 @@ jobs: - name: Clone upstream vercel/chat at pinned parity tag id: clone_upstream run: | - git clone --depth 1 --branch chat@4.26.0 \ + git clone --depth 1 --branch chat@4.29.0 \ https://github.com/vercel/chat.git /tmp/vercel-chat - name: Test fidelity check (strict — zero missing in mapped core files) diff --git a/CHANGELOG.md b/CHANGELOG.md index 796c20e..def8d2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,60 @@ # Changelog -## 0.4.29a3 (unreleased) +## 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`. + +### Upstream parity ports — core (`packages/chat`) + +- **`callback_url` on buttons and modals** (vercel/chat#454). New `src/chat_sdk/callback_url.py` plus plumbing through cards, modals, chat, channel, and thread: card buttons and modals can carry a `callback_url` that the SDK POSTs to when the action/submit fires, alongside regular handlers. Serialized wire keys stay camelCase (`callbackUrl`) for cross-SDK state compatibility. +- **Transcripts API + per-thread cache rename to `thread_history`** (vercel/chat#448). `MessageHistoryCache` → `ThreadHistoryCache` (module `message_history` → `thread_history`) with back-compat shims at the old import path and config names (new name wins when both are set, matching upstream's `?? config.messageHistory` fallback). The persisted state key prefix `msg-history:` is **unchanged** (renaming would orphan existing data — upstream kept it too). New Transcripts surface for cross-platform per-user history; `ChatConfig.transcripts` requires `ChatConfig.identity` and raises on misconfiguration, matching upstream's guard. +- **`message.subject` + `fetch_subject` adapter hook** (vercel/chat#459, PR #131). `MessageSubject` dataclass, optional `BaseAdapter.fetch_subject(raw)` hook, lazily-resolved cached `Message.subject` accessor, adapter bound at the `Chat._dispatch_to_handlers` convergence point. The GitHub/Linear adapter halves are blocked on native-client exposure (see Known Non-Parity rows). +- **`burst` concurrency strategy** (vercel/chat#495, PR #114). Hybrid of `debounce` and `queue`: idle threads coalesce a burst window into one dispatch with earlier messages in `context.skipped`; busy threads drain like `queue`. Note: upstream's PR title says "queue-debounce" but the shipped strategy string is `"burst"` — the Python port matches the string (cross-SDK config parity). +- **`process_message` returns the handler task** (core slice of vercel/chat#444). Streaming callers can await full handler completion and observe handler exceptions; `wait_until` keeps swallowed-error semantics; fire-and-forget callers are unaffected. The `@chat-adapter/web` package that motivated #444 is browser-only and not ported. +- **`chat/ai` subpath** (vercel/chat#492, design #109; PRs #116 + #122). `chat_sdk.ai` is now a package: `ai/messages.py` (`to_ai_messages`, moved with deprecation shims) and `ai/tools.py` — `create_chat_tools(chat, preset=, require_approval=, overrides=)` returning the 17 upstream tool factories as `ChatTool` dataclasses (JSON-Schema `input_schema`, async `execute`, `needs_approval`), keyed by upstream's camelCase tool ids. No new runtime dependencies. + +### New adapter: Messenger (Meta) + +- **`chat_sdk.adapters.messenger`** (vercel/chat#461, design #110; PRs #118 + #124). Full Messenger Platform adapter: GET verification handshake + `X-Hub-Signature-256` HMAC verification, Graph API client with typed error mapping, text/Generic/Button-template sends with documented caps, buffered streaming (no edit API), postback/reaction/delivery/read handlers, attachment extraction with lazy `fetch_data`, local message cache backing `fetch_messages`. New extra: `chat-sdk-python[messenger]`. Capabilities matrix in the design issue; `get_user` tracked as #132. + +### Upstream parity ports — adapters + +- **Slack: `webhook_verifier` now takes precedence over `signing_secret`** (vercel/chat#468, PR #113). Reverses the 0.4.27 precedence after upstream reversed itself; see the Known Non-Parity row update. **Migration**: if you relied on `signing_secret` shadowing a configured `webhook_verifier`, remove one of the two. +- **Slack: native `markdown_text` for outgoing messages** (vercel/chat#440). Outgoing posts use Slack's native markdown rendering instead of the legacy Block Kit conversion (deferred from 0.4.27). +- **Slack: external installation providers for bot token management** (vercel/chat#467). Pluggable multi-workspace token source composing with the existing dynamic `bot_token` resolver (per-request ContextVar caching preserved). +- **Slack: `web_client` property** (vercel/chat#471/#476/#478, PR #127). Sync `slack_sdk.WebClient` bound to the request-context token; `client` retained as a one-release deprecated alias. +- **Teams: outbound file delivery via data-URI activity attachments** (PR #125, ports upstream `filesToAttachments`). Execution artifacts now reach Teams; `edit_message` delivery is a documented deliberate superset. +- **Telegram: `video_note` extraction + typed attachment uploads** (vercel/chat#457, #485; PR #119). +- **Discord: handle interactions in gateway-only mode** (vercel/chat#490). +- **Adapter hardening pass** (slices of upstream `9824d33`): Slack timing-safe socket-token comparison (PR #126), GitHub eager bot-user-ID auto-detection (PR #128), Linear OAuth tokens encrypted at rest with AES-256-GCM (PR #129), and Google Chat fail-closed webhook verification (PR #130 — **breaking** for gchat configs that previously constructed without any verification gate; set `google_chat_project_number`, `pubsub_audience`, or the explicit `disable_signature_verification=True` escape hatch). ### Documentation alignment -- **DM routing precedence docstrings** (vercel/chat#491, commit `67c1794`). The Python runtime already routed direct messages to `on_direct_message` handlers before subscribed-message, mention, and pattern handlers (`Chat._handle_incoming_message`, `chat.py:2154`), but `on_direct_message` and `Thread.subscribe` lacked docstrings clarifying that contract. Refreshed both to match upstream's #491 docstring rewrite, plus a new `tests/integration/test_dm_flow.py::TestDMRoutingDocs` group and a `test_dm_handler_runs_before_pattern_handler` regression test that pin the documented precedence (DM > pattern) the previous suite did not explicitly cover. No runtime behavior change. -- **Streaming docstring refresh** (vercel/chat#463, commit `0cc3d06`). Replaces stale "fallback mode (post + edit on adapters without native streaming)" wording on `StreamingPlanOptions.update_interval_ms` with "Used by post+edit streaming paths", matching upstream's #463 clarification (the interval is consulted regardless of whether the adapter exposes its own `stream` method — Slack/Teams may still rate-limit edits internally). Also reworks the `ThreadImpl._handle_stream` docstring + the in-function "Use native streaming if adapter supports it" comment so they no longer imply a binary native-vs-fallback split. The unrelated `step-finish` → `finish-step` upstream type fix was already correct in the Python port (`from_full_stream.py`, `thread.py`). No runtime behavior change. +- **DM routing precedence docstrings** (vercel/chat#491, PR #121). `on_direct_message` and `Thread.subscribe` docstrings now state the DM > subscribed > mention > pattern precedence the runtime already implemented; regression test pins DM > pattern. +- **Streaming docstring refresh** (vercel/chat#463, PR #123). `StreamingPlanOptions.update_interval_ms` and `ThreadImpl._handle_stream` no longer imply a binary native-vs-fallback split. + +### Python-only improvements + +- **Slack `files_upload_v2` confirmation surfaced through `post()`** (PR #117; also shipped early as 0.4.27.1). `SentMessage.raw` carries `uploaded_file_ids`; history persistence nulls `raw` to avoid storage bloat/PII. +- **Slack: DM block-action responses no longer thread** (PR #133). `_handle_block_actions` mirrors `_handle_message_event`'s DM handling so HITL button replies don't create phantom "1 reply" threads. +- **Google Chat: file delivery via multipart media upload** (PR #112). + +### Not ported / deferred (documented in docs/UPSTREAM_SYNC.md) + +- **`@chat-adapter/web`** (vercel/chat#444) — browser-only; no Python runtime. +- **`@chat-adapter/tests` kit** (vercel/chat#470) — `chat_sdk.testing` already covers the surface; row added. +- **GitHub `octokit` / Linear `linear_client` getters** (vercel/chat#459/#478 halves) — both Python adapters are hand-rolled over `aiohttp` with no SDK object to expose; rows added with the revisit conditions (`githubkit` adoption / an official Linear Python SDK). +- **Teams migration to `microsoft-teams-apps`** (issue #93) — explicitly deferred to the 0.4.30 cycle; row added. The 3.12 floor prerequisite (#111) shipped in this release. + +### Build / infra + +- **Python floor bumped 3.10 → 3.12** (PR #111). +- **Fidelity**: CI re-pinned to `chat@4.29.0`; `MAPPING` updated for the 4.29 layout (`ai.test.ts` split into `ai/messages.test.ts` + `ai/index.test.ts`) and extended to the new core test files; converter-exact test renames in `tests/test_ai_tools.py`; two `chat.test.ts` subject-rehydration ports are `skipif`-gated on `BaseAdapter.fetch_subject` and activate automatically when PR #131 lands. +- **Next wave**: upstream `chat@4.30.0` (tagged 2026-06-01) is tracked in #135. + +## 0.4.27.1 (2026-05-29) -> Version bump from `0.4.29a2` to `0.4.29a3` will be cut by a coordinating PR once the parallel 4.29 ports land; this entry intentionally leaves `pyproject.toml` unchanged. +Python-only point release on the 0.4.27 line (branched from `v0.4.27`; PR #120). Backports the Slack `files_upload_v2` confirmation fix (PR #117) so `SentMessage.raw` carries `uploaded_file_ids`, plus the history-persistence `raw` null-out. Shipped ahead of 0.4.29 to unblock chinchill-api's delivery-confirmation gating. ## 0.4.29a2 (2026-05-28) diff --git a/CLAUDE.md b/CLAUDE.md index 23269c7..dbf0e1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # Claude Code Quick Reference -- chat-sdk-python ## What is this? -Python port of [Vercel Chat SDK](https://github.com/vercel/chat) (porting v4.29.0; last fully-synced release `0.4.27` at upstream `4.27.0`). Multi-platform async chat framework. +Python port of [Vercel Chat SDK](https://github.com/vercel/chat) (synced to upstream v4.29.0; next wave 4.30.0 is tracked separately). Multi-platform async chat framework. ## Key Commands ```bash @@ -25,7 +25,8 @@ Our version embeds the upstream Vercel Chat version: `0.{upstream_major}.{upstre - `0.4.26` = synced to upstream `4.26.0` - `0.4.26.3` = Python-only fixes on top of `4.26.0` - `0.4.27` = synced to upstream `4.27.0` -- `0.4.29a1` = alpha while porting upstream `4.29.0` (current branch; upstream skipped 4.28 tag) +- `0.4.27.1` = Python-only fix on top of `4.27.0` +- `0.4.29` = synced to upstream `4.29.0` (upstream never tagged 4.27/4.28 as `chat@*`) - `UPSTREAM_PARITY` constant in `__init__.py` = programmatic access ## Architecture @@ -34,7 +35,7 @@ Our version embeds the upstream Vercel Chat version: `0.{upstream_major}.{upstre - `src/chat_sdk/channel.py` -- Channel (thread listing, metadata) - `src/chat_sdk/plan.py` -- Plan (PostableObject for structured task lists) - `src/chat_sdk/types.py` -- All types (Message, Author, Adapter protocol) -- `src/chat_sdk/adapters/` -- 8 platform adapters +- `src/chat_sdk/adapters/` -- 9 platform adapters - `src/chat_sdk/shared/` -- Markdown parser, format converter, streaming renderer - `src/chat_sdk/state/` -- Memory, Redis, Postgres backends - `tests/` -- 3,400+ tests @@ -108,26 +109,22 @@ will not pass CI. **Fidelity check** (`scripts/verify_test_fidelity.py`) verifies every TS `it("...")` in the mapped core files has a matching Python `def test_*()`, -pinned to `chat@4.26.0` (upstream skipped tagging `chat@4.27.0` and -`chat@4.28.0`, then resumed at `chat@4.29.0`; pin moves to `chat@4.29.0` -once the in-flight 4.29 sync lands). The `MAPPING` -dict in that script is the authoritative scope list — it currently covers 8 -of 17 `packages/chat/src/*.test.ts` files (extending it is tracked as a -follow-up). **CI runs `--strict`** (see `.github/workflows/lint.yml`): +pinned to `chat@4.29.0` (matches `UPSTREAM_PARITY`; upstream never tagged +`chat@4.27.0`/`chat@4.28.0`). The `MAPPING` dict in that script is the +authoritative scope list — extending it to the remaining unmapped +`packages/chat/src/*.test.ts` files is tracked as issue #78. +**CI runs `--strict`** (see `.github/workflows/lint.yml`): any missing translation in a mapped file fails the build, and a missing upstream checkout also fails (the script exits non-zero when any mapped TS file isn't found). Baseline mode (the default without `--strict`) is retained for local workflows where a few ports land in flight — regenerate via `--update-baseline` after documenting intentional -divergence in `docs/UPSTREAM_SYNC.md`. During this sync cycle baseline -mode reports a parity mismatch (baseline pinned at `chat@4.26.0`, -`UPSTREAM_PARITY` says `4.29.0`); that's the intended signal until the -sync lands. +divergence in `docs/UPSTREAM_SYNC.md`. Before the fidelity check can run locally, clone the pinned upstream checkout (same command CI uses in `lint.yml`): ```bash -git clone --depth 1 --branch chat@4.26.0 \ +git clone --depth 1 --branch chat@4.29.0 \ https://github.com/vercel/chat.git /tmp/vercel-chat ``` Then `TS_ROOT=/tmp/vercel-chat uv run python scripts/verify_test_fidelity.py --strict`. diff --git a/README.md b/README.md index 9e664fd..487ea71 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,11 @@ Multi-platform async chat SDK for Python. Port of [Vercel Chat](https://github.com/vercel/chat). -> **Status: Alpha (0.4.29a1 — porting [Vercel Chat 4.29.0](https://github.com/vercel/chat))** — API may change. Last fully-synced release: `0.4.27` (parity with upstream `chat@4.27.0`). See [CHANGELOG.md](CHANGELOG.md) for the in-flight sync plan. +> **Status: 0.4.29 — synced to [Vercel Chat 4.29.0](https://github.com/vercel/chat)** (`UPSTREAM_PARITY = "4.29.0"`). The 4.30 sync wave is tracked in [#135](https://github.com/Chinchill-AI/chat-sdk-python/issues/135). See [CHANGELOG.md](CHANGELOG.md). ## Why chat-sdk? -- **Write once, deploy to 8 platforms.** One handler runs on Slack, Discord, Teams, Telegram, WhatsApp, Google Chat, GitHub, and Linear. +- **Write once, deploy to 9 platforms.** One handler runs on Slack, Discord, Teams, Telegram, WhatsApp, Messenger, Google Chat, GitHub, and Linear. - **Built-in concurrency primitives.** Deduplication, thread locking, and message queuing are handled for you. - **Cross-platform cards.** Author a `Card` once and it renders as Block Kit (Slack), Adaptive Cards (Teams), embeds (Discord), and more. - **Not a replacement for platform SDKs.** chat-sdk is built *on top of* them. You can always drop down to the native SDK when you need to. @@ -54,10 +54,29 @@ async def handle_mention(thread, message): | Teams | `chat-sdk[teams]` | Alpha | | Telegram | `chat-sdk[telegram]` | Alpha | | WhatsApp | `chat-sdk[whatsapp]` | Alpha | +| Messenger (Meta) | `chat-sdk[messenger]` | Alpha | | Google Chat | `chat-sdk[google-chat]` | Alpha | | GitHub | `chat-sdk[github]` | Alpha | | Linear | `chat-sdk[linear]` | Alpha | +## AI / LLM Integration + +Expose chat actions to an LLM agent as tools (`chat/ai` parity, vercel/chat#492): + +```python +from chat_sdk.ai import create_chat_tools, to_ai_messages + +tools = create_chat_tools(chat, preset="messenger", require_approval=True) +# {"postMessage": ChatTool(description=..., input_schema={...}, execute=..., needs_approval=True), ...} +``` + +Each `ChatTool` is SDK-agnostic: `input_schema` is a JSON-Schema dict you can +hand to any agent runtime (Anthropic tool use, OpenAI tools, pydantic-ai, ...), +`execute` is the async implementation, and `needs_approval` flags write tools +for human-in-the-loop gating. Presets: `reader`, `messenger`, `moderator`. +`to_ai_messages(thread)` converts thread history into model-ready messages. +Runnable demo: [`examples/ai_tools_example.py`](examples/ai_tools_example.py). + ## State Backends | Backend | Install Extra | @@ -70,7 +89,7 @@ async def handle_mention(thread, message): | Feature | chat-sdk | Raw platform SDKs | BotFramework SDK | |---------|----------|--------------------|------------------| -| Multi-platform from one codebase | 8 platforms | 1 per SDK | Teams + limited | +| Multi-platform from one codebase | 9 platforms | 1 per SDK | Teams + limited | | Async-native (Python 3.12+) | Yes | Varies | No | | Cross-platform cards | Card model | Platform-specific | Adaptive Cards only | | Thread locking / dedup | Built-in | DIY | DIY | diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index 4269a08..237cb30 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -14,6 +14,8 @@ Our version embeds the upstream Vercel Chat version: `0.{upstream_major}.{upstre | `0.4.26` | `4.26.0` | Synced to upstream 4.26.0 | | `0.4.26.3` | `4.26.0` | Python-only fixes on top of 4.26.0 | | `0.4.27` | `4.27.0` | Synced to upstream 4.27.0 | +| `0.4.27.1` | `4.27.0` | Python-only fix on top of 4.27.0 (Slack upload confirmation backport) | +| `0.4.29` | `4.29.0` | Synced to upstream 4.29.0 (upstream never tagged `chat@4.27.0`/`chat@4.28.0`) | The `UPSTREAM_PARITY` constant in `chat_sdk/__init__.py` provides programmatic access to the upstream version this release is synced to. @@ -77,12 +79,11 @@ tests. If upstream tests lock in inconsistent behavior, choose one of: ### Test fidelity (strict mode) `scripts/verify_test_fidelity.py` runs in CI (`.github/workflows/lint.yml`) pinned -to `vercel/chat@4.26.0` (matches the `UPSTREAM_PARITY` constant in +to `vercel/chat@4.29.0` (matches the `UPSTREAM_PARITY` constant in `src/chat_sdk/__init__.py`). **CI runs `--strict`** — the repo ships at 0 -missing *for mapped core files* as of `0.4.26.2` and the baseline -(`scripts/fidelity_baseline.json`) is empty. Scope is defined by the -`MAPPING` dict in the script: 8 of 17 `packages/chat/src/*.test.ts` files -today (extending to the remaining 9 is tracked as a follow-up). Unmapped +missing *for mapped core files* as of `0.4.29`. Scope is defined by the +`MAPPING` dict in the script (extending to the remaining unmapped +`packages/chat/src/*.test.ts` files is tracked as issue #78). Unmapped files are not checked — tightening scope requires editing `MAPPING` and re-running `--strict`. @@ -635,6 +636,9 @@ stay explicit instead of being rediscovered in code review. | `_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. | +| `GitHubAdapter.octokit` native client getter (vercel/chat#459, #478) | Not exposed | `get octokit(): Octokit` (plus deprecated `client` alias) returns the underlying Octokit — fixed instance in PAT/single-tenant App mode, per-installation client resolved from `AsyncLocalStorage` inside a webhook handler in multi-tenant mode | The Python adapter is hand-rolled over raw `aiohttp` (`_github_api_request`) with PyJWT for App JWTs and an installation-token cache; the `github` extra is `pyjwt[crypto]` only — there is no Octokit-equivalent object to return, and exposing the raw session or an invented facade under the name `octokit` would misrepresent the surface. Revisit if the adapter adopts an octokit-style SDK (e.g. `githubkit`) as an optional dependency per hazard #10's "prefer official SDKs" sub-rule; the getter (and the GitHub `fetch_subject` half of #459) ports cleanly then. | +| `LinearAdapter.linear_client` native client getter (vercel/chat#459, #478) | Not exposed | `get linearClient(): LinearClient` (plus deprecated `client` alias) returns the `@linear/sdk` `LinearClient`, per-org from `AsyncLocalStorage` in multi-tenant OAuth mode | `@linear/sdk` is TypeScript-only and no official Linear Python SDK exists; the adapter issues GraphQL directly over `aiohttp` (`_graphql_query`) and already documents that stance. Nothing honest to put behind the name. Revisit only if Linear ships an official Python SDK (the Linear `fetch_subject` half of #459 is blocked on the same). | +| `@chat-adapter/tests` adapter test kit (vercel/chat#470) | Not ported | New TS package with test utilities for adapter authors | Python already ships `chat_sdk.testing` (`MockAdapter`, `MockStateAdapter`, `create_test_message()`) covering the same surface for this repo's adapter tests; mirroring the TS kit verbatim would duplicate it. Revisit if upstream's kit grows capabilities ours lacks (e.g. recorded replay fixtures for third-party adapter authors). | ### Platform-specific gaps @@ -645,6 +649,7 @@ stay explicit instead of being rediscovered in code review. | Google Chat file uploads | Ignored in message parse | Supported | API complexity; can add later | | Discord Gateway WebSocket | HTTP interactions only | Both HTTP and Gateway | Gateway requires persistent connection | | Teams `User-Agent: Vercel.ChatSDK` outbound header | Not set on `aiohttp` calls | Propagated by `botbuilder` 2.0.8 | Python Teams adapter doesn't use `botbuilder` (raw `aiohttp`). Upstream's vercel/chat#415 was a JS-only `botbuilder` SDK bump that flipped `X-User-Agent` → `User-Agent`. No equivalent dependency to bump on the Python side. Setting a `User-Agent` on the ~9 outbound `aiohttp` call sites would be a defense-in-depth nice-to-have; deferred to a follow-up. | +| Teams adapter on `microsoft-teams-apps` (official MS Python SDK) | Hand-rolled Bot Framework REST + JWT verification (see the transitional native-streaming rows above) | `@microsoft/teams.apps` owns the wire format, throttling, and activity routing | **Explicitly deferred to the 0.4.30 cycle** (issue #93). The Python SDK only went GA 2026-05-01; the migration is a 4–6 day restructuring of `adapter.py` (~2,300 LOC) + 6 test files and was too large to land inside the 4.29 wave's tail. The 3.12 floor bump (#111) — the migration's prerequisite — already landed in 0.4.29. | | Telegram `get_user().is_bot` | Always `False` (matches upstream — `getChat` does not expose `is_bot`) | Always `false` (same caveat documented in upstream code comment) | The Telegram Bot API's `getChat` endpoint does not surface the `is_bot` field that's available on the `User` object inside incoming `Message` updates. Callers needing bot detection must use `message.author.is_bot` from webhooks instead of `chat.get_user(...).is_bot`. | | WhatsApp `get_user` | Raises `ChatNotImplementedError` (`Chat.get_user` translates to "does not support get_user") | Not implemented upstream either (no `getUser` on the WhatsApp adapter) | WhatsApp Cloud API has no user lookup endpoint — phone numbers are the only stable identifier and there's no equivalent of `users.info` exposed to business apps. Documented explicitly so callers don't expect parity with Slack/Teams/Discord. | diff --git a/examples/ai_tools_example.py b/examples/ai_tools_example.py new file mode 100644 index 0000000..6dfeb17 --- /dev/null +++ b/examples/ai_tools_example.py @@ -0,0 +1,98 @@ +"""Example: expose chat-sdk actions to an LLM agent via ``create_chat_tools``. + +Runs entirely against the in-memory ``MockAdapter`` — no platform +credentials required: + + uv run python examples/ai_tools_example.py + +``create_chat_tools(chat, ...)`` returns the 17 upstream tool factories +(vercel/chat#492) as ``ChatTool`` dataclasses keyed by upstream's camelCase +tool ids, each carrying: + +- ``description`` — model-facing description +- ``input_schema`` — JSON-Schema dict for the tool arguments +- ``execute`` — async callable taking the validated argument dict +- ``needs_approval`` — human-in-the-loop flag (write tools default True) + +The dataclasses are SDK-agnostic on purpose: bind them into whatever agent +runtime you use by translating ``input_schema`` into that runtime's schema +layer and calling ``execute`` from its tool dispatcher. The sketch at the +bottom shows the Anthropic tool-use shape; chinchill-api does the same via +its own runner. +""" + +from __future__ import annotations + +import asyncio + +from chat_sdk import Chat, ChatConfig +from chat_sdk.ai import create_chat_tools +from chat_sdk.testing import MockAdapter, MockStateAdapter, create_test_message + + +async def main() -> None: + adapter = MockAdapter(name="slack") + chat = Chat( + ChatConfig( + user_name="examplebot", + adapters={"slack": adapter}, + state=MockStateAdapter(), + ) + ) + await chat.webhooks["slack"]("request") # triggers adapter/state init + + # ------------------------------------------------------------------ + # 1. Build the toolset. Presets scope what the model may do: + # "reader" (fetch/list only), "messenger" (reader + posting), + # "moderator" (everything, including delete/reactions). + # ------------------------------------------------------------------ + tools = create_chat_tools( + chat, + preset="messenger", + require_approval={"postMessage": False}, # auto-approve plain posts + overrides={"postMessage": {"description": "Post a markdown reply into the current thread."}}, + ) + + print(f"toolset ({len(tools)} tools):") + for name, tool in sorted(tools.items()): + gate = "needs approval" if tool.needs_approval else "auto" + print(f" {name:24s} [{gate}] {tool.description[:60]}") + + # ------------------------------------------------------------------ + # 2. Execute a tool the way an agent runtime would: validated args in, + # JSON-safe result out. + # ------------------------------------------------------------------ + thread_id = "slack:C123:1234.5678" + await chat.handle_incoming_message(adapter, thread_id, create_test_message("msg-1", "Hey @examplebot hello")) + + result = await tools["postMessage"].execute( + { + "threadId": thread_id, + "message": {"markdown": "Hello from the example agent!"}, + } + ) + print(f"\npostMessage -> {result}") + + fetched = await tools["fetchMessages"].execute({"threadId": thread_id}) + print(f"fetchMessages -> {len(fetched['messages'])} message(s)") + + # ------------------------------------------------------------------ + # 3. Binding sketch (Anthropic tool-use shape; any runtime works): + # + # anthropic_tools = [ + # { + # "name": name, + # "description": tool.description, + # "input_schema": tool.input_schema, + # } + # for name, tool in tools.items() + # ] + # ... when the model emits a tool_use block: + # if tools[block.name].needs_approval: + # ...ask a human first... + # result = await tools[block.name].execute(block.input) + # ------------------------------------------------------------------ + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index fb28d8d..2fe0761 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "chat-sdk" -version = "0.4.29a2" +version = "0.4.29" description = "Multi-platform async chat SDK for Python — port of Vercel Chat" keywords = [ "chat", From 20cd921d29208f7cae1f50c862337e631f6f22f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 20:49:29 +0000 Subject: [PATCH 05/14] feat(slack): native markdown_text for outgoing messages (vercel/chat#440) Port of upstream 3546b3f. Slack natively renders markdown via the markdown_text parameter on chat.postMessage / postEphemeral / update / scheduleMessage, so the adapter passes markdown through directly instead of converting to mrkdwn. - str / PostableRaw messages still go to text (preserves literal *). - PostableMarkdown / PostableAst go to markdown_text (12k char limit). - to_blocks_with_table, _mdast_table_to_slack_block, and the _render_with_table_blocks call sites are removed (tables now ride along in markdown_text). - SlackMarkdownConverter alias removed; use SlackFormatConverter. - render_formatted(ast) / from_ast now return standard markdown (was mrkdwn) via stringify_markdown. - response_url payloads reject markdown_text (no_text), so those render through the retained mrkdwn node renderer (to_response_url_text). - Incoming message events still arrive as mrkdwn and parse unchanged. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 (cherry picked from commit f07815e327ef1f2ba8ff688b2eb593af818c4ab6) --- src/chat_sdk/adapters/slack/adapter.py | 154 ++--- .../adapters/slack/format_converter.py | 239 +++----- tests/test_slack_api.py | 187 +++++- tests/test_slack_format.py | 539 ++++++------------ 4 files changed, 512 insertions(+), 607 deletions(-) diff --git a/src/chat_sdk/adapters/slack/adapter.py b/src/chat_sdk/adapters/slack/adapter.py index d1168ee..6b2c9b5 100644 --- a/src/chat_sdk/adapters/slack/adapter.py +++ b/src/chat_sdk/adapters/slack/adapter.py @@ -55,7 +55,7 @@ SlackThreadId, SlackWebhookVerifier, ) -from chat_sdk.emoji import convert_emoji_placeholders, emoji_to_slack, resolve_emoji_from_slack +from chat_sdk.emoji import emoji_to_slack, resolve_emoji_from_slack from chat_sdk.logger import ConsoleLogger, Logger from chat_sdk.modals import ModalElement, OptionsLoadGroup, SelectOptionElement from chat_sdk.shared.adapter_utils import extract_card, extract_files @@ -3354,35 +3354,6 @@ def _is_message_from_self(self, event: dict[str, Any]) -> bool: return True return bool(self._bot_id and event.get("bot_id") == self._bot_id) - # ================================================================== - # Table block rendering - # ================================================================== - - def _render_with_table_blocks(self, message: AdapterPostableMessage) -> dict[str, Any] | None: - """Try to render a message with native Slack table blocks. - - Returns ``{"text": ..., "blocks": ...}`` if the message contains tables, - ``None`` otherwise. - """ - ast: dict[str, Any] | None = None - if isinstance(message, dict): - ast = message.get("ast") # type: ignore[union-attr] - elif hasattr(message, "ast"): - ast = getattr(message, "ast", None) - elif hasattr(message, "markdown"): - # We don't have a full markdown->AST parser in Python; skip table blocks - return None - - if not ast: - return None - - blocks = self._format_converter.to_blocks_with_table(ast) - if not blocks: - return None - - fallback_text = convert_emoji_placeholders(self._format_converter.render_postable(message), "slack") - return {"text": fallback_text, "blocks": blocks} - # ================================================================== # Post / Edit / Delete messages # ================================================================== @@ -3446,38 +3417,21 @@ async def post_message(self, thread_id: str, message: AdapterPostableMessage) -> ), ) - # Table blocks - table_result = self._render_with_table_blocks(message) - if table_result: - result = await client.chat_postMessage( - channel=channel, - thread_ts=thread_ts or None, - text=table_result["text"], - blocks=table_result["blocks"], - unfurl_links=False, - unfurl_media=False, - ) - return RawMessage( - id=result.get("ts", ""), - thread_id=thread_id, - raw=self._augment_raw_with_uploads( - result.data if hasattr(result, "data") else result, - uploaded_file_ids, - ), - ) - - # Regular text - text = convert_emoji_placeholders(self._format_converter.render_postable(message), "slack") + payload = self._format_converter.to_slack_payload(message) self._logger.debug( "Slack API: chat.postMessage", - {"channel": channel, "threadTs": thread_ts, "textLength": len(text)}, + { + "channel": channel, + "threadTs": thread_ts, + "payloadKey": "markdown_text" if "markdown_text" in payload else "text", + }, ) result = await client.chat_postMessage( channel=channel, thread_ts=thread_ts or None, - text=text, unfurl_links=False, unfurl_media=False, + **payload, ) return RawMessage( id=result.get("ts", ""), @@ -3547,22 +3501,16 @@ async def edit_message( raw=result.data if hasattr(result, "data") else result, ) - table_result = self._render_with_table_blocks(message) - if table_result: - result = await client.chat_update( - channel=channel, - ts=message_id, - text=table_result["text"], - blocks=table_result["blocks"], - ) - return RawMessage( - id=result.get("ts", ""), - thread_id=thread_id, - raw=result.data if hasattr(result, "data") else result, - ) - - text = convert_emoji_placeholders(self._format_converter.render_postable(message), "slack") - result = await client.chat_update(channel=channel, ts=message_id, text=text) + payload = self._format_converter.to_slack_payload(message) + self._logger.debug( + "Slack API: chat.update", + { + "channel": channel, + "messageId": message_id, + "payloadKey": "markdown_text" if "markdown_text" in payload else "text", + }, + ) + result = await client.chat_update(channel=channel, ts=message_id, **payload) return RawMessage( id=result.get("ts", ""), thread_id=thread_id, @@ -3864,28 +3812,21 @@ async def post_ephemeral( raw=result.data if hasattr(result, "data") else result, ) - table_result = self._render_with_table_blocks(message) - if table_result: - result = await client.chat_postEphemeral( - channel=channel, - thread_ts=thread_ts or None, - user=user_id, - text=table_result["text"], - blocks=table_result["blocks"], - ) - return EphemeralMessage( - id=result.get("message_ts", ""), - thread_id=thread_id, - used_fallback=False, - raw=result.data if hasattr(result, "data") else result, - ) - - text = convert_emoji_placeholders(self._format_converter.render_postable(message), "slack") + payload = self._format_converter.to_slack_payload(message) + self._logger.debug( + "Slack API: chat.postEphemeral", + { + "channel": channel, + "threadTs": thread_ts, + "userId": user_id, + "payloadKey": "markdown_text" if "markdown_text" in payload else "text", + }, + ) result = await client.chat_postEphemeral( channel=channel, thread_ts=thread_ts or None, user=user_id, - text=text, + **payload, ) return EphemeralMessage( id=result.get("message_ts", ""), @@ -3948,14 +3889,23 @@ async def schedule_message( unfurl_media=False, ) else: - text = convert_emoji_placeholders(self._format_converter.render_postable(message), "slack") + payload = self._format_converter.to_slack_payload(message) + self._logger.debug( + "Slack API: chat.scheduleMessage", + { + "channel": channel, + "threadTs": thread_ts, + "postAt": post_at_unix, + "payloadKey": "markdown_text" if "markdown_text" in payload else "text", + }, + ) result = await client.chat_scheduleMessage( channel=channel, thread_ts=thread_ts or None, post_at=post_at_unix, - text=text, unfurl_links=False, unfurl_media=False, + **payload, ) scheduled_message_id = result.get("scheduled_message_id", "") @@ -4425,7 +4375,10 @@ def parse_message(self, raw: dict[str, Any]) -> Message: return self._parse_slack_message_sync(event, thread_id) def render_formatted(self, content: FormattedContent) -> str: - """Render formatted content (AST) to Slack mrkdwn.""" + """Render formatted content (AST) to standard markdown. + + Slack now accepts markdown natively via ``markdown_text``. + """ return self._format_converter.from_ast(content) # ================================================================== @@ -4547,18 +4500,13 @@ async def _send_to_response_url( "blocks": card_to_block_kit(card), } else: - table_result = self._render_with_table_blocks(message) - if table_result: - payload = { - "replace_original": True, - "text": table_result["text"], - "blocks": table_result["blocks"], - } - else: - payload = { - "replace_original": True, - "text": convert_emoji_placeholders(self._format_converter.render_postable(message), "slack"), - } + # Slack rejects `markdown_text` on response_url payloads + # (`no_text`), so markdown/AST messages are rendered to + # Slack's legacy mrkdwn format for this surface. + payload = { + "replace_original": True, + "text": self._format_converter.to_response_url_text(message), + } if thread_ts: payload["thread_ts"] = thread_ts diff --git a/src/chat_sdk/adapters/slack/format_converter.py b/src/chat_sdk/adapters/slack/format_converter.py index 4330e24..5bf74b3 100644 --- a/src/chat_sdk/adapters/slack/format_converter.py +++ b/src/chat_sdk/adapters/slack/format_converter.py @@ -1,14 +1,14 @@ -"""Slack-specific format conversion using AST-based parsing. +"""Slack format conversion. Port of markdown.ts from the Vercel Chat SDK Slack adapter. -Slack uses "mrkdwn" format which is similar but not identical to markdown: -- Bold: *text* (not **text**) -- Italic: _text_ (same) -- Strikethrough: ~text~ (not ~~text~~) -- Links: (not [text](url)) -- User mentions: <@U123> -- Channel mentions: <#C123|name> +Outgoing: Slack now natively renders markdown via the ``markdown_text`` field +on chat.postMessage / postEphemeral / update / scheduleMessage. We pass +markdown through there and let Slack handle it. Interactive ``response_url`` +payloads do not accept ``markdown_text``, so those still use Slack mrkdwn text. + +Incoming: Slack ``message`` events still deliver text as mrkdwn +(``*bold*``, ``<@U123>``, ````), so the ``to_ast`` parser stays. """ from __future__ import annotations @@ -16,33 +16,39 @@ import re from typing import Any -from chat_sdk.adapters.slack.cards import SlackBlock +from chat_sdk.emoji import convert_emoji_placeholders from chat_sdk.shared.base_format_converter import ( BaseFormatConverter, Content, Root, parse_markdown, + stringify_markdown, table_to_ascii, ) +# Match bare @mentions (e.g. "@george") to rewrite as Slack's `<@george>`. +# The lookbehind excludes `<` (already-formatted mentions like `<@U123>`) and +# any word character, so email addresses like `user@example.com` are left alone. +BARE_MENTION_REGEX = re.compile(r"(? str: - """Render an AST to Slack mrkdwn format.""" - return self._from_ast_with_node_converter(ast, self._node_to_mrkdwn) - - def to_ast(self, platform_text: str) -> Root: - """Parse Slack mrkdwn into an AST. + """Render an AST to standard markdown. - Converts Slack-specific syntax to standard markdown, then parses - with the shared parser. + Slack accepts this directly via ``markdown_text`` and the + ``markdown`` block. """ + return stringify_markdown(ast) + + def to_ast(self, platform_text: str) -> Root: + """Parse Slack mrkdwn into an AST. Used for incoming ``message`` events.""" markdown = platform_text # User mentions: <@U123|name> -> @name or <@U123> -> @U123 @@ -59,7 +65,7 @@ def to_ast(self, platform_text: str) -> Root: # Bare links: -> url markdown = re.sub(r"<(https?://[^<>]+)>", r"\1", markdown) - # Bold: *text* -> **text** (careful with emphasis) + # Bold: *text* -> **text** (Slack uses single * for bold) markdown = re.sub(r"(? ~~text~~ @@ -68,48 +74,73 @@ def to_ast(self, platform_text: str) -> Root: return parse_markdown(markdown) # ------------------------------------------------------------------------- - # Overrides + # Outgoing payload builders # ------------------------------------------------------------------------- - def render_postable(self, message: Any) -> str: - """Render a postable message to Slack mrkdwn string. + def to_slack_payload(self, message: Any) -> dict[str, str]: + """Build the Slack API payload fields for a message. + + - ``str`` / ``{"raw"}`` -> ``{"text"}`` (plain -- preserves literal + ``*``, ``_``, etc.) + - ``{"markdown"}`` / ``{"ast"}`` -> ``{"markdown_text"}`` (Slack + renders natively) + + Bare ``@user`` mentions are rewritten to ``<@user>`` and ``:emoji:`` + placeholders are normalized for Slack in all branches. - Supports str, ``{"raw": ...}``, ``{"markdown": ...}``, ``{"ast": ...}``, - and card types (``{"card": ...}`` / ``CardElement``). + Note: ``markdown_text`` has a 12,000 character limit; ``text`` allows + ~40,000. + Note: ``markdown_text`` is mutually exclusive with ``text`` and + ``blocks``. """ if isinstance(message, str): - return self._convert_mentions_to_slack(message) - if hasattr(message, "raw"): - return self._convert_mentions_to_slack(message.raw) + return {"text": self._finalize(message)} if isinstance(message, dict): if "raw" in message: - return self._convert_mentions_to_slack(message["raw"]) + return {"text": self._finalize(message["raw"])} if "markdown" in message: - return self.from_markdown(message["markdown"]) + return {"markdown_text": self._finalize(message["markdown"])} if "ast" in message: - return self.from_ast(message["ast"]) - if "card" in message: - from chat_sdk.cards import card_to_fallback_text - - return card_to_fallback_text(message["card"]) - if message.get("type") == "card": - from chat_sdk.cards import is_card_element - - if is_card_element(message): - from chat_sdk.cards import card_to_fallback_text - - return card_to_fallback_text(message) # type: ignore[arg-type] - return str(message) - # Dataclass-style objects - if hasattr(message, "markdown"): - return self.from_markdown(message.markdown) - if hasattr(message, "ast"): - return self.from_ast(message.ast) - if hasattr(message, "card"): - from chat_sdk.cards import card_to_fallback_text - - return card_to_fallback_text(message.card) - return str(message) + return {"markdown_text": self._finalize(stringify_markdown(message["ast"]))} + return {"text": ""} + # Dataclass / object-style messages + if getattr(message, "raw", None) is not None: + return {"text": self._finalize(message.raw)} + if getattr(message, "markdown", None) is not None: + return {"markdown_text": self._finalize(message.markdown)} + if getattr(message, "ast", None) is not None: + return {"markdown_text": self._finalize(stringify_markdown(message.ast))} + return {"text": ""} + + def to_response_url_text(self, message: Any) -> str: + """Build text for Slack response_url payloads. + + Slack rejects ``markdown_text`` on response_url (``no_text``), so + markdown/AST messages are rendered to Slack's legacy mrkdwn format + for this surface. + """ + if isinstance(message, str): + return self._finalize(message) + if isinstance(message, dict): + if "raw" in message: + return self._finalize(message["raw"]) + if "markdown" in message: + return convert_emoji_placeholders(self._ast_to_mrkdwn(parse_markdown(message["markdown"])), "slack") + if "ast" in message: + return convert_emoji_placeholders(self._ast_to_mrkdwn(message["ast"]), "slack") + return "" + # Dataclass / object-style messages + if getattr(message, "raw", None) is not None: + return self._finalize(message.raw) + if getattr(message, "markdown", None) is not None: + return convert_emoji_placeholders(self._ast_to_mrkdwn(parse_markdown(message.markdown)), "slack") + if getattr(message, "ast", None) is not None: + return convert_emoji_placeholders(self._ast_to_mrkdwn(message.ast), "slack") + return "" + + # ------------------------------------------------------------------------- + # Overrides + # ------------------------------------------------------------------------- def extract_plain_text(self, platform_text: str) -> str: """Extract plain text from Slack mrkdwn by stripping formatting.""" @@ -134,73 +165,17 @@ def extract_plain_text(self, platform_text: str) -> str: return text - # ------------------------------------------------------------------------- - # Slack table block support - # ------------------------------------------------------------------------- - - def to_blocks_with_table(self, ast: Root) -> list[SlackBlock] | None: - """Convert AST to Slack blocks, using native table block for the first table. - - Returns None if the AST contains no tables (caller should use regular text). - Slack allows at most one table block per message; additional tables use ASCII. - """ - if not isinstance(ast, dict): - return None - - children = ast.get("children", []) - has_table = any(isinstance(node, dict) and node.get("type") == "table" for node in children) - if not has_table: - return None - - blocks: list[SlackBlock] = [] - used_native_table = False - text_buffer: list[str] = [] - - def flush_text() -> None: - nonlocal text_buffer - if text_buffer: - text = "\n\n".join(text_buffer) - if text.strip(): - blocks.append( - { - "type": "section", - "text": {"type": "mrkdwn", "text": text}, - } - ) - text_buffer = [] - - for child in children: - node = child if isinstance(child, dict) else {} - if node.get("type") == "table": - flush_text() - if used_native_table: - # Additional tables fall back to ASCII in a code block - ascii_table = table_to_ascii(node) - blocks.append( - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": f"```\n{ascii_table}\n```", - }, - } - ) - else: - blocks.append(self._mdast_table_to_slack_block(node)) - used_native_table = True - else: - text_buffer.append(self._node_to_mrkdwn(node)) - - flush_text() - return blocks - # ------------------------------------------------------------------------- # Private helpers # ------------------------------------------------------------------------- - def _convert_mentions_to_slack(self, text: str) -> str: - """Convert @mentions to Slack format: @name -> <@name>.""" - return re.sub(r"(?", text) + def _finalize(self, text: str) -> str: + """Rewrite bare @mentions and normalize emoji placeholders for Slack.""" + return convert_emoji_placeholders(BARE_MENTION_REGEX.sub(r"<@\1>", text), "slack") + + def _ast_to_mrkdwn(self, ast: Root) -> str: + """Render an AST to Slack's legacy mrkdwn (response_url surface only).""" + return self._from_ast_with_node_converter(ast, self._node_to_mrkdwn) def _node_to_mrkdwn(self, node: Content) -> str: """Convert a single AST node to Slack mrkdwn.""" @@ -215,7 +190,7 @@ def _node_to_mrkdwn(self, node: Content) -> str: if node_type == "text": value = node.get("value", "") - return re.sub(r"(?", value) + return BARE_MENTION_REGEX.sub(r"<@\1>", value) if node_type == "strong": content = "".join(self._node_to_mrkdwn(c) for c in children) @@ -250,7 +225,7 @@ def _node_to_mrkdwn(self, node: Content) -> str: return "\n".join(f"> {self._node_to_mrkdwn(c)}" for c in children) if node_type == "list": - return self._render_list(node, 0, self._node_to_mrkdwn, "\u2022") + return self._render_list(node, 0, self._node_to_mrkdwn, "•") if node_type == "break": return "\n" @@ -270,35 +245,3 @@ def _node_to_mrkdwn(self, node: Content) -> str: # Default fallback for any node with children return self._default_node_to_text(node, self._node_to_mrkdwn) - - def _mdast_table_to_slack_block(self, node: Content) -> SlackBlock: - """Convert a table AST node to a Slack table block. - - @see https://docs.slack.dev/reference/block-kit/blocks/table-block/ - """ - rows_data: list[list[dict[str, str]]] = [] - - for row in node.get("children", []): - cells = [] - for cell in row.get("children", []): - # Convert cell children to text, defaulting to a space if empty. - # Slack API requires table cell text to be at least 1 character. - # Use an explicit length check rather than a truthiness check to - # avoid substituting valid strings like "0". - raw_text = "".join(self._node_to_mrkdwn(c) for c in cell.get("children", [])) - text = raw_text if len(raw_text) > 0 else " " - cells.append({"type": "raw_text", "text": text}) - rows_data.append(cells) - - block: SlackBlock = {"type": "table", "rows": rows_data} - - align = node.get("align") - if align: - column_settings = [{"align": a or "left"} for a in align] - block["column_settings"] = column_settings - - return block - - -# Backwards compatibility alias -SlackMarkdownConverter = SlackFormatConverter diff --git a/tests/test_slack_api.py b/tests/test_slack_api.py index 4fd7e19..d39d7ba 100644 --- a/tests/test_slack_api.py +++ b/tests/test_slack_api.py @@ -331,6 +331,58 @@ async def test_thread_reply(self): calls = client.get_calls("chat_postMessage") assert calls[0]["kwargs"]["thread_ts"] == "1234567890.000000" + @pytest.mark.asyncio + async def test_markdown_message_posts_via_native_markdown_text(self): + """Markdown is passed through to Slack's markdown_text field for + native rendering -- not converted to mrkdwn ``text``.""" + adapter, client, _ = await _init_adapter() + client.set_response("chat_postMessage", {"ok": True, "ts": "1234567890.444444"}) + + from chat_sdk.types import PostableMarkdown + + await adapter.post_message( + "slack:C123:1234567890.000000", + PostableMarkdown(markdown="**Bold** and _italic_ and `code`"), + ) + + calls = client.get_calls("chat_postMessage") + assert len(calls) == 1 + kwargs = calls[0]["kwargs"] + assert kwargs["markdown_text"] == "**Bold** and _italic_ and `code`" + # markdown_text is mutually exclusive with text. + assert "text" not in kwargs + + @pytest.mark.asyncio + async def test_plain_string_posts_via_text_not_markdown_text(self): + """Plain strings keep going to ``text`` so literal ``*`` survives.""" + adapter, client, _ = await _init_adapter() + client.set_response("chat_postMessage", {"ok": True, "ts": "1234567890.555555"}) + + await adapter.post_message("slack:C123:1234567890.000000", "Use *foo* literally") + + kwargs = client.get_calls("chat_postMessage")[0]["kwargs"] + assert kwargs["text"] == "Use *foo* literally" + assert "markdown_text" not in kwargs + + @pytest.mark.asyncio + async def test_markdown_table_posts_as_markdown_text_without_blocks(self): + """Tables ride along in markdown_text (Slack renders them natively); + the legacy native-table-block conversion is gone.""" + adapter, client, _ = await _init_adapter() + client.set_response("chat_postMessage", {"ok": True, "ts": "1234567890.666666"}) + + from chat_sdk.types import PostableMarkdown + + await adapter.post_message( + "slack:C123:1234567890.000000", + PostableMarkdown(markdown="| A | B |\n|---|---|\n| 1 | 2 |"), + ) + + kwargs = client.get_calls("chat_postMessage")[0]["kwargs"] + assert kwargs["markdown_text"] == "| A | B |\n|---|---|\n| 1 | 2 |" + assert "blocks" not in kwargs + assert "text" not in kwargs + # ============================================================================= # editMessage Tests @@ -369,6 +421,83 @@ async def test_edit_message_returns_correct_thread_id(self): assert result.thread_id == "slack:CABC:1111.2222" + @pytest.mark.asyncio + async def test_edit_with_markdown_uses_markdown_text(self): + """chat.update sends markdown via markdown_text, like postMessage.""" + adapter, client, _ = await _init_adapter() + client.set_response("chat_update", {"ok": True, "ts": "1234567890.123456"}) + + from chat_sdk.types import PostableMarkdown + + await adapter.edit_message( + "slack:C123:1234567890.000000", + "1234567890.123456", + PostableMarkdown(markdown="**Updated** body"), + ) + + kwargs = client.get_calls("chat_update")[0]["kwargs"] + assert kwargs["markdown_text"] == "**Updated** body" + assert "text" not in kwargs + + @pytest.mark.asyncio + async def test_edit_ephemeral_via_response_url_uses_mrkdwn_fallback(self): + """response_url payloads reject markdown_text (`no_text`), so + markdown/AST edits are rendered to legacy mrkdwn ``text``.""" + adapter, _, _ = await _init_adapter() + ephemeral_id = adapter._encode_ephemeral_message_id( + "1234567890.123456", "https://hooks.slack.com/respond", "U1" + ) + + recorded: dict[str, Any] = {} + + class FakeResponse: + is_success = True + status_code = 200 + text = "ok" + + class FakeAsyncClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *args: Any) -> None: + return None + + async def post(self, url: str, *, json: Any = None, headers: Any = None) -> FakeResponse: + recorded["url"] = url + recorded["json"] = json + return FakeResponse() + + # ``_send_to_response_url`` lazily imports httpx (optional dep, not + # installed in the test env) -- inject a stand-in module. + import sys + import types + + fake_httpx = types.ModuleType("httpx") + fake_httpx.AsyncClient = FakeAsyncClient # type: ignore[attr-defined] + original_module = sys.modules.get("httpx") + sys.modules["httpx"] = fake_httpx + try: + from chat_sdk.types import PostableMarkdown + + await adapter.edit_message( + "slack:C123:1234567890.000000", + ephemeral_id, + PostableMarkdown(markdown="**Updated** [text](https://example.com)\n\n| A | B |\n|---|---|\n| 1 | 2 |"), + ) + finally: + if original_module is not None: + sys.modules["httpx"] = original_module + else: + sys.modules.pop("httpx", None) + + assert recorded["url"] == "https://hooks.slack.com/respond" + body = recorded["json"] + assert body["replace_original"] is True + assert "*Updated* " in body["text"] + # Tables fall back to ASCII code blocks on this surface. + assert "```" in body["text"] + assert "markdown_text" not in body + # ============================================================================= # deleteMessage Tests @@ -670,6 +799,24 @@ async def test_handles_empty_message_ts_in_response(self): assert result.id == "" + @pytest.mark.asyncio + async def test_ephemeral_markdown_uses_markdown_text(self): + """chat.postEphemeral sends markdown via markdown_text too.""" + adapter, client, _ = await _init_adapter() + client.set_response("chat_postEphemeral", {"ok": True, "message_ts": "1234567890.777777"}) + + from chat_sdk.types import PostableMarkdown + + await adapter.post_ephemeral( + "slack:C123:1234567890.000000", + "U_USER_1", + PostableMarkdown(markdown="## Only for you"), + ) + + kwargs = client.get_calls("chat_postEphemeral")[0]["kwargs"] + assert kwargs["markdown_text"] == "## Only for you" + assert "text" not in kwargs + # ============================================================================= # scheduleMessage Tests @@ -722,6 +869,25 @@ async def test_cancel_scheduled_message(self): assert len(calls) == 1 assert calls[0]["kwargs"]["scheduled_message_id"] == "Q5678" + @pytest.mark.asyncio + async def test_schedule_markdown_uses_markdown_text(self): + """chat.scheduleMessage sends markdown via markdown_text too.""" + adapter, client, _ = await _init_adapter() + client.set_response("chat_scheduleMessage", {"ok": True, "scheduled_message_id": "Q9999"}) + + from chat_sdk.types import PostableMarkdown + + future_time = datetime.fromtimestamp(time.time() + 3600, tz=timezone.utc) + await adapter.schedule_message( + "slack:C123:1234567890.000000", + PostableMarkdown(markdown="**Reminder** tomorrow"), + future_time, + ) + + kwargs = client.get_calls("chat_scheduleMessage")[0]["kwargs"] + assert kwargs["markdown_text"] == "**Reminder** tomorrow" + assert "text" not in kwargs + @pytest.mark.asyncio async def test_rejects_past_time(self): adapter, client, _ = await _init_adapter() @@ -1316,6 +1482,23 @@ def test_renders_paragraph(self): ) assert "Hello world" in result + def test_renders_ast_to_standard_markdown(self): + """Slack now accepts markdown natively, so renderFormatted emits + standard markdown (``**bold**``), not legacy mrkdwn (``*bold*``).""" + adapter = _make_adapter() + result = adapter.render_formatted( + { + "type": "root", + "children": [ + { + "type": "paragraph", + "children": [{"type": "strong", "children": [{"type": "text", "value": "bold"}]}], + } + ], + } + ) + assert result.strip() == "**bold**" + # ============================================================================= # Link extraction edge cases @@ -1586,7 +1769,9 @@ async def text_gen() -> AsyncIterator[str | StreamChunk]: assert len(post_calls) == 1 assert post_calls[0]["kwargs"]["channel"] == "C123" assert post_calls[0]["kwargs"]["thread_ts"] is None - assert post_calls[0]["kwargs"]["text"] == "Hello from DM" + # Accumulated markdown goes out via Slack's native markdown_text field. + assert post_calls[0]["kwargs"]["markdown_text"] == "Hello from DM" + assert "text" not in post_calls[0]["kwargs"] assert result.id == "5555.5555" @pytest.mark.asyncio diff --git a/tests/test_slack_format.py b/tests/test_slack_format.py index 39dc26e..16a9490 100644 --- a/tests/test_slack_format.py +++ b/tests/test_slack_format.py @@ -1,6 +1,10 @@ -"""Tests for Slack markdown (mrkdwn) format conversion. +"""Tests for Slack format conversion. -Port of packages/adapter-slack/src/markdown.test.ts. +Port of packages/adapter-slack/src/markdown.test.ts (chat@4.29.0). + +Outgoing messages route through ``to_slack_payload`` (native ``markdown_text``) +or ``to_response_url_text`` (legacy mrkdwn -- response_url rejects +``markdown_text``). Incoming mrkdwn still parses via ``to_ast``. """ from __future__ import annotations @@ -8,166 +12,169 @@ from chat_sdk.adapters.slack.format_converter import SlackFormatConverter # --------------------------------------------------------------------------- -# fromMarkdown (markdown -> mrkdwn) +# toMarkdown (mrkdwn -> markdown) # --------------------------------------------------------------------------- -class TestFromMarkdown: +class TestToMarkdown: def setup_method(self): self.converter = SlackFormatConverter() def test_converts_bold(self): - assert self.converter.from_markdown("Hello **world**!") == "Hello *world*!" - - def test_converts_italic(self): - result = self.converter.from_markdown("Hello _world_!") - assert "_world_" in result + result = self.converter.to_markdown("Hello *world*!") + assert "**world**" in result def test_converts_strikethrough(self): - assert self.converter.from_markdown("Hello ~~world~~!") == "Hello ~world~!" + result = self.converter.to_markdown("Hello ~world~!") + assert "~~world~~" in result - def test_converts_links(self): - result = self.converter.from_markdown("Check [this](https://example.com)") - assert "" in result + def test_converts_links_with_text(self): + result = self.converter.to_markdown("Check ") + assert "[this](https://example.com)" in result - def test_preserves_inline_code(self): - result = self.converter.from_markdown("Use `const x = 1`") - assert "`const x = 1`" in result + def test_converts_bare_links(self): + result = self.converter.to_markdown("Visit ") + assert "https://example.com" in result - def test_handles_code_blocks(self): - result = self.converter.from_markdown("```js\nconst x = 1;\n```") - assert "```" in result - assert "const x = 1;" in result + def test_converts_user_mentions(self): + result = self.converter.to_markdown("Hey <@U123|john>!") + assert "@john" in result - def test_mixed_formatting(self): - result = self.converter.from_markdown("**Bold** and _italic_ and [link](https://x.com)") - assert "*Bold*" in result - assert "_italic_" in result - assert "" in result + def test_converts_channel_mentions(self): + result = self.converter.to_markdown("Join <#C123|general>") + assert "#general" in result + + def test_converts_bare_channel_mentions(self): + result = self.converter.to_markdown("Join <#C123>") + assert "#C123" in result # --------------------------------------------------------------------------- -# renderPostable — PostableMarkdown uses AST path (issue #81) +# toSlackPayload # --------------------------------------------------------------------------- -class TestRenderPostable: - """render_postable with PostableMarkdown must use the AST path (from_markdown), - not the regex _markdown_to_mrkdwn, to match the TS SDK's fromAst(parseMarkdown()) - behavior. Regression guard for issue #81. - """ - +class TestToSlackPayload: def setup_method(self): self.converter = SlackFormatConverter() - def test_postable_markdown_converts_link(self): - """[text](url) -> via AST, not regex.""" - from chat_sdk.types import PostableMarkdown - - result = self.converter.render_postable(PostableMarkdown(markdown="Check [this](https://example.com)")) - assert result == "Check " - - def test_dict_markdown_converts_link(self): - result = self.converter.render_postable({"markdown": "Check [this](https://example.com)"}) - assert result == "Check " - - def test_postable_markdown_converts_bold(self): - from chat_sdk.types import PostableMarkdown - - result = self.converter.render_postable(PostableMarkdown(markdown="Hello **world**!")) - assert result == "Hello *world*!" - - def test_postable_markdown_converts_mixed(self): - from chat_sdk.types import PostableMarkdown + def test_routes_plain_strings_to_text_preserving_literal_markdown_chars(self): + assert self.converter.to_slack_payload("Use *foo* literally") == {"text": "Use *foo* literally"} + + def test_routes_raw_strings_to_text(self): + assert self.converter.to_slack_payload({"raw": "*already mrkdwn*"}) == {"text": "*already mrkdwn*"} + + def test_routes_markdown_to_markdown_text(self): + assert self.converter.to_slack_payload({"markdown": "## Heading\n\n- a\n- b"}) == { + "markdown_text": "## Heading\n\n- a\n- b" + } + + def test_routes_ast_to_markdown_text_via_stringify_markdown(self): + ast = { + "type": "root", + "children": [ + { + "type": "paragraph", + "children": [{"type": "strong", "children": [{"type": "text", "value": "bold"}]}], + } + ], + } + result = self.converter.to_slack_payload({"ast": ast}) + assert "markdown_text" in result + assert "**bold**" in result["markdown_text"] + + def test_preserves_tables_when_rendering_ast_to_markdown_text(self): + ast = { + "type": "root", + "children": [ + { + "type": "table", + "align": [None, None], + "children": [ + { + "type": "tableRow", + "children": [ + {"type": "tableCell", "children": [{"type": "text", "value": "A"}]}, + {"type": "tableCell", "children": [{"type": "text", "value": "B"}]}, + ], + }, + { + "type": "tableRow", + "children": [ + {"type": "tableCell", "children": [{"type": "text", "value": "1"}]}, + {"type": "tableCell", "children": [{"type": "text", "value": "2"}]}, + ], + }, + ], + } + ], + } + result = self.converter.to_slack_payload({"ast": ast}) + assert "markdown_text" in result + lines = result["markdown_text"].strip().splitlines() + # Markdown pipe-table rows survive (not the legacy ASCII ``` fallback). + assert lines[0].startswith("|") + assert "A" in lines[0] and "B" in lines[0] + assert "1" in lines[2] and "2" in lines[2] + assert "```" not in result["markdown_text"] + + def test_postable_raw_dataclass_routes_to_text(self): + from chat_sdk.types import PostableRaw - result = self.converter.render_postable(PostableMarkdown(markdown="**Bold** and [link](https://x.com)")) - assert "*Bold*" in result - assert "" in result + assert self.converter.to_slack_payload(PostableRaw(raw="*already mrkdwn*")) == {"text": "*already mrkdwn*"} - def test_postable_markdown_link_with_query_string(self): - """URL with query params (no parens) converts correctly.""" + def test_postable_markdown_dataclass_routes_to_markdown_text(self): from chat_sdk.types import PostableMarkdown - result = self.converter.render_postable( - PostableMarkdown(markdown="See [results](https://example.com/search?q=foo&page=2)") - ) - assert "" in result - - def test_str_passthrough_only_converts_mentions(self): - """str input is treated as already-mrkdwn; only @mentions are wrapped.""" - result = self.converter.render_postable("Hello *world* and @george") - assert "*world*" in result - assert "<@george>" in result - - def test_postable_raw_bypasses_conversion(self): - """PostableRaw reaches Slack byte-for-byte (only mention wrapping).""" - from chat_sdk.types import PostableRaw - - result = self.converter.render_postable(PostableRaw(raw="Already *mrkdwn* text")) - assert result == "Already *mrkdwn* text" + result = self.converter.to_slack_payload(PostableMarkdown(markdown="**Bold** and [link](https://x.com)")) + assert result == {"markdown_text": "**Bold** and [link](https://x.com)"} - def test_dict_ast_converts_via_from_ast(self): - """{"ast": } is rendered via from_ast.""" + def test_postable_ast_dataclass_routes_to_markdown_text(self): from chat_sdk.shared.base_format_converter import parse_markdown + from chat_sdk.types import PostableAst - ast = parse_markdown("Hello **world**!") - result = self.converter.render_postable({"ast": ast}) - assert result == "Hello *world*!" - - def test_dict_card_uses_fallback_text(self): - """{"card": } extracts plain text via card_to_fallback_text.""" - card_payload = {"type": "card", "title": "My Card", "body": [{"type": "text", "text": "Card body"}]} - result = self.converter.render_postable({"card": card_payload}) - assert isinstance(result, str) - assert len(result) > 0 - - def test_object_with_card_attr_uses_fallback_text(self): - """Object with .card attribute extracts plain text via card_to_fallback_text.""" - - class FakeMessage: - card = {"type": "card", "title": "Attr Card", "body": [{"type": "text", "text": "body text"}]} + result = self.converter.to_slack_payload(PostableAst(ast=parse_markdown("Hello **world**!"))) + assert "markdown_text" in result + assert result["markdown_text"].strip() == "Hello **world**!" - result = self.converter.render_postable(FakeMessage()) - assert isinstance(result, str) - assert len(result) > 0 + def test_unrecognized_message_falls_back_to_empty_text(self): + assert self.converter.to_slack_payload({"something": "else"}) == {"text": ""} # --------------------------------------------------------------------------- -# toMarkdown (mrkdwn -> markdown) +# toResponseUrlText # --------------------------------------------------------------------------- -class TestToMarkdown: +class TestToResponseUrlText: def setup_method(self): self.converter = SlackFormatConverter() - def test_converts_bold(self): - result = self.converter.to_markdown("Hello *world*!") - assert "**world**" in result + def test_renders_markdown_to_slack_mrkdwn_text(self): + result = self.converter.to_response_url_text({"markdown": "**Bold** and [link](https://example.com)"}) + assert result == "*Bold* and " - def test_converts_strikethrough(self): - result = self.converter.to_markdown("Hello ~world~!") - assert "~~world~~" in result + def test_renders_markdown_tables_as_ascii_code_blocks(self): + result = self.converter.to_response_url_text({"markdown": "| A | B |\n|---|---|\n| 1 | 2 |"}) + assert "```\n" in result - def test_converts_links_with_text(self): - result = self.converter.to_markdown("Check ") - assert "[this](https://example.com)" in result + def test_plain_string_passes_through_with_mention_wrapping(self): + assert self.converter.to_response_url_text("Hey @george") == "Hey <@george>" - def test_converts_bare_links(self): - result = self.converter.to_markdown("Visit ") - assert "https://example.com" in result + def test_postable_markdown_dataclass_renders_to_mrkdwn(self): + from chat_sdk.types import PostableMarkdown - def test_converts_user_mentions(self): - result = self.converter.to_markdown("Hey <@U123|john>!") - assert "@john" in result + result = self.converter.to_response_url_text(PostableMarkdown(markdown="Hello **world**!")) + assert result == "Hello *world*!" - def test_converts_channel_mentions(self): - result = self.converter.to_markdown("Join <#C123|general>") - assert "#general" in result + def test_renders_nested_lists_with_slack_bullets(self): + """The mrkdwn list renderer (bullet + indent) is still live for the + response_url surface; markdown lists must not leak `-` markers.""" + result = self.converter.to_response_url_text({"markdown": "- parent\n - child 1\n - child 2"}) + assert result == "• parent\n • child 1\n • child 2" - def test_converts_bare_channel_mentions(self): - result = self.converter.to_markdown("Join <#C123>") - assert "#C123" in result + def test_unrecognized_message_falls_back_to_empty_string(self): + assert self.converter.to_response_url_text({"something": "else"}) == "" # --------------------------------------------------------------------------- @@ -179,42 +186,44 @@ class TestMentions: def setup_method(self): self.converter = SlackFormatConverter() - def test_no_double_wrap_existing_mentions(self): - result = self.converter.render_postable("Hey <@U12345>. Please select") - assert result == "Hey <@U12345>. Please select" + def test_no_double_wrap_existing_mentions_in_plain_strings(self): + assert self.converter.to_slack_payload("Hey <@U12345>. Please select") == { + "text": "Hey <@U12345>. Please select" + } - def test_no_double_wrap_mentions_in_markdown(self): - result = self.converter.render_postable({"markdown": "Hey <@U12345>. Please select"}) - assert result == "Hey <@U12345>. Please select" + def test_no_double_wrap_existing_mentions_in_markdown(self): + assert self.converter.to_slack_payload({"markdown": "Hey <@U12345>. Please select"}) == { + "markdown_text": "Hey <@U12345>. Please select" + } - def test_converts_bare_at_mentions(self): - result = self.converter.render_postable("Hey @george. Please select") - assert "<@george>" in result + def test_rewrites_bare_at_mentions_in_plain_strings(self): + assert self.converter.to_slack_payload("Hey @george. Please select") == {"text": "Hey <@george>. Please select"} - def test_converts_bare_mentions_in_markdown(self): - result = self.converter.render_postable({"markdown": "Hey @george. Please select"}) - assert "<@george>" in result + def test_rewrites_bare_at_mentions_in_markdown(self): + assert self.converter.to_slack_payload({"markdown": "Hey @george. Please select"}) == { + "markdown_text": "Hey <@george>. Please select" + } - def test_from_markdown_no_double_wrap(self): - result = self.converter.from_markdown("Hey <@U12345>") - assert result == "Hey <@U12345>" + def test_does_not_mangle_email_addresses_in_plain_strings(self): + assert self.converter.to_slack_payload("Contact user@example.com for help") == { + "text": "Contact user@example.com for help" + } - def test_does_not_wrap_at_in_email_localpart(self): - """`@` preceded by a word char is part of an email address, not a mention. + def test_does_not_mangle_email_addresses_in_markdown(self): + """`@` preceded by a word char is part of an email address, not a + mention -- and markdown now passes through unparsed, so the address + is not rewritten to a mailto autolink either.""" + assert self.converter.to_slack_payload({"markdown": "Contact alice@example.com"}) == { + "markdown_text": "Contact alice@example.com" + } - Without the lookbehind tightening, `alice@example.com` was rewritten to - `alice<@example>.com`, surfacing a broken-looking mention in messages - that quote email addresses from upstream APIs. - """ - result = self.converter.render_postable("Contact alice@example.com or bob@example.org") - assert "<@example>" not in result - assert "alice@example.com" in result - assert "bob@example.org" in result + def test_does_not_mangle_mailto_links(self): + assert self.converter.to_slack_payload("Email ") == { + "text": "Email " + } - def test_does_not_wrap_at_in_email_localpart_from_markdown_ast(self): - result = self.converter.render_postable({"markdown": "Contact alice@example.com"}) - assert "<@example>" not in result - assert "alice@example.com" in result + def test_converts_mentions_adjacent_to_non_word_punctuation(self): + assert self.converter.to_slack_payload("(cc @george, @anne)") == {"text": "(cc <@george>, <@anne>)"} # --------------------------------------------------------------------------- @@ -250,159 +259,45 @@ def test_handles_complex_messages(self): assert "<" not in result -# --------------------------------------------------------------------------- -# Table rendering -# --------------------------------------------------------------------------- - - -class TestTableRendering: +class TestExtractPlainTextAdditional: def setup_method(self): self.converter = SlackFormatConverter() - def test_renders_markdown_tables_as_code_blocks(self): - result = self.converter.from_markdown("| Name | Age |\n|------|-----|\n| Alice | 30 |") - assert "```" in result - assert "Name" in result - assert "Age" in result - assert "Alice" in result - assert "30" in result - - def test_preserves_table_structure_in_code_block(self): - result = self.converter.from_markdown("| A | B |\n|---|---|\n| 1 | 2 |") - assert result.startswith("```\n") - assert result.endswith("\n```") - + def test_removes_strikethrough_markers(self): + assert self.converter.extract_plain_text("Hello ~world~!") == "Hello world!" -# --------------------------------------------------------------------------- -# toBlocksWithTable -# --------------------------------------------------------------------------- + def test_extracts_bare_url(self): + assert self.converter.extract_plain_text("Visit ") == "Visit https://example.com" + def test_extracts_channel_mention_with_name(self): + assert self.converter.extract_plain_text("Join <#C123|general>") == "Join #general" -class TestToBlocksWithTable: - def setup_method(self): - self.converter = SlackFormatConverter() + def test_extracts_bare_channel_mention(self): + assert self.converter.extract_plain_text("Join <#C123>") == "Join #C123" - def test_returns_none_when_no_tables(self): - ast = self.converter.to_ast("Hello world") - assert self.converter.to_blocks_with_table(ast) is None - - def test_returns_native_table_block(self): - ast = self.converter.to_ast("| Name | Age |\n|------|-----|\n| Alice | 30 |") - blocks = self.converter.to_blocks_with_table(ast) - assert blocks is not None - assert len(blocks) == 1 - assert blocks[0]["type"] == "table" - assert blocks[0]["rows"] == [ - [{"type": "raw_text", "text": "Name"}, {"type": "raw_text", "text": "Age"}], - [{"type": "raw_text", "text": "Alice"}, {"type": "raw_text", "text": "30"}], - ] - - def test_includes_surrounding_text(self): - md = "Here are the results:\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\nAll done." - ast = self.converter.to_ast(md) - blocks = self.converter.to_blocks_with_table(ast) - assert blocks is not None - assert len(blocks) == 3 - assert blocks[0]["type"] == "section" - assert "Here are the results" in blocks[0]["text"]["text"] - assert blocks[1]["type"] == "table" - assert blocks[2]["type"] == "section" - assert "All done" in blocks[2]["text"]["text"] - - def test_second_table_falls_back_to_ascii(self): - md = "| A | B |\n|---|---|\n| 1 | 2 |\n\n| C | D |\n|---|---|\n| 3 | 4 |" - ast = self.converter.to_ast(md) - blocks = self.converter.to_blocks_with_table(ast) - assert blocks is not None - assert len(blocks) == 2 - assert blocks[0]["type"] == "table" - assert blocks[1]["type"] == "section" - assert "```" in blocks[1]["text"]["text"] - - def test_should_replace_empty_table_cells_with_a_space_to_satisfy_slack_api(self): - ast = self.converter.to_ast("| Kind | Label |\n|------|-------|\n| FORM | Form Submission |\n| and more... | |") - blocks = self.converter.to_blocks_with_table(ast) - assert blocks is not None - table_block = blocks[0] - assert table_block["type"] == "table" - for row in table_block["rows"]: - for cell in row: - assert len(cell["text"]) > 0 - assert table_block["rows"][2][1]["text"] == " " - - def test_should_handle_empty_header_cells_with_parse_markdown_production_path(self): - from chat_sdk.shared.markdown_parser import parse_markdown - - markdown = "Here is a table:\n\n| | Header2 |\n|---------|----------|\n| Data1 | Data2 |" - ast = parse_markdown(markdown) - blocks = self.converter.to_blocks_with_table(ast) - assert blocks is not None - assert len(blocks) == 2 - assert blocks[0]["type"] == "section" - assert blocks[1]["type"] == "table" - table_block = blocks[1] - assert table_block["rows"][0][0]["text"] == " " - for row in table_block["rows"]: - for cell in row: - assert len(cell["text"]) > 0 + def test_user_mention_with_name_extracted(self): + result = self.converter.extract_plain_text("Hey <@U123|john>!") + assert result == "Hey @john!" # --------------------------------------------------------------------------- -# Nested lists +# render_postable -- base-class behavior after the Slack override was removed # --------------------------------------------------------------------------- -class TestNestedLists: +class TestRenderPostableFallbacks: def setup_method(self): self.converter = SlackFormatConverter() - def test_indent_nested_unordered_lists(self): - result = self.converter.from_markdown("- parent\n - child 1\n - child 2") - assert "\u2022 parent" in result - assert " \u2022 child 1" in result - assert " \u2022 child 2" in result - - def test_indent_nested_ordered_lists(self): - result = self.converter.from_markdown("1. first\n 1. sub-first\n 2. sub-second\n2. second") - assert "1. first" in result - assert "sub-first" in result - assert "sub-second" in result - assert "2. second" in result - - def test_deeply_nested_lists(self): - result = self.converter.from_markdown("- level 1\n - level 2\n - level 3") - assert "\u2022 level 1" in result - assert "\u2022 level 2" in result - assert "\u2022 level 3" in result - - def test_sibling_items_same_indent(self): - result = self.converter.from_markdown("- item 1\n- item 2\n- item 3") - assert result == "\u2022 item 1\n\u2022 item 2\n\u2022 item 3" - - def test_mixed_ordered_and_unordered(self): - result = self.converter.from_markdown("1. first\n - sub a\n - sub b\n2. second") - assert "1. first" in result - assert "sub a" in result - assert "sub b" in result - assert "2. second" in result - - -# --------------------------------------------------------------------------- -# render_postable — remaining branch coverage -# --------------------------------------------------------------------------- - - -class TestRenderPostableRemainingBranches: - def setup_method(self): - self.converter = SlackFormatConverter() + def test_markdown_renders_to_standard_markdown(self): + """render_postable now goes through from_ast = stringify_markdown.""" + from chat_sdk.types import PostableMarkdown - def test_dict_raw_treated_as_mrkdwn_with_mention_wrapping(self): - """{"raw": ...} is treated as already-mrkdwn; only @mentions are wrapped.""" - result = self.converter.render_postable({"raw": "Already *mrkdwn* @george"}) - assert result == "Already *mrkdwn* <@george>" + result = self.converter.render_postable(PostableMarkdown(markdown="Hello **world**!")) + assert result.strip() == "Hello **world**!" def test_card_element_dict_renders_via_fallback_text(self): - """{"type": "card", ...} CardElement dict uses card_to_fallback_text.""" + """CardElement dicts still render via card_to_fallback_text.""" from chat_sdk.cards import Card card = Card(title="My Card") @@ -410,14 +305,14 @@ def test_card_element_dict_renders_via_fallback_text(self): assert "My Card" in result def test_object_with_ast_attr_renders_via_from_ast(self): - """Object with .ast attribute is rendered via from_ast.""" + """Object with .ast attribute is rendered to standard markdown.""" from chat_sdk.shared.base_format_converter import parse_markdown class FakeMsg: ast = parse_markdown("Hello **world**!") result = self.converter.render_postable(FakeMsg()) - assert result == "Hello *world*!" + assert result.strip() == "Hello **world**!" def test_arbitrary_object_falls_back_to_str(self): """Objects with no recognized attributes fall back to str().""" @@ -429,99 +324,33 @@ def __str__(self): result = self.converter.render_postable(Opaque()) assert result == "opaque output" - def test_multiple_at_mentions_in_str_all_wrapped(self): - """All bare @mentions in a str input are converted, not just the first.""" - result = self.converter.render_postable("Ping @alice and @bob please") - assert "<@alice>" in result - assert "<@bob>" in result - # --------------------------------------------------------------------------- -# _node_to_mrkdwn — individual node type rendering +# _node_to_mrkdwn -- node rendering on the response_url surface # --------------------------------------------------------------------------- -class TestNodeRendering: +class TestResponseUrlNodeRendering: def setup_method(self): self.converter = SlackFormatConverter() def test_heading_renders_as_bold(self): - assert self.converter.from_markdown("# My Heading") == "*My Heading*" - - def test_h2_heading_renders_as_bold(self): - assert self.converter.from_markdown("## Section Title") == "*Section Title*" + assert self.converter.to_response_url_text({"markdown": "# My Heading"}) == "*My Heading*" def test_blockquote_renders_with_gt_prefix(self): - result = self.converter.from_markdown("> quoted text") + result = self.converter.to_response_url_text({"markdown": "> quoted text"}) assert result == "> quoted text" def test_thematic_break_renders_as_dashes(self): - result = self.converter.from_markdown("before\n\n---\n\nafter") + result = self.converter.to_response_url_text({"markdown": "before\n\n---\n\nafter"}) assert "---" in result assert "before" in result assert "after" in result def test_image_with_alt_renders_alt_and_url(self): - result = self.converter.from_markdown("![alt text](https://example.com/img.png)") + result = self.converter.to_response_url_text({"markdown": "![alt text](https://example.com/img.png)"}) assert result == "alt text (https://example.com/img.png)" def test_image_without_alt_renders_url_only(self): - result = self.converter.from_markdown("![](https://example.com/img.png)") + result = self.converter.to_response_url_text({"markdown": "![](https://example.com/img.png)"}) assert result == "https://example.com/img.png" - - -# --------------------------------------------------------------------------- -# extract_plain_text — additional cases -# --------------------------------------------------------------------------- - - -class TestExtractPlainTextAdditional: - def setup_method(self): - self.converter = SlackFormatConverter() - - def test_removes_strikethrough_markers(self): - assert self.converter.extract_plain_text("Hello ~world~!") == "Hello world!" - - def test_extracts_bare_url(self): - assert self.converter.extract_plain_text("Visit ") == "Visit https://example.com" - - def test_extracts_channel_mention_with_name(self): - assert self.converter.extract_plain_text("Join <#C123|general>") == "Join #general" - - def test_extracts_bare_channel_mention(self): - assert self.converter.extract_plain_text("Join <#C123>") == "Join #C123" - - def test_user_mention_with_name_extracted(self): - result = self.converter.extract_plain_text("Hey <@U123|john>!") - assert result == "Hey @john!" - - -# --------------------------------------------------------------------------- -# to_blocks_with_table — additional cases -# --------------------------------------------------------------------------- - - -class TestToBlocksWithTableAdditional: - def setup_method(self): - self.converter = SlackFormatConverter() - - def test_returns_none_for_non_dict_ast(self): - assert self.converter.to_blocks_with_table("not a dict") is None # type: ignore[arg-type] - assert self.converter.to_blocks_with_table(None) is None # type: ignore[arg-type] - - def test_standalone_table_emits_no_extra_section_blocks(self): - """A table with no surrounding text produces exactly one block.""" - ast = self.converter.to_ast("| A | B |\n|---|---|\n| 1 | 2 |") - blocks = self.converter.to_blocks_with_table(ast) - assert blocks is not None - assert len(blocks) == 1 - assert blocks[0]["type"] == "table" - - def test_table_with_column_alignment_sets_column_settings(self): - """Aligned table columns produce column_settings on the table block.""" - md = "| Left | Center | Right |\n|:-----|:------:|------:|\n| a | b | c |" - ast = self.converter.to_ast(md) - blocks = self.converter.to_blocks_with_table(ast) - assert blocks is not None - settings = blocks[0].get("column_settings") - assert settings == [{"align": "left"}, {"align": "center"}, {"align": "right"}] From bb2e81b6927b6f7dfbdb1df52c0a13657bad08c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 21:00:04 +0000 Subject: [PATCH 06/14] feat(slack): external installation provider for bot token management (vercel/chat#467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of upstream c46fdb6. Adds SlackAdapterConfig.installation_provider (SlackInstallationProvider protocol) for multi-workspace apps using external token management (e.g. Vercel Connect). When set, the adapter bypasses internal StateAdapter storage for token lookups on incoming webhooks — the provider is authoritative (no state fallback) and read-only (set_installation / delete_installation / OAuth callback still write to internal state). Enterprise Grid support rides along: org-wide installs (is_enterprise_install) resolve by enterprise_id instead of team_id across event_callback, slash command, and interactive payload entry paths; RequestContext carries enterprise_id/is_enterprise_install; attachment fetch_metadata captures enterpriseId/isEnterpriseInstall (omitted when absent) and rehydrate_attachment routes through _resolve_token_for_team so the provider is honored after a JSON roundtrip. Composes with the existing bot_token resolver design (see docs/UPSTREAM_SYNC.md non-parity rows): a configured default bot_token (static or resolver) still selects single-workspace mode and bypasses per-installation resolution entirely; _get_token / client caches are untouched. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 (cherry picked from commit ee1e30703514d8a871d7f64c0ccc17c0774a7d6d) --- src/chat_sdk/adapters/slack/adapter.py | 186 ++++++-- src/chat_sdk/adapters/slack/types.py | 45 +- tests/test_slack_installation_provider.py | 535 ++++++++++++++++++++++ 3 files changed, 731 insertions(+), 35 deletions(-) create mode 100644 tests/test_slack_installation_provider.py diff --git a/src/chat_sdk/adapters/slack/adapter.py b/src/chat_sdk/adapters/slack/adapter.py index 6b2c9b5..56382aa 100644 --- a/src/chat_sdk/adapters/slack/adapter.py +++ b/src/chat_sdk/adapters/slack/adapter.py @@ -22,6 +22,7 @@ from collections import OrderedDict from collections.abc import AsyncIterable, Awaitable, Callable from contextvars import ContextVar +from dataclasses import dataclass, replace from datetime import datetime, timezone from typing import Any, NoReturn, TypedDict, cast from urllib.parse import parse_qs, urlparse @@ -52,6 +53,7 @@ SlackBotToken, SlackBotTokenResolver, SlackInstallation, + SlackInstallationProvider, SlackThreadId, SlackWebhookVerifier, ) @@ -207,6 +209,19 @@ def _make_slack_lookup_failed(user_id: str) -> SlackUserCacheEntry: # --------------------------------------------------------------------------- +@dataclass +class _InstallationInfo: + """Installation identity extracted from an interactive payload. + + ``installation_id`` is the team ID -- or the enterprise ID for + Enterprise Grid org-wide installs (``is_enterprise_install``). + """ + + installation_id: str + is_enterprise_install: bool + enterprise_id: str | None = None + + def _find_next_mention(text: str) -> int: """Find the next ``<@`` or ``<#`` mention in *text*.""" at_idx = text.find("<@") @@ -487,6 +502,9 @@ def __init__(self, config: SlackAdapterConfig | None = None) -> None: else: self._client_secret = None self._installation_key_prefix = config.installation_key_prefix or "slack:installation" + # External installation provider (e.g. Vercel Connect). When set, + # per-installation token lookups bypass internal StateAdapter storage. + self._installation_provider: SlackInstallationProvider | None = config.installation_provider # ``is not None`` (not truthiness) so an explicit ``encryption_key=""`` # is treated as "user explicitly opted out" and is NOT silently @@ -973,30 +991,79 @@ async def with_bot_token_async(self, token: str, fn: Callable[[], Awaitable[Any] # Private helpers - token resolution # ================================================================== - async def _resolve_token_for_team(self, team_id: str) -> RequestContext | None: - """Resolve the bot token for a team from the state adapter.""" + async def _resolve_token_for_team( + self, installation_id: str, is_enterprise_install: bool = False + ) -> RequestContext | None: + """Resolve the bot token for an installation. + + Checks the external installation provider first (e.g. Vercel + Connect); when no provider is configured, falls back to the + internal state adapter. + + ``installation_id`` is the ``team_id`` -- or the ``enterprise_id`` + for Enterprise Grid org-wide installs (``is_enterprise_install``). + """ try: - installation = await self.get_installation(team_id) + # Check external installation provider first (e.g. Vercel Connect) + if self._installation_provider is not None: + installation = await self._installation_provider.get_installation( + installation_id, is_enterprise_install + ) + if installation: + return RequestContext( + token=installation.bot_token, + bot_user_id=installation.bot_user_id, + ) + self._logger.warn( + "No installation found from provider", + {"installationId": installation_id, "isEnterpriseInstall": is_enterprise_install}, + ) + return None + # Fall back to internal state adapter + installation = await self.get_installation(installation_id) if installation: return RequestContext( token=installation.bot_token, bot_user_id=installation.bot_user_id, ) - self._logger.warn("No installation found for team", {"teamId": team_id}) + self._logger.warn( + "No installation found for team", + {"installationId": installation_id, "isEnterpriseInstall": is_enterprise_install}, + ) return None except Exception as exc: - self._logger.error("Failed to resolve token for team", {"teamId": team_id, "error": exc}) + self._logger.error( + "Failed to resolve token for team", + {"installationId": installation_id, "isEnterpriseInstall": is_enterprise_install, "error": exc}, + ) return None - def _extract_team_id_from_interactive(self, body: str) -> str | None: - """Extract team_id from an interactive payload (form-urlencoded).""" + def _extract_installation_from_interactive(self, body: str) -> _InstallationInfo | None: + """Extract installation info from an interactive payload (form-urlencoded). + + For Enterprise Grid org-wide installs, the installation ID is the + enterprise ID; otherwise it is the team ID. + """ try: params = parse_qs(body) payload_str = params.get("payload", [None])[0] if not payload_str: return None payload = json.loads(payload_str) - return payload.get("team", {}).get("id") or payload.get("team_id") + is_enterprise_install = bool(payload.get("is_enterprise_install")) + enterprise = payload.get("enterprise") or {} + enterprise_id = enterprise.get("id") or payload.get("enterprise_id") or None + team = payload.get("team") or {} + team_id = team.get("id") or payload.get("team_id") or None + installation_id = enterprise_id if is_enterprise_install else team_id + + if not installation_id: + return None + return _InstallationInfo( + installation_id=installation_id, + is_enterprise_install=is_enterprise_install, + enterprise_id=enterprise_id, + ) except Exception: return None @@ -1359,24 +1426,47 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No # Slash command if "command" in params and "payload" not in params: - team_id = (params.get("team_id") or [None])[0] - if not self._is_single_workspace and team_id: - ctx = await self._resolve_token_for_team(team_id) - if ctx: - tok = self._request_context.set(ctx) - try: - return await self._handle_slash_command(params, options) - finally: - self._request_context.reset(tok) - self._logger.warn("Could not resolve token for slash command") + if not self._is_single_workspace: + # For Enterprise Grid org-wide installs, use enterprise_id; + # otherwise use team_id. + is_enterprise_install = (params.get("is_enterprise_install") or [None])[0] == "true" + enterprise_id = (params.get("enterprise_id") or [None])[0] + team_id = (params.get("team_id") or [None])[0] + installation_id = enterprise_id if is_enterprise_install else team_id + + if installation_id: + ctx = await self._resolve_token_for_team(installation_id, is_enterprise_install) + if ctx: + ctx = replace( + ctx, + enterprise_id=enterprise_id, + is_enterprise_install=is_enterprise_install, + ) + tok = self._request_context.set(ctx) + try: + return await self._handle_slash_command(params, options) + finally: + self._request_context.reset(tok) + self._logger.warn( + "Could not resolve token for slash command", + {"installationId": installation_id, "isEnterpriseInstall": is_enterprise_install}, + ) return await self._handle_slash_command(params, options) # Interactive payload if not self._is_single_workspace: - team_id_interactive = self._extract_team_id_from_interactive(body) - if team_id_interactive: - ctx = await self._resolve_token_for_team(team_id_interactive) + installation_info = self._extract_installation_from_interactive(body) + if installation_info: + ctx = await self._resolve_token_for_team( + installation_info.installation_id, + installation_info.is_enterprise_install, + ) if ctx: + ctx = replace( + ctx, + enterprise_id=installation_info.enterprise_id, + is_enterprise_install=installation_info.is_enterprise_install, + ) tok = self._request_context.set(ctx) try: return await self._handle_interactive_payload(body, options) @@ -1406,15 +1496,27 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No # isolated -- the ContextVar change does not leak back to the caller # and does not need an explicit reset. if not self._is_single_workspace and payload.get("type") == "event_callback": - team_id_event = payload.get("team_id") - if team_id_event: - ctx = await self._resolve_token_for_team(team_id_event) + # For Enterprise Grid org-wide installs, use enterprise_id; + # otherwise use team_id. + is_enterprise_install = bool(payload.get("is_enterprise_install")) + installation_id = payload.get("enterprise_id") if is_enterprise_install else payload.get("team_id") + + if installation_id: + ctx = await self._resolve_token_for_team(installation_id, is_enterprise_install) if ctx: + ctx = replace( + ctx, + enterprise_id=payload.get("enterprise_id"), + is_enterprise_install=is_enterprise_install, + ) isolated = contextvars.copy_context() isolated.run(self._request_context.set, ctx) isolated.run(self._process_event_payload, payload, options) return {"body": "ok", "status": 200} - self._logger.warn("Could not resolve token for team", {"teamId": team_id_event}) + self._logger.warn( + "Could not resolve token for installation", + {"installationId": installation_id, "isEnterpriseInstall": is_enterprise_install}, + ) return {"body": "ok", "status": 200} # Single-workspace mode or fallback @@ -3201,13 +3303,17 @@ def _create_attachment(self, file: dict[str, Any], team_id: str | None = None) - the queue/debounce path JSON-serializes the message. """ url = file.get("url_private") - # Capture per-request token from the active webhook context so - # ``fetch_data`` can run later without being inside the ContextVar - # frame (e.g. after the message has been queued + rehydrated). + # Capture per-request context (token + Enterprise Grid info) from the + # active webhook context so ``fetch_data`` can run later without being + # inside the ContextVar frame (e.g. after the message has been queued + # + rehydrated), and ``rehydrate_attachment`` can resolve tokens + # through the same lookup logic on a different process invocation. # For single-workspace mode the default provider is re-resolved at # fetch time so dynamic ``bot_token`` resolvers honor rotation. ctx = self._request_context.get() ctx_token: str | None = ctx.token if ctx and ctx.token else None + ctx_enterprise_id: str | None = ctx.enterprise_id if ctx else None + ctx_is_enterprise_install: bool = bool(ctx.is_enterprise_install) if ctx else False mimetype = file.get("mimetype", "") att_type: str = "file" @@ -3227,6 +3333,12 @@ async def fetch_data() -> bytes: fetch_meta["url"] = url if team_id: fetch_meta["teamId"] = team_id + # Omit the Enterprise Grid keys entirely when absent (hazard #7: + # omitted keys, not serialized ``None``/false values). + if ctx_enterprise_id: + fetch_meta["enterpriseId"] = ctx_enterprise_id + if ctx_is_enterprise_install: + fetch_meta["isEnterpriseInstall"] = "true" return Attachment( type=att_type, # type: ignore[arg-type] @@ -3312,20 +3424,28 @@ def rehydrate_attachment(self, attachment: Attachment) -> Attachment: meta_url = meta.get("url") url = meta_url if meta_url is not None else attachment.url team_id = meta.get("teamId") + enterprise_id = meta.get("enterpriseId") + is_enterprise_install = meta.get("isEnterpriseInstall") == "true" if not url: return attachment adapter = self async def fetch_data() -> bytes: - if team_id: - installation = await adapter.get_installation(team_id) - if installation is None: + installation_id = enterprise_id if is_enterprise_install else team_id + if installation_id: + # Route through ``_resolve_token_for_team`` so + # ``installation_provider`` (when configured) is honored -- + # otherwise this falls back to internal state via + # ``get_installation``, matching the prior behavior. + ctx = await adapter._resolve_token_for_team(installation_id, is_enterprise_install) + if ctx is None: raise AuthenticationError( "slack", - f"Installation not found for team {team_id}", + f"Installation not found for " + f"{'enterprise' if is_enterprise_install else 'team'} {installation_id}", ) - token = installation.bot_token + token = ctx.token else: # Use the async resolver so a dynamic ``bot_token`` provider # is invoked at fetch time (rotation-safe). diff --git a/src/chat_sdk/adapters/slack/types.py b/src/chat_sdk/adapters/slack/types.py index 42c2a35..5176256 100644 --- a/src/chat_sdk/adapters/slack/types.py +++ b/src/chat_sdk/adapters/slack/types.py @@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Any, Literal, TypeAlias, TypedDict +from typing import Any, Literal, Protocol, TypeAlias, TypedDict from chat_sdk.logger import Logger @@ -75,8 +75,21 @@ class SlackAdapterConfig: # If provided, bot tokens stored via set_installation() will be encrypted at rest. encryption_key: str | None = None # Prefix for the state key used to store workspace installations. - # Defaults to ``slack:installation``. The full key will be ``{prefix}:{team_id}``. + # Defaults to ``slack:installation``. The full key will be ``{prefix}:{team_id}`` + # (or ``{prefix}:{enterprise_id}`` for Enterprise Grid org-wide installs). installation_key_prefix: str = "slack:installation" + # External installation provider for multi-workspace apps using external + # token management (e.g. Vercel Connect). When set, the adapter bypasses + # internal StateAdapter storage for token lookups on incoming webhooks. + # + # For Enterprise Grid org-wide installs, ``installation_id`` will be the + # enterprise ID; otherwise it will be the team ID. + # + # Precedence: a configured default ``bot_token`` (single-workspace mode, + # static or resolver) still wins — per-installation resolution (and thus + # this provider) only runs in multi-workspace mode. See the resolver rows + # in docs/UPSTREAM_SYNC.md. + installation_provider: SlackInstallationProvider | None = None # Logger instance for error reporting. Defaults to ConsoleLogger. logger: Logger | None = None # Connection mode: ``"webhook"`` (default) or ``"socket"``. When set to @@ -128,6 +141,26 @@ class SlackInstallation: team_name: str | None = None +class SlackInstallationProvider(Protocol): + """External installation provider for multi-workspace token management. + + Implementations resolve a :class:`SlackInstallation` from an external + system (e.g. Vercel Connect) instead of the adapter's internal + StateAdapter storage. ``installation_id`` is the ``enterprise_id`` for + Enterprise Grid org-wide installs (``is_enterprise_install=True``), + otherwise the ``team_id``. Return ``None`` when no installation exists. + + The provider is read-only: ``set_installation`` / ``delete_installation`` + / ``handle_oauth_callback`` continue to write to the internal state + adapter, so callers using a provider should manage their own writes + through their external system. + """ + + def get_installation( + self, installation_id: str, is_enterprise_install: bool + ) -> Awaitable[SlackInstallation | None]: ... + + # ============================================================================= # Thread ID # ============================================================================= @@ -321,9 +354,13 @@ class SlackWebhookPayload(TypedDict, total=False): """Slack webhook payload envelope.""" challenge: str + # Enterprise ID for Enterprise Grid org-wide installs + enterprise_id: str event: Any # SlackEventUnion event_id: str event_time: int + # Whether this is an Enterprise Grid org-wide install + is_enterprise_install: bool # Whether this event occurred in an externally shared channel (Slack Connect) is_ext_shared_channel: bool team_id: str @@ -465,3 +502,7 @@ class RequestContext: token: str bot_user_id: str | None = None is_ext_shared_channel: bool | None = None + # Enterprise ID for Enterprise Grid org-wide installs + enterprise_id: str | None = None + # Whether this request came from an Enterprise Grid org-wide install + is_enterprise_install: bool | None = None diff --git a/tests/test_slack_installation_provider.py b/tests/test_slack_installation_provider.py new file mode 100644 index 0000000..1f97ebf --- /dev/null +++ b/tests/test_slack_installation_provider.py @@ -0,0 +1,535 @@ +"""Tests for the Slack external installation provider (vercel/chat#467). + +Port of the ``describe("installationProvider")`` block from +packages/adapter-slack/src/index.test.ts (chat@4.29.0). + +When ``installation_provider`` is configured, per-installation token lookups +bypass the internal StateAdapter. For Enterprise Grid org-wide installs +(``is_enterprise_install``), the lookup key is the enterprise ID instead of +the team ID. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import json +import time +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +try: + from chat_sdk.adapters.slack.adapter import SlackAdapter + from chat_sdk.adapters.slack.types import SlackAdapterConfig, SlackInstallation + from chat_sdk.shared.errors import AuthenticationError + from chat_sdk.types import Attachment + + _SLACK_AVAILABLE = True +except ImportError: + _SLACK_AVAILABLE = False + +pytestmark = pytest.mark.skipif(not _SLACK_AVAILABLE, reason="Slack adapter import failed") + +SECRET = "test-signing-secret" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class _FakeRequest: + def __init__(self, body: str, headers: dict[str, str]): + self.body = body.encode("utf-8") + self.headers = headers + self.url = "" + + async def text(self) -> str: + return self.body.decode("utf-8") + + +def _make_signed_request(body: str, content_type: str = "application/json") -> _FakeRequest: + ts = str(int(time.time())) + sig_base = f"v0:{ts}:{body}" + sig = "v0=" + hmac.new(SECRET.encode(), sig_base.encode(), hashlib.sha256).hexdigest() + return _FakeRequest( + body, + { + "x-slack-request-timestamp": ts, + "x-slack-signature": sig, + "content-type": content_type, + }, + ) + + +def _make_mock_state() -> MagicMock: + cache: dict[str, Any] = {} + state = MagicMock() + state.get = AsyncMock(side_effect=lambda k: cache.get(k)) + state.set = AsyncMock(side_effect=lambda k, v, *a, **kw: cache.__setitem__(k, v)) + state.delete = AsyncMock(side_effect=lambda k: cache.pop(k, None)) + state.append_to_list = AsyncMock() + state.get_list = AsyncMock(return_value=[]) + state._cache = cache + return state + + +def _make_mock_chat(state: MagicMock) -> MagicMock: + chat = MagicMock() + chat.process_message = MagicMock() + chat.handle_incoming_message = AsyncMock() + chat.process_reaction = MagicMock() + chat.process_action = MagicMock() + chat.process_modal_submit = AsyncMock() + chat.process_modal_close = MagicMock() + chat.process_slash_command = MagicMock() + chat.process_member_joined_channel = MagicMock() + chat.get_state = MagicMock(return_value=state) + chat.get_user_name = MagicMock(return_value="test-bot") + chat.get_logger = MagicMock(return_value=MagicMock()) + return chat + + +def _make_provider(installation: SlackInstallation | None) -> MagicMock: + provider = MagicMock() + provider.get_installation = AsyncMock(return_value=installation) + return provider + + +async def _make_provider_adapter( + provider: MagicMock, +) -> tuple[SlackAdapter, MagicMock, MagicMock]: + """Multi-workspace adapter (no bot_token) with an installation provider.""" + state = _make_mock_state() + chat = _make_mock_chat(state) + adapter = SlackAdapter(SlackAdapterConfig(signing_secret=SECRET, installation_provider=provider)) + await adapter.initialize(chat) + return adapter, chat, state + + +def _event_body(**envelope: Any) -> str: + body = { + "type": "event_callback", + "event": { + "type": "message", + "channel": "C_TEST", + "user": "U_USER", + "username": "testuser", + "text": "hello", + "ts": "1234567890.123456", + }, + } + body.update(envelope) + return json.dumps(body) + + +def _slash_body(params: dict[str, str]) -> str: + from urllib.parse import urlencode + + return urlencode(params) + + +def _interactive_body(payload: dict[str, Any]) -> str: + from urllib.parse import quote + + return f"payload={quote(json.dumps(payload))}" + + +def _block_actions_payload(**extra: Any) -> dict[str, Any]: + payload = { + "type": "block_actions", + "user": {"id": "U123", "username": "testuser", "name": "Test User"}, + "container": { + "type": "message", + "message_ts": "1234567890.123456", + "channel_id": "C_INTER", + }, + "channel": {"id": "C_INTER", "name": "general"}, + "message": {"ts": "1234567890.123456"}, + "actions": [{"type": "button", "action_id": "test_action", "value": "v"}], + } + payload.update(extra) + return payload + + +# --------------------------------------------------------------------------- +# event_callback resolution +# --------------------------------------------------------------------------- + + +class TestInstallationProviderEvents: + @pytest.mark.asyncio + async def test_uses_provider_for_token_resolution_in_event_callback(self): + provider = _make_provider(SlackInstallation(bot_token="xoxb-external-token", bot_user_id="U_BOT_EXT")) + adapter, _, _ = await _make_provider_adapter(provider) + + req = _make_signed_request(_event_body(team_id="T_EXTERNAL_1")) + response = await adapter.handle_webhook(req) + + assert response["status"] == 200 + provider.get_installation.assert_called_once_with("T_EXTERNAL_1", False) + + @pytest.mark.asyncio + async def test_uses_enterprise_id_when_is_enterprise_install_true(self): + provider = _make_provider(SlackInstallation(bot_token="xoxb-enterprise-token", bot_user_id="U_BOT_ENT")) + adapter, _, _ = await _make_provider_adapter(provider) + + req = _make_signed_request( + _event_body( + team_id="T_WORKSPACE_1", + enterprise_id="E_ENTERPRISE_1", + is_enterprise_install=True, + ) + ) + response = await adapter.handle_webhook(req) + + assert response["status"] == 200 + provider.get_installation.assert_called_once_with("E_ENTERPRISE_1", True) + + @pytest.mark.asyncio + async def test_uses_team_id_when_is_enterprise_install_false(self): + provider = _make_provider(SlackInstallation(bot_token="xoxb-team-token", bot_user_id="U_BOT_TEAM")) + adapter, _, _ = await _make_provider_adapter(provider) + + req = _make_signed_request( + _event_body( + team_id="T_TEAM_ONLY", + enterprise_id="E_SHOULD_IGNORE", + is_enterprise_install=False, + ) + ) + await adapter.handle_webhook(req) + + provider.get_installation.assert_called_once_with("T_TEAM_ONLY", False) + + @pytest.mark.asyncio + async def test_returns_200_ok_when_provider_returns_none(self): + provider = _make_provider(None) + adapter, _, _ = await _make_provider_adapter(provider) + + req = _make_signed_request(_event_body(team_id="T_UNKNOWN")) + response = await adapter.handle_webhook(req) + + assert response["status"] == 200 + provider.get_installation.assert_called_once_with("T_UNKNOWN", False) + + @pytest.mark.asyncio + async def test_does_not_fall_back_to_state_when_provider_is_set(self): + provider = _make_provider(None) + adapter, chat, state = await _make_provider_adapter(provider) + + # Set an installation in state - should NOT be used + await adapter.set_installation( + "T_STATE_TEAM", + SlackInstallation(bot_token="xoxb-state-token", bot_user_id="U_BOT_STATE"), + ) + + req = _make_signed_request(_event_body(team_id="T_STATE_TEAM")) + response = await adapter.handle_webhook(req) + + assert response["status"] == 200 + provider.get_installation.assert_called_once_with("T_STATE_TEAM", False) + # Event must not be processed because the provider returned None -- + # falling back to the state-stored token here would mean the provider + # is not authoritative. + chat.process_message.assert_not_called() + # The internal installation key must never have been read. + read_keys = [str(c.args[0]) for c in state.get.call_args_list] + assert not any("slack:installation" in k for k in read_keys) + + @pytest.mark.asyncio + async def test_provider_token_reaches_request_context(self): + """The resolved installation's token is what downstream API calls use.""" + provider = _make_provider(SlackInstallation(bot_token="xoxb-ctx-token", bot_user_id="U_BOT_CTX")) + adapter, chat, _ = await _make_provider_adapter(provider) + + seen_tokens: list[str] = [] + + def capture(adapter_arg: Any, thread_id: str, factory: Any, options: Any = None) -> None: + seen_tokens.append(adapter_arg._get_token()) + + chat.process_message = MagicMock(side_effect=capture) + + req = _make_signed_request(_event_body(team_id="T_CTX")) + await adapter.handle_webhook(req) + + assert seen_tokens == ["xoxb-ctx-token"] + + +# --------------------------------------------------------------------------- +# Slash commands +# --------------------------------------------------------------------------- + + +class TestInstallationProviderSlashCommands: + @pytest.mark.asyncio + async def test_uses_provider_for_slash_commands(self): + provider = _make_provider(SlackInstallation(bot_token="xoxb-slash-token", bot_user_id="U_BOT_SLASH")) + adapter, _, _ = await _make_provider_adapter(provider) + + body = _slash_body( + { + "command": "/test", + "text": "hello", + "team_id": "T_SLASH_TEAM", + "channel_id": "C_SLASH", + "user_id": "U_SLASHER", + "response_url": "https://hooks.slack.com/commands/xxx", + } + ) + req = _make_signed_request(body, content_type="application/x-www-form-urlencoded") + await adapter.handle_webhook(req) + + provider.get_installation.assert_called_once_with("T_SLASH_TEAM", False) + + @pytest.mark.asyncio + async def test_uses_enterprise_id_for_slash_commands_in_enterprise_grid(self): + provider = _make_provider(SlackInstallation(bot_token="xoxb-ent-slash-token", bot_user_id="U_BOT_ENT_SLASH")) + adapter, _, _ = await _make_provider_adapter(provider) + + body = _slash_body( + { + "command": "/test", + "text": "hello", + "team_id": "T_ENT_WORKSPACE", + "enterprise_id": "E_ENT_ORG", + "is_enterprise_install": "true", + "channel_id": "C_SLASH", + "user_id": "U_SLASHER", + "response_url": "https://hooks.slack.com/commands/xxx", + } + ) + req = _make_signed_request(body, content_type="application/x-www-form-urlencoded") + await adapter.handle_webhook(req) + + provider.get_installation.assert_called_once_with("E_ENT_ORG", True) + + +# --------------------------------------------------------------------------- +# Interactive payloads +# --------------------------------------------------------------------------- + + +class TestInstallationProviderInteractive: + @pytest.mark.asyncio + async def test_uses_provider_for_interactive_payloads(self): + provider = _make_provider(SlackInstallation(bot_token="xoxb-interactive-token", bot_user_id="U_BOT_INTER")) + adapter, _, _ = await _make_provider_adapter(provider) + + body = _interactive_body(_block_actions_payload(team={"id": "T_INTER_PROVIDER"})) + req = _make_signed_request(body, content_type="application/x-www-form-urlencoded") + response = await adapter.handle_webhook(req) + + assert response["status"] == 200 + provider.get_installation.assert_called_once_with("T_INTER_PROVIDER", False) + + @pytest.mark.asyncio + async def test_uses_enterprise_id_for_interactive_payloads_in_enterprise_grid(self): + provider = _make_provider( + SlackInstallation(bot_token="xoxb-ent-interactive-token", bot_user_id="U_BOT_ENT_INTER") + ) + adapter, _, _ = await _make_provider_adapter(provider) + + body = _interactive_body( + _block_actions_payload( + team={"id": "T_ENT_INTER_WORKSPACE"}, + enterprise={"id": "E_ENT_INTER_ORG"}, + is_enterprise_install=True, + ) + ) + req = _make_signed_request(body, content_type="application/x-www-form-urlencoded") + response = await adapter.handle_webhook(req) + + assert response["status"] == 200 + provider.get_installation.assert_called_once_with("E_ENT_INTER_ORG", True) + + +# --------------------------------------------------------------------------- +# rehydrate_attachment +# --------------------------------------------------------------------------- + + +class TestInstallationProviderRehydrate: + @pytest.mark.asyncio + async def test_rehydrate_attachment_uses_provider_for_token_resolution(self): + provider = _make_provider(SlackInstallation(bot_token="xoxb-rehydrate-token", bot_user_id="U_BOT_REHYDRATE")) + adapter, _, _ = await _make_provider_adapter(provider) + adapter._fetch_slack_file = AsyncMock(return_value=b"\x00" * 8) # type: ignore[method-assign] + + rehydrated = adapter.rehydrate_attachment( + Attachment( + type="image", + url="https://files.slack.com/img.png", + fetch_metadata={ + "url": "https://files.slack.com/img.png", + "teamId": "T_REHYDRATE", + }, + ) + ) + + assert rehydrated.fetch_data is not None + await rehydrated.fetch_data() + + provider.get_installation.assert_called_once_with("T_REHYDRATE", False) + adapter._fetch_slack_file.assert_awaited_once_with("https://files.slack.com/img.png", "xoxb-rehydrate-token") + + @pytest.mark.asyncio + async def test_rehydrate_attachment_uses_enterprise_id_when_enterprise_install(self): + provider = _make_provider( + SlackInstallation(bot_token="xoxb-ent-rehydrate-token", bot_user_id="U_BOT_ENT_REHYDRATE") + ) + adapter, _, _ = await _make_provider_adapter(provider) + adapter._fetch_slack_file = AsyncMock(return_value=b"\x00" * 8) # type: ignore[method-assign] + + rehydrated = adapter.rehydrate_attachment( + Attachment( + type="image", + url="https://files.slack.com/img.png", + fetch_metadata={ + "url": "https://files.slack.com/img.png", + "teamId": "T_WORKSPACE", + "enterpriseId": "E_ORG", + "isEnterpriseInstall": "true", + }, + ) + ) + + assert rehydrated.fetch_data is not None + await rehydrated.fetch_data() + + provider.get_installation.assert_called_once_with("E_ORG", True) + adapter._fetch_slack_file.assert_awaited_once_with( + "https://files.slack.com/img.png", "xoxb-ent-rehydrate-token" + ) + + @pytest.mark.asyncio + async def test_rehydrate_attachment_raises_when_provider_returns_none(self): + provider = _make_provider(None) + adapter, _, _ = await _make_provider_adapter(provider) + + rehydrated = adapter.rehydrate_attachment( + Attachment( + type="image", + url="https://files.slack.com/img.png", + fetch_metadata={ + "url": "https://files.slack.com/img.png", + "teamId": "T_MISSING", + }, + ) + ) + + assert rehydrated.fetch_data is not None + with pytest.raises(AuthenticationError, match="Installation not found for team T_MISSING"): + await rehydrated.fetch_data() + provider.get_installation.assert_called_once_with("T_MISSING", False) + + +# --------------------------------------------------------------------------- +# Enterprise Grid metadata capture on attachments +# --------------------------------------------------------------------------- + + +class TestEnterpriseGridAttachmentMetadata: + @pytest.mark.asyncio + async def test_event_callback_with_file_captures_enterprise_grid_metadata(self): + provider = _make_provider(SlackInstallation(bot_token="xoxb-ent-event-token", bot_user_id="U_BOT_ENT_EVENT")) + adapter, chat, _ = await _make_provider_adapter(provider) + + # Invoke the factory while still inside the request-context frame so + # _create_attachment can read the per-request enterprise context. The + # task is created during process_message, before the isolated context + # exits, so the contextvars snapshot carries the enterprise fields. + captured: list[asyncio.Task[Any]] = [] + + def capture(adapter_arg: Any, thread_id: str, factory: Any, options: Any = None) -> None: + captured.append(asyncio.get_running_loop().create_task(factory())) + + chat.process_message = MagicMock(side_effect=capture) + + body = json.dumps( + { + "type": "event_callback", + "team_id": "T_ENT_WORKSPACE", + "enterprise_id": "E_ENT_FILE_ORG", + "is_enterprise_install": True, + "event": { + "type": "message", + "channel": "C_TEST", + "user": "U_USER", + # username present so the parse skips the user-lookup API call + "username": "testuser", + "text": "with file", + "ts": "1234567890.123456", + "files": [ + { + "id": "F1", + "mimetype": "image/png", + "url_private": "https://files.slack.com/captured.png", + "name": "captured.png", + } + ], + }, + } + ) + req = _make_signed_request(body) + await adapter.handle_webhook(req) + + assert len(captured) == 1 + message = await captured[0] + attachment = message.attachments[0] + assert attachment.fetch_metadata == { + "url": "https://files.slack.com/captured.png", + "teamId": "T_ENT_WORKSPACE", + "enterpriseId": "E_ENT_FILE_ORG", + "isEnterpriseInstall": "true", + } + + @pytest.mark.asyncio + async def test_non_enterprise_event_omits_enterprise_grid_metadata_keys(self): + """Plain workspace installs must not serialize enterprise keys + (omit, not ``None``/``"false"`` -- hazard #7).""" + provider = _make_provider(SlackInstallation(bot_token="xoxb-plain-token", bot_user_id="U_BOT_PLAIN")) + adapter, chat, _ = await _make_provider_adapter(provider) + + captured: list[asyncio.Task[Any]] = [] + + def capture(adapter_arg: Any, thread_id: str, factory: Any, options: Any = None) -> None: + captured.append(asyncio.get_running_loop().create_task(factory())) + + chat.process_message = MagicMock(side_effect=capture) + + body = json.dumps( + { + "type": "event_callback", + "team_id": "T_PLAIN", + "event": { + "type": "message", + "channel": "C_TEST", + "user": "U_USER", + "username": "testuser", + "text": "with file", + "ts": "1234567890.123456", + "files": [ + { + "id": "F2", + "mimetype": "image/png", + "url_private": "https://files.slack.com/plain.png", + "name": "plain.png", + } + ], + }, + } + ) + req = _make_signed_request(body) + await adapter.handle_webhook(req) + + assert len(captured) == 1 + message = await captured[0] + attachment = message.attachments[0] + assert attachment.fetch_metadata == { + "url": "https://files.slack.com/plain.png", + "teamId": "T_PLAIN", + } From 731e37cabde9e3be29808a919ad2507eae7bc52a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 21:06:26 +0000 Subject: [PATCH 07/14] fix(discord): handle interactions in gateway-only mode (vercel/chat#490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of upstream b9b17cd. Discord sends interactions through either the Gateway or an Interactions Endpoint URL, not both — deployments without an endpoint URL receive interactions over the Gateway, and the adapter previously dropped them ("Forwarded Gateway event (no handler)"). The Python adapter is HTTP-interactions-only with a gateway-forwarder receiver, so the port lands on that surface: a forwarded GATEWAY_INTERACTION_CREATE event (raw wire-format INTERACTION_CREATE dispatch payload) is now acknowledged via the interaction callback REST endpoint — POST /interactions/{id}/{token}/callback with type 5 for slash commands / type 6 for components, the same wire calls upstream's resident discord.js handler makes via deferReply() / deferUpdate() — then routed through the existing slash-command and action handler paths. Deferred slash responses resolve exactly like HTTP ones (post_message PATCHes the @original webhook message). Callback path segments are URL-quoted (hazard #12) and defer failures are logged without dispatching the handler, matching upstream's listener-level catch. Also aligns slash-command boolean option flattening with TS String(true) — "status true", not Python str(True) "status True" — pinned by the ported upstream test. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 (cherry picked from commit 458428afc383b57704e6fe7c6d8abde23235afda) --- src/chat_sdk/adapters/discord/adapter.py | 111 +++++++++++- src/chat_sdk/adapters/discord/types.py | 8 +- tests/test_discord_adapter.py | 4 +- tests/test_discord_extended.py | 212 +++++++++++++++++++++++ 4 files changed, 332 insertions(+), 3 deletions(-) diff --git a/src/chat_sdk/adapters/discord/adapter.py b/src/chat_sdk/adapters/discord/adapter.py index dbcfd9f..3e6800a 100644 --- a/src/chat_sdk/adapters/discord/adapter.py +++ b/src/chat_sdk/adapters/discord/adapter.py @@ -505,10 +505,20 @@ def _parse_slash_command( command_parts: list[str] = [name if name.startswith("/") else f"/{name}"] value_parts: list[str] = [] + def stringify(value: Any) -> str: + # Match TS `String(value)` for boolean options: JSON booleans + # arrive as Python bools, and `str(True)` would emit "True" + # where upstream emits "true" (e.g. a `verbose: true` option). + if value is True: + return "true" + if value is False: + return "false" + return str(value) + def collect(items: list[DiscordCommandOption]) -> None: for option in items: if option.get("value") is not None: - value_parts.append(str(option["value"])) + value_parts.append(stringify(option["value"])) continue sub_options = option.get("options", []) if sub_options: @@ -541,11 +551,110 @@ async def _handle_forwarded_gateway_event( await self._handle_forwarded_reaction(event.get("data", {}), True, options) elif event_type == "GATEWAY_MESSAGE_REACTION_REMOVE": await self._handle_forwarded_reaction(event.get("data", {}), False, options) + elif event_type == "GATEWAY_INTERACTION_CREATE": + await self._handle_forwarded_interaction(event.get("data", {}), options) else: self._logger.debug("Forwarded Gateway event (no handler)", {"type": event_type}) return self._make_json_response(json.dumps({"ok": True}), 200) + async def _handle_forwarded_interaction( + self, + interaction: DiscordInteraction, + options: WebhookOptions | None = None, + ) -> None: + """Handle a forwarded INTERACTION_CREATE event (gateway-only mode). + + Discord sends interactions through either the Gateway or an + Interactions Endpoint URL, not both (vercel/chat#490). Deployments + that leave the endpoint URL unset receive interactions over the + Gateway; the forwarder relays the raw INTERACTION_CREATE dispatch + payload here, which is already in wire format, so the existing HTTP + interaction handlers consume it unchanged. + + Unlike HTTP interactions -- where the deferral rides the HTTP + response body -- a gateway interaction is acknowledged with an + explicit REST call to the interaction callback endpoint. That is + the same wire call upstream's gateway-only handler makes via + discord.js ``deferReply()`` (slash commands) and ``deferUpdate()`` + (components). Slash commands then route through the existing slash + command handler path (the deferred response is later resolved by + ``post_message`` PATCHing the ``@original`` interaction webhook + message), and component interactions route through the existing + action handler path. + """ + interaction_id = interaction.get("id") + interaction_token = interaction.get("token") + interaction_type = interaction.get("type", 0) + + self._logger.info( + "Discord Gateway interaction received", + {"id": interaction_id, "type": interaction_type}, + ) + + if interaction_type not in ( + INTERACTION_TYPE_APPLICATION_COMMAND, + INTERACTION_TYPE_MESSAGE_COMPONENT, + ): + self._logger.debug( + "Forwarded Gateway interaction (no handler)", + {"type": interaction_type}, + ) + return + + if not (interaction_id and interaction_token): + # A gateway INTERACTION_CREATE always carries id + token; a + # malformed forward must not produce a garbage callback URL. + self._logger.warn( + "Forwarded Gateway interaction missing id or token", + {"id": interaction_id, "type": interaction_type}, + ) + return + + try: + if interaction_type == INTERACTION_TYPE_APPLICATION_COMMAND: + # deferReply: ACK now, respond via the interaction webhook later. + await self._defer_gateway_interaction( + interaction_id, + interaction_token, + InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, + ) + self._handle_application_command_interaction(interaction, options) + return + + # deferUpdate: ACK the component, update the message later. + await self._defer_gateway_interaction( + interaction_id, + interaction_token, + InteractionResponseType.DEFERRED_UPDATE_MESSAGE, + ) + self._handle_component_interaction(interaction, options) + except Exception as error: + self._logger.error( + "Error handling Gateway interaction", + {"error": str(error), "interactionId": interaction_id}, + ) + + async def _defer_gateway_interaction( + self, + interaction_id: str, + interaction_token: str, + response_type: int, + ) -> None: + """ACK a gateway-received interaction via the callback endpoint. + + ``POST /interactions/{id}/{token}/callback`` is the REST equivalent + of returning the deferral as the HTTP response body on the + Interactions Endpoint path. Path segments are URL-quoted so a + crafted id/token in a forwarded payload cannot pivot the request + (hazard #12, same guard as :meth:`get_user`). + """ + await self._discord_fetch( + f"/interactions/{quote(interaction_id, safe='')}/{quote(interaction_token, safe='')}/callback", + "POST", + {"type": response_type}, + ) + async def _handle_forwarded_message( self, data: DiscordGatewayMessageData, diff --git a/src/chat_sdk/adapters/discord/types.py b/src/chat_sdk/adapters/discord/types.py index c803c78..e6e1a72 100644 --- a/src/chat_sdk/adapters/discord/types.py +++ b/src/chat_sdk/adapters/discord/types.py @@ -306,7 +306,13 @@ class DiscordGatewayReactionData(TypedDict, total=False): class DiscordForwardedEvent(TypedDict): - """A Gateway event forwarded to the webhook endpoint.""" + """A Gateway event forwarded to the webhook endpoint. + + Known types: ``GATEWAY_MESSAGE_CREATE``, ``GATEWAY_MESSAGE_REACTION_ADD``, + ``GATEWAY_MESSAGE_REACTION_REMOVE``, ``GATEWAY_INTERACTION_CREATE``. + For ``GATEWAY_INTERACTION_CREATE``, ``data`` is the raw wire-format + INTERACTION_CREATE dispatch payload (:class:`DiscordInteraction`). + """ data: Any timestamp: int diff --git a/tests/test_discord_adapter.py b/tests/test_discord_adapter.py index f85cfb9..9e15fb3 100644 --- a/tests/test_discord_adapter.py +++ b/tests/test_discord_adapter.py @@ -406,7 +406,9 @@ async def test_dispatches_slash_command_to_chat(self): mock_chat.process_slash_command.assert_called_once() call_args = mock_chat.process_slash_command.call_args[0][0] assert call_args.command == "/test" - assert call_args.text == "status True" + # Boolean option values flatten as JSON-style "true"/"false", + # matching TS `String(true)` (wire parity, vercel/chat#490 test). + assert call_args.text == "status true" @pytest.mark.asyncio async def test_expands_subcommand_path(self): diff --git a/tests/test_discord_extended.py b/tests/test_discord_extended.py index 33aacf3..9189e73 100644 --- a/tests/test_discord_extended.py +++ b/tests/test_discord_extended.py @@ -923,6 +923,218 @@ async def test_handles_reaction_remove(self): assert call_args.message_id == "msg123" +# ============================================================================ +# Forwarded gateway interactions (gateway-only mode, vercel/chat#490) +# ============================================================================ + + +class TestForwardedGatewayInteractions: + """Port of the "legacy gateway interactions" describe block. + + Discord sends interactions through either the Gateway or an + Interactions Endpoint URL, not both. In gateway-only deployments the + forwarder relays the raw INTERACTION_CREATE payload; the adapter must + defer it via the interaction callback REST endpoint (the wire call + discord.js makes for deferReply/deferUpdate) and route through the + existing slash-command / action handler paths. + """ + + def _slash_interaction(self, **overrides): + interaction = { + "id": "interaction123", + "application_id": "test-app-id", + "token": "interaction-token", + "type": 2, # APPLICATION_COMMAND + "version": 1, + "guild_id": "guild123", + "channel_id": "channel456", + "channel": {"id": "channel456", "type": 0}, + "user": { + "id": "user789", + "username": "testuser", + "discriminator": "0001", + "global_name": "Test User", + "bot": False, + }, + "data": { + "name": "test", + "type": 1, + "options": [ + {"name": "topic", "type": 3, "value": "status"}, + {"name": "verbose", "type": 5, "value": True}, + ], + }, + } + interaction.update(overrides) + return interaction + + def _component_interaction(self, **overrides): + interaction = { + "id": "interaction123", + "application_id": "test-app-id", + "token": "interaction-token", + "type": 3, # MESSAGE_COMPONENT + "version": 1, + "guild_id": "guild123", + "channel_id": "channel456", + "channel": {"id": "channel456", "type": 0}, + "user": { + "id": "user789", + "username": "testuser", + "discriminator": "0001", + "global_name": "Test User", + "bot": False, + }, + "data": {"custom_id": "approve_btn", "component_type": 2}, + "message": {"id": "message123"}, + } + interaction.update(overrides) + return interaction + + def _forwarded(self, interaction) -> str: + return json.dumps( + { + "type": "GATEWAY_INTERACTION_CREATE", + "timestamp": 1234567890, + "data": interaction, + } + ) + + @pytest.mark.asyncio + async def test_handles_slash_command_interactions_from_the_gateway(self): + adapter = _make_adapter(logger=_make_logger()) + mock_chat = MagicMock() + mock_chat.process_slash_command = MagicMock() + adapter._chat = mock_chat + adapter._discord_fetch = AsyncMock(return_value=None) + + response = await adapter.handle_webhook(_gateway_request(self._forwarded(self._slash_interaction()))) + + assert response["status"] == 200 + # deferReply: explicit callback REST call (type 5) + adapter._discord_fetch.assert_awaited_once_with( + "/interactions/interaction123/interaction-token/callback", + "POST", + {"type": 5}, + ) + mock_chat.process_slash_command.assert_called_once() + event = mock_chat.process_slash_command.call_args[0][0] + assert event.command == "/test" + assert event.text == "status true" + assert event.channel_id == "discord:guild123:channel456" + assert event.user.user_id == "user789" + assert event.user.user_name == "testuser" + assert event.user.full_name == "Test User" + + @pytest.mark.asyncio + async def test_handles_component_interactions_from_the_gateway(self): + adapter = _make_adapter(logger=_make_logger()) + mock_chat = MagicMock() + mock_chat.process_action = MagicMock() + adapter._chat = mock_chat + adapter._discord_fetch = AsyncMock(return_value=None) + + response = await adapter.handle_webhook(_gateway_request(self._forwarded(self._component_interaction()))) + + assert response["status"] == 200 + # deferUpdate: explicit callback REST call (type 6) + adapter._discord_fetch.assert_awaited_once_with( + "/interactions/interaction123/interaction-token/callback", + "POST", + {"type": 6}, + ) + mock_chat.process_action.assert_called_once() + event = mock_chat.process_action.call_args[0][0] + assert event.action_id == "approve_btn" + assert event.value == "approve_btn" + assert event.message_id == "message123" + assert event.thread_id == "discord:guild123:channel456" + + @pytest.mark.asyncio + async def test_slash_command_defer_failure_skips_handler(self): + """If the deferral REST call fails the handler must not run -- + matches upstream where a deferReply rejection is caught and logged + before the normalize+handle step.""" + logger = _make_logger() + adapter = _make_adapter(logger=logger) + mock_chat = MagicMock() + mock_chat.process_slash_command = MagicMock() + adapter._chat = mock_chat + adapter._discord_fetch = AsyncMock(side_effect=NetworkError("discord", "boom")) + + response = await adapter.handle_webhook(_gateway_request(self._forwarded(self._slash_interaction()))) + + # Forwarded-event responses stay 200; the failure is logged. + assert response["status"] == 200 + mock_chat.process_slash_command.assert_not_called() + error_messages = [c.args[0] for c in logger.error.call_args_list] + assert "Error handling Gateway interaction" in error_messages + + @pytest.mark.asyncio + async def test_unhandled_interaction_type_is_ignored_without_defer(self): + """PING/autocomplete-style interactions are not deferred or routed.""" + adapter = _make_adapter(logger=_make_logger()) + mock_chat = MagicMock() + mock_chat.process_slash_command = MagicMock() + mock_chat.process_action = MagicMock() + adapter._chat = mock_chat + adapter._discord_fetch = AsyncMock(return_value=None) + + response = await adapter.handle_webhook(_gateway_request(self._forwarded(self._slash_interaction(type=4)))) + + assert response["status"] == 200 + adapter._discord_fetch.assert_not_awaited() + mock_chat.process_slash_command.assert_not_called() + mock_chat.process_action.assert_not_called() + + @pytest.mark.asyncio + async def test_interaction_missing_token_is_not_deferred_or_routed(self): + """A malformed forward without id/token must not produce a garbage + callback URL or dispatch a handler.""" + adapter = _make_adapter(logger=_make_logger()) + mock_chat = MagicMock() + mock_chat.process_action = MagicMock() + adapter._chat = mock_chat + adapter._discord_fetch = AsyncMock(return_value=None) + + interaction = self._component_interaction() + del interaction["token"] + response = await adapter.handle_webhook(_gateway_request(self._forwarded(interaction))) + + assert response["status"] == 200 + adapter._discord_fetch.assert_not_awaited() + mock_chat.process_action.assert_not_called() + + @pytest.mark.asyncio + async def test_gateway_slash_command_supports_deferred_response_flow(self): + """The gateway-deferred slash interaction resolves like an HTTP one: + the handler's first post_message PATCHes the @original webhook + message using the interaction token.""" + adapter = _make_adapter(logger=_make_logger()) + mock_chat = MagicMock() + + def run_handler(event, options=None): + # Simulate Chat dispatching a handler that replies immediately. + import asyncio as _asyncio + + task = _asyncio.get_running_loop().create_task(adapter.post_message(event.channel_id, "reply from handler")) + run_handler.task = task + + mock_chat.process_slash_command = MagicMock(side_effect=run_handler) + adapter._chat = mock_chat + adapter._discord_fetch = AsyncMock(return_value={"id": "reply-msg-1"}) + + await adapter.handle_webhook(_gateway_request(self._forwarded(self._slash_interaction()))) + await run_handler.task + + paths = [c.args[0] for c in adapter._discord_fetch.await_args_list] + # First the deferral, then the @original PATCH (not a channel POST). + assert paths[0] == "/interactions/interaction123/interaction-token/callback" + assert paths[1] == "/webhooks/test-app-id/interaction-token/messages/@original" + patch_call = adapter._discord_fetch.await_args_list[1] + assert patch_call.args[1] == "PATCH" + + # ============================================================================ # Forwarded message -- thread detection # ============================================================================ From c59e6cd74abdd7831f06b7d9ab2a51dea9cbd3d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 21:07:47 +0000 Subject: [PATCH 08/14] feat(chat): Transcripts API + rename message history cache to thread_history (vercel/chat#448) Port of upstream 46d183b (chat@4.29.0). Rename (with back-compat, mirroring upstream): - message_history.py -> thread_history.py; MessageHistoryCache -> ThreadHistoryCache. Old module path kept as a deprecated re-export shim. - ChatConfig.thread_history added; deprecated ChatConfig.message_history still read, thread_history wins when both are set. - Adapter.persist_thread_history added; deprecated persist_message_history still honored (either flag enables persistence). Telegram and WhatsApp adapters switch to the new flag, matching upstream. - State storage key prefix "msg-history:" is deliberately unchanged so existing persisted data is not orphaned. New Transcripts API: - transcripts.py: TranscriptsApiImpl (append/list/count/delete) keyed by a cross-platform user key, backed by StateAdapter.append_to_list. delete() writes a tombstone via append_to_list(max_length=1) because state.delete only addresses the k/v namespace on non-memory adapters. - ChatConfig.transcripts + ChatConfig.identity (IdentityResolver); the constructor raises when transcripts is set without identity. Inbound dispatch resolves message.user_key once per message via the resolver. - chat.transcripts accessor raises when not configured (fail loudly). - New types: TranscriptEntry, TranscriptsConfig, TranscriptRole, AppendInput, AppendOptions, ListQuery, CountQuery, DeleteTarget, DeleteResult, IdentityContext, IdentityResolver, DurationString. Tests: test_message_history.py renamed to test_thread_history.py (names aligned to thread-history.test.ts titles, deprecated-alias test added); chat.test.ts persistThreadHistory block ported into test_chat_faithful.py; new test_transcripts.py and test_transcripts_wiring.py port transcripts.test.ts and transcripts-wiring.test.ts 1:1. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 (cherry picked from commit 765420dc4d21804b19f1d8eccddebc3a392b0e61) --- src/chat_sdk/__init__.py | 34 +- src/chat_sdk/adapters/teams/adapter.py | 2 +- src/chat_sdk/adapters/telegram/adapter.py | 6 +- src/chat_sdk/adapters/whatsapp/adapter.py | 6 +- src/chat_sdk/channel.py | 26 +- src/chat_sdk/chat.py | 104 +++- src/chat_sdk/message_history.py | 109 ++--- src/chat_sdk/shared/mock_adapter.py | 4 + src/chat_sdk/thread.py | 44 +- src/chat_sdk/thread_history.py | 97 ++++ src/chat_sdk/transcripts.py | 195 ++++++++ src/chat_sdk/types.py | 276 ++++++++++- tests/test_channel_faithful.py | 2 +- tests/test_chat_faithful.py | 131 ++++- tests/test_chat_resolver.py | 32 +- tests/test_serialization.py | 14 +- tests/test_teams_native_streaming.py | 4 +- tests/test_telegram_adapter.py | 2 +- ...sage_history.py => test_thread_history.py} | 118 +++-- tests/test_transcripts.py | 450 ++++++++++++++++++ tests/test_transcripts_wiring.py | 240 ++++++++++ tests/test_whatsapp_adapter.py | 2 +- 22 files changed, 1683 insertions(+), 215 deletions(-) create mode 100644 src/chat_sdk/thread_history.py create mode 100644 src/chat_sdk/transcripts.py rename tests/{test_message_history.py => test_thread_history.py} (72%) create mode 100644 tests/test_transcripts.py create mode 100644 tests/test_transcripts_wiring.py diff --git a/src/chat_sdk/__init__.py b/src/chat_sdk/__init__.py index 7b121a8..c7a13a1 100644 --- a/src/chat_sdk/__init__.py +++ b/src/chat_sdk/__init__.py @@ -65,6 +65,8 @@ from chat_sdk.errors import ChatError, ChatNotImplementedError, LockError, RateLimitError, StateNotConnectedError from chat_sdk.from_full_stream import from_full_stream from chat_sdk.logger import ConsoleLogger, Logger, LogLevel + +# Deprecated aliases — renamed to ThreadHistoryCache / ThreadHistoryConfig. from chat_sdk.message_history import MessageHistoryCache, MessageHistoryConfig from chat_sdk.modals import ( ExternalSelect, @@ -121,10 +123,13 @@ from chat_sdk.shared.streaming_markdown import StreamingMarkdownRenderer from chat_sdk.state.memory import MemoryStateAdapter from chat_sdk.thread import ThreadImpl +from chat_sdk.thread_history import ThreadHistoryCache, ThreadHistoryConfig from chat_sdk.types import ( ActionEvent, Adapter, AdapterPostableMessage, + AppendInput, + AppendOptions, AppHomeOpenedEvent, AssistantContextChangedEvent, AssistantThreadStartedEvent, @@ -138,6 +143,10 @@ ChatInstance, ConcurrencyConfig, ConcurrencyStrategy, + CountQuery, + DeleteResult, + DeleteTarget, + DurationString, EmojiFormats, EmojiValue, EphemeralMessage, @@ -146,7 +155,10 @@ FetchResult, FileUpload, FormattedContent, + IdentityContext, + IdentityResolver, LinkPreview, + ListQuery, ListThreadsOptions, ListThreadsResult, Lock, @@ -185,6 +197,10 @@ Thread, ThreadInfo, ThreadSummary, + TranscriptEntry, + TranscriptRole, + TranscriptsApi, + TranscriptsConfig, UserInfo, WebhookOptions, WellKnownEmoji, @@ -298,7 +314,10 @@ "ConsoleLogger", "Logger", "LogLevel", - # Message history + # Thread history (per-thread cache) + "ThreadHistoryCache", + "ThreadHistoryConfig", + # Thread history — deprecated aliases (renamed upstream) "MessageHistoryCache", "MessageHistoryConfig", # Modal builders (PascalCase primary — matches source TS SDK) @@ -335,6 +354,8 @@ "ActionEvent", "Adapter", "AdapterPostableMessage", + "AppendInput", + "AppendOptions", "AppHomeOpenedEvent", "AssistantContextChangedEvent", "AssistantThreadStartedEvent", @@ -348,6 +369,10 @@ "ChatInstance", "ConcurrencyConfig", "ConcurrencyStrategy", + "CountQuery", + "DeleteResult", + "DeleteTarget", + "DurationString", "EmojiFormats", "EmojiValue", "EphemeralMessage", @@ -356,7 +381,10 @@ "FetchResult", "FileUpload", "FormattedContent", + "IdentityContext", + "IdentityResolver", "LinkPreview", + "ListQuery", "ListThreadsOptions", "ListThreadsResult", "Lock", @@ -396,6 +424,10 @@ "Thread", "ThreadInfo", "ThreadSummary", + "TranscriptEntry", + "TranscriptRole", + "TranscriptsApi", + "TranscriptsConfig", "UserInfo", "WebhookOptions", "WellKnownEmoji", diff --git a/src/chat_sdk/adapters/teams/adapter.py b/src/chat_sdk/adapters/teams/adapter.py index b58e47d..5232a23 100644 --- a/src/chat_sdk/adapters/teams/adapter.py +++ b/src/chat_sdk/adapters/teams/adapter.py @@ -1700,7 +1700,7 @@ async def _close_stream_session( # from typing indicator to message bubble; if that send fails # the streaming UI may stay until Teams times the session out # client-side, but the recorded ``SentMessage`` and - # ``_message_history`` entry still match what the user saw. + # ``_thread_history`` entry still match what the user saw. self._logger.warn( "Teams stream final activity failed", {"threadId": thread_id, "error": str(exc)}, diff --git a/src/chat_sdk/adapters/telegram/adapter.py b/src/chat_sdk/adapters/telegram/adapter.py index 48651b4..92efff7 100644 --- a/src/chat_sdk/adapters/telegram/adapter.py +++ b/src/chat_sdk/adapters/telegram/adapter.py @@ -594,7 +594,7 @@ def __init__(self, config: TelegramAdapterConfig | None = None) -> None: self._name: str = "telegram" self._lock_scope: LockScope = "channel" - self._persist_message_history: bool = True + self._persist_thread_history: bool = True self._bot_token: str = bot_token self._api_base_url: str = _trim_trailing_slashes( @@ -640,8 +640,8 @@ def lock_scope(self) -> LockScope: return self._lock_scope @property - def persist_message_history(self) -> bool: - return self._persist_message_history + def persist_thread_history(self) -> bool: + return self._persist_thread_history @property def bot_user_id(self) -> str | None: diff --git a/src/chat_sdk/adapters/whatsapp/adapter.py b/src/chat_sdk/adapters/whatsapp/adapter.py index 08b7489..f64ad30 100644 --- a/src/chat_sdk/adapters/whatsapp/adapter.py +++ b/src/chat_sdk/adapters/whatsapp/adapter.py @@ -112,7 +112,7 @@ class WhatsAppAdapter: def __init__(self, config: WhatsAppAdapterConfig) -> None: self._name = "whatsapp" self._lock_scope: LockScope = "channel" - self._persist_message_history = True + self._persist_thread_history = True self._user_name = config.user_name self._access_token = config.access_token self._app_secret = config.app_secret @@ -137,8 +137,8 @@ def lock_scope(self) -> LockScope: return self._lock_scope @property - def persist_message_history(self) -> bool: - return self._persist_message_history + def persist_thread_history(self) -> bool: + return self._persist_thread_history @property def user_name(self) -> str: diff --git a/src/chat_sdk/channel.py b/src/chat_sdk/channel.py index 2981137..80fde8a 100644 --- a/src/chat_sdk/channel.py +++ b/src/chat_sdk/channel.py @@ -83,7 +83,7 @@ class _ChannelImplConfigWithAdapter: state_adapter: StateAdapter channel_visibility: ChannelVisibility = "unknown" is_dm: bool = False - message_history: Any = None + thread_history: Any = None @dataclass @@ -122,15 +122,15 @@ def __init__(self, config: _ChannelImplConfig) -> None: self._adapter: Adapter | None = None self._adapter_name: str | None = config.adapter_name self._state_adapter_instance: StateAdapter | None = None - self._message_history: Any = None + self._thread_history: Any = None else: # _ChannelImplConfigWithAdapter, _ChannelImplConfigForThread, # or _ChannelImplConfigForChat (from chat.py) -- all have - # adapter, state_adapter, and optional message_history attrs. + # adapter, state_adapter, and optional thread_history attrs. self._adapter = config.adapter # type: ignore[union-attr] self._adapter_name = None self._state_adapter_instance = config.state_adapter # type: ignore[union-attr] - self._message_history = getattr(config, "message_history", None) + self._thread_history = getattr(config, "thread_history", None) # -- Properties ---------------------------------------------------------- @@ -206,7 +206,7 @@ async def messages(self) -> AsyncIterator[Message]: """ adapter = self.adapter channel_id = self._id - message_history = self._message_history + thread_history = self._thread_history cursor: str | None = None yielded_any = False @@ -227,8 +227,8 @@ async def messages(self) -> AsyncIterator[Message]: cursor = result.next_cursor # Fallback to cached history - if not yielded_any and message_history is not None: - cached: list[Message] = await message_history.get_messages(channel_id) + if not yielded_any and thread_history is not None: + cached: list[Message] = await thread_history.get_messages(channel_id) for msg in reversed(cached): yield msg @@ -285,10 +285,10 @@ async def post( # Handle PostableObject (e.g. Plan) if is_postable_object(message): raw = await self._handle_postable_object(message) - if self._message_history is not None and raw is not None: + if self._thread_history is not None and raw is not None: fallback = message.get_fallback_text() if hasattr(message, "get_fallback_text") else "" sent = self._create_sent_message(raw.id, PostableMarkdown(markdown=fallback), raw.thread_id) - await self._message_history.append(self._id, _to_message(sent)) + await self._thread_history.append(self._id, _to_message(sent)) return message if _is_async_iterable(message): @@ -312,8 +312,8 @@ async def _post_single_message( sent = self._create_sent_message(raw_msg.id, postable, raw_msg.thread_id) - if self._message_history is not None: - await self._message_history.append(self._id, _to_message(sent)) + if self._thread_history is not None: + await self._thread_history.append(self._id, _to_message(sent)) return sent @@ -439,11 +439,11 @@ def from_json( # Validation passed — safe to invalidate. # Both `_state_adapter_instance` (old chat's state backend) - # and `_message_history` (old chat's cache) would route to + # and `_thread_history` (old chat's cache) would route to # the previous context otherwise. if adapter is not None or chat is not None: channel._state_adapter_instance = None - channel._message_history = None + channel._thread_history = None else: # Explicit None-checks (not `or`) to avoid the truthiness trap: # `""` is a valid-but-falsy value that shouldn't silently fall diff --git a/src/chat_sdk/chat.py b/src/chat_sdk/chat.py index 7b3e57c..426e9dc 100644 --- a/src/chat_sdk/chat.py +++ b/src/chat_sdk/chat.py @@ -29,6 +29,7 @@ has_chat_singleton, set_chat_singleton, ) +from chat_sdk.transcripts import TranscriptsApiImpl from chat_sdk.types import ( ActionEvent, Adapter, @@ -43,6 +44,8 @@ ConcurrencyConfig, ConcurrencyStrategy, EmojiValue, + IdentityContext, + IdentityResolver, Lock, LockScope, LockScopeContext, @@ -60,6 +63,7 @@ ReactionEvent, SlashCommandEvent, StateAdapter, + TranscriptsApi, UserInfo, WebhookOptions, _parse_iso, @@ -361,8 +365,25 @@ def __init__(self, config: ChatConfig | None = None, **kwargs: Any) -> None: else None ) - # -- Message history (placeholder -- real impl would use MessageHistoryCache) - self._message_history = _MessageHistoryCache(self._state_adapter, config.message_history) + # -- Thread history (placeholder -- real impl would use ThreadHistoryCache) + # `config.message_history` is the deprecated alias; `thread_history` + # takes precedence when both are set (mirrors upstream + # `config.threadHistory ?? config.messageHistory`). + self._thread_history = _ThreadHistoryCache( + self._state_adapter, + config.thread_history if config.thread_history is not None else config.message_history, + ) + + # -- Transcripts API (cross-platform per-user persistence) ------------- + self._identity: IdentityResolver | None = config.identity + self._transcripts: TranscriptsApiImpl | None = None + if config.transcripts is not None: + if config.identity is None: + raise ValueError( + "ChatConfig.transcripts requires ChatConfig.identity to be set " + "— the cross-platform user key must be resolvable" + ) + self._transcripts = TranscriptsApiImpl(self._state_adapter, config.transcripts) # -- Logger ----------------------------------------------------------- if isinstance(config.logger, str): @@ -408,6 +429,24 @@ def __init__(self, config: ChatConfig | None = None, **kwargs: Any) -> None: self._logger.debug("Chat instance created", {"adapters": list(config.adapters.keys())}) + # ======================================================================== + # Transcripts API + # ======================================================================== + + @property + def transcripts(self) -> TranscriptsApi: + """Cross-platform per-user transcript store. + + Available only when ``transcripts`` is configured on the Chat + instance (and an ``identity`` resolver is set). Raises on access + otherwise so callers fail loudly rather than silently no-op'ing. + """ + if self._transcripts is None: + raise ChatError( + "chat.transcripts is not configured — pass `transcripts` and `identity` to ChatConfig to enable it" + ) + return self._transcripts + # ======================================================================== # Singleton management # ======================================================================== @@ -1825,12 +1864,16 @@ async def handle_incoming_message( self._logger.debug("Skipping duplicate message", {"message_id": message.id}) return - # Persist incoming message before acquiring lock - if adapter.persist_message_history: + # Persist incoming message before acquiring lock. + # `persist_message_history` is the deprecated adapter flag; either + # being truthy enables persistence (mirrors upstream + # `adapter.persistThreadHistory || adapter.persistMessageHistory`). + # Both flags are optional on the adapter, hence getattr. + if getattr(adapter, "persist_thread_history", None) or getattr(adapter, "persist_message_history", None): channel_id = adapter.channel_id_from_thread_id(thread_id) - appends = [self._message_history.append(thread_id, message)] + appends = [self._thread_history.append(thread_id, message)] if channel_id != thread_id: - appends.append(self._message_history.append(channel_id, message)) + appends.append(self._thread_history.append(channel_id, message)) await asyncio.gather(*appends) # Resolve lock key @@ -2165,6 +2208,27 @@ async def _dispatch_to_handlers( thread = self._create_thread(adapter, thread_id, message, is_subscribed) + # Resolve cross-platform user key (Transcripts API). Cached on the + # Message instance so handlers and the Transcripts API see the same + # value without re-invoking the resolver. + if self._identity is not None and message.user_key is None: + try: + resolved = self._identity(IdentityContext(adapter=adapter.name, author=message.author, message=message)) + if inspect.isawaitable(resolved): + resolved = await resolved + if resolved: + message.user_key = resolved + except Exception as err: + self._logger.warn( + "Identity resolver threw; skipping userKey", + { + "error": err, + "adapter": adapter.name, + "thread_id": thread_id, + "author_user_id": message.author.user_id, + }, + ) + # DM routing is_dm = ( adapter.is_dm(thread_id) # type: ignore[union-attr] @@ -2247,7 +2311,14 @@ def _create_thread( logger=self._logger, streaming_update_interval_ms=self._streaming_update_interval_ms, fallback_streaming_placeholder_text=self._fallback_streaming_placeholder_text, - message_history=(self._message_history if adapter.persist_message_history else None), + thread_history=( + self._thread_history + if ( + getattr(adapter, "persist_thread_history", None) + or getattr(adapter, "persist_message_history", None) + ) + else None + ), ) ) @@ -2490,12 +2561,19 @@ def _message_from_json(data: dict[str, Any]) -> Message: # --------------------------------------------------------------------------- -# Minimal MessageHistoryCache (placeholder -- real impl uses StateAdapter lists) +# Minimal ThreadHistoryCache (placeholder -- real impl uses StateAdapter lists) # --------------------------------------------------------------------------- +# Key prefix for thread history entries. +# +# Kept as ``msg-history:`` for backwards compatibility — renaming would +# silently orphan every existing user's stored data. The user-facing names +# changed; the storage shape didn't. (Matches KEY_PREFIX in thread_history.py.) +_THREAD_HISTORY_KEY_PREFIX = "msg-history:" + -class _MessageHistoryCache: - """Lightweight in-SDK message history cache backed by the state adapter.""" +class _ThreadHistoryCache: + """Lightweight in-SDK per-thread history cache backed by the state adapter.""" def __init__(self, state: StateAdapter, config: dict[str, Any] | None = None) -> None: self._state = state @@ -2503,9 +2581,9 @@ def __init__(self, state: StateAdapter, config: dict[str, Any] | None = None) -> self._ttl_ms = (config or {}).get("ttl_ms", 30 * 24 * 60 * 60 * 1000) async def append(self, thread_id: str, message: Message) -> None: - key = f"msg-history:{thread_id}" + key = f"{_THREAD_HISTORY_KEY_PREFIX}{thread_id}" # Serialize with raw nulled out to save storage (matches - # MessageHistoryCache.append in message_history.py). Without this, + # ThreadHistoryCache.append in thread_history.py). Without this, # SentMessage.raw — now populated post-#117 with platform payloads # like Slack team_id/user_id, Discord guild IDs — would persist to # the state adapter on every reply, inflating storage and PII surface. @@ -2514,7 +2592,7 @@ async def append(self, thread_id: str, message: Message) -> None: await self._state.append_to_list(key, data, max_length=self._max_messages, ttl_ms=self._ttl_ms) async def get_messages(self, thread_id: str, limit: int | None = None) -> list[Message]: - key = f"msg-history:{thread_id}" + key = f"{_THREAD_HISTORY_KEY_PREFIX}{thread_id}" raw_list = await self._state.get_list(key) messages = [_message_from_json(r) if isinstance(r, dict) else r for r in raw_list] if limit is not None: diff --git a/src/chat_sdk/message_history.py b/src/chat_sdk/message_history.py index b2121e7..70d6cc1 100644 --- a/src/chat_sdk/message_history.py +++ b/src/chat_sdk/message_history.py @@ -1,86 +1,35 @@ -"""Persistent message history cache backed by the StateAdapter. +"""Deprecated — renamed to ``chat_sdk.thread_history``. -Python port of message-history.ts. +Python port of message-history.ts (which upstream turned into a +backwards-compatibility re-export shim when the cache was renamed to +``ThreadHistoryCache`` in thread-history.ts). -Used by adapters that lack server-side message history APIs -(e.g. WhatsApp, Telegram). Messages are atomically appended via -``state.append_to_list()``, which is safe without holding a thread lock. +This module is preserved for backwards compatibility and re-exports the +new names under the old identifiers. New code should import +``ThreadHistoryCache`` and ``ThreadHistoryConfig`` from +``chat_sdk.thread_history`` directly. """ from __future__ import annotations -from dataclasses import dataclass -from typing import Any - -from chat_sdk.types import Message, StateAdapter - -# Default maximum number of messages to store per thread -DEFAULT_MAX_MESSAGES = 100 - -# Default TTL for message history (7 days in milliseconds) -DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000 - -# Key prefix for message history entries -KEY_PREFIX = "msg-history:" - - -@dataclass -class MessageHistoryConfig: - """Configuration for message history cache.""" - - max_messages: int = DEFAULT_MAX_MESSAGES - ttl_ms: int = DEFAULT_TTL_MS - - -class MessageHistoryCache: - """Persistent message history cache backed by the StateAdapter. - - Used by adapters that lack server-side message history APIs - (e.g. WhatsApp, Telegram). Messages are atomically appended via - ``state.append_to_list()``, which is safe without holding a thread lock. - """ - - def __init__( - self, - state: StateAdapter, - config: MessageHistoryConfig | None = None, - ) -> None: - self._state = state - cfg = config or MessageHistoryConfig() - self._max_messages = cfg.max_messages - self._ttl_ms = cfg.ttl_ms - - async def append(self, thread_id: str, message: Message) -> None: - """Atomically append a message to the history for a thread. - - Trims to ``max_messages`` (keeps newest) and refreshes TTL. - """ - key = f"{KEY_PREFIX}{thread_id}" - - # Serialize with raw nulled out to save storage - serialized = message.to_json() - serialized["raw"] = None - - await self._state.append_to_list( - key, - serialized, - max_length=self._max_messages, - ttl_ms=self._ttl_ms, - ) - - async def get_messages(self, thread_id: str, limit: int | None = None) -> list[Message]: - """Get messages for a thread in chronological order (oldest first). - - Parameters - ---------- - thread_id: - The thread ID. - limit: - Optional limit on number of messages to return (returns newest N). - """ - key = f"{KEY_PREFIX}{thread_id}" - stored: list[dict[str, Any]] = await self._state.get_list(key) - - sliced = stored[len(stored) - limit :] if limit and len(stored) > limit else stored - - return [Message.from_json(s) for s in sliced] +from chat_sdk.thread_history import ( + DEFAULT_MAX_MESSAGES, + DEFAULT_TTL_MS, + KEY_PREFIX, + ThreadHistoryCache, + ThreadHistoryConfig, +) + +# Deprecated: use ``ThreadHistoryCache`` from ``chat_sdk.thread_history``. +MessageHistoryCache = ThreadHistoryCache + +# Deprecated: use ``ThreadHistoryConfig`` from ``chat_sdk.thread_history``. +MessageHistoryConfig = ThreadHistoryConfig + +__all__ = [ + "DEFAULT_MAX_MESSAGES", + "DEFAULT_TTL_MS", + "KEY_PREFIX", + "MessageHistoryCache", + "MessageHistoryConfig", +] diff --git a/src/chat_sdk/shared/mock_adapter.py b/src/chat_sdk/shared/mock_adapter.py index f7fcef4..cdaa20d 100644 --- a/src/chat_sdk/shared/mock_adapter.py +++ b/src/chat_sdk/shared/mock_adapter.py @@ -82,7 +82,11 @@ def __init__(self, name: str = "slack") -> None: self.user_name = f"{name}-bot" self.bot_user_id: str | None = None self.lock_scope = None + # Deprecated alias read alongside persist_thread_history; tests set + # either flag per-instance (mirrors the upstream mock, where neither + # is defined until a test assigns it). self.persist_message_history = None + self.persist_thread_history = None # Call recorders self._post_calls: list[tuple[str, AdapterPostableMessage]] = [] diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index 06f7679..6bd0d45 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -269,7 +269,7 @@ class _ThreadImplConfig: # Direct adapter mode adapter: Adapter | None = None state_adapter: StateAdapter | None = None - message_history: Any = None # MessageHistoryCache + thread_history: Any = None # ThreadHistoryCache # Lazy resolution mode adapter_name: str | None = None @@ -303,7 +303,7 @@ def __init__(self, config: _ThreadImplConfig) -> None: self._adapter: Adapter | None = config.adapter self._adapter_name: str | None = config.adapter_name self._state_adapter_instance: StateAdapter | None = config.state_adapter - self._message_history: Any = config.message_history + self._thread_history: Any = config.thread_history # Lazy channel cache self._channel_cache: ChannelImpl | None = None @@ -405,7 +405,7 @@ def channel(self) -> Channel: state_adapter=self._state_adapter, is_dm=self._is_dm, channel_visibility=self._channel_visibility, - message_history=self._message_history, + thread_history=self._thread_history, ) ) return self._channel_cache @@ -419,7 +419,7 @@ async def messages(self) -> AsyncIterator[Message]: """ adapter = self.adapter thread_id = self._id - message_history = self._message_history + thread_history = self._thread_history cursor: str | None = None yielded_any = False @@ -438,8 +438,8 @@ async def messages(self) -> AsyncIterator[Message]: cursor = result.next_cursor # Fallback to cached history - if not yielded_any and message_history is not None: - cached: list[Message] = await message_history.get_messages(thread_id) + if not yielded_any and thread_history is not None: + cached: list[Message] = await thread_history.get_messages(thread_id) for msg in reversed(cached): yield msg @@ -450,7 +450,7 @@ async def all_messages(self) -> AsyncIterator[Message]: """ adapter = self.adapter thread_id = self._id - message_history = self._message_history + thread_history = self._thread_history cursor: str | None = None yielded_any = False @@ -467,8 +467,8 @@ async def all_messages(self) -> AsyncIterator[Message]: break cursor = result.next_cursor - if not yielded_any and message_history is not None: - cached: list[Message] = await message_history.get_messages(thread_id) + if not yielded_any and thread_history is not None: + cached: list[Message] = await thread_history.get_messages(thread_id) for msg in cached: yield msg @@ -562,10 +562,10 @@ async def post( raw = await self._handle_postable_object(message) # Cache in history with the real message ID (upstream skips this, # but that's a gap — posted messages should appear in history). - if self._message_history is not None and raw is not None: + if self._thread_history is not None and raw is not None: fallback = message.get_fallback_text() if hasattr(message, "get_fallback_text") else "" sent = self._create_sent_message(raw.id, PostableMarkdown(markdown=fallback), raw.thread_id) - await self._message_history.append(self._id, _to_message(sent)) + await self._thread_history.append(self._id, _to_message(sent)) return message # Handle AsyncIterable (streaming) @@ -576,8 +576,8 @@ async def post( raw_msg = await self.adapter.post_message(self._id, postable) result = self._create_sent_message(raw_msg.id, postable, raw_msg.thread_id, raw=raw_msg.raw) - if self._message_history is not None: - await self._message_history.append(self._id, _to_message(result)) + if self._thread_history is not None: + await self._thread_history.append(self._id, _to_message(result)) return result @@ -716,8 +716,8 @@ async def _wrapped_stream() -> AsyncIterator[str | StreamChunk]: PostableMarkdown(markdown=recorded_text), raw_result.thread_id, ) - if self._message_history is not None: - await self._message_history.append(self._id, _to_message(sent)) + if self._thread_history is not None: + await self._thread_history.append(self._id, _to_message(sent)) return sent # Fallback: post + edit with throttling (text-only) @@ -930,8 +930,8 @@ async def _edit_loop() -> None: PostableMarkdown(markdown=last_edit_content), thread_id_for_edits, ) - if self._message_history is not None: - await self._message_history.append(self._id, _to_message(sent)) + if self._thread_history is not None: + await self._thread_history.append(self._id, _to_message(sent)) # If the stream raised mid-way, we've now flushed whatever partial # content was rendered (so the user sees real content instead of a @@ -954,8 +954,8 @@ async def refresh(self) -> None: result = await self.adapter.fetch_messages(self._id, FetchOptions(limit=50)) if result.messages: self._recent_messages = result.messages - elif self._message_history is not None: - self._recent_messages = await self._message_history.get_messages(self._id, 50) + elif self._thread_history is not None: + self._recent_messages = await self._thread_history.get_messages(self._id, 50) else: self._recent_messages = [] @@ -1036,7 +1036,7 @@ def from_json( # Every attribute that embeds a reference to the old context # gets cleared here: `_state_adapter_instance` (old chat's # state backend), `_channel_cache` (old adapter's channel), - # `_message_history` (old chat's history cache), + # `_thread_history` (old chat's history cache), # `_recent_messages` (old adapter's fetched content), and # `_is_subscribed_context` (handler-context flag from the # old chat — a thread rebound to a new context shouldn't @@ -1045,7 +1045,7 @@ def from_json( if adapter is not None or chat is not None: thread._state_adapter_instance = None thread._channel_cache = None - thread._message_history = None + thread._thread_history = None thread._recent_messages = [] thread._is_subscribed_context = False else: @@ -1323,4 +1323,4 @@ class _ChannelImplConfigForThread: state_adapter: StateAdapter is_dm: bool = False channel_visibility: ChannelVisibility = "unknown" - message_history: Any = None + thread_history: Any = None diff --git a/src/chat_sdk/thread_history.py b/src/chat_sdk/thread_history.py new file mode 100644 index 0000000..82129cd --- /dev/null +++ b/src/chat_sdk/thread_history.py @@ -0,0 +1,97 @@ +"""Persistent per-thread history cache backed by the StateAdapter. + +Python port of thread-history.ts (renamed upstream from message-history.ts). + +Used by adapters that lack server-side message history APIs +(e.g. WhatsApp, Telegram). Messages are atomically appended via +``state.append_to_list()``, which is safe without holding a thread lock. + +Distinct from the cross-platform per-user Transcripts API (see +``transcripts.py``) — this cache is keyed by thread, not user. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from chat_sdk.types import Message, StateAdapter + +# Default maximum number of messages to store per thread +DEFAULT_MAX_MESSAGES = 100 + +# Default TTL for thread history (7 days in milliseconds) +DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000 + +# Key prefix for thread history entries. +# +# Kept as ``msg-history:`` for backwards compatibility — renaming would +# silently orphan every existing user's stored data. The user-facing names +# changed; the storage shape didn't. +KEY_PREFIX = "msg-history:" + + +@dataclass +class ThreadHistoryConfig: + """Configuration for the thread history cache.""" + + max_messages: int = DEFAULT_MAX_MESSAGES + ttl_ms: int = DEFAULT_TTL_MS + + +class ThreadHistoryCache: + """Persistent per-thread history cache backed by the StateAdapter. + + Used by adapters that lack server-side message history APIs + (e.g. WhatsApp, Telegram). Messages are atomically appended via + ``state.append_to_list()``, which is safe without holding a thread lock. + + Distinct from the cross-platform per-user + :class:`~chat_sdk.transcripts.TranscriptsApiImpl` (see ``transcripts.py``) + — this cache is keyed by thread, not user. + """ + + def __init__( + self, + state: StateAdapter, + config: ThreadHistoryConfig | None = None, + ) -> None: + self._state = state + cfg = config or ThreadHistoryConfig() + self._max_messages = cfg.max_messages + self._ttl_ms = cfg.ttl_ms + + async def append(self, thread_id: str, message: Message) -> None: + """Atomically append a message to the history for a thread. + + Trims to ``max_messages`` (keeps newest) and refreshes TTL. + """ + key = f"{KEY_PREFIX}{thread_id}" + + # Serialize with raw nulled out to save storage + serialized = message.to_json() + serialized["raw"] = None + + await self._state.append_to_list( + key, + serialized, + max_length=self._max_messages, + ttl_ms=self._ttl_ms, + ) + + async def get_messages(self, thread_id: str, limit: int | None = None) -> list[Message]: + """Get messages for a thread in chronological order (oldest first). + + Parameters + ---------- + thread_id: + The thread ID. + limit: + Optional limit on number of messages to return (returns newest N). + """ + key = f"{KEY_PREFIX}{thread_id}" + stored: list[dict[str, Any]] = await self._state.get_list(key) + + sliced = stored[len(stored) - limit :] if limit and len(stored) > limit else stored + + return [Message.from_json(s) for s in sliced] diff --git a/src/chat_sdk/transcripts.py b/src/chat_sdk/transcripts.py new file mode 100644 index 0000000..4d15aad --- /dev/null +++ b/src/chat_sdk/transcripts.py @@ -0,0 +1,195 @@ +"""Cross-platform per-user transcript store. + +Python port of transcripts.ts. + +Backed by ``StateAdapter.append_to_list`` — every built-in state adapter +supports it with no contract changes. Keyed by a resolved cross-platform +user key from ``ChatConfig.identity``. + +Distinct from :class:`~chat_sdk.thread_history.ThreadHistoryCache` (which is +per-thread, used by adapters that lack server-side history APIs). +""" + +from __future__ import annotations + +import re +import time +import uuid +from typing import Any + +from chat_sdk.types import ( + AppendInput, + AppendOptions, + CountQuery, + DeleteResult, + DeleteTarget, + DurationString, + ListQuery, + Message, + Postable, + StateAdapter, + TranscriptEntry, + TranscriptRole, + TranscriptsConfig, +) + +KEY_PREFIX = "transcripts:user:" +DEFAULT_MAX_PER_USER = 200 +DEFAULT_LIST_LIMIT = 50 +DURATION_RE = re.compile(r"^(\d+)([smhd])$") + +# Sentinel value written by ``delete()`` so the underlying list is +# functionally empty without needing a ``clear_list`` primitive on the state +# adapter contract. +# +# ``append_to_list(key, tombstone, max_length=1)`` evicts every prior entry +# across all built-in state adapters (memory trims the shared list; +# redis uses LTRIM; postgres trims oldest rows). The remaining single +# tombstone is filtered out by ``list()`` and ``count()``, and is pushed out +# naturally on the next append once ``max_per_user`` writes have happened. +# +# The marker string matches the upstream TS SDK so stores are interoperable. +TOMBSTONE_MARKER = "__chatSdkTombstone" + +MS_PER_UNIT = { + "s": 1_000, + "m": 60_000, + "h": 3_600_000, + "d": 86_400_000, +} + + +def _is_tombstone(value: Any) -> bool: + return isinstance(value, dict) and value.get(TOMBSTONE_MARKER) is True + + +def _key_for(user_key: str) -> str: + return f"{KEY_PREFIX}{user_key}" + + +def _parse_duration(value: int | float | DurationString | None) -> int | float | None: + if value is None: + return None + # Numbers pass through unchanged (upstream `typeof value === "number"`). + # bool is an int subclass but is not a valid duration — fall through to + # the string path's error (mirrors upstream, where a boolean fails the + # regex and raises). + if isinstance(value, (int, float)) and not isinstance(value, bool): + return value + match = DURATION_RE.match(value) if isinstance(value, str) else None + if not match: + raise ValueError(f'Invalid duration: {value} (expected number of ms, or "[smhd]")') + n = int(match.group(1)) + return n * MS_PER_UNIT[match.group(2)] + + +class TranscriptsApiImpl: + """Cross-platform per-user transcript store, backed by + ``StateAdapter.append_to_list``. + + Distinct from :class:`~chat_sdk.thread_history.ThreadHistoryCache` (which + is per-thread, used by adapters that lack server-side history APIs). This + store is keyed by a resolved cross-platform user key from + ``ChatConfig.identity``. + """ + + def __init__(self, state: StateAdapter, config: TranscriptsConfig) -> None: + self._state = state + self._max_per_user = config.max_per_user if config.max_per_user is not None else DEFAULT_MAX_PER_USER + self._retention_ms = _parse_duration(config.retention) + self._store_formatted = config.store_formatted + + async def append( + self, + thread: Postable, + message: Message | AppendInput, + options: AppendOptions | None = None, + ) -> TranscriptEntry | None: + """Persist a Message (or AppendInput) under the user key. + + - For Message: ``user_key`` is read from the Message instance (set by + the SDK during inbound dispatch via the configured IdentityResolver). + No-op (returns ``None``) if the Message has no ``user_key``. + - For AppendInput: ``options.user_key`` is required. + """ + if isinstance(message, Message): + user_key = message.user_key + role: TranscriptRole = "user" + platform_message_id: str | None = message.id + if not user_key: + return None + else: + user_key = options.user_key if options is not None else None + role = message.role + platform_message_id = message.platform_message_id + if not user_key: + raise ValueError("transcripts.append: options.user_key is required when appending an AppendInput") + + entry = TranscriptEntry( + id=str(uuid.uuid4()), + user_key=user_key, + role=role, + text=message.text, + platform=thread.adapter.name, + thread_id=thread.id, + timestamp=int(time.time() * 1000), + ) + if self._store_formatted and message.formatted: + entry.formatted = message.formatted + if platform_message_id is not None: + entry.platform_message_id = platform_message_id + + await self._state.append_to_list( + _key_for(user_key), + entry.to_json(), + max_length=self._max_per_user, + ttl_ms=self._retention_ms, + ) + + return entry + + async def list(self, query: ListQuery) -> list[TranscriptEntry]: + """Return the most recent entries in chronological order (oldest + first), capped at ``query.limit`` (default 50). + + Pagination is intentionally not supported — the store keeps at most + ``max_per_user`` entries per user. To widen the window, raise + ``max_per_user``; to fetch a different slice, narrow with + ``thread_id`` / ``platforms`` / ``roles``. + """ + raw = await self._state.get_list(_key_for(query.user_key)) + filtered = [TranscriptEntry.from_json(entry) for entry in raw if not _is_tombstone(entry)] + + if query.platforms: + platforms = set(query.platforms) + filtered = [m for m in filtered if m.platform in platforms] + if query.thread_id is not None: + tid = query.thread_id + filtered = [m for m in filtered if m.thread_id == tid] + if query.roles: + roles = set(query.roles) + filtered = [m for m in filtered if m.role in roles] + + limit = query.limit if query.limit is not None else DEFAULT_LIST_LIMIT + if len(filtered) > limit: + filtered = filtered[len(filtered) - limit :] + return filtered + + async def count(self, query: CountQuery) -> int: + """Total stored count for a user key.""" + raw = await self._state.get_list(_key_for(query.user_key)) + return sum(1 for entry in raw if not _is_tombstone(entry)) + + async def delete(self, target: DeleteTarget) -> DeleteResult: + """GDPR / DSR delete — wipes every stored message under the user key.""" + key = _key_for(target.user_key) + existing = await self._state.get_list(key) + previous = sum(1 for entry in existing if not _is_tombstone(entry)) + # Append a tombstone with max_length=1 to evict every prior entry. The + # remaining tombstone is filtered out by list()/count(), and is + # naturally pushed out by the next append once max_per_user writes + # accumulate. (Not ``state.delete(key)`` — that only addresses the + # k/v namespace on every non-memory state adapter.) + tombstone: dict[str, Any] = {TOMBSTONE_MARKER: True} + await self._state.append_to_list(key, tombstone, max_length=1, ttl_ms=self._retention_ms) + return DeleteResult(deleted=previous) diff --git a/src/chat_sdk/types.py b/src/chat_sdk/types.py index 28cb259..97e1e65 100644 --- a/src/chat_sdk/types.py +++ b/src/chat_sdk/types.py @@ -411,6 +411,15 @@ class Message: is_mention: bool | None = None links: list[LinkPreview] | None = None raw: Any = None + # Cross-platform user key for this message's author. + # + # Set by the Chat SDK before passing the message to handlers, when + # ``ChatConfig.identity`` is configured. ``None`` if no resolver is + # configured or when the resolver returned ``None``. + # + # Used by the Transcripts API to look up / append per-user transcripts. + # Not part of the serialized ``SerializedMessage`` shape. + user_key: str | None = None def to_json(self) -> dict[str, Any]: """Serialize to JSON-compatible dict. @@ -1226,9 +1235,21 @@ def user_name(self) -> str: ... def bot_user_id(self) -> str | None: ... @property def lock_scope(self) -> LockScope | None: ... + + # Deprecated: renamed to ``persist_thread_history``. Both flags are read + # for backwards compatibility; either being truthy enables persistence. + # Like the upstream TS interface members, both flags are *optional* — + # the SDK reads them via ``getattr(..., None)``, so adapters may omit + # either attribute entirely. @property def persist_message_history(self) -> bool | None: ... + # When true, the SDK persists per-thread message history in the state + # adapter for this platform. Use for platforms that lack server-side + # message history APIs (e.g. WhatsApp, Telegram). + @property + def persist_thread_history(self) -> bool | None: ... + def encode_thread_id(self, platform_data: Any) -> str: ... def decode_thread_id(self, thread_id: str) -> Any: ... def channel_id_from_thread_id(self, thread_id: str) -> str: ... @@ -1285,10 +1306,16 @@ def bot_user_id(self) -> str | None: def lock_scope(self) -> LockScope | None: return None + # Deprecated: renamed to ``persist_thread_history``. Kept for + # backwards compatibility; either flag being truthy enables persistence. @property def persist_message_history(self) -> bool | None: return None + @property + def persist_thread_history(self) -> bool | None: + return None + # -- Optional methods with default (not-implemented) -------------------- async def stream( @@ -1455,17 +1482,38 @@ class ChatConfig: # Milliseconds to remember a message ID for deduplication (default 5 min). dedupe_ttl_ms: int = 300000 fallback_streaming_placeholder_text: str | None = "..." + # Resolves a stable cross-platform user key from inbound messages. + # + # Required when ``transcripts`` is configured. Called once per inbound + # message during dispatch; the result is attached to the Message + # instance as ``message.user_key`` for handlers to use. + identity: IdentityResolver | None = None # Whether locks are scoped per-thread or per-channel. # Can also be a callable that inspects context and returns the scope. lock_scope: LockScope | Callable[..., LockScope | Awaitable[LockScope]] | None = None logger: Logger | LogLevel | None = None - # Configuration dict forwarded to MessageHistoryCache - # (e.g. {"max_messages": 50, "persist": True}). + # Deprecated: renamed to ``thread_history``. Both fields are read for + # backwards compatibility; ``thread_history`` takes precedence when both + # are set. message_history: dict[str, Any] | None = None # What to do when a lock is already held: "drop" the new message, # "force" acquire, or a callable that decides at runtime. on_lock_conflict: OnLockConflict | None = None streaming_update_interval_ms: int = 500 + # Configuration dict for persistent per-thread message history backfill + # (e.g. {"max_messages": 50, "ttl_ms": 86_400_000}). + # + # Only used by adapters that set ``persist_thread_history`` (e.g. + # Telegram, WhatsApp). Distinct from ``transcripts`` (the cross-platform + # per-user Transcripts API). + thread_history: dict[str, Any] | None = None + # Cross-platform per-user message persistence. + # + # When set, ``chat.transcripts`` is available for append/list/count/delete + # keyed by a resolved cross-platform user key. + # + # Requires ``identity`` to also be set; the constructor raises otherwise. + transcripts: TranscriptsConfig | None = None # ============================================================================= @@ -1511,6 +1559,12 @@ def process_member_joined_channel( self, event: MemberJoinedChannelEvent, options: WebhookOptions | None = None ) -> None: ... + # Cross-platform per-user transcript store. Raises on access when + # ``transcripts`` is not configured on the Chat instance — callers should + # check ``ChatConfig.transcripts`` if they need a no-raise guard. + @property + def transcripts(self) -> TranscriptsApi: ... + # ============================================================================= # Postable / Channel / Thread interfaces (simplified for adapter use) @@ -1679,3 +1733,221 @@ def create_sent_message_from_message(self, message: Message) -> SentMessage: def to_json(self) -> dict[str, Any]: """Serialize the thread to a plain JSON object.""" ... + + +# ============================================================================= +# Transcripts API (cross-platform per-user message persistence) +# ============================================================================= + + +@dataclass +class IdentityContext: + """Context passed to an :data:`IdentityResolver`.""" + + # Adapter name (e.g. "slack", "discord"). + adapter: str + author: Author + message: Message + + +# Resolves a stable, cross-platform user key from an inbound message context. +# +# Return ``None`` to skip persistence for this event (unknown user, system +# message, or the bot itself). The SDK fails loudly rather than silently +# falling back to a platform-specific ID. May be sync or async. +IdentityResolver = Callable[[IdentityContext], "str | None | Awaitable[str | None]"] + +# Role tag on a stored message. +# +# - ``user``: produced by the resolved end-user +# - ``assistant``: produced by this bot +# - ``system``: SDK-injected marker (handoff, summary). Adapters never produce it. +TranscriptRole = Literal["user", "assistant", "system"] + +# Duration shorthand: e.g. ``"7d"``, ``"30m"``, ``"2h"``, ``"45s"``. +# (TS models this as a template-literal type; Python uses a plain ``str``.) +DurationString = str + + +@dataclass +class TranscriptEntry: + """A stored transcript entry. + + Serialized at the storage boundary via :meth:`to_json` using the same + camelCase shape the upstream TS SDK writes, so stores are interoperable. + """ + + # UUID assigned by the SDK at append time. Opaque — not lexicographically + # sortable. Entries are returned by ``list()`` in append order (the + # underlying list semantics of ``state.append_to_list``); use + # ``timestamp`` to reason about ordering across stores. + id: str + # Cross-platform user key from the IdentityResolver. + user_key: str + role: TranscriptRole + # Plain-text body — canonical field for prompt building. + text: str + # Originating adapter name. + platform: str + # Originating thread ID. + thread_id: str + # ms-since-epoch, set at append time on the SDK side. + timestamp: int + # mdast AST. Only present when ``transcripts.store_formatted`` is true. + formatted: FormattedContent | None = None + # Platform-native message ID, when known. + platform_message_id: str | None = None + + def to_json(self) -> dict[str, Any]: + """Serialize to the camelCase storage shape (optional keys omitted).""" + result: dict[str, Any] = { + "id": self.id, + "userKey": self.user_key, + "role": self.role, + "text": self.text, + "platform": self.platform, + "threadId": self.thread_id, + "timestamp": self.timestamp, + } + if self.formatted is not None: + result["formatted"] = self.formatted + if self.platform_message_id is not None: + result["platformMessageId"] = self.platform_message_id + return result + + @classmethod + def from_json(cls, data: dict[str, Any]) -> TranscriptEntry: + """Reconstruct an entry from its camelCase storage shape.""" + return cls( + id=data.get("id", ""), + user_key=data.get("userKey", ""), + role=data.get("role", "user"), + text=data.get("text", ""), + platform=data.get("platform", ""), + thread_id=data.get("threadId", ""), + timestamp=data.get("timestamp", 0), + formatted=data.get("formatted"), + platform_message_id=data.get("platformMessageId"), + ) + + +@dataclass +class TranscriptsConfig: + """Configuration for the cross-platform per-user Transcripts API.""" + + # Hard cap; older messages evicted on append. Default 200. + max_per_user: int | None = None + # Default retention applied as the list TTL (ms, or a DurationString such + # as "7d"). Refreshed on every append (matches ``append_to_list`` + # semantics). Omit for no expiry. + retention: int | DurationString | None = None + # Persist ``formatted`` (mdast). Default False to keep storage small. + store_formatted: bool = False + + +@dataclass +class AppendInput: + """Input shape for appending a non-Message (e.g. an assistant reply you + just posted via ``thread.post()``).""" + + role: TranscriptRole + text: str + formatted: FormattedContent | None = None + platform_message_id: str | None = None + + +@dataclass +class AppendOptions: + """Options for :meth:`TranscriptsApi.append`.""" + + # Required when appending an ``AppendInput`` (assistant/system role) — the + # SDK has no Message instance from which to read the resolved key. + # + # Ignored when appending a Message; the Message's own ``user_key`` is used. + user_key: str | None = None + + +@dataclass +class ListQuery: + """Query for :meth:`TranscriptsApi.list`.""" + + user_key: str + # Newest N kept (still returned in chronological order). Default 50. + limit: int | None = None + # Filter to a subset of adapter names. + platforms: list[str] | None = None + # Filter to specific roles. Default: all. + roles: list[TranscriptRole] | None = None + # Filter to a single thread. + thread_id: str | None = None + + +@dataclass +class DeleteTarget: + """Target for :meth:`TranscriptsApi.delete`. Wipes every stored message + under the given user key.""" + + user_key: str + + +@dataclass +class CountQuery: + """Query shape for :meth:`TranscriptsApi.count`.""" + + user_key: str + + +@dataclass +class DeleteResult: + """Result of :meth:`TranscriptsApi.delete`. + + Python-side named type for the upstream inline ``{ deleted: number }`` + return shape. + """ + + deleted: int + + +class TranscriptsApi(Protocol): + """Cross-platform per-user message store. + + Distinct from the existing per-thread ``thread_history`` config (which + exists to backfill thread context for adapters that lack server-side + history APIs). The Transcripts API is keyed by a resolved cross-platform + user key and is intended for transcript-style use cases (LLM context + building, audit). + """ + + async def append( + self, + thread: Postable, + message: Message | AppendInput, + options: AppendOptions | None = None, + ) -> TranscriptEntry | None: + """Persist a Message (or AppendInput) under the user key. + + - For Message: ``user_key`` is read from the Message instance (set by + the SDK during inbound dispatch via the configured IdentityResolver). + No-op if the Message has no ``user_key`` (resolver returned None). + - For AppendInput: ``options.user_key`` is required. + """ + ... + + async def count(self, query: CountQuery) -> int: + """Total stored count for a user key.""" + ... + + async def delete(self, target: DeleteTarget) -> DeleteResult: + """GDPR / DSR delete — wipes every stored message under the user key.""" + ... + + async def list(self, query: ListQuery) -> list[TranscriptEntry]: + """Return the most recent entries in chronological order (oldest + first), capped at ``query.limit`` (default 50). + + Pagination is intentionally not supported — the store keeps at most + ``transcripts.max_per_user`` entries per user. To widen the window, + raise ``max_per_user``; to fetch a different slice, narrow with + ``thread_id`` / ``platforms`` / ``roles``. + """ + ... diff --git a/tests/test_channel_faithful.py b/tests/test_channel_faithful.py index ac74bec..7de70ba 100644 --- a/tests/test_channel_faithful.py +++ b/tests/test_channel_faithful.py @@ -752,7 +752,7 @@ def test_should_leave_channel_unchanged_when_chat_rebind_lookup_fails(self): "_adapter": original._adapter, "_adapter_name": original._adapter_name, "_state_adapter_instance": original._state_adapter_instance, - "_message_history": original._message_history, + "_thread_history": original._thread_history, } with pytest.raises(RuntimeError, match='Adapter "slack" not found'): diff --git a/tests/test_chat_faithful.py b/tests/test_chat_faithful.py index 56e5fed..cd0a085 100644 --- a/tests/test_chat_faithful.py +++ b/tests/test_chat_faithful.py @@ -3852,13 +3852,55 @@ async def handler(thread, message, context=None): # ============================================================================ -# 23. persistMessageHistory (tests 92-93) +# 23. persistThreadHistory (tests 92-97) # ============================================================================ -class TestPersistMessageHistory: - # TS: "should cache incoming messages when adapter has persistMessageHistory" - async def test_should_cache_incoming_messages_when_adapter_has_persistmessagehistory(self): +def _record_append_to_list_calls(state: MockStateAdapter) -> list[tuple[str, Any, int | None, int | None]]: + """Wrap ``state.append_to_list`` to record ``(key, value, max_length, ttl_ms)``. + + Python stand-in for the TS mock state's ``vi.fn()``-wrapped + ``appendToList`` that the upstream config-precedence tests assert on. + """ + calls: list[tuple[str, Any, int | None, int | None]] = [] + real_append = state.append_to_list + + async def _recording_append( + key: str, value: Any, *, max_length: int | None = None, ttl_ms: int | None = None + ) -> None: + calls.append((key, value, max_length, ttl_ms)) + await real_append(key, value, max_length=max_length, ttl_ms=ttl_ms) + + state.append_to_list = _recording_append + return calls + + +class TestPersistThreadHistory: + # TS: "caches incoming messages when adapter sets persistThreadHistory" + async def test_caches_incoming_messages_when_adapter_sets_persistthreadhistory(self): + adapter = create_mock_adapter("whatsapp") + adapter.persist_thread_history = True + state = create_mock_state() + + config = ChatConfig( + user_name="testbot", + adapters={"whatsapp": adapter}, + state=state, + logger=MockLogger(), + ) + chat = Chat(config) + await chat.webhooks["whatsapp"]("request") + + msg = create_test_message("msg-1", "Hello from WhatsApp") + await chat.handle_incoming_message(adapter, "whatsapp:phone:user1", msg) + + stored = state.cache.get("msg-history:whatsapp:phone:user1") + assert stored is not None + assert isinstance(stored, list) + assert stored[0]["id"] == "msg-1" + + # TS: "caches incoming messages when adapter sets the deprecated persistMessageHistory flag" + async def test_caches_incoming_messages_when_adapter_sets_the_deprecated_persistmessagehistory_flag(self): adapter = create_mock_adapter("whatsapp") adapter.persist_message_history = True state = create_mock_state() @@ -3880,8 +3922,8 @@ async def test_should_cache_incoming_messages_when_adapter_has_persistmessagehis assert isinstance(stored, list) assert stored[0]["id"] == "msg-1" - # TS: "should NOT cache incoming messages when adapter does not set persistMessageHistory" - async def test_should_not_cache_incoming_messages_when_adapter_does_not_set_persistmessagehistory(self): + # TS: "does not cache when adapter sets neither flag" + async def test_does_not_cache_when_adapter_sets_neither_flag(self): chat, adapter, state = await _init_chat() msg = create_test_message("msg-2", "Hello from Slack") @@ -3890,6 +3932,83 @@ async def test_should_not_cache_incoming_messages_when_adapter_does_not_set_pers history_keys = [k for k in state.cache if k.startswith("msg-history:")] assert len(history_keys) == 0 + # TS: "honors top-level config.messageHistory (deprecated alias) when threadHistory is not set" + async def test_honors_top_level_config_messagehistory_deprecated_alias_when_threadhistory_is_not_set(self): + adapter = create_mock_adapter("whatsapp") + adapter.persist_thread_history = True + state = create_mock_state() + append_calls = _record_append_to_list_calls(state) + + config = ChatConfig( + user_name="testbot", + adapters={"whatsapp": adapter}, + state=state, + logger=MockLogger(), + message_history={"max_messages": 5, "ttl_ms": 12_345}, + ) + chat = Chat(config) + await chat.webhooks["whatsapp"]("request") + + msg = create_test_message("msg-1", "Hello") + await chat.handle_incoming_message(adapter, "whatsapp:phone:user1", msg) + + matching = [call for call in append_calls if call[0] == "msg-history:whatsapp:phone:user1"] + assert len(matching) == 1 + _key, value, max_length, ttl_ms = matching[0] + assert value["id"] == "msg-1" + assert max_length == 5 + assert ttl_ms == 12_345 + + # TS: "threadHistory takes precedence when both threadHistory and messageHistory are set" + async def test_threadhistory_takes_precedence_when_both_threadhistory_and_messagehistory_are_set(self): + adapter = create_mock_adapter("whatsapp") + adapter.persist_thread_history = True + state = create_mock_state() + append_calls = _record_append_to_list_calls(state) + + config = ChatConfig( + user_name="testbot", + adapters={"whatsapp": adapter}, + state=state, + logger=MockLogger(), + thread_history={"max_messages": 5, "ttl_ms": 1_000}, + message_history={"max_messages": 999, "ttl_ms": 999_999}, + ) + chat = Chat(config) + await chat.webhooks["whatsapp"]("request") + + msg = create_test_message("msg-1", "Hello") + await chat.handle_incoming_message(adapter, "whatsapp:phone:user1", msg) + + matching = [call for call in append_calls if call[0] == "msg-history:whatsapp:phone:user1"] + assert len(matching) == 1 + _key, value, max_length, ttl_ms = matching[0] + assert value["id"] == "msg-1" + assert max_length == 5 + assert ttl_ms == 1_000 + + # TS: "persists when both persistThreadHistory and persistMessageHistory are set on the adapter" + async def test_persists_when_both_persistthreadhistory_and_persistmessagehistory_are_set_on_the_adapter(self): + adapter = create_mock_adapter("whatsapp") + adapter.persist_thread_history = True + adapter.persist_message_history = True + state = create_mock_state() + + config = ChatConfig( + user_name="testbot", + adapters={"whatsapp": adapter}, + state=state, + logger=MockLogger(), + ) + chat = Chat(config) + await chat.webhooks["whatsapp"]("request") + + msg = create_test_message("msg-1", "Hello") + await chat.handle_incoming_message(adapter, "whatsapp:phone:user1", msg) + + stored = state.cache.get("msg-history:whatsapp:phone:user1") + assert stored is not None + assert stored[0]["id"] == "msg-1" # ============================================================================ # 21. processMessage return value (core slice of vercel/chat#444) diff --git a/tests/test_chat_resolver.py b/tests/test_chat_resolver.py index c89530e..fdfe367 100644 --- a/tests/test_chat_resolver.py +++ b/tests/test_chat_resolver.py @@ -481,45 +481,47 @@ def test_omitting_current_message_stubs_a_placeholder(self): def test_reuses_parent_chat_state_and_history(self): """The factory must bind the new Thread to the parent Chat's state - adapter and message history. This is the core contract of + adapter and thread history. This is the core contract of `chat.thread()` — worker processes reconstruct a Thread that shares state with the original Chat instance, not a fresh detached thread. Without this, state writes/reads from the worker wouldn't be visible to the Chat in-process. - For adapters that don't persist message history (persist_message_history + For adapters that don't persist thread history (persist_thread_history falsy), `_create_thread` intentionally skips the history bind — there's no cache to share. This test uses a persisting adapter to exercise the positive case. """ chat = _make_chat("slack") - # Opt the mock adapter into message history so the factory binds it + # Opt the mock adapter into thread history so the factory binds it adapter = chat._adapters["slack"] - adapter.persist_message_history = True + adapter.persist_thread_history = True thread = chat.thread("slack:C123:1234567890.123456") # State adapter must be the exact same instance, not a copy assert thread._state_adapter is chat._state_adapter - # Message history must be the exact same instance too - assert thread._message_history is chat._message_history + # Thread history must be the exact same instance too + assert thread._thread_history is chat._thread_history def test_omits_history_when_adapter_does_not_persist(self): - """Adapters with `persist_message_history` falsy opt out of the - shared history cache. `Chat.thread()` respects that: the Thread - gets `None` for history rather than a shared cache the adapter - won't populate. - - Explicitly set `persist_message_history = None` rather than - relying on the mock's default — prevents silent test regression - if the mock default ever changes. + """Adapters with `persist_thread_history` falsy (and the deprecated + `persist_message_history` alias also falsy) opt out of the shared + history cache. `Chat.thread()` respects that: the Thread gets + `None` for history rather than a shared cache the adapter won't + populate. + + Explicitly set both flags to `None` rather than relying on the + mock's defaults — prevents silent test regression if the mock + defaults ever change. """ chat = _make_chat("slack") adapter = chat._adapters["slack"] + adapter.persist_thread_history = None adapter.persist_message_history = None thread = chat.thread("slack:C123:1234567890.123456") assert thread._state_adapter is chat._state_adapter - assert thread._message_history is None + assert thread._thread_history is None def test_invalid_thread_id_raises(self): from chat_sdk.errors import ChatError diff --git a/tests/test_serialization.py b/tests/test_serialization.py index f48a46a..217f004 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -702,7 +702,7 @@ def test_should_leave_thread_unchanged_when_chat_rebind_lookup_fails(self, mock_ "_adapter_name": original._adapter_name, "_state_adapter_instance": original._state_adapter_instance, "_channel_cache": original._channel_cache, - "_message_history": original._message_history, + "_thread_history": original._thread_history, "_recent_messages": list(original._recent_messages), "_is_subscribed_context": original._is_subscribed_context, } @@ -720,10 +720,10 @@ def test_should_leave_thread_unchanged_when_chat_rebind_lookup_fails(self, mock_ assert original._adapter is snapshot["_adapter"] assert original._state_adapter_instance is snapshot["_state_adapter_instance"] - def test_should_invalidate_recent_messages_and_message_history_on_rebind(self, mock_state): + def test_should_invalidate_recent_messages_and_thread_history_on_rebind(self, mock_state): """Two additional caches that carry previous-binding references must also drop on idempotent rebind: `_recent_messages` (populated from - adapter fetches) and `_message_history` (tied to chat's cache). + adapter fetches) and `_thread_history` (tied to chat's cache). Regression for a self-review-subagent finding.""" from chat_sdk.testing import create_mock_adapter, create_mock_state, create_test_message @@ -732,7 +732,7 @@ def test_should_invalidate_recent_messages_and_message_history_on_rebind(self, m first_state = create_mock_state() # Prime caches: initial_message populates _recent_messages, and - # message_history is a sentinel cache object. + # thread_history is a sentinel cache object. sentinel_history = object() original = ThreadImpl( _ThreadImplConfig( @@ -741,15 +741,15 @@ def test_should_invalidate_recent_messages_and_message_history_on_rebind(self, m state_adapter=first_state, channel_id="C123", initial_message=create_test_message("m1", "hello"), - message_history=sentinel_history, + thread_history=sentinel_history, ) ) assert len(original._recent_messages) == 1 - assert original._message_history is sentinel_history + assert original._thread_history is sentinel_history rebound = ThreadImpl.from_json(original, adapter=second_adapter) assert rebound._recent_messages == [], "recent_messages should drop on rebind" - assert rebound._message_history is None, "message_history should drop on rebind" + assert rebound._thread_history is None, "thread_history should drop on rebind" def test_should_set_state_from_chat_when_both_adapter_and_chat_passed(self, mock_state): """adapter= and chat= are orthogonal. Passing both must apply both: diff --git a/tests/test_teams_native_streaming.py b/tests/test_teams_native_streaming.py index 6f023a4..e51ee15 100644 --- a/tests/test_teams_native_streaming.py +++ b/tests/test_teams_native_streaming.py @@ -422,7 +422,7 @@ async def test_flush_failure_propagates_and_cancels_session(self): the text via the flush), but a flush failure means buffered text was never accepted by Teams. Swallowing it would let ``Thread.stream`` record the buffered text in ``SentMessage`` / - ``_message_history`` even though the user never saw it. Re-raise + ``_thread_history`` even though the user never saw it. Re-raise so the outer ``Thread.stream`` short-circuits the history append. """ adapter = _make_adapter(clock_step_ms=0.0) @@ -1413,7 +1413,7 @@ async def test_markdown_text_chunk_dataclass_extract_text(self): unconditionally on the happy path) would record empty text, and ``Thread.stream`` would override its own correct accumulator with the empty adapter view. Result: ``SentMessage`` and - ``_message_history`` both record an empty string while the user + ``_thread_history`` both record an empty string while the user sees nothing in Teams. """ from chat_sdk.types import MarkdownTextChunk, PlanUpdateChunk, TaskUpdateChunk diff --git a/tests/test_telegram_adapter.py b/tests/test_telegram_adapter.py index 69f10ea..e5c8993 100644 --- a/tests/test_telegram_adapter.py +++ b/tests/test_telegram_adapter.py @@ -364,7 +364,7 @@ def test_adapter_class_properties(self): adapter = _make_adapter() assert adapter.name == "telegram" assert adapter.lock_scope == "channel" - assert adapter.persist_message_history is True + assert adapter.persist_thread_history is True assert adapter.bot_user_id is None # not yet initialized assert adapter.is_polling is False assert adapter.runtime_mode == "webhook" diff --git a/tests/test_message_history.py b/tests/test_thread_history.py similarity index 72% rename from tests/test_message_history.py rename to tests/test_thread_history.py index 44d12f5..8861aeb 100644 --- a/tests/test_message_history.py +++ b/tests/test_thread_history.py @@ -1,6 +1,7 @@ -"""Tests for MessageHistoryCache: append, get, TTL, and limit behavior. +"""Tests for ThreadHistoryCache: append, get, TTL, and limit behavior. -Port of message-history related tests from the Vercel Chat SDK. +Port of thread-history related tests from the Vercel Chat SDK +(thread-history.test.ts, renamed upstream from message-history.test.ts). """ from __future__ import annotations @@ -11,12 +12,12 @@ import pytest -from chat_sdk.message_history import ( +from chat_sdk.thread_history import ( DEFAULT_MAX_MESSAGES, DEFAULT_TTL_MS, KEY_PREFIX, - MessageHistoryCache, - MessageHistoryConfig, + ThreadHistoryCache, + ThreadHistoryConfig, ) from chat_sdk.types import Attachment, Author, Message, MessageMetadata @@ -89,17 +90,17 @@ async def _set(key: str, value: Any, *a: Any, **kw: Any) -> None: # --------------------------------------------------------------------------- -class TestMessageHistoryConstruction: +class TestThreadHistoryConstruction: def test_default_config(self): state = _make_mock_state() - cache = MessageHistoryCache(state) + cache = ThreadHistoryCache(state) assert cache._max_messages == DEFAULT_MAX_MESSAGES assert cache._ttl_ms == DEFAULT_TTL_MS def test_custom_config(self): state = _make_mock_state() - config = MessageHistoryConfig(max_messages=50, ttl_ms=3600000) - cache = MessageHistoryCache(state, config) + config = ThreadHistoryConfig(max_messages=50, ttl_ms=3600000) + cache = ThreadHistoryCache(state, config) assert cache._max_messages == 50 assert cache._ttl_ms == 3600000 @@ -110,31 +111,35 @@ def test_custom_config(self): class TestAppendAndGet: + # TS: "should use appendToList for atomic appends" @pytest.mark.asyncio - async def test_append_single_message(self): + async def test_should_use_appendtolist_for_atomic_appends(self): state = _make_mock_state() - cache = MessageHistoryCache(state) + cache = ThreadHistoryCache(state) - msg = _make_message("Hello world") + msg = _make_message("Hello world", msg_id="m1") await cache.append("thread-1", msg) state.append_to_list.assert_called_once() - call_kwargs = state.append_to_list.call_args - key = call_kwargs[0][0] - assert key == f"{KEY_PREFIX}thread-1" + args, kwargs = state.append_to_list.call_args + assert args[0] == f"{KEY_PREFIX}thread-1" + assert args[1]["id"] == "m1" + assert kwargs == {"max_length": 100, "ttl_ms": 7 * 24 * 60 * 60 * 1000} + # TS: "should return empty array for unknown thread" @pytest.mark.asyncio - async def test_get_messages_empty(self): + async def test_should_return_empty_array_for_unknown_thread(self): state = _make_mock_state() - cache = MessageHistoryCache(state) + cache = ThreadHistoryCache(state) messages = await cache.get_messages("nonexistent-thread") assert messages == [] + # TS: "should append and retrieve messages" @pytest.mark.asyncio - async def test_append_and_retrieve(self): + async def test_should_append_and_retrieve_messages(self): state = _make_mock_state() - cache = MessageHistoryCache(state) + cache = ThreadHistoryCache(state) msg1 = _make_message("First", msg_id="m1") msg2 = _make_message("Second", msg_id="m2") @@ -150,10 +155,11 @@ async def test_append_and_retrieve(self): assert messages[1].text == "Second" assert messages[2].text == "Third" + # TS: "should strip raw field on storage" @pytest.mark.asyncio - async def test_serialized_raw_is_nulled(self): + async def test_should_strip_raw_field_on_storage(self): state = _make_mock_state() - cache = MessageHistoryCache(state) + cache = ThreadHistoryCache(state) msg = _make_message("With raw") msg.raw = {"some": "data"} @@ -171,10 +177,11 @@ async def test_serialized_raw_is_nulled(self): class TestGetMessagesLimit: + # TS: "should support limit parameter in getMessages" @pytest.mark.asyncio - async def test_limit_returns_newest_n(self): + async def test_should_support_limit_parameter_in_getmessages(self): state = _make_mock_state() - cache = MessageHistoryCache(state) + cache = ThreadHistoryCache(state) for i in range(5): await cache.append("thread-1", _make_message(f"msg-{i}", msg_id=f"m{i}")) @@ -187,7 +194,7 @@ async def test_limit_returns_newest_n(self): @pytest.mark.asyncio async def test_limit_larger_than_stored(self): state = _make_mock_state() - cache = MessageHistoryCache(state) + cache = ThreadHistoryCache(state) await cache.append("thread-1", _make_message("only one")) messages = await cache.get_messages("thread-1", limit=100) @@ -196,7 +203,7 @@ async def test_limit_larger_than_stored(self): @pytest.mark.asyncio async def test_limit_none_returns_all(self): state = _make_mock_state() - cache = MessageHistoryCache(state) + cache = ThreadHistoryCache(state) for i in range(10): await cache.append("thread-1", _make_message(f"msg-{i}", msg_id=f"m{i}")) @@ -211,11 +218,12 @@ async def test_limit_none_returns_all(self): class TestMaxMessagesTrimming: + # TS: "should trim to maxMessages, keeping newest" @pytest.mark.asyncio - async def test_trims_to_max_messages(self): + async def test_should_trim_to_maxmessages_keeping_newest(self): state = _make_mock_state() - config = MessageHistoryConfig(max_messages=3) - cache = MessageHistoryCache(state, config) + config = ThreadHistoryConfig(max_messages=3) + cache = ThreadHistoryCache(state, config) for i in range(5): await cache.append("thread-1", _make_message(f"msg-{i}", msg_id=f"m{i}")) @@ -241,24 +249,14 @@ class TestTTLPropagation: @pytest.mark.asyncio async def test_passes_ttl_to_state(self): state = _make_mock_state() - config = MessageHistoryConfig(ttl_ms=86400000) - cache = MessageHistoryCache(state, config) + config = ThreadHistoryConfig(ttl_ms=86400000) + cache = ThreadHistoryCache(state, config) await cache.append("thread-1", _make_message("test")) call_kwargs = state.append_to_list.call_args[1] assert call_kwargs.get("ttl_ms") == 86400000 - @pytest.mark.asyncio - async def test_default_ttl_is_7_days(self): - state = _make_mock_state() - cache = MessageHistoryCache(state) - - await cache.append("thread-1", _make_message("test")) - - call_kwargs = state.append_to_list.call_args[1] - assert call_kwargs.get("ttl_ms") == 7 * 24 * 60 * 60 * 1000 - # --------------------------------------------------------------------------- # Thread isolation @@ -266,10 +264,11 @@ async def test_default_ttl_is_7_days(self): class TestThreadIsolation: + # TS: "should keep threads isolated" @pytest.mark.asyncio - async def test_messages_are_per_thread(self): + async def test_should_keep_threads_isolated(self): state = _make_mock_state() - cache = MessageHistoryCache(state) + cache = ThreadHistoryCache(state) await cache.append("thread-A", _make_message("msg A", msg_id="mA")) await cache.append("thread-B", _make_message("msg B", msg_id="mB")) @@ -292,7 +291,7 @@ class TestKeyPrefix: @pytest.mark.asyncio async def test_uses_correct_key_prefix(self): state = _make_mock_state() - cache = MessageHistoryCache(state) + cache = ThreadHistoryCache(state) await cache.append("my-thread", _make_message("test")) @@ -309,7 +308,7 @@ class TestMessageSerializationRoundtrip: @pytest.mark.asyncio async def test_message_survives_roundtrip(self): state = _make_mock_state() - cache = MessageHistoryCache(state) + cache = ThreadHistoryCache(state) original = _make_message("Round trip test", msg_id="m-rt", thread_id="t-rt") original.attachments = [ @@ -326,3 +325,34 @@ async def test_message_survives_roundtrip(self): assert len(restored.attachments) == 1 assert restored.attachments[0].type == "image" assert restored.attachments[0].name == "img.png" + + +# --------------------------------------------------------------------------- +# MessageHistoryCache (deprecated alias) +# --------------------------------------------------------------------------- + + +class TestMessageHistoryCacheDeprecatedAlias: + # TS: "re-exports ThreadHistoryCache under the old name" + @pytest.mark.asyncio + async def test_reexports_threadhistorycache_under_the_old_name(self): + from chat_sdk.message_history import ( + KEY_PREFIX as OLD_KEY_PREFIX, + ) + from chat_sdk.message_history import ( + MessageHistoryCache, + MessageHistoryConfig, + ) + + assert MessageHistoryCache is ThreadHistoryCache + assert MessageHistoryConfig is ThreadHistoryConfig + # The storage key prefix is deliberately unchanged — renaming it + # would silently orphan persisted data. + assert OLD_KEY_PREFIX == "msg-history:" + + state = _make_mock_state() + cache = MessageHistoryCache(state) + await cache.append("t-1", _make_message("hello", msg_id="m1")) + msgs = await cache.get_messages("t-1") + assert len(msgs) == 1 + assert msgs[0].text == "hello" diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py new file mode 100644 index 0000000..96bf046 --- /dev/null +++ b/tests/test_transcripts.py @@ -0,0 +1,450 @@ +"""Faithful translation of transcripts.test.ts. + +Tests for TranscriptsApiImpl: append, list, count, delete, eviction, and +formatted round-trip behavior. + +TS file: packages/chat/src/transcripts.test.ts +""" + +from __future__ import annotations + +import asyncio +import re +from dataclasses import dataclass +from types import SimpleNamespace +from typing import Any + +import pytest + +from chat_sdk.shared.markdown_parser import parse_markdown +from chat_sdk.testing import MockStateAdapter, create_mock_state, create_test_message +from chat_sdk.transcripts import TranscriptsApiImpl +from chat_sdk.types import ( + AppendInput, + AppendOptions, + CountQuery, + DeleteTarget, + ListQuery, + TranscriptsConfig, +) + +UUID_RE = re.compile(r"^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$") +USER_KEY_REQUIRED_RE = r"options\.user_key is required" +INVALID_DURATION_RE = r"Invalid duration" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +@dataclass +class _ThreadStub: + """Minimal Postable stand-in (mirrors the TS test's cast stub).""" + + adapter: Any + id: str + + +def create_test_thread(adapter_name: str = "slack", thread_id: str = "slack:C123:1234.5678") -> _ThreadStub: + return _ThreadStub(adapter=SimpleNamespace(name=adapter_name), id=thread_id) + + +def _record_append_to_list_calls(state: MockStateAdapter) -> list[tuple[str, Any, int | None, int | None]]: + """Wrap ``state.append_to_list`` to record ``(key, value, max_length, ttl_ms)``. + + Python stand-in for the TS mock state's ``vi.fn()``-wrapped ``appendToList``. + """ + calls: list[tuple[str, Any, int | None, int | None]] = [] + real_append = state.append_to_list + + async def _recording_append( + key: str, value: Any, *, max_length: int | None = None, ttl_ms: int | None = None + ) -> None: + calls.append((key, value, max_length, ttl_ms)) + await real_append(key, value, max_length=max_length, ttl_ms=ttl_ms) + + state.append_to_list = _recording_append + return calls + + +@pytest.fixture +def state() -> MockStateAdapter: + return create_mock_state() + + +@pytest.fixture +def api(state: MockStateAdapter) -> TranscriptsApiImpl: + return TranscriptsApiImpl(state, TranscriptsConfig()) + + +async def _seed(api: TranscriptsApiImpl, user_key: str, count: int = 5) -> None: + thread = create_test_thread() + for i in range(count): + msg = create_test_message(f"m{i}", f"msg {i}") + msg.user_key = user_key + await api.append(thread, msg) + + +# --------------------------------------------------------------------------- +# append +# --------------------------------------------------------------------------- + + +class TestAppend: + # TS: "persists a Message under the resolved userKey" + async def test_persists_a_message_under_the_resolved_userkey(self, state, api): + append_calls = _record_append_to_list_calls(state) + thread = create_test_thread() + msg = create_test_message("m1", "Hello") + msg.user_key = "user@example.com" + + stored = await api.append(thread, msg) + + assert stored is not None + assert stored.user_key == "user@example.com" + assert stored.text == "Hello" + assert stored.role == "user" + assert stored.platform == "slack" + assert stored.thread_id == "slack:C123:1234.5678" + assert stored.platform_message_id == "m1" + assert UUID_RE.match(stored.id) + assert stored.timestamp > 0 + + assert len(append_calls) == 1 + key, value, max_length, ttl_ms = append_calls[0] + assert key == "transcripts:user:user@example.com" + assert value["userKey"] == "user@example.com" + assert max_length == 200 + assert ttl_ms is None + + # TS: "no-ops when Message has no userKey" + async def test_noops_when_message_has_no_userkey(self, state, api): + append_calls = _record_append_to_list_calls(state) + thread = create_test_thread() + msg = create_test_message("m1", "Hello") + # user_key deliberately not set + + stored = await api.append(thread, msg) + + assert stored is None + assert append_calls == [] + + # TS: "requires options.userKey when appending an AppendInput" + async def test_requires_optionsuserkey_when_appending_an_appendinput(self, api): + thread = create_test_thread() + + with pytest.raises(ValueError, match=USER_KEY_REQUIRED_RE): + await api.append(thread, AppendInput(role="assistant", text="hi")) + + # TS: "appends an assistant message with explicit userKey" + async def test_appends_an_assistant_message_with_explicit_userkey(self, api): + thread = create_test_thread() + + stored = await api.append( + thread, + AppendInput(role="assistant", text="Hello, Mike"), + AppendOptions(user_key="mike@acme.com"), + ) + + assert stored is not None + assert stored.role == "assistant" + assert stored.user_key == "mike@acme.com" + assert stored.text == "Hello, Mike" + assert stored.platform_message_id is None + + # TS: "omits formatted by default" + async def test_omits_formatted_by_default(self, api): + thread = create_test_thread() + msg = create_test_message("m1", "Hello") + msg.user_key = "u1" + + stored = await api.append(thread, msg) + + assert stored is not None + assert stored.formatted is None + + # TS: "includes formatted when storeFormatted is true" + async def test_includes_formatted_when_storeformatted_is_true(self, state): + api = TranscriptsApiImpl(state, TranscriptsConfig(store_formatted=True)) + thread = create_test_thread() + msg = create_test_message("m1", "**bold**") + msg.user_key = "u1" + + stored = await api.append(thread, msg) + + assert stored is not None + assert stored.formatted is not None + assert stored.formatted["type"] == "root" + + round_trip = await api.list(ListQuery(user_key="u1")) + assert round_trip[0].formatted == stored.formatted + + # TS: "passes retention duration string through as ttlMs" + async def test_passes_retention_duration_string_through_as_ttlms(self, state): + api = TranscriptsApiImpl(state, TranscriptsConfig(retention="7d")) + append_calls = _record_append_to_list_calls(state) + thread = create_test_thread() + msg = create_test_message("m1", "Hello") + msg.user_key = "u1" + + await api.append(thread, msg) + + assert len(append_calls) == 1 + key, _value, max_length, ttl_ms = append_calls[0] + assert key == "transcripts:user:u1" + assert max_length == 200 + assert ttl_ms == 7 * 24 * 60 * 60 * 1000 + + # TS: "passes numeric retention through unchanged" + async def test_passes_numeric_retention_through_unchanged(self, state): + api = TranscriptsApiImpl(state, TranscriptsConfig(retention=60_000, max_per_user=50)) + append_calls = _record_append_to_list_calls(state) + thread = create_test_thread() + msg = create_test_message("m1", "Hello") + msg.user_key = "u1" + + await api.append(thread, msg) + + assert len(append_calls) == 1 + key, _value, max_length, ttl_ms = append_calls[0] + assert key == "transcripts:user:u1" + assert max_length == 50 + assert ttl_ms == 60_000 + + # TS: "rejects malformed duration strings" + def test_rejects_malformed_duration_strings(self, state): + with pytest.raises(ValueError, match=INVALID_DURATION_RE): + TranscriptsApiImpl(state, TranscriptsConfig(retention="7days")) + + +# --------------------------------------------------------------------------- +# list +# --------------------------------------------------------------------------- + + +class TestList: + # TS: "returns all messages in chronological order by default" + async def test_returns_all_messages_in_chronological_order_by_default(self, api): + await _seed(api, "u1") + + listed = await api.list(ListQuery(user_key="u1")) + + assert len(listed) == 5 + assert [m.text for m in listed] == ["msg 0", "msg 1", "msg 2", "msg 3", "msg 4"] + + # TS: "returns empty array when no messages exist" + async def test_returns_empty_array_when_no_messages_exist(self, api): + listed = await api.list(ListQuery(user_key="nobody")) + assert listed == [] + + # TS: "returns the newest N when limit is set, still chronological" + async def test_returns_the_newest_n_when_limit_is_set_still_chronological(self, api): + await _seed(api, "u1") + + listed = await api.list(ListQuery(user_key="u1", limit=2)) + + assert len(listed) == 2 + assert [m.text for m in listed] == ["msg 3", "msg 4"] + + # TS: "filters by platform" + async def test_filters_by_platform(self, api): + slack_thread = create_test_thread("slack") + discord_thread = create_test_thread("discord", "discord:C:T") + slack_msg = create_test_message("s1", "from slack") + slack_msg.user_key = "u1" + discord_msg = create_test_message("d1", "from discord") + discord_msg.user_key = "u1" + + await api.append(slack_thread, slack_msg) + await api.append(discord_thread, discord_msg) + + slack_only = await api.list(ListQuery(user_key="u1", platforms=["slack"])) + assert len(slack_only) == 1 + assert slack_only[0].platform == "slack" + + # TS: "filters by threadId" + async def test_filters_by_threadid(self, api): + thread_a = create_test_thread("slack", "slack:C:A") + thread_b = create_test_thread("slack", "slack:C:B") + m1 = create_test_message("m1", "thread A") + m1.user_key = "u1" + m2 = create_test_message("m2", "thread B") + m2.user_key = "u1" + + await api.append(thread_a, m1) + await api.append(thread_b, m2) + + listed = await api.list(ListQuery(user_key="u1", thread_id="slack:C:A")) + assert len(listed) == 1 + assert listed[0].text == "thread A" + + # TS: "filters by role" + async def test_filters_by_role(self, api): + thread = create_test_thread() + user_msg = create_test_message("m1", "user msg") + user_msg.user_key = "u1" + await api.append(thread, user_msg) + await api.append( + thread, + AppendInput(role="assistant", text="bot msg"), + AppendOptions(user_key="u1"), + ) + + user_only = await api.list(ListQuery(user_key="u1", roles=["user"])) + assert len(user_only) == 1 + assert user_only[0].role == "user" + + assistant_only = await api.list(ListQuery(user_key="u1", roles=["assistant"])) + assert len(assistant_only) == 1 + assert assistant_only[0].role == "assistant" + + +# --------------------------------------------------------------------------- +# count +# --------------------------------------------------------------------------- + + +class TestCount: + # TS: "returns the total stored count for a userKey" + async def test_returns_the_total_stored_count_for_a_userkey(self, api): + await _seed(api, "u1", count=3) + + total = await api.count(CountQuery(user_key="u1")) + assert total == 3 + + # TS: "returns 0 for unknown userKey" + async def test_returns_0_for_unknown_userkey(self, api): + assert await api.count(CountQuery(user_key="nobody")) == 0 + + +# --------------------------------------------------------------------------- +# delete +# --------------------------------------------------------------------------- + + +class TestDelete: + # TS: "clears all stored entries for a userKey and reports the count" + async def test_clears_all_stored_entries_for_a_userkey_and_reports_the_count(self, api): + await _seed(api, "u1", count=4) + + result = await api.delete(DeleteTarget(user_key="u1")) + + assert result.deleted == 4 + assert await api.count(CountQuery(user_key="u1")) == 0 + assert await api.list(ListQuery(user_key="u1")) == [] + + # TS: "returns deleted: 0 for unknown userKey" + async def test_returns_deleted_0_for_unknown_userkey(self, api): + result = await api.delete(DeleteTarget(user_key="nobody")) + assert result.deleted == 0 + + # TS: "hides the tombstone from list/count after deletion" + async def test_hides_the_tombstone_from_listcount_after_deletion(self, api): + thread = create_test_thread() + msg = create_test_message("m1", "before") + msg.user_key = "u1" + await api.append(thread, msg) + await api.delete(DeleteTarget(user_key="u1")) + + # list and count both ignore the tombstone marker + assert await api.list(ListQuery(user_key="u1")) == [] + assert await api.count(CountQuery(user_key="u1")) == 0 + + # TS: "appends after delete behave as if the list were freshly empty" + async def test_appends_after_delete_behave_as_if_the_list_were_freshly_empty(self, api): + thread = create_test_thread() + before = create_test_message("m1", "before") + before.user_key = "u1" + await api.append(thread, before) + await api.delete(DeleteTarget(user_key="u1")) + + after = create_test_message("m2", "after") + after.user_key = "u1" + await api.append(thread, after) + + listed = await api.list(ListQuery(user_key="u1")) + assert len(listed) == 1 + assert listed[0].text == "after" + assert await api.count(CountQuery(user_key="u1")) == 1 + + # TS: "does not double-count if delete is called twice" + async def test_does_not_doublecount_if_delete_is_called_twice(self, api): + thread = create_test_thread() + msg = create_test_message("m1", "hello") + msg.user_key = "u1" + await api.append(thread, msg) + + first = await api.delete(DeleteTarget(user_key="u1")) + second = await api.delete(DeleteTarget(user_key="u1")) + + assert first.deleted == 1 + assert second.deleted == 0 + + # TS: "preserves invariants when append/delete/append are interleaved without awaits" + async def test_preserves_invariants_when_appenddeleteappend_are_interleaved_without_awaits(self, api): + thread = create_test_thread() + before = create_test_message("m0", "before") + before.user_key = "u1" + await api.append(thread, before) + + post1 = create_test_message("m1", "post1") + post1.user_key = "u1" + post2 = create_test_message("m2", "post2") + post2.user_key = "u1" + + await asyncio.gather( + api.append(thread, post1), + api.delete(DeleteTarget(user_key="u1")), + api.append(thread, post2), + ) + + listed = await api.list(ListQuery(user_key="u1")) + count = await api.count(CountQuery(user_key="u1")) + + # count() and list() must agree — neither should leak the tombstone. + assert count == len(listed) + # Whatever survives is a real entry under the right userKey, and + # never the pre-delete entry (which delete() must have evicted). + for entry in listed: + assert entry.user_key == "u1" + assert entry.text != "before" + # Final size is bounded by the two post-delete appends. + assert count <= 2 + + +# --------------------------------------------------------------------------- +# maxPerUser eviction +# --------------------------------------------------------------------------- + + +class TestMaxPerUserEviction: + # TS: "trims to maxPerUser via appendToList semantics" + async def test_trims_to_maxperuser_via_appendtolist_semantics(self, state): + api = TranscriptsApiImpl(state, TranscriptsConfig(max_per_user=3)) + await _seed(api, "u1") + + listed = await api.list(ListQuery(user_key="u1")) + assert len(listed) == 3 + assert [m.text for m in listed] == ["msg 2", "msg 3", "msg 4"] + + +# --------------------------------------------------------------------------- +# formatted round-trip +# --------------------------------------------------------------------------- + + +class TestFormattedRoundTrip: + # TS: "preserves mdast Root through state serialization" + async def test_preserves_mdast_root_through_state_serialization(self, state): + api = TranscriptsApiImpl(state, TranscriptsConfig(store_formatted=True)) + thread = create_test_thread() + original = parse_markdown("# Hello\n\n*world*") + msg = create_test_message("m1", "Hello world") + msg.user_key = "u1" + msg.formatted = original + + await api.append(thread, msg) + listed = await api.list(ListQuery(user_key="u1")) + + assert listed[0].formatted == original diff --git a/tests/test_transcripts_wiring.py b/tests/test_transcripts_wiring.py new file mode 100644 index 0000000..c542fee --- /dev/null +++ b/tests/test_transcripts_wiring.py @@ -0,0 +1,240 @@ +"""Faithful translation of transcripts-wiring.test.ts. + +Tests for the Chat-level Transcripts API wiring: the constructor guard +(``transcripts`` requires ``identity``), the ``chat.transcripts`` accessor, +and the identity-resolution dispatch hook that populates +``message.user_key`` before handlers run. + +TS file: packages/chat/src/transcripts-wiring.test.ts +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from chat_sdk.chat import Chat +from chat_sdk.errors import ChatError +from chat_sdk.testing import ( + MockAdapter, + MockLogger, + MockStateAdapter, + create_mock_adapter, + create_mock_state, + create_test_message, +) +from chat_sdk.types import ChatConfig, TranscriptsConfig + +TRANSCRIPTS_NOT_CONFIGURED_RE = r"chat\.transcripts is not configured" +IDENTITY_REQUIRED_RE = r"requires ChatConfig\.identity" + +THREAD_ID = "slack:C123:1234.5678" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_chat( + adapter: MockAdapter, + state: MockStateAdapter, + **overrides: object, +) -> Chat: + return Chat( + ChatConfig( + user_name="testbot", + adapters={"slack": adapter}, + state=state, + logger=overrides.pop("logger", MockLogger()), + **overrides, # type: ignore[arg-type] + ) + ) + + +async def _dispatch_subscribed_message( + chat: Chat, + adapter: MockAdapter, + state: MockStateAdapter, + handler: AsyncMock, +): + """Initialize, register a subscribed handler, and dispatch one message.""" + await chat.webhooks["slack"]("request") + chat.on_subscribed_message(handler) + await state.subscribe(THREAD_ID) + + message = create_test_message("msg-1", "hello") + await chat.handle_incoming_message(adapter, THREAD_ID, message) + return message + + +@pytest.fixture +def mock_adapter() -> MockAdapter: + return create_mock_adapter("slack") + + +@pytest.fixture +def mock_state() -> MockStateAdapter: + return create_mock_state() + + +# --------------------------------------------------------------------------- +# Construction / accessor +# --------------------------------------------------------------------------- + + +class TestTranscriptsApiWiring: + # TS: "throws at construction when transcripts is set without identity" + def test_throws_at_construction_when_transcripts_is_set_without_identity(self, mock_adapter, mock_state): + with pytest.raises(ValueError, match=IDENTITY_REQUIRED_RE): + _make_chat(mock_adapter, mock_state, transcripts=TranscriptsConfig()) + + # TS: "does not throw when neither transcripts nor identity is set" + def test_does_not_throw_when_neither_transcripts_nor_identity_is_set(self, mock_adapter, mock_state): + chat = _make_chat(mock_adapter, mock_state) + assert isinstance(chat, Chat) + + # TS: "does not throw when identity is set without transcripts" + def test_does_not_throw_when_identity_is_set_without_transcripts(self, mock_adapter, mock_state): + chat = _make_chat(mock_adapter, mock_state, identity=lambda ctx: "u1") + assert isinstance(chat, Chat) + + # TS: "chat.transcripts getter throws when transcripts was not configured" + def test_chattranscripts_getter_throws_when_transcripts_was_not_configured(self, mock_adapter, mock_state): + chat = _make_chat(mock_adapter, mock_state) + + with pytest.raises(ChatError, match=TRANSCRIPTS_NOT_CONFIGURED_RE): + _ = chat.transcripts + + # TS: "chat.transcripts returns the API instance when configured" + def test_chattranscripts_returns_the_api_instance_when_configured(self, mock_adapter, mock_state): + chat = _make_chat( + mock_adapter, + mock_state, + identity=lambda ctx: "u1", + transcripts=TranscriptsConfig(), + ) + + api = chat.transcripts + assert api is not None + assert callable(api.append) + assert callable(api.list) + assert callable(api.count) + assert callable(api.delete) + + +# --------------------------------------------------------------------------- +# Dispatch hook +# --------------------------------------------------------------------------- + + +class TestDispatchHook: + # TS: "populates message.userKey from the resolver before handlers run" + async def test_populates_messageuserkey_from_the_resolver_before_handlers_run(self, mock_adapter, mock_state): + identity = AsyncMock(return_value="user@example.com") + handler = AsyncMock(return_value=None) + + chat = _make_chat( + mock_adapter, + mock_state, + identity=identity, + transcripts=TranscriptsConfig(), + ) + message = await _dispatch_subscribed_message(chat, mock_adapter, mock_state, handler) + + identity.assert_called_once() + context = identity.call_args.args[0] + assert context.adapter == "slack" + assert context.author is message.author + assert context.message is message + handler.assert_called() + assert message.user_key == "user@example.com" + + # TS: "populates message.userKey from a sync resolver that returns a plain string" + async def test_populates_messageuserkey_from_a_sync_resolver_that_returns_a_plain_string( + self, mock_adapter, mock_state + ): + # The resolver contract allows plain (non-async) callables; MagicMock + # is deliberate here — its return value is used directly, without + # being awaited. + identity = MagicMock(return_value="sync-user@example.com") + handler = AsyncMock(return_value=None) + + chat = _make_chat( + mock_adapter, + mock_state, + identity=identity, + transcripts=TranscriptsConfig(), + ) + message = await _dispatch_subscribed_message(chat, mock_adapter, mock_state, handler) + + identity.assert_called_once() + handler.assert_called() + assert message.user_key == "sync-user@example.com" + + # TS: "leaves userKey undefined when the resolver returns null" + async def test_leaves_userkey_undefined_when_the_resolver_returns_null(self, mock_adapter, mock_state): + identity = AsyncMock(return_value=None) + handler = AsyncMock(return_value=None) + + chat = _make_chat( + mock_adapter, + mock_state, + identity=identity, + transcripts=TranscriptsConfig(), + ) + message = await _dispatch_subscribed_message(chat, mock_adapter, mock_state, handler) + + handler.assert_called() + assert message.user_key is None + + # TS: "treats resolver returning empty string as no userKey" + async def test_treats_resolver_returning_empty_string_as_no_userkey(self, mock_adapter, mock_state): + identity = AsyncMock(return_value="") + handler = AsyncMock(return_value=None) + + chat = _make_chat( + mock_adapter, + mock_state, + identity=identity, + transcripts=TranscriptsConfig(), + ) + message = await _dispatch_subscribed_message(chat, mock_adapter, mock_state, handler) + + handler.assert_called() + assert message.user_key is None + + # TS: "logs and proceeds without userKey when the resolver throws" + async def test_logs_and_proceeds_without_userkey_when_the_resolver_throws(self, mock_adapter, mock_state): + identity = AsyncMock(side_effect=Exception("lookup failed")) + handler = AsyncMock(return_value=None) + logger = MockLogger() + + chat = _make_chat( + mock_adapter, + mock_state, + logger=logger, + identity=identity, + transcripts=TranscriptsConfig(), + ) + message = await _dispatch_subscribed_message(chat, mock_adapter, mock_state, handler) + + warn_calls = [call for call in logger.warn.calls if "Identity resolver threw" in call[0]] + assert len(warn_calls) == 1 + warn_context = warn_calls[0][1] + assert isinstance(warn_context["error"], Exception) + assert warn_context["adapter"] == "slack" + assert warn_context["thread_id"] == THREAD_ID + handler.assert_called() + assert message.user_key is None + + # TS: "does not call the resolver when no identity is configured" + async def test_does_not_call_the_resolver_when_no_identity_is_configured(self, mock_adapter, mock_state): + handler = AsyncMock(return_value=None) + chat = _make_chat(mock_adapter, mock_state) + + message = await _dispatch_subscribed_message(chat, mock_adapter, mock_state, handler) + + handler.assert_called() + assert message.user_key is None diff --git a/tests/test_whatsapp_adapter.py b/tests/test_whatsapp_adapter.py index c04dc33..f3d0851 100644 --- a/tests/test_whatsapp_adapter.py +++ b/tests/test_whatsapp_adapter.py @@ -361,5 +361,5 @@ def test_adapter_properties(self): adapter = _make_adapter() assert adapter.name == "whatsapp" assert adapter.lock_scope == "channel" - assert adapter.persist_message_history is True + assert adapter.persist_thread_history is True assert adapter.bot_user_id is None # not yet initialized From 39432c328eed46571cdb6ca5546cae2bce951928 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 21:08:49 +0000 Subject: [PATCH 09/14] feat(chat): add callback_url to buttons and modals (vercel/chat#454) https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 (cherry picked from commit 20a5fd10a2dd46094fdf812ff47628a12bb9d362) --- src/chat_sdk/adapters/discord/__init__.py | 11 +- src/chat_sdk/adapters/discord/adapter.py | 6 +- src/chat_sdk/adapters/discord/cards.py | 44 +- src/chat_sdk/callback_url.py | 229 ++++++++++ src/chat_sdk/cards.py | 6 + src/chat_sdk/channel.py | 32 +- src/chat_sdk/chat.py | 110 ++++- src/chat_sdk/modals.py | 5 + src/chat_sdk/thread.py | 32 +- tests/integration/test_replay_callback_url.py | 326 +++++++++++++++ tests/test_callback_url.py | 324 +++++++++++++++ tests/test_channel_faithful.py | 200 +++++++++ tests/test_chat_faithful.py | 391 ++++++++++++++++++ tests/test_discord_cards.py | 129 +++++- tests/test_thread_faithful.py | 157 +++++++ 15 files changed, 1990 insertions(+), 12 deletions(-) create mode 100644 src/chat_sdk/callback_url.py create mode 100644 tests/integration/test_replay_callback_url.py create mode 100644 tests/test_callback_url.py diff --git a/src/chat_sdk/adapters/discord/__init__.py b/src/chat_sdk/adapters/discord/__init__.py index e689df0..fdf4307 100644 --- a/src/chat_sdk/adapters/discord/__init__.py +++ b/src/chat_sdk/adapters/discord/__init__.py @@ -1,5 +1,14 @@ """Discord adapter for chat-sdk.""" from chat_sdk.adapters.discord.adapter import DiscordAdapter, create_discord_adapter +from chat_sdk.adapters.discord.cards import ( + decode_discord_custom_id, + encode_discord_custom_id, +) -__all__ = ["DiscordAdapter", "create_discord_adapter"] +__all__ = [ + "DiscordAdapter", + "create_discord_adapter", + "decode_discord_custom_id", + "encode_discord_custom_id", +] diff --git a/src/chat_sdk/adapters/discord/adapter.py b/src/chat_sdk/adapters/discord/adapter.py index 3e6800a..f01195f 100644 --- a/src/chat_sdk/adapters/discord/adapter.py +++ b/src/chat_sdk/adapters/discord/adapter.py @@ -20,6 +20,7 @@ from chat_sdk.adapters.discord.cards import ( card_to_discord_payload, + decode_discord_custom_id, ) from chat_sdk.adapters.discord.format_converter import DiscordFormatConverter from chat_sdk.adapters.discord.types import ( @@ -385,10 +386,11 @@ def _handle_component_interaction( }, ) + decoded = decode_discord_custom_id(custom_id) self._chat.process_action( ActionEvent( - action_id=custom_id, - value=custom_id, + action_id=decoded.action_id, + value=decoded.value if decoded.value is not None else decoded.action_id, user=Author( user_id=user.get("id", ""), user_name=user.get("username", ""), diff --git a/src/chat_sdk/adapters/discord/cards.py b/src/chat_sdk/adapters/discord/cards.py index c5114b3..7744e40 100644 --- a/src/chat_sdk/adapters/discord/cards.py +++ b/src/chat_sdk/adapters/discord/cards.py @@ -7,6 +7,7 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any, cast from chat_sdk.adapters.discord.types import DiscordActionRow, DiscordButton @@ -24,6 +25,7 @@ ) from chat_sdk.emoji import convert_emoji_placeholders from chat_sdk.shared.card_utils import render_gfm_table +from chat_sdk.shared.errors import ValidationError # Discord button styles (discord-api-types/v10 ButtonStyle) BUTTON_STYLE_PRIMARY = 1 @@ -34,6 +36,46 @@ # Discord blurple color DISCORD_BLURPLE = 0x5865F2 +# Discord packs the button value into custom_id (max 100 chars). The +# delimiter is a newline -- it can't appear in a Button id and survives +# Discord's round-trip intact. +DISCORD_CUSTOM_ID_DELIMITER = "\n" +DISCORD_CUSTOM_ID_MAX_LENGTH = 100 + + +@dataclass(frozen=True) +class DecodedDiscordCustomId: + """Result of :func:`decode_discord_custom_id`.""" + + action_id: str + value: str | None + + +def _validate_discord_custom_id(custom_id: str) -> None: + if len(custom_id) == 0 or len(custom_id) > DISCORD_CUSTOM_ID_MAX_LENGTH: + raise ValidationError( + "discord", + f"Discord custom_id must be 1-{DISCORD_CUSTOM_ID_MAX_LENGTH} characters. Shorten the button id or value.", + ) + + +def encode_discord_custom_id(action_id: str, value: str | None = None) -> str: + """Encode a button's action ID and optional value into a custom_id.""" + if value is None or value == "": + _validate_discord_custom_id(action_id) + return action_id + encoded = f"{action_id}{DISCORD_CUSTOM_ID_DELIMITER}{value}" + _validate_discord_custom_id(encoded) + return encoded + + +def decode_discord_custom_id(custom_id: str) -> DecodedDiscordCustomId: + """Split a custom_id back into (action_id, value). Splits on the first delimiter only.""" + idx = custom_id.find(DISCORD_CUSTOM_ID_DELIMITER) + if idx == -1: + return DecodedDiscordCustomId(action_id=custom_id, value=None) + return DecodedDiscordCustomId(action_id=custom_id[:idx], value=custom_id[idx + 1 :]) + def _convert_emoji(text: str) -> str: """Convert emoji placeholders to Discord format.""" @@ -172,7 +214,7 @@ def _convert_button_element(button: ButtonElement) -> DiscordButton: "type": 2, # Button "style": _get_button_style(button.get("style")), "label": button.get("label", ""), - "custom_id": button.get("id", ""), + "custom_id": encode_discord_custom_id(button.get("id", ""), button.get("value")), } if button.get("disabled"): diff --git a/src/chat_sdk/callback_url.py b/src/chat_sdk/callback_url.py new file mode 100644 index 0000000..c573928 --- /dev/null +++ b/src/chat_sdk/callback_url.py @@ -0,0 +1,229 @@ +"""Callback URL handling for buttons and modals. + +Python port of callback-url.ts (vercel/chat#454). + +When a button (or modal) carries a ``callback_url``, the SDK stores the URL +in the state adapter under a short random token at post time and rewrites +the button's ``value`` to an encoded token (``__cb:``). When the +button is clicked, :meth:`Chat.process_action` decodes the token, restores +the original value for handlers, and POSTs the action payload to the stored +URL. Modal callback URLs are stored in the modal context and POSTed on +submit. +""" + +from __future__ import annotations + +import json +import secrets +from dataclasses import dataclass +from typing import Any + +from chat_sdk.cards import ActionsElement, ButtonElement, CardChild, CardElement +from chat_sdk.errors import ChatError +from chat_sdk.types import StateAdapter + +CALLBACK_TOKEN_PREFIX = "__cb:" +CALLBACK_CACHE_KEY_PREFIX = "chat:callback:" +CALLBACK_TTL_MS = 30 * 24 * 60 * 60 * 1000 # 30 days + + +# --------------------------------------------------------------------------- +# Result types (TS uses inline object literals; port rule #9: typed objects) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class DecodedCallbackValue: + """Result of :func:`decode_callback_value`.""" + + callback_token: str | None + + +@dataclass(frozen=True) +class ResolvedCallback: + """A stored callback resolved from the state adapter.""" + + url: str + original_value: str | None = None + + +@dataclass(frozen=True) +class CallbackPostResult: + """Result of :func:`post_to_callback_url`.""" + + error: Exception | None = None + status: int | None = None + + +# --------------------------------------------------------------------------- +# Token encoding +# --------------------------------------------------------------------------- + + +def encode_callback_value(token: str) -> str: + """Encode a callback token into a button value.""" + return f"{CALLBACK_TOKEN_PREFIX}{token}" + + +def decode_callback_value(value: str | None) -> DecodedCallbackValue: + """Extract the callback token from an encoded button value, if any.""" + if not value or not value.startswith(CALLBACK_TOKEN_PREFIX): + return DecodedCallbackValue(callback_token=None) + return DecodedCallbackValue(callback_token=value[len(CALLBACK_TOKEN_PREFIX) :]) + + +def _generate_token() -> str: + # Upstream: crypto.randomUUID().replace(/-/g, "").slice(0, 16). + # Port rule #12: the token gates where action payloads are POSTed, so + # use `secrets` (16 hex chars = 64 bits, same shape as upstream). + return secrets.token_hex(8) + + +# --------------------------------------------------------------------------- +# Card processing (runs at post time) +# --------------------------------------------------------------------------- + + +async def _process_actions_element( + actions: ActionsElement, + state_adapter: StateAdapter, +) -> ActionsElement: + children: list[Any] = [] + for el in actions.get("children", []): + if not isinstance(el, dict) or el.get("type") != "button" or not el.get("callback_url"): + children.append(el) + continue + + token = _generate_token() + # Stored shape matches the TS SDK (`{url, originalValue}`) so state + # written by either SDK resolves in both. `originalValue` is omitted + # (not None) when the button has no value — hazard #7. + stored: dict[str, Any] = {"url": el["callback_url"]} + original_value = el.get("value") + if original_value is not None: + stored["originalValue"] = original_value + await state_adapter.set(f"{CALLBACK_CACHE_KEY_PREFIX}{token}", stored, CALLBACK_TTL_MS) + + # Rebuild the button without `callback_url` (mirrors upstream's + # explicit-key copy; keys absent on the source stay absent). + processed: ButtonElement = {"type": "button", "id": el["id"], "label": el["label"]} + if "style" in el: + processed["style"] = el["style"] + if "disabled" in el: + processed["disabled"] = el["disabled"] + processed["value"] = encode_callback_value(token) + if "action_type" in el: + processed["action_type"] = el["action_type"] + children.append(processed) + return {"type": "actions", "children": children} + + +def _has_callback_buttons(children: list[CardChild]) -> bool: + for child in children: + if not isinstance(child, dict): + continue + if child.get("type") == "actions": + for el in child.get("children", []): + if isinstance(el, dict) and el.get("type") == "button" and el.get("callback_url"): + return True + if child.get("type") == "section" and "children" in child and _has_callback_buttons(child["children"]): + return True + return False + + +async def _process_children( + children: list[CardChild], + state_adapter: StateAdapter, +) -> list[CardChild]: + result: list[CardChild] = [] + for child in children: + if isinstance(child, dict) and child.get("type") == "actions": + result.append(await _process_actions_element(child, state_adapter)) # type: ignore[arg-type] + elif isinstance(child, dict) and child.get("type") == "section" and "children" in child: + result.append({**child, "children": await _process_children(child["children"], state_adapter)}) # type: ignore[misc] + else: + result.append(child) + return result + + +async def process_card_callback_urls( + card: CardElement, + state_adapter: StateAdapter, +) -> CardElement: + """Replace ``callback_url`` buttons with encoded token values. + + Returns the *same* card object when no button carries a callback URL; + otherwise returns a new card (the original is never mutated). + """ + if not _has_callback_buttons(card.get("children", [])): + return card + + return {**card, "children": await _process_children(card.get("children", []), state_adapter)} + + +# --------------------------------------------------------------------------- +# Resolution + POST (runs at click/submit time) +# --------------------------------------------------------------------------- + + +async def resolve_callback_url( + token: str, + state_adapter: StateAdapter, +) -> ResolvedCallback | None: + """Look up a stored callback by token. Returns ``None`` when unknown.""" + stored = await state_adapter.get(f"{CALLBACK_CACHE_KEY_PREFIX}{token}") + if not stored: + return None + if isinstance(stored, str): + # Legacy format: the URL was stored as a bare string. + return ResolvedCallback(url=stored) + original_value = stored["originalValue"] if "originalValue" in stored else stored.get("original_value") + return ResolvedCallback(url=stored.get("url"), original_value=original_value) + + +async def _fetch(url: str, *, method: str, headers: dict[str, str], body: str) -> tuple[int, str]: + """POST ``body`` to ``url`` and return ``(status, text)``. + + Thin seam over aiohttp so tests can stub the network the way upstream + stubs global ``fetch``. aiohttp is an optional dependency, so it is + imported lazily (hazard #10); only http(s) URLs are supported, matching + the WHATWG ``fetch`` upstream relies on. + """ + import aiohttp + + async with ( + aiohttp.ClientSession() as session, + session.request(method, url, data=body.encode("utf-8"), headers=headers) as response, + ): + try: + text = await response.text() + except Exception: + # Mirrors upstream's `response.text().catch(() => "")`. + text = "" + return response.status, text + + +async def post_to_callback_url( + callback_url: str, + payload: dict[str, Any], +) -> CallbackPostResult: + """POST a JSON payload to a callback URL. + + Never raises: network and HTTP errors are returned in + :class:`CallbackPostResult` for the caller to log. + """ + try: + status, text = await _fetch( + callback_url, + method="POST", + headers={"Content-Type": "application/json"}, + body=json.dumps(payload), + ) + if not 200 <= status < 300: + return CallbackPostResult( + error=ChatError(f"Callback URL returned {status}: {text}"), + status=status, + ) + return CallbackPostResult(status=status) + except Exception as error: + return CallbackPostResult(error=error) diff --git a/src/chat_sdk/cards.py b/src/chat_sdk/cards.py index f7b96b3..1939b91 100644 --- a/src/chat_sdk/cards.py +++ b/src/chat_sdk/cards.py @@ -29,6 +29,8 @@ class ButtonElement(_ButtonRequired, total=False): value: str disabled: bool action_type: Literal["action", "modal"] | None + # URL to POST action data to when this button is clicked + callback_url: str class _LinkButtonRequired(TypedDict): @@ -256,6 +258,7 @@ def Button( value: str | None = None, disabled: bool | None = None, action_type: Literal["action", "modal"] | None = None, + callback_url: str | None = None, ) -> ButtonElement: """Create a Button element. @@ -264,6 +267,7 @@ def Button( Button(id="submit", label="Submit", style="primary") Button(id="delete", label="Delete", style="danger", value="item-123") Button(id="open", label="Open", action_type="modal") + Button(id="approve", label="Approve", callback_url="https://example.com/hook") """ element: ButtonElement = {"type": "button", "id": id, "label": label} if style is not None: @@ -274,6 +278,8 @@ def Button( element["disabled"] = disabled if action_type is not None: element["action_type"] = action_type + if callback_url is not None: + element["callback_url"] = callback_url return element diff --git a/src/chat_sdk/channel.py b/src/chat_sdk/channel.py index 80fde8a..3bf5713 100644 --- a/src/chat_sdk/channel.py +++ b/src/chat_sdk/channel.py @@ -8,10 +8,11 @@ from __future__ import annotations from collections.abc import AsyncIterator -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import datetime, timezone from typing import Any +from chat_sdk.callback_url import process_card_callback_urls from chat_sdk.errors import ChatNotImplementedError from chat_sdk.plan import is_postable_object, post_postable_object from chat_sdk.thread import ( @@ -36,6 +37,7 @@ FetchOptions, Message, MessageMetadata, + PostableCard, PostableMarkdown, PostableMessage, PostEphemeralOptions, @@ -299,6 +301,7 @@ async def post( return await self._post_single_message(PostableMarkdown(markdown=accumulated)) postable: AdapterPostableMessage = message # type: ignore[assignment] + postable = await self._process_callback_urls(postable) return await self._post_single_message(postable) async def _post_single_message( @@ -337,6 +340,8 @@ async def post_ephemeral( """Post an ephemeral message visible only to the specified user.""" user_id = user if isinstance(user, str) else user.user_id + message = await self._process_callback_urls(message) # type: ignore[assignment] + if hasattr(self.adapter, "post_ephemeral") and self.adapter.post_ephemeral: # type: ignore[union-attr] return await self.adapter.post_ephemeral(self._id, user_id, message) # type: ignore[union-attr] @@ -362,6 +367,8 @@ async def schedule( post_at: datetime, ) -> ScheduledMessage: """Schedule a message for future delivery.""" + message = await self._process_callback_urls(message) # type: ignore[assignment] + if not hasattr(self.adapter, "schedule_message") or not self.adapter.schedule_message: # type: ignore[union-attr] raise ChatNotImplementedError( self.adapter.name, @@ -369,6 +376,28 @@ async def schedule( ) return await self.adapter.schedule_message(self._id, message, {"post_at": post_at}) # type: ignore[union-attr] + async def _process_callback_urls( + self, + postable: str | AdapterPostableMessage, + ) -> str | AdapterPostableMessage: + """Encode ``callback_url`` buttons in outgoing cards (vercel/chat#454).""" + if isinstance(postable, str): + return postable + + if isinstance(postable, dict) and postable.get("type") == "card": + return await process_card_callback_urls(postable, self._state_adapter) + + if ( + isinstance(postable, PostableCard) + and isinstance(postable.card, dict) + and postable.card.get("type") == "card" + ): + processed = await process_card_callback_urls(postable.card, self._state_adapter) + if processed is not postable.card: + return replace(postable, card=processed) + + return postable + # -- Typing indicator ---------------------------------------------------- async def start_typing(self, status: str | None = None) -> None: @@ -519,6 +548,7 @@ def _create_sent_message( plain_text, formatted, attachments = _extract_message_content(postable) async def _edit(new_content: Any) -> SentMessage: + new_content = await channel_impl._process_callback_urls(new_content) await adapter.edit_message(thread_id, message_id, new_content) return channel_impl._create_sent_message(message_id, new_content) diff --git a/src/chat_sdk/chat.py b/src/chat_sdk/chat.py index 426e9dc..f2bafcb 100644 --- a/src/chat_sdk/chat.py +++ b/src/chat_sdk/chat.py @@ -18,6 +18,11 @@ from datetime import datetime, timezone from typing import Any +from chat_sdk.callback_url import ( + decode_callback_value, + post_to_callback_url, + resolve_callback_url, +) from chat_sdk.channel import ChannelImpl, _ChannelImplConfigWithAdapter from chat_sdk.errors import ChatError, ChatNotImplementedError, LockError from chat_sdk.logger import ConsoleLogger, Logger @@ -176,17 +181,19 @@ def __init__(self, commands: list[str], handler: SlashCommandHandler) -> None: class _StoredModalContext: - __slots__ = ("thread", "message", "channel") + __slots__ = ("thread", "message", "channel", "callback_url") def __init__( self, thread: dict[str, Any] | None = None, message: dict[str, Any] | None = None, channel: dict[str, Any] | None = None, + callback_url: str | None = None, ) -> None: self.thread = thread self.message = message self.channel = channel + self.callback_url = callback_url # --------------------------------------------------------------------------- @@ -1054,6 +1061,7 @@ async def process_modal_submit( ) -> ModalResponse | None: """Process a modal form submission. Returns optional response.""" related = await self._retrieve_modal_context(event.adapter.name, context_id) + callback_url = related.get("callback_url") full_event = ModalSubmitEvent( adapter=event.adapter, @@ -1068,18 +1076,50 @@ async def process_modal_submit( raw=event.raw, ) + result: ModalResponse | None = None for pat in self._modal_submit_handlers: if not pat.callback_ids or event.callback_id in pat.callback_ids: try: response = await self._invoke_handler(pat.handler, full_event) if response is not None: - return response + result = response + break except Exception as exc: self._logger.error( "Modal submit handler error", {"callback_id": event.callback_id, "error": str(exc)}, ) - return None + + if callback_url and getattr(result, "action", None) != "errors": + # POST the form values to the modal's callback URL. The response + # is returned to the platform without waiting for the POST; the + # task is handed to options.wait_until for serverless callers. + payload = { + "type": "modal_submit", + "callbackId": event.callback_id, + "values": event.values, + "user": {"id": event.user.user_id, "name": event.user.user_name}, + } + + async def _post_modal_callback() -> None: + try: + post_result = await post_to_callback_url(callback_url, payload) + if post_result.error is not None: + self._logger.error( + "Modal callbackUrl POST failed", + {"callback_url": callback_url, "error": str(post_result.error)}, + ) + except Exception as error: # mirrors upstream's trailing .catch + self._logger.error( + "Modal callbackUrl POST failed", + {"callback_url": callback_url, "error": str(error)}, + ) + + task = _create_task(_post_modal_callback(), self._active_tasks) + if task is not None and options and options.wait_until: + options.wait_until(task) + + return result def process_modal_close( self, @@ -1261,7 +1301,12 @@ async def _open_modal(modal: Any) -> dict[str, str] | None: self._logger.warn(f"Cannot open modal: {event.adapter.name} does not support modals") return None context_id = str(uuid.uuid4()) - self._store_modal_context(event.adapter.name, context_id, channel=channel) + self._store_modal_context( + event.adapter.name, + context_id, + channel=channel, + callback_url=modal.get("callback_url") if isinstance(modal, dict) else None, + ) return await event.adapter.open_modal(trigger_id, modal, context_id) # type: ignore[union-attr] full_event = SlashCommandEvent( @@ -1295,12 +1340,16 @@ def _store_modal_context( thread: ThreadImpl | None = None, message: Message | None = None, channel: Channel | None = None, + callback_url: str | None = None, ) -> None: key = f"modal-context:{adapter_name}:{context_id}" context = { "thread": thread.to_json() if thread else None, "message": message.to_json() if message else None, "channel": channel.to_json() if channel else None, + # camelCase: stored state is a serialization boundary shared + # with the TS SDK (matches the serialized thread/message keys). + "callbackUrl": callback_url, } task = _create_task(self._state_adapter.set(key, context, MODAL_CONTEXT_TTL_MS), self._active_tasks) if task is not None: @@ -1319,6 +1368,7 @@ async def _retrieve_modal_context( ) -> dict[str, Any]: if not context_id: return { + "callback_url": None, "related_thread": None, "related_message": None, "related_channel": None, @@ -1329,6 +1379,7 @@ async def _retrieve_modal_context( if not stored: return { + "callback_url": None, "related_thread": None, "related_message": None, "related_channel": None, @@ -1349,7 +1400,12 @@ async def _retrieve_modal_context( if stored.get("channel"): related_channel = ChannelImpl.from_json(stored["channel"], adapter) + # Accept both camelCase (written by this SDK and the TS SDK) and + # snake_case (hand-written state) — same tolerance as from_json. + callback_url = stored["callbackUrl"] if "callbackUrl" in stored else stored.get("callback_url") + return { + "callback_url": callback_url, "related_thread": related_thread, "related_message": related_message, "related_channel": related_channel, @@ -1374,6 +1430,46 @@ async def _handle_action_event(self, event: ActionEvent) -> None: self._logger.debug("Skipping action from self") return + # Decode a callback token (`__cb:`) planted at post time by + # process_card_callback_urls. When one resolves, handlers see the + # button's original value and the action payload is POSTed to the + # stored callback URL concurrently with the handlers. + callback_token = decode_callback_value(event.value).callback_token + + resolved = None + if callback_token: + resolved = await resolve_callback_url(callback_token, self._state_adapter) + + action_value = resolved.original_value if resolved is not None else event.value + + callback_url_task: asyncio.Task[Any] | None = None + if resolved is not None: + callback_url = resolved.url + # Wire payload: camelCase keys, optional keys omitted (not None) + # to mirror upstream's JSON.stringify semantics — hazard #7. + payload: dict[str, Any] = {"type": "action", "actionId": event.action_id} + if resolved.original_value is not None: + payload["value"] = resolved.original_value + payload["user"] = {"id": event.user.user_id, "name": event.user.user_name} + if event.thread_id is not None: + payload["threadId"] = event.thread_id + if event.message_id is not None: + payload["messageId"] = event.message_id + + async def _post_action_callback() -> None: + post_result = await post_to_callback_url(callback_url, payload) + if post_result.error is not None: + self._logger.error( + "Button callbackUrl POST failed", + { + "callback_url": callback_url, + "action_id": event.action_id, + "error": str(post_result.error), + }, + ) + + callback_url_task = _create_task(_post_action_callback(), self._active_tasks) + thread: ThreadImpl | None = None if event.thread_id: is_subscribed = False @@ -1434,6 +1530,7 @@ async def _open_modal(modal: Any) -> dict[str, str] | None: thread=thread, message=fetched_message, channel=channel_impl, + callback_url=modal.get("callback_url") if isinstance(modal, dict) else None, ) return await event.adapter.open_modal(trigger_id, modal, context_id) # type: ignore[union-attr] @@ -1444,7 +1541,7 @@ async def _open_modal(modal: Any) -> dict[str, str] | None: message_id=event.message_id, user=event.user, action_id=event.action_id, - value=event.value, + value=action_value, trigger_id=event.trigger_id, raw=event.raw, _open_modal=_open_modal, @@ -1459,6 +1556,9 @@ async def _open_modal(modal: Any) -> dict[str, str] | None: self._logger.debug("Running matched action handler", {"action_id": event.action_id}) await self._invoke_handler(pat.handler, full_event) + if callback_url_task is not None: + await callback_url_task + # ======================================================================== # Reaction handling # ======================================================================== diff --git a/src/chat_sdk/modals.py b/src/chat_sdk/modals.py index 50ab6a6..4d64b69 100644 --- a/src/chat_sdk/modals.py +++ b/src/chat_sdk/modals.py @@ -89,6 +89,8 @@ class ModalElement(TypedDict, total=False): type: str # "modal" title: str callback_id: str + # URL to POST form values to when this modal is submitted + callback_url: str submit_label: str close_label: str notify_on_close: bool @@ -127,6 +129,7 @@ def Modal( *, title: str, callback_id: str, + callback_url: str | None = None, children: list[ModalChild] | None = None, submit_label: str | None = None, close_label: str | None = None, @@ -140,6 +143,8 @@ def Modal( "callback_id": callback_id, "children": children or [], } + if callback_url is not None: + result["callback_url"] = callback_url if submit_label is not None: result["submit_label"] = submit_label if close_label is not None: diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index 6bd0d45..1eea4af 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -10,10 +10,11 @@ import asyncio import contextvars from collections.abc import AsyncIterator -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable +from chat_sdk.callback_url import process_card_callback_urls from chat_sdk.errors import ChatNotImplementedError from chat_sdk.logger import Logger from chat_sdk.plan import is_postable_object, post_postable_object @@ -573,6 +574,7 @@ async def post( return await self._handle_stream(message) postable: AdapterPostableMessage = message # type: ignore[assignment] + postable = await self._process_callback_urls(postable) raw_msg = await self.adapter.post_message(self._id, postable) result = self._create_sent_message(raw_msg.id, postable, raw_msg.thread_id, raw=raw_msg.raw) @@ -604,6 +606,8 @@ async def post_ephemeral( """ user_id = user if isinstance(user, str) else user.user_id + message = await self._process_callback_urls(message) # type: ignore[assignment] + # Try native ephemeral if hasattr(self.adapter, "post_ephemeral") and self.adapter.post_ephemeral: # type: ignore[union-attr] return await self.adapter.post_ephemeral(self._id, user_id, message) # type: ignore[union-attr] @@ -624,6 +628,28 @@ async def post_ephemeral( return None + async def _process_callback_urls( + self, + postable: str | AdapterPostableMessage, + ) -> str | AdapterPostableMessage: + """Encode ``callback_url`` buttons in outgoing cards (vercel/chat#454).""" + if isinstance(postable, str): + return postable + + if isinstance(postable, dict) and postable.get("type") == "card": + return await process_card_callback_urls(postable, self._state_adapter) + + if ( + isinstance(postable, PostableCard) + and isinstance(postable.card, dict) + and postable.card.get("type") == "card" + ): + processed = await process_card_callback_urls(postable.card, self._state_adapter) + if processed is not postable.card: + return replace(postable, card=processed) + + return postable + async def schedule( self, message: AdapterPostableMessage, @@ -631,6 +657,8 @@ async def schedule( post_at: datetime, ) -> ScheduledMessage: """Schedule a message for future delivery.""" + message = await self._process_callback_urls(message) # type: ignore[assignment] + if not hasattr(self.adapter, "schedule_message") or not self.adapter.schedule_message: # type: ignore[union-attr] raise ChatNotImplementedError( self.adapter.name, @@ -1140,6 +1168,7 @@ def _create_sent_message( plain_text, formatted, attachments = _extract_message_content(postable) async def _edit(new_content: Any) -> SentMessage: + new_content = await thread_impl._process_callback_urls(new_content) await adapter.edit_message(thread_id, message_id, new_content) return thread_impl._create_sent_message(message_id, new_content) @@ -1185,6 +1214,7 @@ def create_sent_message_from_message(self, message: Message) -> SentMessage: thread_impl = self async def _edit(new_content: Any) -> SentMessage: + new_content = await thread_impl._process_callback_urls(new_content) await adapter.edit_message(thread_id, message_id, new_content) return thread_impl._create_sent_message(message_id, new_content, thread_id) diff --git a/tests/integration/test_replay_callback_url.py b/tests/integration/test_replay_callback_url.py new file mode 100644 index 0000000..2a0edc0 --- /dev/null +++ b/tests/integration/test_replay_callback_url.py @@ -0,0 +1,326 @@ +"""Replay integration tests: callbackUrl handling on buttons and modals. + +Port of replay-callback-url.test.ts (4 tests). + +When a button or modal carries a ``callback_url``, the SDK POSTs the action +payload to that URL in addition to firing any registered handler. These +tests replay real Slack webhook payloads (from tests/fixtures/replay/) +through the real ``SlackAdapter.handle_webhook()`` into a real ``Chat``, +with the SDK's encoded callback token (``__cb:``) and a stored modal +context with ``callbackUrl``, then assert the SDK resolves the URL and +POSTs the right shape. + +Discord adapter encoding/decoding is covered by unit tests in +``tests/test_discord_cards.py``. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import json +import time +from typing import Any +from unittest.mock import AsyncMock, patch +from urllib.parse import quote + +import pytest + +from chat_sdk.chat import Chat +from chat_sdk.testing import MockLogger, MockStateAdapter, create_mock_state +from chat_sdk.types import ActionEvent, ChatConfig, ModalSubmitEvent, WebhookOptions +from tests.fixtures.conftest import load_fixture + +try: + from chat_sdk.adapters.slack.adapter import SlackAdapter + from chat_sdk.adapters.slack.types import SlackAdapterConfig + + _SLACK_OK = True +except ImportError: + _SLACK_OK = False + +CALLBACK_BUTTON_URL = "https://hook.example.com/button-cb" +CALLBACK_MODAL_URL = "https://hook.example.com/modal-cb" +CALLBACK_TOKEN = "abcdef0123456789" +SLACK_SIGNING_SECRET = "test-signing-secret" + + +# --------------------------------------------------------------------------- +# Slack webhook plumbing (same conventions as tests/test_fixture_replay.py) +# --------------------------------------------------------------------------- + + +class _FakeRequest: + """Minimal request-like object for adapter webhook testing.""" + + def __init__(self, body: str, headers: dict[str, str] | None = None): + self._body = body.encode("utf-8") + self.headers = headers or {} + + async def text(self) -> str: + return self._body.decode("utf-8") + + +def _slack_signed_request(body: str, content_type: str = "application/json") -> _FakeRequest: + ts = str(int(time.time())) + sig_base = f"v0:{ts}:{body}" + sig = "v0=" + hmac.new(SLACK_SIGNING_SECRET.encode(), sig_base.encode(), hashlib.sha256).hexdigest() + return _FakeRequest( + body, + { + "x-slack-request-timestamp": ts, + "x-slack-signature": sig, + "content-type": content_type, + }, + ) + + +def _slack_interactive_request(payload: dict[str, Any]) -> _FakeRequest: + """Slack sends interactive payloads form-encoded as ``payload=``.""" + body = f"payload={quote(json.dumps(payload))}" + return _slack_signed_request(body, content_type="application/x-www-form-urlencoded") + + +class _SlackReplayContext: + """Real Chat + real SlackAdapter wired together (TS createSlackTestContext).""" + + def __init__(self, fixture: dict[str, Any]): + self.adapter = SlackAdapter( + SlackAdapterConfig( + signing_secret=SLACK_SIGNING_SECRET, + bot_token="xoxb-test-token", + bot_user_id=fixture.get("botUserId"), + ) + ) + self.state: MockStateAdapter = create_mock_state() + self.chat = Chat( + ChatConfig( + user_name=fixture.get("botName", "testbot"), + adapters={"slack": self.adapter}, + state=self.state, + logger=MockLogger(), + ) + ) + + async def send_webhook(self, payload: dict[str, Any]) -> dict[str, Any]: + """Send an event-callback webhook and wait for the spawned tasks.""" + tasks: list[Any] = [] + request = _slack_signed_request(json.dumps(payload)) + response = await self.chat.webhooks["slack"](request, WebhookOptions(wait_until=tasks.append)) + await asyncio.gather(*tasks) + return response + + async def send_interactive(self, payload: dict[str, Any]) -> dict[str, Any]: + """Send a block_actions / view_submission webhook and drain its tasks.""" + tasks: list[Any] = [] + request = _slack_interactive_request(payload) + response = await self.chat.webhooks["slack"](request, WebhookOptions(wait_until=tasks.append)) + await asyncio.gather(*tasks) + return response + + async def shutdown(self) -> None: + await self.chat.shutdown() + + +# =========================================================================== +# Slack button click with callback token +# =========================================================================== + + +@pytest.mark.skipif(not _SLACK_OK, reason="Slack adapter not available") +class TestSlackButtonClickWithCallbackToken: + """describe("Slack button click with callback token")""" + + # it("decodes the token, POSTs the URL, and passes the original value to onAction") + @pytest.mark.asyncio + async def test_decodes_the_token_posts_the_url_and_passes_the_original_value_to_onaction(self): + fixture = load_fixture("actions-reactions/slack.json") + ctx = _SlackReplayContext(fixture) + captured: list[ActionEvent] = [] + + @ctx.chat.on_mention + async def on_mention(thread, message, context): + await thread.subscribe() + + ctx.chat.on_action(lambda event: captured.append(event)) + + try: + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as mock_fetch: + await ctx.send_webhook(fixture["mention"]) + + # Pre-populate the callback URL store as the SDK would have + # done at post time. + await ctx.state.set( + f"chat:callback:{CALLBACK_TOKEN}", + {"url": CALLBACK_BUTTON_URL, "originalValue": "order-99"}, + ) + + # Synthesize a block_actions payload with the SDK's encoded + # token as the value. + action = { + **fixture["action"], + "actions": [ + { + **fixture["action"]["actions"][0], + "action_id": "approve", + "value": f"__cb:{CALLBACK_TOKEN}", + } + ], + } + + mock_fetch.reset_mock() + await ctx.send_interactive(action) + + # Handler sees the original value, not the encoded token. + assert len(captured) == 1 + assert captured[0].action_id == "approve" + assert captured[0].value == "order-99" + + # The SDK POSTed to the stored callback URL. + callback_calls = [c for c in mock_fetch.await_args_list if c.args[0] == CALLBACK_BUTTON_URL] + assert len(callback_calls) == 1 + + assert callback_calls[0].kwargs["method"] == "POST" + body = json.loads(callback_calls[0].kwargs["body"]) + assert body["type"] == "action" + assert body["actionId"] == "approve" + assert body["value"] == "order-99" + assert body["user"]["id"] == "U00FAKEUSER1" + finally: + await ctx.shutdown() + + # it("treats an unknown token as a regular value when nothing is stored") + @pytest.mark.asyncio + async def test_treats_an_unknown_token_as_a_regular_value_when_nothing_is_stored(self): + fixture = load_fixture("actions-reactions/slack.json") + ctx = _SlackReplayContext(fixture) + captured: list[ActionEvent] = [] + + @ctx.chat.on_mention + async def on_mention(thread, message, context): + await thread.subscribe() + + ctx.chat.on_action(lambda event: captured.append(event)) + + try: + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as mock_fetch: + await ctx.send_webhook(fixture["mention"]) + + action = { + **fixture["action"], + "actions": [ + { + **fixture["action"]["actions"][0], + "action_id": "approve", + "value": "__cb:not-a-real-token", + } + ], + } + + mock_fetch.reset_mock() + await ctx.send_interactive(action) + + # Handler still fires; value is preserved verbatim because no + # store entry exists. + assert len(captured) == 1 + assert captured[0].value == "__cb:not-a-real-token" + + # No fetch went to any callback URL. + callback_calls = [ + c for c in mock_fetch.await_args_list if str(c.args[0]).startswith("https://hook.example.com/") + ] + assert len(callback_calls) == 0 + finally: + await ctx.shutdown() + + +# =========================================================================== +# Slack modal submit with stored callbackUrl +# =========================================================================== + + +@pytest.mark.skipif(not _SLACK_OK, reason="Slack adapter not available") +class TestSlackModalSubmitWithStoredCallbackUrl: + """describe("Slack modal submit with stored callbackUrl")""" + + # it("POSTs the form values to the modal callbackUrl after the handler runs") + @pytest.mark.asyncio + async def test_posts_the_form_values_to_the_modal_callbackurl_after_the_handler_runs(self): + fixture = load_fixture("modals/slack.json") + ctx = _SlackReplayContext(fixture) + captured: list[ModalSubmitEvent] = [] + + ctx.chat.on_modal_submit(lambda event: captured.append(event)) + + try: + context_id = fixture["modalContext"]["contextId"] + + # Simulate what openModal would have stored, including the + # callbackUrl. + await ctx.state.set( + f"modal-context:slack:{context_id}", + { + "thread": fixture["modalContext"]["thread"], + "message": fixture["modalContext"]["message"], + "callbackUrl": CALLBACK_MODAL_URL, + }, + ) + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as mock_fetch: + response = await ctx.send_interactive(fixture["viewSubmission"]) + assert response["status"] == 200 + + # The user-provided handler still fires. + assert len(captured) == 1 + assert captured[0].callback_id == "feedback_form" + + # SDK POSTed the modal_submit payload to the stored URL. + callback_calls = [c for c in mock_fetch.await_args_list if c.args[0] == CALLBACK_MODAL_URL] + assert len(callback_calls) == 1 + + assert callback_calls[0].kwargs["method"] == "POST" + body = json.loads(callback_calls[0].kwargs["body"]) + assert body["type"] == "modal_submit" + assert body["callbackId"] == "feedback_form" + assert body["values"] == { + "message": "Hello!", + "category": "feature", + "email": "user@example.com", + } + finally: + await ctx.shutdown() + + # it("does not POST when the modal context lacks a callbackUrl") + @pytest.mark.asyncio + async def test_does_not_post_when_the_modal_context_lacks_a_callbackurl(self): + fixture = load_fixture("modals/slack.json") + ctx = _SlackReplayContext(fixture) + captured: list[ModalSubmitEvent] = [] + + ctx.chat.on_modal_submit(lambda event: captured.append(event)) + + try: + context_id = fixture["modalContext"]["contextId"] + + # Modal context exists but has no callbackUrl -- the existing flow. + await ctx.state.set( + f"modal-context:slack:{context_id}", + { + "thread": fixture["modalContext"]["thread"], + "message": fixture["modalContext"]["message"], + }, + ) + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as mock_fetch: + await ctx.send_interactive(fixture["viewSubmission"]) + + # The handler ran against the replayed payload, but nothing was + # POSTed to any callback URL. + assert len(captured) == 1 + callback_calls = [ + c for c in mock_fetch.await_args_list if str(c.args[0]).startswith("https://hook.example.com/") + ] + assert len(callback_calls) == 0 + finally: + await ctx.shutdown() diff --git a/tests/test_callback_url.py b/tests/test_callback_url.py new file mode 100644 index 0000000..6a4a683 --- /dev/null +++ b/tests/test_callback_url.py @@ -0,0 +1,324 @@ +"""Faithful translation of callback-url.test.ts (17 tests). + +Each ``it("...")`` block from the TypeScript test suite is translated +to a corresponding ``async def test_...`` method, preserving the same +inputs, assertions, and test structure. + +TS stubs the global ``fetch``; Python patches the ``_fetch`` seam in +``chat_sdk.callback_url`` (the lazy aiohttp wrapper). + +TS file: packages/chat/src/callback-url.test.ts +""" + +from __future__ import annotations + +import copy +import json +import re +from unittest.mock import AsyncMock, call, patch + +from chat_sdk.callback_url import ( + decode_callback_value, + encode_callback_value, + post_to_callback_url, + process_card_callback_urls, + resolve_callback_url, +) +from chat_sdk.cards import Actions, Button, Card, CardText, Section +from chat_sdk.testing import MockStateAdapter, create_mock_state + +CALLBACK_TOKEN_PATTERN = re.compile(r"^__cb:[a-f0-9]{16}$") +CALLBACK_PREFIX_PATTERN = re.compile(r"^__cb:") + + +# =========================================================================== +# encodeCallbackValue / decodeCallbackValue +# =========================================================================== + + +class TestEncodeDecodeCallbackValue: + """describe("encodeCallbackValue / decodeCallbackValue")""" + + # it("encodes token") + def test_encodes_token(self): + encoded = encode_callback_value("abc123") + assert encoded == "__cb:abc123" + + # it("decodes token from encoded value") + def test_decodes_token_from_encoded_value(self): + decoded = decode_callback_value("__cb:abc123") + assert decoded.callback_token == "abc123" + + # it("returns no token for regular values") + def test_returns_no_token_for_regular_values(self): + decoded = decode_callback_value("regular-value") + assert decoded.callback_token is None + + # it("returns no token for undefined value") + def test_returns_no_token_for_undefined_value(self): + decoded = decode_callback_value(None) + assert decoded.callback_token is None + + # it("round-trips encode/decode") + def test_roundtrips_encodedecode(self): + encoded = encode_callback_value("tok123") + decoded = decode_callback_value(encoded) + assert decoded.callback_token == "tok123" + + +# =========================================================================== +# processCardCallbackUrls +# =========================================================================== + + +class TestProcessCardCallbackUrls: + """describe("processCardCallbackUrls")""" + + def _state(self) -> MockStateAdapter: + return create_mock_state() + + # it("returns card unchanged when no buttons have callbackUrl") + async def test_returns_card_unchanged_when_no_buttons_have_callbackurl(self): + state = self._state() + card = Card( + title="Test", + children=[ + CardText("Hello"), + Actions([Button(id="btn", label="Click")]), + ], + ) + + result = await process_card_callback_urls(card, state) + assert result is card + + # it("encodes callbackUrl into button value and stores in state") + async def test_encodes_callbackurl_into_button_value_and_stores_in_state(self): + state = self._state() + card = Card( + title="Test", + children=[ + Actions( + [ + Button( + id="approve", + label="Approve", + callback_url="https://example.com/webhook/123", + ) + ] + ), + ], + ) + + result = await process_card_callback_urls(card, state) + + actions = next(c for c in result["children"] if c["type"] == "actions") + button = actions["children"][0] + assert button["type"] == "button" + assert CALLBACK_TOKEN_PATTERN.match(button["value"]) + assert "callback_url" not in button + + decoded = decode_callback_value(button["value"]) + assert decoded.callback_token is not None + + resolved = await resolve_callback_url(decoded.callback_token, state) + assert resolved is not None + assert resolved.url == "https://example.com/webhook/123" + + # it("stores original value in state alongside callback URL") + async def test_stores_original_value_in_state_alongside_callback_url(self): + state = self._state() + card = Card( + title="Test", + children=[ + Actions( + [ + Button( + id="btn", + label="Go", + value="item-99", + callback_url="https://hook.example.com", + ) + ] + ), + ], + ) + + result = await process_card_callback_urls(card, state) + button = next(c for c in result["children"] if c["type"] == "actions")["children"][0] + + assert CALLBACK_TOKEN_PATTERN.match(button["value"]) + + decoded = decode_callback_value(button["value"]) + resolved = await resolve_callback_url(decoded.callback_token or "", state) + assert resolved is not None + assert resolved.url == "https://hook.example.com" + assert resolved.original_value == "item-99" + + # it("only processes buttons with callbackUrl, leaves others untouched") + async def test_only_processes_buttons_with_callbackurl_leaves_others_untouched(self): + state = self._state() + card = Card( + title="Test", + children=[ + Actions( + [ + Button(id="normal", label="Normal", value="keep"), + Button( + id="callback", + label="Callback", + callback_url="https://example.com", + ), + ] + ), + ], + ) + + result = await process_card_callback_urls(card, state) + actions = next(c for c in result["children"] if c["type"] == "actions") + normal_btn = actions["children"][0] + callback_btn = actions["children"][1] + + assert normal_btn["value"] == "keep" + assert CALLBACK_PREFIX_PATTERN.match(callback_btn["value"]) + + # it("processes buttons nested inside sections") + async def test_processes_buttons_nested_inside_sections(self): + state = self._state() + card = Card( + title="Test", + children=[ + Section( + [ + CardText("Nested"), + Actions( + [ + Button( + id="nested-btn", + label="Go", + callback_url="https://example.com/nested", + ) + ] + ), + ] + ), + ], + ) + + result = await process_card_callback_urls(card, state) + section = next(c for c in result["children"] if c["type"] == "section") + actions = next(c for c in section["children"] if c["type"] == "actions") + button = actions["children"][0] + assert button["type"] == "button" + + assert CALLBACK_TOKEN_PATTERN.match(button["value"]) + assert "callback_url" not in button + + decoded = decode_callback_value(button["value"]) + resolved = await resolve_callback_url(decoded.callback_token or "", state) + assert resolved is not None + assert resolved.url == "https://example.com/nested" + + # it("does not mutate the original card") + async def test_does_not_mutate_the_original_card(self): + state = self._state() + card = Card( + title="Test", + children=[ + Actions( + [ + Button( + id="btn", + label="Go", + callback_url="https://example.com", + ) + ] + ), + ], + ) + + original = copy.deepcopy(card) + await process_card_callback_urls(card, state) + assert card == original + + +# =========================================================================== +# resolveCallbackUrl +# =========================================================================== + + +class TestResolveCallbackUrl: + """describe("resolveCallbackUrl")""" + + # it("returns null for unknown token") + async def test_returns_null_for_unknown_token(self): + state = create_mock_state() + result = await resolve_callback_url("nonexistent", state) + assert result is None + + # it("resolves stored callback with URL and original value") + async def test_resolves_stored_callback_with_url_and_original_value(self): + state = create_mock_state() + await state.set( + "chat:callback:test-token", + {"url": "https://example.com/hook", "originalValue": "item-42"}, + ) + result = await resolve_callback_url("test-token", state) + assert result is not None + assert result.url == "https://example.com/hook" + assert result.original_value == "item-42" + + # it("handles legacy string format") + async def test_handles_legacy_string_format(self): + state = create_mock_state() + await state.set("chat:callback:legacy-token", "https://example.com/hook") + result = await resolve_callback_url("legacy-token", state) + assert result is not None + assert result.url == "https://example.com/hook" + assert result.original_value is None + + +# =========================================================================== +# postToCallbackUrl +# =========================================================================== + + +class TestPostToCallbackUrl: + """describe("postToCallbackUrl")""" + + # it("POSTs JSON payload to the URL") + async def test_posts_json_payload_to_the_url(self): + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as fetch_mock: + result = await post_to_callback_url( + "https://example.com/hook", + {"type": "action", "actionId": "approve"}, + ) + + assert result.error is None + assert result.status == 200 + assert fetch_mock.await_args == call( + "https://example.com/hook", + method="POST", + headers={"Content-Type": "application/json"}, + body=json.dumps({"type": "action", "actionId": "approve"}), + ) + + # it("returns error for non-2xx responses") + async def test_returns_error_for_non2xx_responses(self): + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(404, "Not Found"))): + result = await post_to_callback_url("https://example.com/hook", {}) + + assert isinstance(result.error, Exception) + assert "Callback URL returned 404: Not Found" in str(result.error) + assert result.status == 404 + + # it("catches fetch errors and returns them") + async def test_catches_fetch_errors_and_returns_them(self): + with patch( + "chat_sdk.callback_url._fetch", + new=AsyncMock(side_effect=Exception("Network error")), + ): + result = await post_to_callback_url("https://example.com/hook", {}) + + assert isinstance(result.error, Exception) + assert str(result.error) == "Network error" + assert result.status is None diff --git a/tests/test_channel_faithful.py b/tests/test_channel_faithful.py index 7de70ba..d14b1f2 100644 --- a/tests/test_channel_faithful.py +++ b/tests/test_channel_faithful.py @@ -17,6 +17,8 @@ import pytest +from chat_sdk.callback_url import decode_callback_value +from chat_sdk.cards import Actions, Button, Card from chat_sdk.channel import ChannelImpl, _ChannelImplConfigWithAdapter, derive_channel_id from chat_sdk.errors import ChatNotImplementedError from chat_sdk.testing import ( @@ -29,12 +31,14 @@ from chat_sdk.thread import ThreadImpl, _ThreadImplConfig from chat_sdk.types import ( Attachment, + EphemeralMessage, FetchResult, ListThreadsResult, Message, PostableAst, PostableMarkdown, PostableRaw, + PostEphemeralOptions, RawMessage, ScheduledMessage, ThreadSummary, @@ -1381,3 +1385,199 @@ class TestJsxAbsorbers: # test cannot be faithfully translated. Kept as an absorber for verify_test_fidelity.py. def test_should_convert_jsx_card_elements_to_cardelement(self): assert True + + +# =========================================================================== +# callbackUrl processing (vercel/chat#454) +# =========================================================================== + + +class TestCallbackUrlProcessing: + """describe("callbackUrl processing") from channel.test.ts""" + + FUTURE_DATE = datetime(2030, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + + def _make_tracked_channel(self) -> tuple[ChannelImpl, MockAdapter, MockStateAdapter, list[tuple[str, Any]]]: + adapter = create_mock_adapter() + state = create_mock_state() + post_calls: list[tuple[str, Any]] = [] + + async def tracking_post(channel_id: str, message: Any) -> RawMessage: + post_calls.append((channel_id, message)) + return RawMessage(id="msg-1", thread_id=channel_id, raw={}) + + adapter.post_channel_message = tracking_post # type: ignore[assignment] + channel = _make_channel(adapter, state) + return channel, adapter, state, post_calls + + def _callback_keys(self, state: MockStateAdapter) -> list[str]: + return [k for k in state.cache if k.startswith("chat:callback:")] + + # it("should encode callbackUrl tokens when posting a card") + @pytest.mark.asyncio + async def test_should_encode_callbackurl_tokens_when_posting_a_card(self): + channel, adapter, state, post_calls = self._make_tracked_channel() + + card = Card( + title="Test", + children=[ + Actions( + [ + Button( + id="approve", + label="Approve", + callback_url="https://example.com/hook", + ) + ] + ), + ], + ) + + await channel.post(card) + + posted_card = post_calls[0][1] + actions = next(c for c in posted_card["children"] if c["type"] == "actions") + button = actions["children"][0] + + decoded = decode_callback_value(button["value"]) + assert decoded.callback_token is not None + assert "callback_url" not in button + + stored = await state.get(f"chat:callback:{decoded.callback_token}") + assert stored is not None + assert stored["url"] == "https://example.com/hook" + + # it("should encode callbackUrl when posting via postEphemeral") + @pytest.mark.asyncio + async def test_should_encode_callbackurl_when_posting_via_postephemeral(self): + adapter = create_mock_adapter() + state = create_mock_state() + mock_post_ephemeral = AsyncMock( + return_value=EphemeralMessage(id="e1", thread_id="slack:C123", used_fallback=False, raw={}) + ) + adapter.post_ephemeral = mock_post_ephemeral # type: ignore[attr-defined] + channel = _make_channel(adapter, state) + + await channel.post_ephemeral( + "U1", + Card( + children=[ + Actions( + [ + Button( + id="ack", + label="Ack", + callback_url="https://example.com/eph", + ) + ] + ), + ] + ), + PostEphemeralOptions(fallback_to_dm=False), + ) + + sent_card = mock_post_ephemeral.call_args[0][2] + button = sent_card["children"][0]["children"][0] + callback_token = decode_callback_value(button["value"]).callback_token + assert callback_token is not None + stored = await state.get(f"chat:callback:{callback_token}") + assert stored is not None + assert stored["url"] == "https://example.com/eph" + + # it("should encode callbackUrl when scheduling") + @pytest.mark.asyncio + async def test_should_encode_callbackurl_when_scheduling(self): + adapter = create_mock_adapter() + state = create_mock_state() + adapter.schedule_message = AsyncMock( # type: ignore[attr-defined] + return_value=ScheduledMessage( + scheduled_message_id="Q1", + channel_id="C123", + post_at=self.FUTURE_DATE, + raw={}, + _cancel=AsyncMock(return_value=None), + ) + ) + channel = _make_channel(adapter, state) + + await channel.schedule( + Card( + children=[ + Actions( + [ + Button( + id="later", + label="Later", + callback_url="https://example.com/sch", + ) + ] + ), + ] + ), + post_at=self.FUTURE_DATE, + ) + + sent_card = adapter.schedule_message.call_args[0][1] + button = sent_card["children"][0]["children"][0] + callback_token = decode_callback_value(button["value"]).callback_token + assert callback_token is not None + stored = await state.get(f"chat:callback:{callback_token}") + assert stored is not None + assert stored["url"] == "https://example.com/sch" + + # it("should encode callbackUrl when editing a sent card") + @pytest.mark.asyncio + async def test_should_encode_callbackurl_when_editing_a_sent_card(self): + channel, adapter, state, post_calls = self._make_tracked_channel() + + sent = await channel.post("Hello") + await sent.edit( + Card( + children=[ + Actions( + [ + Button( + id="redo", + label="Redo", + callback_url="https://example.com/edit", + ) + ] + ), + ] + ) + ) + + edited_card = adapter._edit_calls[0][2] + button = edited_card["children"][0]["children"][0] + callback_token = decode_callback_value(button["value"]).callback_token + assert callback_token is not None + stored = await state.get(f"chat:callback:{callback_token}") + assert stored is not None + assert stored["url"] == "https://example.com/edit" + + # it("should pass plain string posts through unchanged") + @pytest.mark.asyncio + async def test_should_pass_plain_string_posts_through_unchanged(self): + channel, adapter, state, post_calls = self._make_tracked_channel() + + await channel.post("Just text") + + assert post_calls == [("slack:C123", "Just text")] + assert self._callback_keys(state) == [] + + # it("should leave cards without callback buttons untouched") + @pytest.mark.asyncio + async def test_should_leave_cards_without_callback_buttons_untouched(self): + channel, adapter, state, post_calls = self._make_tracked_channel() + + await channel.post( + Card( + children=[ + Actions([Button(id="ok", label="OK", value="keep")]), + ] + ) + ) + + posted_card = post_calls[0][1] + assert posted_card["children"][0]["children"][0]["value"] == "keep" + assert self._callback_keys(state) == [] diff --git a/tests/test_chat_faithful.py b/tests/test_chat_faithful.py index cd0a085..8627cc0 100644 --- a/tests/test_chat_faithful.py +++ b/tests/test_chat_faithful.py @@ -10,8 +10,10 @@ from __future__ import annotations import asyncio +import json import re from typing import Any +from unittest.mock import AsyncMock, patch import pytest @@ -34,10 +36,12 @@ ConcurrencyConfig, EmojiValue, MessageContext, + ModalResponse, ModalSubmitEvent, QueueEntry, ReactionEvent, SlashCommandEvent, + WebhookOptions, ) HELP_REGEX = re.compile(r"help", re.IGNORECASE) @@ -4174,3 +4178,390 @@ async def handler(thread, message, context=None): assert received_subject == mock_subject assert received_skipped_subject == mock_subject assert adapter.fetch_subject.await_count == 2 # type: ignore[attr-defined] + +# ============================================================================ +# 24. Action callbackUrl handling (vercel/chat#454) +# ============================================================================ + + +def _make_callback_chat() -> tuple[Chat, MockAdapter, MockStateAdapter, MockLogger]: + """Like _make_chat but returns the logger so error logs can be asserted.""" + adapter = create_mock_adapter("slack") + state = create_mock_state() + logger = MockLogger() + config = ChatConfig( + user_name="testbot", + adapters={"slack": adapter}, + state=state, + logger=logger, + ) + return Chat(config), adapter, state, logger + + +async def _process_action_and_wait(chat: Chat, event: ActionEvent) -> None: + """Dispatch an action and deterministically wait for the handler task.""" + tasks: list[Any] = [] + chat.process_action(event, WebhookOptions(wait_until=tasks.append)) + await asyncio.gather(*tasks) + + +class TestActionsCallbackUrl: + """TS describe("processAction") — callbackUrl additions.""" + + # TS: "should decode callbackUrl token and POST to it" + async def test_should_decode_callbackurl_token_and_post_to_it(self): + chat, adapter, state = await _init_chat() + received: list[ActionEvent] = [] + + async def _handler(event): + received.append(event) + + chat.on_action("approve", _handler) + + state.cache["chat:callback:testtoken123"] = { + "url": "https://example.com/webhook/hook1", + "originalValue": "order-789", + } + + event = _make_action_event(adapter, action_id="approve", value="__cb:testtoken123") + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as mock_fetch: + await _process_action_and_wait(chat, event) + + assert len(received) == 1 + assert received[0].value == "order-789" + + assert mock_fetch.await_count == 1 + assert mock_fetch.await_args.args == ("https://example.com/webhook/hook1",) + assert mock_fetch.await_args.kwargs["method"] == "POST" + assert mock_fetch.await_args.kwargs["headers"] == {"Content-Type": "application/json"} + + body = json.loads(mock_fetch.await_args.kwargs["body"]) + assert body == { + "type": "action", + "actionId": "approve", + "value": "order-789", + "user": {"id": "U123", "name": "user"}, + "threadId": "slack:C123:1234.5678", + "messageId": "msg-1", + } + + # TS: "should decode callbackUrl token with no original value" + async def test_should_decode_callbackurl_token_with_no_original_value(self): + chat, adapter, state = await _init_chat() + received: list[ActionEvent] = [] + + chat.on_action(lambda event: received.append(event)) + + state.cache["chat:callback:tok999"] = {"url": "https://example.com/webhook/hook2"} + + event = _make_action_event(adapter, action_id="deny", value="__cb:tok999") + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as mock_fetch: + await _process_action_and_wait(chat, event) + + assert len(received) == 1 + assert received[0].value is None + + body = json.loads(mock_fetch.await_args.kwargs["body"]) + assert "value" not in body + + # TS: "should preserve callback-like values when no callbackUrl is stored" + async def test_should_preserve_callbacklike_values_when_no_callbackurl_is_stored(self): + chat, adapter, state = await _init_chat() + received: list[ActionEvent] = [] + + async def _handler(event): + received.append(event) + + chat.on_action("approve", _handler) + + event = _make_action_event(adapter, action_id="approve", value="__cb:not-a-stored-token") + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as mock_fetch: + await _process_action_and_wait(chat, event) + + assert len(received) == 1 + assert received[0].value == "__cb:not-a-stored-token" + assert mock_fetch.await_count == 0 + + # TS: "should fire onAction handlers alongside callbackUrl POST" + async def test_should_fire_onaction_handlers_alongside_callbackurl_post(self): + chat, adapter, state = await _init_chat() + catch_all_calls: list[ActionEvent] = [] + specific_calls: list[ActionEvent] = [] + + chat.on_action(lambda event: catch_all_calls.append(event)) + + async def _specific(event): + specific_calls.append(event) + + chat.on_action("approve", _specific) + + state.cache["chat:callback:tok555"] = {"url": "https://example.com/webhook/hook3"} + + event = _make_action_event(adapter, action_id="approve", value="__cb:tok555") + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as mock_fetch: + await _process_action_and_wait(chat, event) + + assert len(catch_all_calls) == 1 + assert len(specific_calls) == 1 + assert mock_fetch.await_count == 1 + + +# ============================================================================ +# 25. Modal callbackUrl handling (vercel/chat#454) +# ============================================================================ + + +def _make_modal_submit_event( + adapter: MockAdapter, + *, + callback_id: str = "feedback_modal", + view_id: str = "V789", + values: dict[str, str] | None = None, +) -> ModalSubmitEvent: + return ModalSubmitEvent( + callback_id=callback_id, + view_id=view_id, + values=values if values is not None else {}, + user=_make_author(), + adapter=adapter, + raw={}, + ) + + +class TestModalCallbackUrl: + """TS describe("processModalSubmit") — callbackUrl additions.""" + + # TS: "should POST to modal callbackUrl on submit" + async def test_should_post_to_modal_callbackurl_on_submit(self): + chat, adapter, state = await _init_chat() + captured_action: list[ActionEvent] = [] + + async def _action_handler(event): + captured_action.append(event) + + chat.on_action("open_form", _action_handler) + + action_event = _make_action_event(adapter, action_id="open_form", trigger_id="trigger-123") + await _process_action_and_wait(chat, action_event) + + modal = { + "type": "modal", + "callback_id": "feedback_modal", + "title": "Feedback", + "callback_url": "https://example.com/webhook/modal-hook", + "children": [], + } + assert len(captured_action) == 1 + await captured_action[0].open_modal(modal) + # _store_modal_context persists via a background task + await asyncio.sleep(0.02) + + modal_context_keys = [k for k in state.cache if k.startswith("modal-context:")] + assert len(modal_context_keys) == 1 + context_id = modal_context_keys[0].split(":")[-1] + + modal_handler_calls: list[ModalSubmitEvent] = [] + + async def _modal_handler(event): + modal_handler_calls.append(event) + + chat.on_modal_submit("feedback_modal", _modal_handler) + tasks: list[Any] = [] + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as mock_fetch: + await chat.process_modal_submit( + _make_modal_submit_event(adapter, values={"message": "Great!"}), + context_id, + WebhookOptions(wait_until=tasks.append), + ) + await asyncio.gather(*tasks) + + assert len(modal_handler_calls) == 1 + assert mock_fetch.await_count == 1 + assert mock_fetch.await_args.args == ("https://example.com/webhook/modal-hook",) + assert mock_fetch.await_args.kwargs["method"] == "POST" + + body = json.loads(mock_fetch.await_args.kwargs["body"]) + assert body == { + "type": "modal_submit", + "callbackId": "feedback_modal", + "values": {"message": "Great!"}, + "user": {"id": "U123", "name": "user"}, + } + + # TS: "should not POST to modal callbackUrl when submit returns errors" + async def test_should_not_post_to_modal_callbackurl_when_submit_returns_errors(self): + chat, adapter, state = await _init_chat() + wait_until_calls: list[Any] = [] + + state.cache["modal-context:slack:ctx-errors"] = { + "callbackUrl": "https://example.com/webhook/modal-hook", + } + + async def _modal_handler(event): + return ModalResponse(action="errors", errors={"message": "Required"}) + + chat.on_modal_submit("feedback_modal", _modal_handler) + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as mock_fetch: + response = await chat.process_modal_submit( + _make_modal_submit_event(adapter, values={"message": ""}), + "ctx-errors", + WebhookOptions(wait_until=wait_until_calls.append), + ) + + assert response == ModalResponse(action="errors", errors={"message": "Required"}) + assert len(wait_until_calls) == 0 + assert mock_fetch.await_count == 0 + + # TS: "should not wait for modal callbackUrl before returning response" + async def test_should_not_wait_for_modal_callbackurl_before_returning_response(self): + chat, adapter, state = await _init_chat() + wait_until_calls: list[Any] = [] + fetch_started = asyncio.Event() + + async def _never_resolving_fetch(*args: Any, **kwargs: Any) -> Any: + fetch_started.set() + await asyncio.Event().wait() # never resolves + + state.cache["modal-context:slack:ctx-slow"] = { + "callbackUrl": "https://example.com/webhook/modal-hook", + } + + async def _modal_handler(event): + return ModalResponse(action="clear") + + chat.on_modal_submit("feedback_modal", _modal_handler) + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(side_effect=_never_resolving_fetch)) as mock_fetch: + response = await chat.process_modal_submit( + _make_modal_submit_event(adapter, values={"message": "Great!"}), + "ctx-slow", + WebhookOptions(wait_until=wait_until_calls.append), + ) + + # The response came back while the POST is still in flight. + assert response == ModalResponse(action="clear") + assert len(wait_until_calls) == 1 + + await fetch_started.wait() + assert mock_fetch.await_count == 1 + assert mock_fetch.await_args.args == ("https://example.com/webhook/modal-hook",) + assert mock_fetch.await_args.kwargs["method"] == "POST" + + # Clean up the in-flight task. + wait_until_calls[0].cancel() + await asyncio.gather(*wait_until_calls, return_exceptions=True) + + # TS: "should fire-and-forget modal callbackUrl when no waitUntil is provided" + async def test_should_fireandforget_modal_callbackurl_when_no_waituntil_is_provided(self): + chat, adapter, state = await _init_chat() + fetch_started = asyncio.Event() + release_fetch = asyncio.Event() + + async def _deferred_fetch(*args: Any, **kwargs: Any) -> tuple[int, str]: + fetch_started.set() + await release_fetch.wait() + return (200, "ok") + + state.cache["modal-context:slack:ctx-noWait"] = { + "callbackUrl": "https://example.com/webhook/modal-noWait", + } + + async def _modal_handler(event): + return ModalResponse(action="clear") + + chat.on_modal_submit("feedback_modal", _modal_handler) + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(side_effect=_deferred_fetch)) as mock_fetch: + response = await chat.process_modal_submit( + _make_modal_submit_event(adapter, values={"msg": "ok"}), + "ctx-noWait", + ) + + assert response == ModalResponse(action="clear") + + await fetch_started.wait() + assert mock_fetch.await_count == 1 + assert mock_fetch.await_args.args == ("https://example.com/webhook/modal-noWait",) + assert mock_fetch.await_args.kwargs["method"] == "POST" + + release_fetch.set() + await asyncio.sleep(0) + + # TS: "should not POST when modal context has no callbackUrl" + async def test_should_not_post_when_modal_context_has_no_callbackurl(self): + chat, adapter, state = await _init_chat() + + state.cache["modal-context:slack:ctx-nocallback"] = {} + + async def _modal_handler(event): + return ModalResponse(action="clear") + + chat.on_modal_submit("feedback_modal", _modal_handler) + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as mock_fetch: + await chat.process_modal_submit( + _make_modal_submit_event(adapter), + "ctx-nocallback", + ) + await asyncio.sleep(0.02) + + assert mock_fetch.await_count == 0 + + # TS: "should log error when modal callbackUrl POST returns non-2xx" + async def test_should_log_error_when_modal_callbackurl_post_returns_non2xx(self): + chat, adapter, state, logger = _make_callback_chat() + await chat.webhooks["slack"]("request") + tasks: list[Any] = [] + + state.cache["modal-context:slack:ctx-fail"] = { + "callbackUrl": "https://example.com/webhook/fail", + } + + async def _modal_handler(event): + return ModalResponse(action="clear") + + chat.on_modal_submit("feedback_modal", _modal_handler) + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(500, "nope"))): + await chat.process_modal_submit( + _make_modal_submit_event(adapter), + "ctx-fail", + WebhookOptions(wait_until=tasks.append), + ) + await asyncio.gather(*tasks) + + assert any(call_args[0] == "Modal callbackUrl POST failed" for call_args in logger.error.calls) + + +# ============================================================================ +# 26. Action callbackUrl error logging (vercel/chat#454) +# ============================================================================ + + +class TestActionCallbackUrlErrorLogging: + """TS describe("action callbackUrl error logging")""" + + # TS: "should log error when action callbackUrl POST returns non-2xx" + async def test_should_log_error_when_action_callbackurl_post_returns_non2xx(self): + chat, adapter, state, logger = _make_callback_chat() + await chat.webhooks["slack"]("request") + + async def _handler(event): + return None + + chat.on_action("approve", _handler) + + state.cache["chat:callback:bad-token"] = {"url": "https://example.com/webhook/will-fail"} + + event = _make_action_event(adapter, action_id="approve", value="__cb:bad-token") + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(500, "nope"))): + await _process_action_and_wait(chat, event) + + assert any(call_args[0] == "Button callbackUrl POST failed" for call_args in logger.error.calls) diff --git a/tests/test_discord_cards.py b/tests/test_discord_cards.py index ea99ba5..c3b1bce 100644 --- a/tests/test_discord_cards.py +++ b/tests/test_discord_cards.py @@ -5,6 +5,8 @@ from __future__ import annotations +import pytest + from chat_sdk.adapters.discord.cards import ( BUTTON_STYLE_DANGER, BUTTON_STYLE_LINK, @@ -13,6 +15,8 @@ DISCORD_BLURPLE, card_to_discord_payload, card_to_fallback_text, + decode_discord_custom_id, + encode_discord_custom_id, ) from chat_sdk.cards import ( Actions, @@ -27,6 +31,7 @@ LinkButton, Section, ) +from chat_sdk.shared.errors import ValidationError # --------------------------------------------------------------------------- # cardToDiscordPayload @@ -113,7 +118,8 @@ def test_actions_with_buttons(self): assert buttons[1]["type"] == 2 assert buttons[1]["style"] == BUTTON_STYLE_DANGER assert buttons[1]["label"] == "Reject" - assert buttons[1]["custom_id"] == "reject" + # Button values are packed into custom_id (vercel/chat#454) + assert buttons[1]["custom_id"] == "reject\ndata-123" assert buttons[2]["type"] == 2 assert buttons[2]["style"] == BUTTON_STYLE_SECONDARY @@ -272,3 +278,124 @@ def test_empty_card(self): card = Card() text = card_to_fallback_text(card) assert text == "" + + +# --------------------------------------------------------------------------- +# encodeDiscordCustomId / decodeDiscordCustomId (vercel/chat#454) +# --------------------------------------------------------------------------- + + +class TestEncodeDecodeDiscordCustomId: + """describe("encodeDiscordCustomId / decodeDiscordCustomId")""" + + # it("encodes actionId only when no value") + def test_encodes_actionid_only_when_no_value(self): + assert encode_discord_custom_id("approve") == "approve" + + # it("encodes actionId with value") + def test_encodes_actionid_with_value(self): + assert encode_discord_custom_id("approve", "order-123") == "approve\norder-123" + + # it("skips encoding when empty value") + def test_skips_encoding_when_empty_value(self): + assert encode_discord_custom_id("approve", "") == "approve" + + # it("throws when actionId is empty") + def test_throws_when_actionid_is_empty(self): + with pytest.raises(ValidationError): + encode_discord_custom_id("") + + # it("throws when actionId exceeds 100 chars") + def test_throws_when_actionid_exceeds_100_chars(self): + with pytest.raises(ValidationError): + encode_discord_custom_id("x" * 101) + + # it("throws when encoded custom_id exceeds 100 chars") + def test_throws_when_encoded_custom_id_exceeds_100_chars(self): + long_value = "x" * 100 + with pytest.raises(ValidationError): + encode_discord_custom_id("btn", long_value) + + # it("throws when a button value makes custom_id too long") + def test_throws_when_a_button_value_makes_custom_id_too_long(self): + card = Card( + children=[ + Actions( + [ + Button( + id="x" * 90, + label="Approve", + value="__cb:1234567890abcdef", + ) + ] + ), + ] + ) + + with pytest.raises(ValidationError): + card_to_discord_payload(card) + + # it("decodes actionId only") + def test_decodes_actionid_only(self): + decoded = decode_discord_custom_id("approve") + assert decoded.action_id == "approve" + assert decoded.value is None + + # it("decodes actionId with value") + def test_decodes_actionid_with_value(self): + decoded = decode_discord_custom_id("approve\norder-123") + assert decoded.action_id == "approve" + assert decoded.value == "order-123" + + # it("round-trips encode/decode") + def test_roundtrips_encodedecode(self): + encoded = encode_discord_custom_id("btn", "__cb:a1b2c3d4e5f6g7h8") + decoded = decode_discord_custom_id(encoded) + assert decoded.action_id == "btn" + assert decoded.value == "__cb:a1b2c3d4e5f6g7h8" + + # it("preserves embedded delimiter chars in the value (decoder splits on first only)") + def test_preserves_embedded_delimiter_chars_in_the_value_decoder_splits_on_first_only(self): + decoded = decode_discord_custom_id("btn\nfirst\nsecond") + assert decoded.action_id == "btn" + assert decoded.value == "first\nsecond" + + # it("treats explicitly null value as no value") + def test_treats_explicitly_null_value_as_no_value(self): + assert encode_discord_custom_id("approve", None) == "approve" + + # it("encodes a custom_id at the 100 char boundary") + def test_encodes_a_custom_id_at_the_100_char_boundary(self): + action_id = "a" * 50 + value = "b" * 49 + encoded = encode_discord_custom_id(action_id, value) + assert len(encoded) == 100 + decoded = decode_discord_custom_id(encoded) + assert decoded.action_id == action_id + assert decoded.value == value + + # it("rejects a custom_id one char past the boundary") + def test_rejects_a_custom_id_one_char_past_the_boundary(self): + action_id = "a" * 50 + value = "b" * 50 + with pytest.raises(ValidationError): + encode_discord_custom_id(action_id, value) + + # it("renders cards with values into Discord button payloads") + def test_renders_cards_with_values_into_discord_button_payloads(self): + card = Card( + children=[ + Actions( + [ + Button(id="approve", label="Approve", value="order-99"), + Button(id="deny", label="Deny"), + ] + ), + ] + ) + + payload = card_to_discord_payload(card) + buttons = payload["components"][0]["components"] + + assert buttons[0]["custom_id"] == "approve\norder-99" + assert buttons[1]["custom_id"] == "deny" diff --git a/tests/test_thread_faithful.py b/tests/test_thread_faithful.py index 13f7245..846a0ff 100644 --- a/tests/test_thread_faithful.py +++ b/tests/test_thread_faithful.py @@ -18,6 +18,8 @@ import pytest +from chat_sdk.callback_url import decode_callback_value +from chat_sdk.cards import Actions, Button, Card from chat_sdk.channel import derive_channel_id from chat_sdk.errors import ChatNotImplementedError from chat_sdk.plan import StreamingPlan, StreamingPlanOptions @@ -32,6 +34,7 @@ from chat_sdk.thread import ThreadImpl, _ThreadImplConfig from chat_sdk.types import ( Author, + EphemeralMessage, FetchResult, MarkdownTextChunk, Message, @@ -39,6 +42,7 @@ PlanUpdateChunk, PostableMarkdown, PostableRaw, + PostEphemeralOptions, RawMessage, ScheduledMessage, TaskUpdateChunk, @@ -3755,3 +3759,156 @@ def test_should_convert_jsx_card_elements_to_cardelement_before_passing_to_adapt def test_should_convert_card_jsx_with_children_to_cardelement(self): assert True + + +# =========================================================================== +# callbackUrl processing (vercel/chat#454) +# =========================================================================== + + +def _make_card_with_callback(callback_url: str = "https://example.com/hook") -> Any: + return Card( + title="Test", + children=[ + Actions([Button(id="approve", label="Approve", callback_url=callback_url)]), + ], + ) + + +class TestCallbackUrlProcessing: + """describe("callbackUrl processing") from thread.test.ts""" + + FUTURE_DATE = datetime(2030, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + + def _callback_keys(self, state: MockStateAdapter) -> list[str]: + return [k for k in state.cache if k.startswith("chat:callback:")] + + # it("should encode callbackUrl when posting a card") + @pytest.mark.asyncio + async def test_should_encode_callbackurl_when_posting_a_card(self): + adapter = create_mock_adapter() + state = create_mock_state() + thread = _make_thread(adapter, state) + + await thread.post(_make_card_with_callback("https://example.com/post-hook")) + + posted_card = adapter._post_calls[0][1] + button = posted_card["children"][0]["children"][0] + + callback_token = decode_callback_value(button["value"]).callback_token + assert callback_token is not None + assert "callback_url" not in button + + stored = await state.get(f"chat:callback:{callback_token}") + assert stored is not None + assert stored["url"] == "https://example.com/post-hook" + + # it("should encode callbackUrl when posting via postEphemeral with native support") + @pytest.mark.asyncio + async def test_should_encode_callbackurl_when_posting_via_postephemeral_with_native_support(self): + adapter = create_mock_adapter() + state = create_mock_state() + mock_post_ephemeral = AsyncMock( + return_value=EphemeralMessage( + id="ephemeral-1", + thread_id="slack:C123:1234.5678", + used_fallback=False, + raw={}, + ) + ) + adapter.post_ephemeral = mock_post_ephemeral # type: ignore[attr-defined] + thread = _make_thread(adapter, state) + + await thread.post_ephemeral( + "U456", + _make_card_with_callback("https://example.com/eph"), + PostEphemeralOptions(fallback_to_dm=False), + ) + + sent_card = mock_post_ephemeral.call_args[0][2] + button = sent_card["children"][0]["children"][0] + callback_token = decode_callback_value(button["value"]).callback_token + + assert callback_token is not None + stored = await state.get(f"chat:callback:{callback_token}") + assert stored is not None + assert stored["url"] == "https://example.com/eph" + + # it("should encode callbackUrl when scheduling a card") + @pytest.mark.asyncio + async def test_should_encode_callbackurl_when_scheduling_a_card(self): + adapter = create_mock_adapter() + state = create_mock_state() + adapter.schedule_message = AsyncMock( # type: ignore[attr-defined] + return_value=ScheduledMessage( + scheduled_message_id="Q1", + channel_id="C123", + post_at=self.FUTURE_DATE, + raw={}, + _cancel=AsyncMock(return_value=None), + ) + ) + thread = _make_thread(adapter, state) + + await thread.schedule( + _make_card_with_callback("https://example.com/sched"), + post_at=self.FUTURE_DATE, + ) + + sent_card = adapter.schedule_message.call_args[0][1] + button = sent_card["children"][0]["children"][0] + callback_token = decode_callback_value(button["value"]).callback_token + + assert callback_token is not None + stored = await state.get(f"chat:callback:{callback_token}") + assert stored is not None + assert stored["url"] == "https://example.com/sched" + + # it("should encode callbackUrl when editing a sent message with a card") + @pytest.mark.asyncio + async def test_should_encode_callbackurl_when_editing_a_sent_message_with_a_card(self): + adapter = create_mock_adapter() + state = create_mock_state() + thread = _make_thread(adapter, state) + + sent = await thread.post("Hello") + await sent.edit(_make_card_with_callback("https://example.com/edit")) + + edited_card = adapter._edit_calls[0][2] + button = edited_card["children"][0]["children"][0] + callback_token = decode_callback_value(button["value"]).callback_token + + assert callback_token is not None + stored = await state.get(f"chat:callback:{callback_token}") + assert stored is not None + assert stored["url"] == "https://example.com/edit" + + # it("should pass plain string posts through unchanged") + @pytest.mark.asyncio + async def test_should_pass_plain_string_posts_through_unchanged(self): + adapter = create_mock_adapter() + state = create_mock_state() + thread = _make_thread(adapter, state) + + await thread.post("Just text") + + assert adapter._post_calls == [("slack:C123:1234.5678", "Just text")] + assert self._callback_keys(state) == [] + + # it("should leave cards without callback buttons untouched") + @pytest.mark.asyncio + async def test_should_leave_cards_without_callback_buttons_untouched(self): + adapter = create_mock_adapter() + state = create_mock_state() + thread = _make_thread(adapter, state) + + card = Card( + title="Plain", + children=[Actions([Button(id="ok", label="OK", value="keep")])], + ) + await thread.post(card) + + posted_card = adapter._post_calls[0][1] + # No state writes for callback storage + assert self._callback_keys(state) == [] + assert posted_card["children"][0]["children"][0]["value"] == "keep" From 3835d741b916d5a9df48ae82e67543cbdcd35ac4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 21:15:14 +0000 Subject: [PATCH 10/14] sync(4.29): fidelity scope extension, divergence rows, final test ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MAPPING: four new chat@4.29.0 core test files mapped (callback-url, thread-history, transcripts, transcripts-wiring) — 12 of 19 files now in scope; stale 4.26-era references in the script/template updated - fidelity_baseline.json regenerated at chat@4.29.0 (731 TS tests in scope, 0 missing; strict mode green) - docs/UPSTREAM_SYNC.md: Known Non-Parity rows from the port wave — GitHub octokit / Linear linear_client getters, @chat-adapter/tests kit, Teams modal-submit webhook options slice, jsx-runtime callbackUrl props, Transcripts API Python adaptations, Slack legacy mrkdwn renderer scope note, Discord gateway-only interactions surface, and the Teams microsoft-teams-apps deferral to 0.4.30 - tests/test_chat_faithful.py: the [Slash Commands] duplicate-title openModal-unsupported port (matcher counts names as a multiset) and the #459 subject-wiring port (skipif-gated on BaseAdapter.fetch_subject, activates when PR #131 lands) https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 --- docs/UPSTREAM_SYNC.md | 5 +++ scripts/fidelity_baseline.json | 4 +- scripts/verify_test_fidelity.py | 18 +++++---- tests/test_chat_faithful.py | 68 +++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 9 deletions(-) diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index 237cb30..f40f7ef 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -639,6 +639,10 @@ stay explicit instead of being rediscovered in code review. | `GitHubAdapter.octokit` native client getter (vercel/chat#459, #478) | Not exposed | `get octokit(): Octokit` (plus deprecated `client` alias) returns the underlying Octokit — fixed instance in PAT/single-tenant App mode, per-installation client resolved from `AsyncLocalStorage` inside a webhook handler in multi-tenant mode | The Python adapter is hand-rolled over raw `aiohttp` (`_github_api_request`) with PyJWT for App JWTs and an installation-token cache; the `github` extra is `pyjwt[crypto]` only — there is no Octokit-equivalent object to return, and exposing the raw session or an invented facade under the name `octokit` would misrepresent the surface. Revisit if the adapter adopts an octokit-style SDK (e.g. `githubkit`) as an optional dependency per hazard #10's "prefer official SDKs" sub-rule; the getter (and the GitHub `fetch_subject` half of #459) ports cleanly then. | | `LinearAdapter.linear_client` native client getter (vercel/chat#459, #478) | Not exposed | `get linearClient(): LinearClient` (plus deprecated `client` alias) returns the `@linear/sdk` `LinearClient`, per-org from `AsyncLocalStorage` in multi-tenant OAuth mode | `@linear/sdk` is TypeScript-only and no official Linear Python SDK exists; the adapter issues GraphQL directly over `aiohttp` (`_graphql_query`) and already documents that stance. Nothing honest to put behind the name. Revisit only if Linear ships an official Python SDK (the Linear `fetch_subject` half of #459 is blocked on the same). | | `@chat-adapter/tests` adapter test kit (vercel/chat#470) | Not ported | New TS package with test utilities for adapter authors | Python already ships `chat_sdk.testing` (`MockAdapter`, `MockStateAdapter`, `create_test_message()`) covering the same surface for this repo's adapter tests; mirroring the TS kit verbatim would duplicate it. Revisit if upstream's kit grows capabilities ours lacks (e.g. recorded replay fixtures for third-party adapter authors). | +| Teams modal-submit webhook options (vercel/chat#454 adapter-teams slice) | Not ported — the Python Teams adapter has no task-module/modal-submit flow (`handleTaskSubmit`/`processModalSubmit` are absent), so upstream's change passing `bridgeAdapter.getWebhookOptions(activity.id)` into `processModalSubmit` has no landing site | `TeamsAdapter.handleTaskSubmit` forwards webhook options so modal callbackUrl POSTs are registered with `waitUntil` | Pre-existing gap: Teams modals are unported. The Slack adapter already forwards options to `process_modal_submit`, so the new waitUntil plumbing is exercised there. Add the Teams call when Teams modal support lands. | +| jsx-runtime `callbackUrl` props (vercel/chat#454 slice) | Not ported | `ButtonProps`/`ModalProps` gain `callbackUrl`; `resolveJSXElement` forwards it | Covered by the existing "JSX Card/Modal elements" row — Python has no JSX runtime; `Button()`/`Modal()` builders accept `callback_url` directly. | +| Transcripts API Python adaptations (vercel/chat#448) | `transcripts.delete()` returns a `DeleteResult` dataclass; misconfiguration raises `ValueError` (constructor/`AppendInput` guards, invalid duration) or `ChatError` (`chat.transcripts` accessor); guard messages name the Python kwarg (`options.user_key`); `DurationString` is a `str` alias validated at runtime by `_parse_duration` | Inline `{ deleted: number }`; generic `Error` for all of the above; template-literal `` `${number}${"s"\|"m"\|"h"\|"d"}` `` type | Port rules: typed dataclasses over raw dicts; repo error-type conventions (constructor misconfig → `ValueError`, runtime API misuse → `ChatError`) with upstream-matching message wording; Python has no template-literal types. Same shapes and values throughout. | +| Slack legacy mrkdwn renderer (response_url surface only, post-#440) | `_node_to_mrkdwn` renders headings as `*bold*` and images as `{alt} ({url})` / bare URL | TS `nodeToMrkdwn` has no heading/image branches — both fall through to `defaultNodeToText`, dropping heading emphasis and image URLs | Pre-existing Python improvement; after vercel/chat#440 it affects only `to_response_url_text` (ephemeral edits via response_url). Preserves visual hierarchy and image URLs Slack would otherwise lose. | ### Platform-specific gaps @@ -648,6 +652,7 @@ stay explicit instead of being rediscovered in code review. | Teams `dialog_open_timeout_ms` config | Not implemented | Configurable | Low demand | | Google Chat file uploads | Ignored in message parse | Supported | API complexity; can add later | | Discord Gateway WebSocket | HTTP interactions only | Both HTTP and Gateway | Gateway requires persistent connection | +| Discord gateway-only interactions (vercel/chat#490) | Handled on the forwarded-event surface: a `GATEWAY_INTERACTION_CREATE` envelope (raw INTERACTION_CREATE dispatch payload in `data`) is deferred via `POST /interactions/{id}/{token}/callback` (type 5 slash / type 6 component) and routed through the existing HTTP interaction handlers; a malformed forward missing `id`/`token` is logged and skipped | Upstream handles `Events.InteractionCreate` directly on the resident discord.js client via `deferReply()`/`deferUpdate()`; the upstream forwarder never forwards interactions | Python has no resident Gateway client (row above), so gateway-only deployments run an external listener shim that forwards raw dispatch payloads (`x-discord-gateway-token`). Observable wire behavior is identical — same callback REST calls, same handler routing, same `@original` deferred-response resolution. | | Teams `User-Agent: Vercel.ChatSDK` outbound header | Not set on `aiohttp` calls | Propagated by `botbuilder` 2.0.8 | Python Teams adapter doesn't use `botbuilder` (raw `aiohttp`). Upstream's vercel/chat#415 was a JS-only `botbuilder` SDK bump that flipped `X-User-Agent` → `User-Agent`. No equivalent dependency to bump on the Python side. Setting a `User-Agent` on the ~9 outbound `aiohttp` call sites would be a defense-in-depth nice-to-have; deferred to a follow-up. | | Teams adapter on `microsoft-teams-apps` (official MS Python SDK) | Hand-rolled Bot Framework REST + JWT verification (see the transitional native-streaming rows above) | `@microsoft/teams.apps` owns the wire format, throttling, and activity routing | **Explicitly deferred to the 0.4.30 cycle** (issue #93). The Python SDK only went GA 2026-05-01; the migration is a 4–6 day restructuring of `adapter.py` (~2,300 LOC) + 6 test files and was too large to land inside the 4.29 wave's tail. The 3.12 floor bump (#111) — the migration's prerequisite — already landed in 0.4.29. | | Telegram `get_user().is_bot` | Always `False` (matches upstream — `getChat` does not expose `is_bot`) | Always `false` (same caveat documented in upstream code comment) | The Telegram Bot API's `getChat` endpoint does not surface the `is_bot` field that's available on the `User` object inside incoming `Message` updates. Callers needing bot detection must use `message.author.is_bot` from webhooks instead of `chat.get_user(...).is_bot`. | diff --git a/scripts/fidelity_baseline.json b/scripts/fidelity_baseline.json index d9cfc42..a4e46fe 100644 --- a/scripts/fidelity_baseline.json +++ b/scripts/fidelity_baseline.json @@ -1,7 +1,7 @@ { "_comment": "Ratchet-down baseline for scripts/verify_test_fidelity.py. This repo ships at strict fidelity for mapped core files (0 missing) against chat@4.26.0, so the baseline is empty. Scope: the MAPPING dict in scripts/verify_test_fidelity.py is the authoritative list of TS files checked; it currently covers 8 of the 17 packages/chat/src/*.test.ts files. Default CI mode runs --strict via .github/workflows/lint.yml; this file is retained for local workflows that want to opt back into baseline mode (e.g. during an upstream sync where several ports land in flight). To baseline genuinely-divergent tests, run scripts/verify_test_fidelity.py --update-baseline after documenting the divergence in docs/UPSTREAM_SYNC.md.", - "ts_parity": "chat@4.26.0", - "total_ts_tests": 588, + "ts_parity": "chat@4.29.0", + "total_ts_tests": 731, "total_missing": 0, "missing": {} } diff --git a/scripts/verify_test_fidelity.py b/scripts/verify_test_fidelity.py index f65d152..e36a133 100644 --- a/scripts/verify_test_fidelity.py +++ b/scripts/verify_test_fidelity.py @@ -14,10 +14,9 @@ ``--strict`` is the current CI contract (see ``.github/workflows/lint.yml``): the baseline is ignored and any missing translation — or a missing upstream checkout — fails the build. This repo ships at strict fidelity for mapped -core files (0 missing) against ``chat@4.26.0``. The ``MAPPING`` dict below -is the authoritative scope list; it currently covers 8 of the 17 -``packages/chat/src/*.test.ts`` files (extending it is tracked as a -follow-up). +core files (0 missing) against ``chat@4.29.0``. The ``MAPPING`` dict below +is the authoritative scope list (extending it to the remaining unmapped +``packages/chat/src/*.test.ts`` files is tracked as issue #78). Baseline mode (the default without ``--strict``) is retained for local workflows where a few ports land in flight: it succeeds iff the set of @@ -48,6 +47,11 @@ # chat@4.29.0 moved ai.test.ts into ai/ and split it (vercel/chat#492) "packages/chat/src/ai/messages.test.ts": "tests/test_ai_messages.py", "packages/chat/src/ai/index.test.ts": "tests/test_ai_tools.py", + # New core test files in chat@4.29.0 + "packages/chat/src/callback-url.test.ts": "tests/test_callback_url.py", + "packages/chat/src/thread-history.test.ts": "tests/test_thread_history.py", + "packages/chat/src/transcripts.test.ts": "tests/test_transcripts.py", + "packages/chat/src/transcripts-wiring.test.ts": "tests/test_transcripts_wiring.py", "packages/chat/src/from-full-stream.test.ts": "tests/test_from_full_stream.py", } @@ -277,7 +281,7 @@ def load_baseline(path: Path) -> dict[str, set[tuple[str, str]]]: "against the current UPSTREAM_PARITY tag, so the baseline is " "normally empty. Scope: the MAPPING dict in " "scripts/verify_test_fidelity.py is the authoritative list of TS " - "files checked; it currently covers 8 of the 17 " + "files checked (extending to the remaining unmapped files is issue #78) " "packages/chat/src/*.test.ts files. Default CI mode runs --strict " "via .github/workflows/lint.yml; this file is retained for local " "workflows that want to opt back into baseline mode (e.g. during " @@ -313,7 +317,7 @@ def write_baseline(path: Path, all_missing: dict[str, list], total_ts: int) -> N current_parity = _current_parity_tag() payload = { "_comment": existing_comment if existing_comment is not None else _DEFAULT_BASELINE_COMMENT, - "ts_parity": current_parity if current_parity is not None else "chat@4.26.0", + "ts_parity": current_parity if current_parity is not None else "chat@4.29.0", "total_ts_tests": total_ts, "total_missing": sum(len(v) for v in all_missing.values()), "missing": { @@ -442,7 +446,7 @@ def main() -> int: print(f" - {path}") print( "\nClone the upstream repo at the pinned parity tag, e.g.:\n" - " git clone --depth 1 --branch chat@4.26.0 " + " git clone --depth 1 --branch chat@4.29.0 " "https://github.com/vercel/chat.git /tmp/vercel-chat\n" "then re-run with TS_ROOT=/tmp/vercel-chat." ) diff --git a/tests/test_chat_faithful.py b/tests/test_chat_faithful.py index 8627cc0..9a8cc39 100644 --- a/tests/test_chat_faithful.py +++ b/tests/test_chat_faithful.py @@ -4014,6 +4014,7 @@ async def test_persists_when_both_persistthreadhistory_and_persistmessagehistory assert stored is not None assert stored[0]["id"] == "msg-1" + # ============================================================================ # 21. processMessage return value (core slice of vercel/chat#444) # ============================================================================ @@ -4179,6 +4180,7 @@ async def handler(thread, message, context=None): assert received_skipped_subject == mock_subject assert adapter.fetch_subject.await_count == 2 # type: ignore[attr-defined] + # ============================================================================ # 24. Action callbackUrl handling (vercel/chat#454) # ============================================================================ @@ -4565,3 +4567,69 @@ async def _handler(event): await _process_action_and_wait(chat, event) assert any(call_args[0] == "Button callbackUrl POST failed" for call_args in logger.error.calls) + + +# ============================================================================ +# 25. Slash-command openModal without adapter support + subject wiring +# ============================================================================ + + +class TestSlashCommandOpenModalUnsupported: + """[Slash Commands] flavor of the openModal-unsupported test. + + Upstream chat.test.ts has two tests with this exact title (one under + the actions describe, one under slash commands); the fidelity matcher + counts Python names as a multiset, so each needs its own def. + """ + + # TS: "should return undefined from openModal when adapter does not support modals" + async def test_should_return_undefined_from_openmodal_when_adapter_does_not_support_modals( + self, + ): + adapter = create_mock_adapter("slack") + adapter.open_modal = None # type: ignore[assignment] + + chat, _, _ = await _init_chat(adapter=adapter) + captured_event: list[SlashCommandEvent] = [] + + async def _handler(event): + captured_event.append(event) + + chat.on_slash_command("/feedback", _handler) + + event = _make_slash_event(adapter, command="/feedback", text="", trigger_id="trigger-123") + chat.process_slash_command(event) + await asyncio.sleep(0.02) + + assert len(captured_event) == 1 + + modal = { + "type": "modal", + "callback_id": "test_modal", + "title": "Test Modal", + "children": [], + } + result = await captured_event[0].open_modal(modal) + assert result is None + + +class TestSubjectAdapterWiring: + # TS: "should wire adapter on message for subject access" + @requires_fetch_subject + async def test_should_wire_adapter_on_message_for_subject_access(self): + chat, adapter, _state = await _init_chat() + received: list[Any] = [] + + @chat.on_mention + async def handler(thread, message, context=None): + received.append(message) + + await chat.handle_incoming_message( + adapter, + "slack:C123:1234.5678", + create_test_message("msg-subject", "Hey @slack-bot test"), + ) + + assert len(received) == 1 + # The mock adapter exposes no fetch_subject hook -> resolves None + assert await received[0].subject is None From 9e1cb882862ea6ab8c7571ba7f48bd2246fd2620 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 22:36:11 +0000 Subject: [PATCH 11/14] =?UTF-8?q?fix(types):=20restore=20pyrefly=20conform?= =?UTF-8?q?ance=20=E2=80=94=20history=20flags=20off=20the=20Adapter=20Prot?= =?UTF-8?q?ocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's Lint & Type Check job failed on pyrefly (43 errors) while every other step was green: - persist_thread_history / persist_message_history were declared on the structural Adapter Protocol, making them REQUIRED members and breaking conformance for every adapter that satisfies the Protocol without extending BaseAdapter (the exact trap documented in PR #131). Moved to BaseAdapter only, with a NOTE on the Protocol; the SDK reads both flags via getattr. - MessengerAdapter (recovered #124 — the original cause of the failure on the recovery push) now defines get_user, mirroring BaseAdapter's ChatNotImplementedError contract; the real Graph-backed lookup remains issue #132. Also: _map_attachment_type returns the Attachment.type Literal, and the user-profile cache write is typed. - transcripts: retention_ms coerced to int (append_to_list ttl_ms contract; fractional milliseconds are meaningless). uv run pyrefly check: 0 errors (matches the origin/main baseline). https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 --- src/chat_sdk/adapters/messenger/adapter.py | 22 ++++++++++++++++++---- src/chat_sdk/transcripts.py | 4 +++- src/chat_sdk/types.py | 19 ++++++------------- 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/chat_sdk/adapters/messenger/adapter.py b/src/chat_sdk/adapters/messenger/adapter.py index 9d30434..1d8bf4c 100644 --- a/src/chat_sdk/adapters/messenger/adapter.py +++ b/src/chat_sdk/adapters/messenger/adapter.py @@ -19,7 +19,7 @@ import time from collections.abc import AsyncIterable from datetime import datetime, timezone -from typing import Any, cast +from typing import Any, Literal, cast from urllib.parse import parse_qs, urlparse from chat_sdk.adapters.messenger.cards import ( @@ -43,6 +43,7 @@ MessengerWebhookPayload, ) from chat_sdk.emoji import convert_emoji_placeholders, default_emoji_resolver +from chat_sdk.errors import ChatNotImplementedError from chat_sdk.logger import ConsoleLogger, Logger from chat_sdk.shared.adapter_utils import extract_card from chat_sdk.shared.errors import ( @@ -72,6 +73,7 @@ StreamChunk, StreamOptions, ThreadInfo, + UserInfo, WebhookOptions, ) @@ -864,7 +866,7 @@ def rehydrate_attachment(self, attachment: Attachment) -> Attachment: ) @staticmethod - def _map_attachment_type(fb_type: str) -> str: + def _map_attachment_type(fb_type: str) -> Literal["audio", "file", "image", "video"]: """Map a Messenger attachment type to the SDK ``Attachment.type`` enum.""" if fb_type == "image": return "image" @@ -874,6 +876,17 @@ def _map_attachment_type(fb_type: str) -> str: return "audio" return "file" + async def get_user(self, user_id: str) -> UserInfo | None: + """Messenger has no user-lookup parity with the other adapters yet. + + Mirrors :meth:`BaseAdapter.get_user`: raises + :class:`~chat_sdk.errors.ChatNotImplementedError`, which + ``Chat.get_user`` translates into a "does not support get_user" + ``ChatError``. A real Graph-API-backed implementation is tracked as + issue #132. + """ + raise ChatNotImplementedError(self.name, "getUser") + async def _download_attachment(self, url: str) -> bytes: """Download an attachment payload URL. Wraps errors in ``NetworkError``.""" try: @@ -913,8 +926,9 @@ async def _fetch_user_profile(self, user_id: str) -> MessengerUserProfile: ) if not isinstance(profile, dict): return {"id": user_id} - self._user_profile_cache[user_id] = profile - return cast(MessengerUserProfile, profile) + typed_profile = cast(MessengerUserProfile, profile) + self._user_profile_cache[user_id] = typed_profile + return typed_profile except Exception: # On any error, fall back to a minimal profile carrying just the # user ID. Matches upstream's silent fallback. diff --git a/src/chat_sdk/transcripts.py b/src/chat_sdk/transcripts.py index 4d15aad..707be38 100644 --- a/src/chat_sdk/transcripts.py +++ b/src/chat_sdk/transcripts.py @@ -96,7 +96,9 @@ class TranscriptsApiImpl: def __init__(self, state: StateAdapter, config: TranscriptsConfig) -> None: self._state = state self._max_per_user = config.max_per_user if config.max_per_user is not None else DEFAULT_MAX_PER_USER - self._retention_ms = _parse_duration(config.retention) + parsed_retention = _parse_duration(config.retention) + # append_to_list takes ttl_ms: int | None; fractional ms are meaningless + self._retention_ms = int(parsed_retention) if parsed_retention is not None else None self._store_formatted = config.store_formatted async def append( diff --git a/src/chat_sdk/types.py b/src/chat_sdk/types.py index 97e1e65..c590111 100644 --- a/src/chat_sdk/types.py +++ b/src/chat_sdk/types.py @@ -1236,19 +1236,12 @@ def bot_user_id(self) -> str | None: ... @property def lock_scope(self) -> LockScope | None: ... - # Deprecated: renamed to ``persist_thread_history``. Both flags are read - # for backwards compatibility; either being truthy enables persistence. - # Like the upstream TS interface members, both flags are *optional* — - # the SDK reads them via ``getattr(..., None)``, so adapters may omit - # either attribute entirely. - @property - def persist_message_history(self) -> bool | None: ... - - # When true, the SDK persists per-thread message history in the state - # adapter for this platform. Use for platforms that lack server-side - # message history APIs (e.g. WhatsApp, Telegram). - @property - def persist_thread_history(self) -> bool | None: ... + # NOTE: the optional history-persistence flags (``persist_thread_history`` + # and its deprecated alias ``persist_message_history``) are declared on + # ``BaseAdapter``, NOT here. Adding optional hooks to this structural + # Protocol makes them *required* members and breaks adapters that don't + # define them (several adapters satisfy the Protocol without extending + # BaseAdapter). The SDK reads both flags via ``getattr(..., None)``. def encode_thread_id(self, platform_data: Any) -> str: ... def decode_thread_id(self, thread_id: str) -> Any: ... From be28808526df5b4040e1344de74380176804fa92 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 10:05:51 +0000 Subject: [PATCH 12/14] fix(chat): await modal-context state write before opening modal `_store_modal_context` was synchronous and scheduled the state write as a fire-and-forget task before `open_modal` was awaited. With a remote (Redis/Postgres) state backend a fast modal submit could race ahead of the write and miss the stored callbackUrl/channel, silently breaking modal callback POSTs and context restoration. Make `_store_modal_context` `async` and `await` it at both modal-opening call sites (slash + action paths) before `adapter.open_modal`, matching upstream where `openModal` awaits `storeModalContext` (chat.ts :1280/:1342/:1554). Remove the now-unnecessary `asyncio.sleep(0.02)` workaround in the modal-callback test; the context is persisted deterministically by the time `open_modal` returns. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 --- src/chat_sdk/chat.py | 22 ++++++++++------------ tests/test_chat_faithful.py | 2 -- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/chat_sdk/chat.py b/src/chat_sdk/chat.py index f2bafcb..1339275 100644 --- a/src/chat_sdk/chat.py +++ b/src/chat_sdk/chat.py @@ -1301,7 +1301,7 @@ async def _open_modal(modal: Any) -> dict[str, str] | None: self._logger.warn(f"Cannot open modal: {event.adapter.name} does not support modals") return None context_id = str(uuid.uuid4()) - self._store_modal_context( + await self._store_modal_context( event.adapter.name, context_id, channel=channel, @@ -1333,7 +1333,7 @@ async def _open_modal(modal: Any) -> dict[str, str] | None: # Modal context persistence # ======================================================================== - def _store_modal_context( + async def _store_modal_context( self, adapter_name: str, context_id: str, @@ -1342,6 +1342,12 @@ def _store_modal_context( channel: Channel | None = None, callback_url: str | None = None, ) -> None: + # Upstream ``storeModalContext`` is ``async`` and the ``openModal`` + # helper *awaits* it before calling ``adapter.openModal`` (chat.ts + # :1280/:1342/:1554). We mirror that: the state write must land + # before the modal can be submitted, otherwise with a remote + # (Redis/Postgres) backend a fast submit can race ahead of a + # fire-and-forget write and miss the stored callbackUrl/channel. key = f"modal-context:{adapter_name}:{context_id}" context = { "thread": thread.to_json() if thread else None, @@ -1351,15 +1357,7 @@ def _store_modal_context( # with the TS SDK (matches the serialized thread/message keys). "callbackUrl": callback_url, } - task = _create_task(self._state_adapter.set(key, context, MODAL_CONTEXT_TTL_MS), self._active_tasks) - if task is not None: - task.add_done_callback( - lambda t: ( - self._logger.error("Failed to store modal context", {"context_id": context_id}) - if not t.cancelled() and t.exception() - else None - ) - ) + await self._state_adapter.set(key, context, MODAL_CONTEXT_TTL_MS) async def _retrieve_modal_context( self, @@ -1524,7 +1522,7 @@ async def _open_modal(modal: Any) -> dict[str, str] | None: context_id = str(uuid.uuid4()) channel_impl = thread.channel if thread else None - self._store_modal_context( + await self._store_modal_context( event.adapter.name, context_id, thread=thread, diff --git a/tests/test_chat_faithful.py b/tests/test_chat_faithful.py index 9a8cc39..6d650f5 100644 --- a/tests/test_chat_faithful.py +++ b/tests/test_chat_faithful.py @@ -4359,8 +4359,6 @@ async def _action_handler(event): } assert len(captured_action) == 1 await captured_action[0].open_modal(modal) - # _store_modal_context persists via a background task - await asyncio.sleep(0.02) modal_context_keys = [k for k in state.cache if k.startswith("modal-context:")] assert len(modal_context_keys) == 1 From 19a8848cd689b50e1f099fa119ca982adbb68ea1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 10:05:57 +0000 Subject: [PATCH 13/14] fix(messenger): use `is not None` for config credential fallbacks Convert the `config.X or ` truthiness patterns to `config.X if config.X is not None else ` for api_version (adapter) and the app_secret / page_access_token / verify_token env fallbacks (config helpers), per the TS->Python truthiness port rule. Mirrors upstream's `??` null-coalescing (index.ts:92/931). Behaviour is unchanged for real configs; an explicit value now wins over the fallback even if it is falsy. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 --- src/chat_sdk/adapters/messenger/adapter.py | 2 +- src/chat_sdk/adapters/messenger/types.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/chat_sdk/adapters/messenger/adapter.py b/src/chat_sdk/adapters/messenger/adapter.py index 1d8bf4c..2d795a6 100644 --- a/src/chat_sdk/adapters/messenger/adapter.py +++ b/src/chat_sdk/adapters/messenger/adapter.py @@ -127,7 +127,7 @@ def __init__(self, config: MessengerAdapterConfig) -> None: self._app_secret = app_secret self._page_access_token = page_access_token self._verify_token = verify_token - self._api_version = config.api_version or DEFAULT_API_VERSION + self._api_version = config.api_version if config.api_version is not None else DEFAULT_API_VERSION self._graph_api_url = f"{GRAPH_API_BASE}/{self._api_version}" self._logger: Logger = config.logger self._format_converter = MessengerFormatConverter() diff --git a/src/chat_sdk/adapters/messenger/types.py b/src/chat_sdk/adapters/messenger/types.py index 6cd9d21..88a26cb 100644 --- a/src/chat_sdk/adapters/messenger/types.py +++ b/src/chat_sdk/adapters/messenger/types.py @@ -52,15 +52,17 @@ class MessengerAdapterConfig: def resolved_app_secret(self) -> str | None: """App secret with the ``FACEBOOK_APP_SECRET`` env fallback.""" - return self.app_secret or os.environ.get(ENV_APP_SECRET) + # ``is not None`` (not truthy) mirrors upstream's ``??`` null-coalescing + # (index.ts:931) — an explicit config value wins over the env var. + return self.app_secret if self.app_secret is not None else os.environ.get(ENV_APP_SECRET) def resolved_page_access_token(self) -> str | None: """Page access token with the ``FACEBOOK_PAGE_ACCESS_TOKEN`` env fallback.""" - return self.page_access_token or os.environ.get(ENV_PAGE_ACCESS_TOKEN) + return self.page_access_token if self.page_access_token is not None else os.environ.get(ENV_PAGE_ACCESS_TOKEN) def resolved_verify_token(self) -> str | None: """Verify token with the ``FACEBOOK_VERIFY_TOKEN`` env fallback.""" - return self.verify_token or os.environ.get(ENV_VERIFY_TOKEN) + return self.verify_token if self.verify_token is not None else os.environ.get(ENV_VERIFY_TOKEN) # ============================================================================= From 44c44fd57d8e231cdf2d0b60160e37b8d835f57c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 10:06:03 +0000 Subject: [PATCH 14/14] fix(types): widen ChatInstance.process_message return annotation The `ChatInstance` Protocol annotated `process_message` as `-> None`, but the concrete `Chat.process_message` returns `asyncio.Task[None] | None` (it hands back the handler task so streaming callers can await it). Widen the Protocol annotation to match the implementation and upstream's interface-equals-impl typing. No runtime impact. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 --- src/chat_sdk/types.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/chat_sdk/types.py b/src/chat_sdk/types.py index c590111..a758838 100644 --- a/src/chat_sdk/types.py +++ b/src/chat_sdk/types.py @@ -5,6 +5,7 @@ from __future__ import annotations +import asyncio from collections.abc import AsyncIterable, Awaitable, Callable from dataclasses import dataclass, field from datetime import datetime @@ -1527,7 +1528,7 @@ def process_message( thread_id: str, message: Message | Callable[[], Awaitable[Message]], options: WebhookOptions | None = None, - ) -> None: ... + ) -> asyncio.Task[None] | None: ... async def handle_incoming_message(self, adapter: Adapter, thread_id: str, message: Message) -> None: ... def process_action(self, event: Any, options: WebhookOptions | None = None) -> None: ... def process_reaction(self, event: Any, options: WebhookOptions | None = None) -> None: ...