Skip to content

Design: Messenger (Meta) adapter port (vercel/chat#461) #110

Description

@patrick-chinchill

Background

Upstream PR vercel/chat#461 added a new @chat-adapter/messenger package for Meta's Messenger Platform. Brand-new platform adapter — similar scope to porting WhatsApp or Telegram from scratch. Source: 4,688 LOC src + tests in packages/adapter-messenger/.

What is Messenger Platform?

Meta's 1:1 messaging API tied to a Facebook Page. Bots receive events via:

  • GET verification handshake: Meta sends hub.mode=subscribe, hub.verify_token, hub.challenge — bot echoes the challenge if the token matches.
  • POST event delivery: incoming messages, messaging_postbacks, messaging_reactions, message_deliveries, message_reads. Authenticated via X-Hub-Signature-256 HMAC-SHA256 of the raw body using the App Secret.

Outbound calls go to https://graph.facebook.com/v21.0/me/messages authenticated with a Page Access Token as access_token query param (not Bearer).

Message types: text (2000 char cap), generic/button templates (max 3 buttons, 20 char titles, 80 char subtitle, 640 char button-template text), media attachments (image/video/audio/file). Every conversation is a DM — there is no concept of channels or threads beyond recipientId.

Recommended Python design

Mirror WhatsApp adapter conventions. Files under src/chat_sdk/adapters/messenger/ (~1,500 LOC prod):

  • __init__.py (~5 LOC) — re-export MessengerAdapter, create_messenger_adapter
  • adapter.py (~950 LOC) — MessengerAdapter class. Methods: initialize, handle_webhook (dispatches GET verification vs POST event delivery), _handle_incoming_message, _handle_echo, _handle_postback, _handle_reaction, delivery/read no-ops, post_message (extracts card → template-or-text), _send_text_message, _send_template_message, edit_message/delete_message/add_reaction/remove_reaction (raise — Messenger API limitation), start_typing (sends sender_action: typing_on), stream (buffered: accumulate chunks, then post_message), parse_message, get_user (Graph userId?fields=first_name,last_name,profile_pic with cache), fetch_messages (in-memory cache pagination), fetch_thread/fetch_channel_info/open_dm/is_dm (DM-trivial), encode_thread_id/decode_thread_id (messenger:{recipientId}), _graph_api_fetch, _throw_graph_api_error (maps codes 4/32/613→RateLimit, 190→Auth, 10/200→Validation, 404→NotFound)
  • cards.py (~460 LOC) — card_to_messenger(card) returns MessengerCardResult (template | text); encode_messenger_callback_data/decode_messenger_callback_data (chat:{json} prefix with {a, v?} payload); Generic template when title or imageUrl present, Button template otherwise; tables/selects/radio_selects force text fallback
  • format_converter.py (~30 LOC) — MessengerFormatConverter(BaseFormatConverter). Messenger does NOT render markdown; from_ast and render_postable strip to plain text via stringify_markdown
  • types.py (~140 LOC) — dataclasses for config + raw payload types

HMAC verification stays inline in adapter.py (matches WhatsApp adapter convention; no separate crypto.py).

Auth model

Three secrets:

  • FACEBOOK_APP_SECRET — HMAC key for X-Hub-Signature-256 body verification
  • FACEBOOK_PAGE_ACCESS_TOKEN — long-lived page token, sent as access_token query param to Graph API (not Bearer)
  • FACEBOOK_VERIFY_TOKEN — user-defined string matched against hub.verify_token during GET handshake

No OAuth flow, no system user token in upstream — page access token is generated manually from the Meta app dashboard. create_messenger_adapter() falls back to env vars, raising ValidationError if any of the three are missing.

Streaming

Buffered, no native streaming. Upstream stream() accumulates all string | StreamChunk(markdown_text) chunks into one string then calls post_message. Edit is unsupported, so incremental edit-based streaming (the _fallback_stream pattern) is impossible. Typing indicator is the only "live" feedback. Override stream in the Python port the same way rather than inheriting the base _fallback_stream (which would try to edit).

Capabilities matrix (non-parity table entries)

Feature Messenger
Edit message NO (API limitation)
Delete message NO (API limitation)
Add reaction (outbound) NO (receive only)
Threading NO (DM-only, flat conversations)
Channels NO (DM-only)
File upload from bot NO (upstream sends text/templates only)
Modals / dialogs NO
Select / radio menus NO (forces text fallback)
Tables in cards NO (text fallback)
Native streaming NO (buffered only)
Fetch historical messages NO (cached sent messages only)
Buttons per card Max 3
Button label Max 20 chars
Message length 2000 chars

Effort estimate (~5–6 days, 2 PRs)

  • LOC: ~1,500 production + ~2,500 tests (mirroring upstream's 4,688-LOC src+tests footprint). WhatsApp is 1,929 prod LOC — Messenger is slightly smaller.
  • PRs:
    • PR 1: scaffolding — types.py, format_converter.py, cards.py + cards/markdown tests (~700 LOC, low risk, easy review)
    • PR 2: adapter — adapter.py + webhook/end-to-end tests (~800 LOC + 1,500 test LOC, the meaty part)
  • Days: ~5–6 engineer-days. 1d scaffolding + cards, 2–3d adapter + Graph API + webhook + signature, 1–2d test porting (2,145+853+76 LOC of upstream tests), 0.5d capabilities matrix + docs + fidelity-mapping update.

Open questions

  1. Page-identity init failure semantics. Upstream initialize swallows /me errors with a warn and continues with _bot_user_id = undefined. Do we keep that (parity), or fail fast in Python (consistent with our stricter init in Slack/Teams)? Affects whether is_me detection in parse_message works at all.

  2. Postback value passthrough behavior. decode_messenger_callback_data has surprising legacy behavior: if the payload doesn't start with chat:, it returns the raw string as both actionId and value. Intentional for interop with non-SDK-generated Page menu items, or should we tighten it (security: untrusted payload becomes both fields)?

  3. Webhook signature-failure HTTP status. Upstream returns 403 for bad signature and 400 for bad JSON. Our Slack/Teams adapters return 401 for signature failures. Keep upstream parity (403) or align with our existing convention (401)? Same question for the object !== "page" case (upstream returns 404).

References

Activity

  1. coderabbitai commented on May 29, 2026

    @coderabbitai
    🔗 Related PRs

    #49 - chore(types): pyrefly 213 → 0 errors + fix 3 latent runtime bugs [merged]
    #67 - feat(attachments): port rehydrateAttachment hook (#52) [merged]
    #86 - feat(slack): Socket Mode transport (vercel/chat#162) [merged]
    #87 - feat(slack): dynamic bot_token resolver and custom webhook_verifier (vercel/chat#421) [merged]
    #89 - fix(adapters): bug-fix sweep from upstream 4.27.0 (slack/discord/telegram) [merged]


    📝 Issue Planner

    Check the box below or use the @coderabbitai plan command to generate an implementation plan and prompts that you can use with your favorite coding assistant.

    • Create Plan

    🧪 Issue enrichment is currently in open beta.

    You can configure auto-planning by selecting labels in the issue_enrichment configuration.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

  2. patrick-chinchill commented on Jun 21, 2026

    @patrick-chinchill
    CollaboratorAuthor

    Design delivered — the Messenger adapter shipped in 0.4.29. Sibling-consistency follow-up (get_user) is tracked separately in #132.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions