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
-
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.
-
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)?
-
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
Background
Upstream PR vercel/chat#461 added a new
@chat-adapter/messengerpackage for Meta's Messenger Platform. Brand-new platform adapter — similar scope to porting WhatsApp or Telegram from scratch. Source: 4,688 LOC src + tests inpackages/adapter-messenger/.What is Messenger Platform?
Meta's 1:1 messaging API tied to a Facebook Page. Bots receive events via:
hub.mode=subscribe,hub.verify_token,hub.challenge— bot echoes the challenge if the token matches.messages,messaging_postbacks,messaging_reactions,message_deliveries,message_reads. Authenticated viaX-Hub-Signature-256HMAC-SHA256 of the raw body using the App Secret.Outbound calls go to
https://graph.facebook.com/v21.0/me/messagesauthenticated with a Page Access Token asaccess_tokenquery 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-exportMessengerAdapter,create_messenger_adapteradapter.py(~950 LOC) —MessengerAdapterclass. 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(sendssender_action: typing_on),stream(buffered: accumulate chunks, thenpost_message),parse_message,get_user(GraphuserId?fields=first_name,last_name,profile_picwith 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)returnsMessengerCardResult(template | text);encode_messenger_callback_data/decode_messenger_callback_data(chat:{json}prefix with{a, v?}payload); Generic template whentitleorimageUrlpresent, Button template otherwise; tables/selects/radio_selects force text fallbackformat_converter.py(~30 LOC) —MessengerFormatConverter(BaseFormatConverter). Messenger does NOT render markdown;from_astandrender_postablestrip to plain text viastringify_markdowntypes.py(~140 LOC) — dataclasses for config + raw payload typesHMAC verification stays inline in
adapter.py(matches WhatsApp adapter convention; no separatecrypto.py).Auth model
Three secrets:
FACEBOOK_APP_SECRET— HMAC key forX-Hub-Signature-256body verificationFACEBOOK_PAGE_ACCESS_TOKEN— long-lived page token, sent asaccess_tokenquery param to Graph API (not Bearer)FACEBOOK_VERIFY_TOKEN— user-defined string matched againsthub.verify_tokenduring GET handshakeNo 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, raisingValidationErrorif any of the three are missing.Streaming
Buffered, no native streaming. Upstream
stream()accumulates allstring | StreamChunk(markdown_text)chunks into one string then callspost_message. Edit is unsupported, so incremental edit-based streaming (the_fallback_streampattern) is impossible. Typing indicator is the only "live" feedback. Overridestreamin the Python port the same way rather than inheriting the base_fallback_stream(which would try to edit).Capabilities matrix (non-parity table entries)
Effort estimate (~5–6 days, 2 PRs)
types.py,format_converter.py,cards.py+ cards/markdown tests (~700 LOC, low risk, easy review)adapter.py+ webhook/end-to-end tests (~800 LOC + 1,500 test LOC, the meaty part)Open questions
Page-identity init failure semantics. Upstream
initializeswallows/meerrors with awarnand 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 whetheris_medetection inparse_messageworks at all.Postback
valuepassthrough behavior.decode_messenger_callback_datahas surprising legacy behavior: if the payload doesn't start withchat:, it returns the raw string as bothactionIdandvalue. Intentional for interop with non-SDK-generated Page menu items, or should we tighten it (security: untrusted payload becomes both fields)?Webhook signature-failure HTTP status. Upstream returns
403for bad signature and400for bad JSON. Our Slack/Teams adapters return401for signature failures. Keep upstream parity (403) or align with our existing convention (401)? Same question for theobject !== "page"case (upstream returns404).References
packages/adapter-messenger/src/src/chat_sdk/adapters/whatsapp/src/chat_sdk/adapters/telegram/adapter.pyscripts/verify_test_fidelity.py