From 7038dcca3ca4119dfc5188c38afdce9f23e206f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 03:33:44 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(messenger):=20scaffolding=20=E2=80=94?= =?UTF-8?q?=20types,=20format=20converter,=20cards=20(vercel/chat#461)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 1 of 2 for the Messenger (Meta) adapter port. Non-adapter scaffolding only; the adapter (webhook routing, Graph API, signature verification, send/stream) lands in PR 2. Mirrors how the Telegram port was split. Adds under src/chat_sdk/adapters/messenger/: - types.py: MessengerAdapterConfig (app_secret / page_access_token / verify_token with FACEBOOK_* env fallbacks), MessengerThreadId, sender/recipient/attachment/message/postback/reaction/delivery/read, webhook entry/payload, send-API response, user profile, button + template-element + generic/button template payloads, MessengerRawMessage. - format_converter.py: MessengerFormatConverter (no markdown rendering; stringifies AST, overrides render_postable). Mirrors upstream markdown.ts. - cards.py: card_to_messenger (template|text), card_to_messenger_text, encode/decode_messenger_callback_data ("chat:{json}" prefix). Generic template when title/imageUrl present, Button template otherwise; tables / selects / radio_selects fall back to text. Caps: 20-char button titles, 80 subtitle, 640 button-template text, 3 buttons max. - __init__.py: exports converter, card helpers, and types only (PR 2 adds the adapter export). Tests: tests/test_messenger_cards.py + tests/test_messenger_format.py (56 tests) ported from upstream cards.test.ts / markdown.test.ts. Design open question (#110 Q2): decode_messenger_callback_data returns the raw string as both action_id and value for non-"chat:" payloads. Mirrored from upstream exactly and pinned with a test + "# Divergence-candidate (see #110)" breadcrumb; not tightened in this PR. Refs #110, #98. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj --- src/chat_sdk/adapters/messenger/__init__.py | 52 ++ src/chat_sdk/adapters/messenger/cards.py | 431 ++++++++++++ .../adapters/messenger/format_converter.py | 46 ++ src/chat_sdk/adapters/messenger/types.py | 274 ++++++++ tests/test_messenger_cards.py | 638 ++++++++++++++++++ tests/test_messenger_format.py | 103 +++ 6 files changed, 1544 insertions(+) create mode 100644 src/chat_sdk/adapters/messenger/__init__.py create mode 100644 src/chat_sdk/adapters/messenger/cards.py create mode 100644 src/chat_sdk/adapters/messenger/format_converter.py create mode 100644 src/chat_sdk/adapters/messenger/types.py create mode 100644 tests/test_messenger_cards.py create mode 100644 tests/test_messenger_format.py diff --git a/src/chat_sdk/adapters/messenger/__init__.py b/src/chat_sdk/adapters/messenger/__init__.py new file mode 100644 index 0000000..0e86386 --- /dev/null +++ b/src/chat_sdk/adapters/messenger/__init__.py @@ -0,0 +1,52 @@ +"""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. +""" + +from chat_sdk.adapters.messenger.cards import ( + MessengerCardResult, + card_to_messenger, + card_to_messenger_text, + decode_messenger_callback_data, + encode_messenger_callback_data, +) +from chat_sdk.adapters.messenger.format_converter import MessengerFormatConverter +from chat_sdk.adapters.messenger.types import ( + MessengerAdapterConfig, + MessengerButton, + MessengerButtonTemplatePayload, + MessengerGenericTemplatePayload, + MessengerMessagingEvent, + MessengerRawMessage, + MessengerSendApiResponse, + MessengerTemplateElement, + MessengerTemplatePayload, + MessengerThreadId, + MessengerUserProfile, + MessengerWebhookEntry, + MessengerWebhookPayload, +) + +__all__ = [ + "MessengerAdapterConfig", + "MessengerButton", + "MessengerButtonTemplatePayload", + "MessengerCardResult", + "MessengerFormatConverter", + "MessengerGenericTemplatePayload", + "MessengerMessagingEvent", + "MessengerRawMessage", + "MessengerSendApiResponse", + "MessengerTemplateElement", + "MessengerTemplatePayload", + "MessengerThreadId", + "MessengerUserProfile", + "MessengerWebhookEntry", + "MessengerWebhookPayload", + "card_to_messenger", + "card_to_messenger_text", + "decode_messenger_callback_data", + "encode_messenger_callback_data", +] diff --git a/src/chat_sdk/adapters/messenger/cards.py b/src/chat_sdk/adapters/messenger/cards.py new file mode 100644 index 0000000..144cc26 --- /dev/null +++ b/src/chat_sdk/adapters/messenger/cards.py @@ -0,0 +1,431 @@ +"""Convert CardElement to Messenger templates or text fallback. + +Messenger supports two template types for buttons: +- Generic Template: title, subtitle, image, up to 3 buttons +- Button Template: text with up to 3 buttons (no image) + +Cards that exceed constraints fall back to formatted text messages. + +See: +- https://developers.facebook.com/docs/messenger-platform/send-messages/template/generic/ +- https://developers.facebook.com/docs/messenger-platform/send-messages/buttons/ + +Ported from upstream ``adapter-messenger/src/cards.ts``. +""" + +from __future__ import annotations + +import json +from typing import Literal, TypedDict, cast + +from chat_sdk.adapters.messenger.types import ( + MessengerButton, + MessengerButtonTemplatePayload, + MessengerGenericTemplatePayload, + MessengerTemplateElement, + MessengerTemplatePayload, +) +from chat_sdk.cards import ( + ActionsElement, + ButtonElement, + CardChild, + CardElement, + FieldsElement, + LinkButtonElement, + LinkElement, + TableElement, + TextElement, +) + +CALLBACK_DATA_PREFIX = "chat:" + +# Maximum number of buttons Messenger allows per template +MAX_BUTTONS = 3 + +# Maximum character length for a button title +MAX_BUTTON_TITLE_LENGTH = 20 + +# Maximum character length for subtitle in Generic Template +MAX_SUBTITLE_LENGTH = 80 + +# Maximum character length for text in Button Template +MAX_BUTTON_TEMPLATE_TEXT_LENGTH = 640 + +# Maximum character length for title in Generic Template +MAX_TITLE_LENGTH = 80 + +# Maximum character length for a plain text message +MAX_TEXT_LENGTH = 2000 + + +class _MessengerCardActionPayload(TypedDict, total=False): + a: str + v: str + + +class MessengerCardResultTemplate(TypedDict): + """Template card result.""" + + type: Literal["template"] + payload: MessengerTemplatePayload + + +class MessengerCardResultText(TypedDict): + """Text fallback card result.""" + + type: Literal["text"] + text: str + + +MessengerCardResult = MessengerCardResultTemplate | MessengerCardResultText + + +def encode_messenger_callback_data(action_id: str, value: str | None = None) -> str: + """Encode an action ID and optional value into a callback data string. + + Format: ``"chat:{json}"`` where json is ``{"a": action_id, "v"?: value}``. + """ + payload: _MessengerCardActionPayload = {"a": action_id} + if isinstance(value, str): + payload["v"] = value + return f"{CALLBACK_DATA_PREFIX}{json.dumps(payload, separators=(',', ':'))}" + + +def decode_messenger_callback_data(data: str | None = None) -> dict[str, str | None]: + """Decode callback data from a Messenger postback. + + Returns a dict with ``action_id`` and ``value`` keys. + """ + if not data: + return {"action_id": "messenger_callback", "value": None} + + # Divergence-candidate (see #110): passthrough for legacy or + # externally-generated payloads that don't use the chat: prefix -- upstream + # treats the raw string as BOTH action_id and value. Mirrored exactly here; + # do not tighten without resolving #110. + if not data.startswith(CALLBACK_DATA_PREFIX): + return {"action_id": data, "value": data} + + try: + decoded = json.loads(data[len(CALLBACK_DATA_PREFIX) :]) + + if isinstance(decoded.get("a"), str) and decoded["a"]: + return { + "action_id": decoded["a"], + "value": decoded["v"] if isinstance(decoded.get("v"), str) else None, + } + except (json.JSONDecodeError, AttributeError, KeyError, TypeError): + # Malformed JSON after prefix -- fall back to passthrough. + pass + + # Divergence-candidate (see #110): same passthrough as non-prefixed data -- + # raw string as both fields. Mirrors upstream exactly. + return {"action_id": data, "value": data} + + +def card_to_messenger(card: CardElement) -> MessengerCardResult: + """Convert a CardElement to a Messenger message payload. + + If the card has action buttons that fit Messenger's constraints + (max 3 buttons, titles max 20 chars), produces a template message. + Otherwise, produces a text fallback. + """ + children = card.get("children", []) + + # Check for unsupported elements that force text fallback + if _has_unsupported_elements(children): + return {"type": "text", "text": card_to_messenger_text(card)} + + actions = _find_actions(children) + buttons = _extract_buttons(actions) if actions else None + + # If we have valid buttons within constraints + if buttons and 0 < len(buttons) <= MAX_BUTTONS: + # Check if any button title exceeds the limit + all_buttons_fit = all(len(btn.get("title", "")) <= MAX_BUTTON_TITLE_LENGTH for btn in buttons) + + if all_buttons_fit: + # Use Generic Template if card has title or image + if card.get("title") or card.get("image_url"): + return { + "type": "template", + "payload": _build_generic_template(card, buttons), + } + + # Use Button Template for text-only cards with buttons + body_text = _build_body_text(card) + if body_text: + return { + "type": "template", + "payload": _build_button_template(body_text, buttons), + } + + # Fallback to text + return {"type": "text", "text": card_to_messenger_text(card)} + + +def card_to_messenger_text(card: CardElement) -> str: + """Convert a CardElement to Messenger-formatted plain text. + + Used as fallback when templates can't represent the card. + Messenger doesn't support markdown formatting in regular messages. + """ + lines: list[str] = [] + + title = card.get("title") + subtitle = card.get("subtitle") + children = card.get("children", []) + image_url = card.get("image_url") + + if title: + lines.append(title) + + if subtitle: + lines.append(subtitle) + + if (title or subtitle) and len(children) > 0: + lines.append("") + + if image_url: + lines.append(image_url) + lines.append("") + + for i, child in enumerate(children): + child_lines = _render_child(child) + + if len(child_lines) > 0: + lines.extend(child_lines) + + if i < len(children) - 1: + lines.append("") + + return "\n".join(lines) + + +# ============================================================================= +# Private helpers +# ============================================================================= + + +def _build_generic_template(card: CardElement, buttons: list[MessengerButton]) -> MessengerTemplatePayload: + """Build a Generic Template payload.""" + body_text = _build_body_text(card) + title = card.get("title") or body_text or "Menu" + # Only add subtitle if it provides new information (not duplicating title) + subtitle = card.get("subtitle") or (body_text if (card.get("title") and body_text) else None) + + element: MessengerTemplateElement = {"title": _truncate(title, MAX_TITLE_LENGTH)} + if subtitle: + element["subtitle"] = _truncate(subtitle, MAX_SUBTITLE_LENGTH) + image_url = card.get("image_url") + if image_url: + element["image_url"] = image_url + element["buttons"] = buttons + + payload: MessengerGenericTemplatePayload = { + "template_type": "generic", + "elements": [element], + } + return payload + + +def _build_button_template(text: str, buttons: list[MessengerButton]) -> MessengerTemplatePayload: + """Build a Button Template payload.""" + payload: MessengerButtonTemplatePayload = { + "template_type": "button", + "text": _truncate(text, MAX_BUTTON_TEMPLATE_TEXT_LENGTH), + "buttons": buttons, + } + return payload + + +def _has_unsupported_elements(children: list[CardChild]) -> bool: + """Check if children contain elements that can't be represented in templates.""" + for child in children: + child_type = child.get("type") + if child_type == "table": + return True + if child_type == "section" and _has_unsupported_elements(child.get("children", [])): # type: ignore[union-attr] + return True + if child_type == "actions": + for action in child.get("children", []): # type: ignore[union-attr] + if action.get("type") in ("select", "radio_select"): + return True + return False + + +def _find_actions(children: list[CardChild]) -> ActionsElement | None: + """Find the first ActionsElement in a list of card children.""" + for child in children: + if child.get("type") == "actions": + return child # type: ignore[return-value] + if child.get("type") == "section": + nested = _find_actions(child.get("children", [])) # type: ignore[union-attr] + if nested: + return nested + return None + + +def _extract_buttons(actions: ActionsElement) -> list[MessengerButton] | None: + """Extract Messenger buttons from an ActionsElement. + + Converts SDK Button to ``postback`` and LinkButton to ``web_url``. + """ + buttons: list[MessengerButton] = [] + + for child in actions.get("children", []): + if child.get("type") == "button" and child.get("id"): + buttons.append(_convert_button(child)) + elif child.get("type") == "link-button": + buttons.append(_convert_link_button(child)) + + if len(buttons) == 0: + return None + + # Messenger allows max 3 buttons -- take the first 3 + return buttons[:MAX_BUTTONS] + + +def _convert_button(button: ButtonElement) -> MessengerButton: + """Convert an SDK Button to a Messenger postback button.""" + return { + "type": "postback", + "title": _truncate(button.get("label", ""), MAX_BUTTON_TITLE_LENGTH), + "payload": encode_messenger_callback_data(button.get("id", ""), button.get("value")), + } + + +def _convert_link_button(button: LinkButtonElement) -> MessengerButton: + """Convert an SDK LinkButton to a Messenger web_url button.""" + return { + "type": "web_url", + "title": _truncate(button.get("label", ""), MAX_BUTTON_TITLE_LENGTH), + "url": button.get("url", ""), + } + + +def _build_body_text(card: CardElement) -> str: + """Build body text from card content (excluding actions).""" + parts: list[str] = [] + + for child in card.get("children", []): + if child.get("type") == "actions": + continue + text = _child_to_plain_text(child) + if text: + parts.append(text) + + return "\n".join(parts) + + +def _render_child(child: CardChild) -> list[str]: + """Render a card child to text lines.""" + child_type = child.get("type", "") + + if child_type == "text": + return _render_text(child) # type: ignore[arg-type] + + if child_type == "fields": + return _render_fields(child) # type: ignore[arg-type] + + if child_type == "actions": + return _render_actions(child) # type: ignore[arg-type] + + if child_type == "section": + result: list[str] = [] + for c in child.get("children", []): # type: ignore[union-attr] + result.extend(_render_child(c)) + return result + + if child_type == "image": + alt = cast("str", child.get("alt", "")) + url = cast("str", child.get("url", "")) + if alt: + return [f"{alt}: {url}"] + return [url] + + if child_type == "divider": + return ["---"] + + if child_type == "link": + return [f"{child.get('label', '')}: {child.get('url', '')}"] + + if child_type == "table": + return _render_table(child) # type: ignore[arg-type] + + return [] + + +def _render_text(text: TextElement) -> list[str]: + """Render text element.""" + return [text.get("content", "")] + + +def _render_fields(fields: FieldsElement) -> list[str]: + """Render fields as ``Label: Value`` lines.""" + return [f"{f['label']}: {f['value']}" for f in fields.get("children", [])] + + +def _render_actions(actions: ActionsElement) -> list[str]: + """Render actions as button labels for text fallback.""" + button_texts: list[str] = [] + for button in actions.get("children", []): + if button.get("type") == "link-button": + button_texts.append(f"{button.get('label', '')}: {button.get('url', '')}") + else: + # Buttons, selects, and radio selects all render as bracketed labels + button_texts.append(f"[{button.get('label', '')}]") + + return [" | ".join(button_texts)] + + +def _render_table(table: TableElement) -> list[str]: + """Render a table as ASCII text.""" + lines: list[str] = [] + + headers = table.get("headers", []) + if len(headers) > 0: + lines.append(" | ".join(headers)) + lines.append(" | ".join("---" for _ in headers)) + + for row in table.get("rows", []): + lines.append(" | ".join(row)) + + return lines + + +def _child_to_plain_text(child: CardChild) -> str | None: + """Convert a card child to plain text.""" + child_type = child.get("type", "") + + if child_type == "text": + return child.get("content", "") # type: ignore[union-attr] + + if child_type == "fields": + return "\n".join( + f"{f['label']}: {f['value']}" + for f in child.get("children", []) # type: ignore[union-attr] + ) + + if child_type == "actions": + return None + + if child_type == "section": + parts = [ + _child_to_plain_text(c) + for c in child.get("children", []) # type: ignore[union-attr] + ] + return "\n".join(p for p in parts if p) + + if child_type == "link": + link = cast("LinkElement", child) + return f"{link.get('label', '')}: {link.get('url', '')}" + + return None + + +def _truncate(text: str, max_length: int) -> str: + """Truncate text to a maximum length, adding ellipsis if needed.""" + if len(text) <= max_length: + return text + return f"{text[: max_length - 1]}…" diff --git a/src/chat_sdk/adapters/messenger/format_converter.py b/src/chat_sdk/adapters/messenger/format_converter.py new file mode 100644 index 0000000..5622817 --- /dev/null +++ b/src/chat_sdk/adapters/messenger/format_converter.py @@ -0,0 +1,46 @@ +"""Messenger format conversion. + +Messenger regular messages do not render markdown, so there is no custom +platform syntax to emit. ``from_ast`` simply stringifies the AST (preserving +markdown markers as plain literal text) and ``to_ast`` parses incoming text +with the shared parser. Mirrors upstream ``adapter-messenger/src/markdown.ts``. +""" + +from __future__ import annotations + +from chat_sdk.shared.base_format_converter import ( + BaseFormatConverter, + PostableMessageInput, + Root, + parse_markdown, + stringify_markdown, +) + + +class MessengerFormatConverter(BaseFormatConverter): + """Format converter for the Messenger adapter.""" + + def from_ast(self, ast: Root) -> str: + """Stringify an AST to text (Messenger renders no markdown).""" + return stringify_markdown(ast).strip() + + def to_ast(self, platform_text: str) -> Root: + """Parse plain text / markdown into an AST using the shared parser.""" + return parse_markdown(platform_text) + + def render_postable(self, message: PostableMessageInput) -> str: + """Render an ``AdapterPostableMessage`` to Messenger text. + + Handles ``str`` / ``raw`` / ``markdown`` / ``ast`` shapes directly and + defers to the base implementation for anything else (e.g. ``card``). + """ + if isinstance(message, str): + return message + if isinstance(message, dict): + if "raw" in message: + return message["raw"] + if "markdown" in message: + return self.from_markdown(message["markdown"]) + if "ast" in message: + return self.from_ast(message["ast"]) + return super().render_postable(message) diff --git a/src/chat_sdk/adapters/messenger/types.py b/src/chat_sdk/adapters/messenger/types.py new file mode 100644 index 0000000..6cd9d21 --- /dev/null +++ b/src/chat_sdk/adapters/messenger/types.py @@ -0,0 +1,274 @@ +"""Type definitions for the Messenger (Meta) adapter. + +Based on the Messenger Platform (Meta Graph API). +See: https://developers.facebook.com/docs/messenger-platform +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Literal, TypedDict + +from chat_sdk.logger import Logger + +# Environment-variable fallbacks for credentials, matching upstream +# (FACEBOOK_APP_SECRET / FACEBOOK_PAGE_ACCESS_TOKEN / FACEBOOK_VERIFY_TOKEN). +ENV_APP_SECRET = "FACEBOOK_APP_SECRET" +ENV_PAGE_ACCESS_TOKEN = "FACEBOOK_PAGE_ACCESS_TOKEN" +ENV_VERIFY_TOKEN = "FACEBOOK_VERIFY_TOKEN" + +# ============================================================================= +# Configuration +# ============================================================================= + + +@dataclass +class MessengerAdapterConfig: + """Messenger adapter configuration. + + Requires a Page access token for Send API calls and an App Secret + for webhook signature verification. Credentials fall back to the + ``FACEBOOK_*`` environment variables when not supplied explicitly. + + See: https://developers.facebook.com/docs/messenger-platform/getting-started + """ + + # Logger instance for error reporting + logger: Logger + # Facebook App Secret for webhook HMAC-SHA256 signature verification. + # Falls back to the FACEBOOK_APP_SECRET env var. + app_secret: str | None = None + # Facebook Page access token for Send API calls. + # Falls back to the FACEBOOK_PAGE_ACCESS_TOKEN env var. + page_access_token: str | None = None + # Token used to verify the webhook subscription challenge-response. + # Falls back to the FACEBOOK_VERIFY_TOKEN env var. + verify_token: str | None = None + # Override bot username (optional) + user_name: str | None = None + # Meta Graph API version (default chosen by the adapter in PR 2) + api_version: str | None = None + + 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) + + 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) + + 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) + + +# ============================================================================= +# Thread ID +# ============================================================================= + + +@dataclass(frozen=True) +class MessengerThreadId: + """Decoded thread ID for Messenger. + + Messenger conversations are 1:1 between a Page and a user (PSID). + There is no concept of channels. + """ + + # Page-scoped ID (PSID) of the recipient user + recipient_id: str + + +# ============================================================================= +# Messaging Event Components +# ============================================================================= + + +class MessengerSender(TypedDict): + """The sender of a messaging event (the user's PSID).""" + + id: str + + +class MessengerRecipient(TypedDict): + """The recipient of a messaging event (the Page ID).""" + + id: str + + +class MessengerAttachmentPayload(TypedDict, total=False): + """Payload carried by an inbound attachment.""" + + sticker_id: int + url: str + + +class MessengerAttachment(TypedDict, total=False): + """An attachment on an inbound message.""" + + payload: MessengerAttachmentPayload + type: Literal["image", "video", "audio", "file", "fallback", "location"] + + +class MessengerQuickReply(TypedDict): + """Quick-reply payload echoed back when a user taps a quick reply.""" + + payload: str + + +class MessengerMessagePayload(TypedDict, total=False): + """An inbound (or echoed) message payload.""" + + attachments: list[MessengerAttachment] + is_echo: bool + mid: str + quick_reply: MessengerQuickReply + text: str + + +class MessengerDelivery(TypedDict, total=False): + """Delivery receipt for previously sent messages.""" + + mids: list[str] + watermark: int + + +class MessengerRead(TypedDict): + """Read receipt watermark.""" + + watermark: int + + +class MessengerPostback(TypedDict, total=False): + """Postback event from a tapped button or persistent-menu item.""" + + mid: str + payload: str + title: str + + +class MessengerReaction(TypedDict): + """Reaction (react/unreact) on a message.""" + + action: Literal["react", "unreact"] + emoji: str + mid: str + reaction: str + + +class MessengerMessagingEvent(TypedDict, total=False): + """A single messaging event inside a webhook entry. + + Exactly one of ``message`` / ``postback`` / ``reaction`` / ``delivery`` / + ``read`` is typically present, alongside ``sender`` / ``recipient`` / + ``timestamp``. + """ + + delivery: MessengerDelivery + message: MessengerMessagePayload + postback: MessengerPostback + reaction: MessengerReaction + read: MessengerRead + recipient: MessengerRecipient + sender: MessengerSender + timestamp: int + + +# ============================================================================= +# Webhook Payloads +# ============================================================================= + + +class MessengerWebhookEntry(TypedDict): + """A single entry in the webhook notification.""" + + id: str + messaging: list[MessengerMessagingEvent] + time: int + + +class MessengerWebhookPayload(TypedDict): + """Top-level webhook notification envelope from Meta. + + See: https://developers.facebook.com/docs/messenger-platform/webhooks + """ + + entry: list[MessengerWebhookEntry] + object: str # "page" + + +# ============================================================================= +# API Response / Profile Types +# ============================================================================= + + +class MessengerSendApiResponse(TypedDict): + """Response from the Send API.""" + + message_id: str + recipient_id: str + + +class MessengerUserProfile(TypedDict, total=False): + """User profile fetched from the Graph API.""" + + first_name: str + id: str + last_name: str + profile_pic: str + + +# ============================================================================= +# Raw Message Type +# ============================================================================= + + +# Platform-specific raw message type for Messenger. A messaging event is the +# unit the adapter dispatches on, so the alias mirrors upstream exactly. +MessengerRawMessage = MessengerMessagingEvent + + +# ============================================================================= +# Buttons & Templates (Send API) +# ============================================================================= + + +class MessengerButton(TypedDict, total=False): + """A button inside a template payload. + + ``postback`` buttons carry a ``payload``; ``web_url`` buttons carry a + ``url``. + """ + + payload: str + title: str + type: Literal["postback", "web_url"] + url: str + + +class MessengerTemplateElement(TypedDict, total=False): + """An element of a Generic Template.""" + + buttons: list[MessengerButton] + image_url: str + subtitle: str + title: str + + +class MessengerGenericTemplatePayload(TypedDict): + """Generic Template payload (title/subtitle/image + buttons).""" + + elements: list[MessengerTemplateElement] + template_type: Literal["generic"] + + +class MessengerButtonTemplatePayload(TypedDict): + """Button Template payload (text + buttons, no image).""" + + buttons: list[MessengerButton] + template_type: Literal["button"] + text: str + + +MessengerTemplatePayload = MessengerGenericTemplatePayload | MessengerButtonTemplatePayload diff --git a/tests/test_messenger_cards.py b/tests/test_messenger_cards.py new file mode 100644 index 0000000..998ce6b --- /dev/null +++ b/tests/test_messenger_cards.py @@ -0,0 +1,638 @@ +"""Port of adapter-messenger/src/cards.test.ts -- Messenger card rendering tests. + +Tests card_to_messenger, card_to_messenger_text, and encode/decode callback +data (generic-vs-button selection, truncation caps, text fallback for +unsupported elements, callback-data round-trip + passthrough). +""" + +from __future__ import annotations + +from chat_sdk.adapters.messenger.cards import ( + card_to_messenger, + card_to_messenger_text, + decode_messenger_callback_data, + encode_messenger_callback_data, +) + +# --------------------------------------------------------------------------- +# Text fallback rendering +# --------------------------------------------------------------------------- + + +class TestCardToMessengerText: + """Tests for card_to_messenger_text.""" + + def test_simple_card_with_title(self): + card = {"type": "card", "title": "Hello World", "children": []} + assert card_to_messenger_text(card) == "Hello World" + + def test_card_with_title_and_subtitle(self): + card = { + "type": "card", + "title": "Order #1234", + "subtitle": "Status update", + "children": [], + } + assert card_to_messenger_text(card) == "Order #1234\nStatus update" + + def test_card_with_text_content(self): + card = { + "type": "card", + "title": "Notification", + "children": [{"type": "text", "content": "Your order has been shipped!"}], + } + assert card_to_messenger_text(card) == "Notification\n\nYour order has been shipped!" + + def test_card_with_fields(self): + card = { + "type": "card", + "title": "Order Details", + "children": [ + { + "type": "fields", + "children": [ + {"type": "field", "label": "Order ID", "value": "12345"}, + {"type": "field", "label": "Status", "value": "Shipped"}, + ], + } + ], + } + result = card_to_messenger_text(card) + assert "Order ID: 12345" in result + assert "Status: Shipped" in result + + def test_card_with_link_buttons_as_text_with_urls(self): + card = { + "type": "card", + "title": "Actions", + "children": [ + { + "type": "actions", + "children": [ + {"type": "link-button", "url": "https://example.com/track", "label": "Track Order"}, + {"type": "link-button", "url": "https://example.com/help", "label": "Get Help"}, + ], + } + ], + } + result = card_to_messenger_text(card) + assert "Track Order: https://example.com/track" in result + assert "Get Help: https://example.com/help" in result + + def test_card_with_action_buttons_as_bracketed_text(self): + card = { + "type": "card", + "title": "Approve?", + "children": [ + { + "type": "actions", + "children": [ + {"type": "button", "id": "approve", "label": "Approve", "style": "primary"}, + {"type": "button", "id": "reject", "label": "Reject", "style": "danger"}, + ], + } + ], + } + result = card_to_messenger_text(card) + assert "[Approve]" in result + assert "[Reject]" in result + + def test_card_with_inline_image(self): + card = { + "type": "card", + "title": "Image Card", + "children": [ + {"type": "image", "url": "https://example.com/image.png", "alt": "Example image"}, + ], + } + result = card_to_messenger_text(card) + assert "Example image: https://example.com/image.png" in result + + def test_image_url_without_alt_text(self): + card = { + "type": "card", + "children": [{"type": "image", "url": "https://example.com/photo.jpg"}], + } + assert card_to_messenger_text(card) == "https://example.com/photo.jpg" + + def test_card_with_divider(self): + card = { + "type": "card", + "children": [ + {"type": "text", "content": "Before"}, + {"type": "divider"}, + {"type": "text", "content": "After"}, + ], + } + assert "---" in card_to_messenger_text(card) + + def test_card_with_section(self): + card = { + "type": "card", + "children": [ + {"type": "section", "children": [{"type": "text", "content": "Section content"}]}, + ], + } + assert "Section content" in card_to_messenger_text(card) + + def test_card_with_link_element(self): + card = { + "type": "card", + "children": [{"type": "link", "url": "https://example.com", "label": "Example Link"}], + } + assert "Example Link: https://example.com" in card_to_messenger_text(card) + + def test_card_with_table(self): + card = { + "type": "card", + "children": [ + { + "type": "table", + "headers": ["Name", "Age"], + "rows": [["Alice", "30"], ["Bob", "25"]], + } + ], + } + result = card_to_messenger_text(card) + assert "Name | Age" in result + assert "Alice | 30" in result + assert "Bob | 25" in result + + def test_card_image_url(self): + card = { + "type": "card", + "title": "Card with Header Image", + "image_url": "https://example.com/header.png", + "children": [], + } + assert "https://example.com/header.png" in card_to_messenger_text(card) + + +# --------------------------------------------------------------------------- +# Template conversion -- Generic Template +# --------------------------------------------------------------------------- + + +class TestGenericTemplate: + """Tests for Generic Template selection (title or image present).""" + + def test_produces_template_for_card_with_title_and_buttons(self): + card = { + "type": "card", + "title": "Choose an action", + "children": [ + {"type": "text", "content": "What would you like to do?"}, + { + "type": "actions", + "children": [ + {"type": "button", "id": "btn_yes", "label": "Yes"}, + {"type": "button", "id": "btn_no", "label": "No"}, + ], + }, + ], + } + result = card_to_messenger(card) + assert result["type"] == "template" + payload = result["payload"] + assert payload["template_type"] == "generic" + assert len(payload["elements"]) == 1 + assert payload["elements"][0]["title"] == "Choose an action" + assert len(payload["elements"][0]["buttons"]) == 2 + assert payload["elements"][0]["buttons"][0]["type"] == "postback" + assert payload["elements"][0]["buttons"][0]["title"] == "Yes" + + def test_produces_template_for_card_with_image_url(self): + card = { + "type": "card", + "title": "Product", + "image_url": "https://example.com/product.jpg", + "children": [ + {"type": "actions", "children": [{"type": "button", "id": "buy", "label": "Buy Now"}]}, + ], + } + result = card_to_messenger(card) + assert result["type"] == "template" + assert result["payload"]["elements"][0]["image_url"] == "https://example.com/product.jpg" + + def test_includes_subtitle_in_generic_template(self): + card = { + "type": "card", + "title": "Order #123", + "subtitle": "Your order is ready", + "children": [ + {"type": "actions", "children": [{"type": "button", "id": "view", "label": "View"}]}, + ], + } + result = card_to_messenger(card) + assert result["type"] == "template" + assert result["payload"]["elements"][0]["subtitle"] == "Your order is ready" + + def test_supports_link_buttons_as_web_url_type(self): + card = { + "type": "card", + "title": "Resources", + "children": [ + { + "type": "actions", + "children": [ + {"type": "link-button", "url": "https://example.com/docs", "label": "View Docs"}, + ], + }, + ], + } + result = card_to_messenger(card) + assert result["type"] == "template" + button = result["payload"]["elements"][0]["buttons"][0] + assert button["type"] == "web_url" + assert button["url"] == "https://example.com/docs" + + def test_mixes_postback_and_web_url_buttons(self): + card = { + "type": "card", + "title": "Options", + "children": [ + { + "type": "actions", + "children": [ + {"type": "button", "id": "action1", "label": "Do Action"}, + {"type": "link-button", "url": "https://example.com", "label": "Learn More"}, + ], + }, + ], + } + result = card_to_messenger(card) + assert result["type"] == "template" + buttons = result["payload"]["elements"][0]["buttons"] + assert len(buttons) == 2 + assert buttons[0]["type"] == "postback" + assert buttons[1]["type"] == "web_url" + + +# --------------------------------------------------------------------------- +# Template conversion -- Button Template +# --------------------------------------------------------------------------- + + +class TestButtonTemplate: + """Tests for Button Template selection (no title/image, has body + buttons).""" + + def test_produces_template_for_card_without_title_but_with_text_and_buttons(self): + card = { + "type": "card", + "children": [ + {"type": "text", "content": "Please select an option:"}, + { + "type": "actions", + "children": [ + {"type": "button", "id": "opt1", "label": "Option 1"}, + {"type": "button", "id": "opt2", "label": "Option 2"}, + ], + }, + ], + } + result = card_to_messenger(card) + assert result["type"] == "template" + payload = result["payload"] + assert payload["template_type"] == "button" + assert payload["text"] == "Please select an option:" + assert len(payload["buttons"]) == 2 + + def test_builds_body_text_from_fields_element(self): + card = { + "type": "card", + "children": [ + { + "type": "fields", + "children": [ + {"type": "field", "label": "Status", "value": "Active"}, + {"type": "field", "label": "Priority", "value": "High"}, + ], + }, + {"type": "actions", "children": [{"type": "button", "id": "ok", "label": "OK"}]}, + ], + } + result = card_to_messenger(card) + assert result["type"] == "template" + assert result["payload"]["template_type"] == "button" + assert "Status: Active" in result["payload"]["text"] + assert "Priority: High" in result["payload"]["text"] + + def test_builds_body_text_from_link_element(self): + card = { + "type": "card", + "children": [ + {"type": "link", "url": "https://example.com/docs", "label": "Documentation"}, + {"type": "actions", "children": [{"type": "button", "id": "view", "label": "View"}]}, + ], + } + result = card_to_messenger(card) + assert result["type"] == "template" + assert result["payload"]["template_type"] == "button" + assert "Documentation: https://example.com/docs" in result["payload"]["text"] + + def test_builds_body_text_from_section_containing_fields(self): + card = { + "type": "card", + "children": [ + { + "type": "section", + "children": [ + {"type": "fields", "children": [{"type": "field", "label": "Name", "value": "Test"}]}, + ], + }, + {"type": "actions", "children": [{"type": "button", "id": "submit", "label": "Submit"}]}, + ], + } + result = card_to_messenger(card) + assert result["type"] == "template" + assert result["payload"]["template_type"] == "button" + assert "Name: Test" in result["payload"]["text"] + + +# --------------------------------------------------------------------------- +# Constraint handling +# --------------------------------------------------------------------------- + + +class TestConstraintHandling: + """Tests for fallback and truncation constraints.""" + + def test_falls_back_to_text_for_table_nested_in_section(self): + card = { + "type": "card", + "title": "Nested Table", + "children": [ + { + "type": "section", + "children": [{"type": "table", "headers": ["A", "B"], "rows": [["1", "2"]]}], + }, + {"type": "actions", "children": [{"type": "button", "id": "btn", "label": "Click"}]}, + ], + } + assert card_to_messenger(card)["type"] == "text" + + def test_falls_back_to_text_when_actions_contain_only_select(self): + card = { + "type": "card", + "title": "Select Only", + "children": [ + { + "type": "actions", + "children": [ + { + "type": "select", + "id": "sel1", + "label": "Choose one", + "options": [ + {"label": "Option A", "value": "a"}, + {"label": "Option B", "value": "b"}, + ], + } + ], + } + ], + } + assert card_to_messenger(card)["type"] == "text" + + def test_limits_to_3_buttons_max(self): + card = { + "type": "card", + "title": "Many buttons", + "children": [ + { + "type": "actions", + "children": [ + {"type": "button", "id": "btn1", "label": "One"}, + {"type": "button", "id": "btn2", "label": "Two"}, + {"type": "button", "id": "btn3", "label": "Three"}, + {"type": "button", "id": "btn4", "label": "Four"}, + ], + } + ], + } + result = card_to_messenger(card) + assert result["type"] == "template" + assert len(result["payload"]["elements"][0]["buttons"]) == 3 + + def test_truncates_long_button_titles_to_20_chars(self): + card = { + "type": "card", + "title": "Long titles", + "children": [ + { + "type": "actions", + "children": [ + {"type": "button", "id": "btn_long", "label": "This is a very long button title"}, + ], + } + ], + } + result = card_to_messenger(card) + assert result["type"] == "template" + button_title = result["payload"]["elements"][0]["buttons"][0]["title"] + assert len(button_title) <= 20 + assert "…" in button_title + + def test_falls_back_to_text_for_cards_without_buttons(self): + card = { + "type": "card", + "title": "Info only", + "children": [{"type": "text", "content": "Just some info"}], + } + assert card_to_messenger(card)["type"] == "text" + + def test_falls_back_to_text_for_cards_with_only_link_buttons_and_no_title(self): + card = { + "type": "card", + "children": [ + { + "type": "actions", + "children": [{"type": "link-button", "url": "https://example.com", "label": "Visit"}], + } + ], + } + assert card_to_messenger(card)["type"] == "text" + + def test_falls_back_to_text_for_cards_with_select_elements(self): + card = { + "type": "card", + "title": "With select", + "children": [ + { + "type": "actions", + "children": [ + {"type": "select", "id": "sel1", "label": "Choose", "options": [{"label": "A", "value": "a"}]}, + ], + } + ], + } + assert card_to_messenger(card)["type"] == "text" + + def test_falls_back_to_text_for_cards_with_radio_select_elements(self): + card = { + "type": "card", + "title": "With radio", + "children": [ + { + "type": "actions", + "children": [ + { + "type": "radio_select", + "id": "radio1", + "label": "Pick one", + "options": [{"label": "X", "value": "x"}], + }, + ], + } + ], + } + assert card_to_messenger(card)["type"] == "text" + + def test_falls_back_to_text_for_cards_with_table_elements(self): + card = { + "type": "card", + "title": "With table", + "children": [ + {"type": "table", "headers": ["Col1", "Col2"], "rows": [["A", "B"]]}, + {"type": "actions", "children": [{"type": "button", "id": "btn", "label": "Click"}]}, + ], + } + assert card_to_messenger(card)["type"] == "text" + + def test_truncates_long_subtitles_to_80_chars(self): + long_subtitle = ( + "This is an extremely long subtitle that definitely exceeds the 80 character limit imposed by Messenger" + ) + card = { + "type": "card", + "title": "Test", + "subtitle": long_subtitle, + "children": [ + {"type": "actions", "children": [{"type": "button", "id": "btn", "label": "Click"}]}, + ], + } + result = card_to_messenger(card) + assert result["type"] == "template" + subtitle = result["payload"]["elements"][0]["subtitle"] + assert len(subtitle) <= 80 + assert "…" in subtitle + + def test_handles_nested_actions_in_sections(self): + card = { + "type": "card", + "title": "Nested", + "children": [ + { + "type": "section", + "children": [ + {"type": "actions", "children": [{"type": "button", "id": "nested", "label": "Nested"}]}, + ], + }, + ], + } + result = card_to_messenger(card) + assert result["type"] == "template" + assert len(result["payload"]["elements"][0]["buttons"]) == 1 + assert result["payload"]["elements"][0]["buttons"][0]["title"] == "Nested" + + +# --------------------------------------------------------------------------- +# Callback data +# --------------------------------------------------------------------------- + + +class TestCallbackDataEncoding: + """Tests for encode_messenger_callback_data.""" + + def test_encodes_action_id_only(self): + assert encode_messenger_callback_data("my_action") == 'chat:{"a":"my_action"}' + + def test_encodes_action_id_and_value(self): + assert encode_messenger_callback_data("my_action", "some_value") == 'chat:{"a":"my_action","v":"some_value"}' + + def test_handles_special_characters_in_action_id(self): + assert encode_messenger_callback_data("action:with:colons") == 'chat:{"a":"action:with:colons"}' + + +class TestCallbackDataDecoding: + """Tests for decode_messenger_callback_data.""" + + def test_decodes_encoded_callback_data_with_value(self): + encoded = encode_messenger_callback_data("my_action", "some_value") + result = decode_messenger_callback_data(encoded) + assert result["action_id"] == "my_action" + assert result["value"] == "some_value" + + def test_decodes_action_id_without_value(self): + encoded = encode_messenger_callback_data("my_action") + result = decode_messenger_callback_data(encoded) + assert result["action_id"] == "my_action" + assert result["value"] is None + + def test_handles_non_prefixed_data_as_passthrough(self): + # Divergence-candidate (see #110): non-"chat:" payloads return the raw + # string as BOTH action_id and value. Pinning upstream behavior exactly. + result = decode_messenger_callback_data("raw_payload") + assert result["action_id"] == "raw_payload" + assert result["value"] == "raw_payload" + + def test_handles_none_data(self): + result = decode_messenger_callback_data(None) + assert result["action_id"] == "messenger_callback" + assert result["value"] is None + + def test_handles_malformed_json_after_prefix(self): + # Divergence-candidate (see #110): malformed JSON after the "chat:" + # prefix also falls back to the raw-string-as-both passthrough. + result = decode_messenger_callback_data("chat:not-valid-json") + assert result["action_id"] == "chat:not-valid-json" + assert result["value"] == "chat:not-valid-json" + + def test_handles_empty_string_as_missing_data(self): + result = decode_messenger_callback_data("") + assert result["action_id"] == "messenger_callback" + assert result["value"] is None + + def test_roundtrips_encode_decode(self): + action_id = "test_action" + value = "test_value" + encoded = encode_messenger_callback_data(action_id, value) + decoded = decode_messenger_callback_data(encoded) + assert decoded["action_id"] == action_id + assert decoded["value"] == value + + +class TestCallbackDataTemplateIntegration: + """Tests that template buttons embed encoded callback data.""" + + def test_encodes_button_id_and_value_in_postback_payload(self): + card = { + "type": "card", + "title": "Test", + "children": [ + { + "type": "actions", + "children": [ + {"type": "button", "id": "action_id", "label": "Click", "value": "action_value"}, + ], + } + ], + } + result = card_to_messenger(card) + assert result["type"] == "template" + button = result["payload"]["elements"][0]["buttons"][0] + assert button["type"] == "postback" + assert button["payload"] == encode_messenger_callback_data("action_id", "action_value") + + def test_encodes_button_id_without_value_when_value_is_undefined(self): + card = { + "type": "card", + "title": "Test", + "children": [ + {"type": "actions", "children": [{"type": "button", "id": "just_id", "label": "Click"}]}, + ], + } + result = card_to_messenger(card) + assert result["type"] == "template" + button = result["payload"]["elements"][0]["buttons"][0] + assert button["payload"] == encode_messenger_callback_data("just_id") diff --git a/tests/test_messenger_format.py b/tests/test_messenger_format.py new file mode 100644 index 0000000..7f70f70 --- /dev/null +++ b/tests/test_messenger_format.py @@ -0,0 +1,103 @@ +"""Port of adapter-messenger/src/markdown.test.ts -- Messenger format converter tests. + +Tests MessengerFormatConverter's to_ast, from_ast, render_postable, and +extract_plain_text methods. Messenger renders no markdown, so from_ast simply +stringifies the AST (markers preserved as literal text). +""" + +from __future__ import annotations + +from chat_sdk.adapters.messenger.format_converter import MessengerFormatConverter + +converter = MessengerFormatConverter() + + +# --------------------------------------------------------------------------- +# to_ast +# --------------------------------------------------------------------------- + + +class TestMessengerToAst: + """Tests for MessengerFormatConverter.to_ast.""" + + def test_parses_plain_text(self): + ast = converter.to_ast("Hello world") + assert ast["type"] == "root" + assert len(ast.get("children", [])) > 0 + + def test_parses_markdown_bold(self): + ast = converter.to_ast("**bold**") + assert ast["type"] == "root" + + def test_handles_empty_text(self): + ast = converter.to_ast("") + assert ast["type"] == "root" + + +# --------------------------------------------------------------------------- +# from_ast +# --------------------------------------------------------------------------- + + +class TestMessengerFromAst: + """Tests for MessengerFormatConverter.from_ast.""" + + def test_roundtrips_plain_text(self): + text = "Hello world" + ast = converter.to_ast(text) + result = converter.from_ast(ast) + assert result == text + + def test_roundtrips_markdown_formatting(self): + text = "**bold** and *italic*" + ast = converter.to_ast(text) + result = converter.from_ast(ast) + assert "bold" in result + assert "italic" in result + + +# --------------------------------------------------------------------------- +# render_postable +# --------------------------------------------------------------------------- + + +class TestMessengerRenderPostable: + """Tests for MessengerFormatConverter.render_postable.""" + + def test_renders_string_messages(self): + assert converter.render_postable("hello") == "hello" + + def test_renders_raw_messages(self): + assert converter.render_postable({"raw": "raw text"}) == "raw text" + + def test_renders_markdown_messages(self): + result = converter.render_postable({"markdown": "**bold**"}) + assert "bold" in result + + def test_renders_ast_messages(self): + ast = converter.to_ast("hello from ast") + result = converter.render_postable({"ast": ast}) + assert "hello from ast" in result + + def test_falls_back_for_invalid_postable_message_shapes(self): + # Divergence from upstream: TS renderPostable throws on unknown shapes; + # the shared Python BaseFormatConverter degrades to str(message) instead + # so a stray message can't crash delivery. Pin the Python behavior. + result = converter.render_postable({"unknown": "value"}) + assert result == str({"unknown": "value"}) + + +# --------------------------------------------------------------------------- +# extract_plain_text +# --------------------------------------------------------------------------- + + +class TestMessengerExtractPlainText: + """Tests for MessengerFormatConverter.extract_plain_text.""" + + def test_extracts_plain_text_from_markdown(self): + result = converter.extract_plain_text("**bold** text") + assert "bold" in result + assert "text" in result + # extract_plain_text strips markers (unlike from_ast) + assert "**" not in result From 34a32af5a28497dad10e2a24ee02f9002e3cb965 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 00:01:35 +0000 Subject: [PATCH 2/2] refactor(messenger): drop dead MAX_TEXT_LENGTH from cards scaffolding (review) https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj --- src/chat_sdk/adapters/messenger/cards.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/chat_sdk/adapters/messenger/cards.py b/src/chat_sdk/adapters/messenger/cards.py index 144cc26..09c261f 100644 --- a/src/chat_sdk/adapters/messenger/cards.py +++ b/src/chat_sdk/adapters/messenger/cards.py @@ -54,9 +54,6 @@ # Maximum character length for title in Generic Template MAX_TITLE_LENGTH = 80 -# Maximum character length for a plain text message -MAX_TEXT_LENGTH = 2000 - class _MessengerCardActionPayload(TypedDict, total=False): a: str