You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Wire WhatsApp and Messenger into the new #200/#193 core APIs and move their downloads onto the #204 guarded downloader: mark_as_read(thread_id, message_id) (back-compat shim for WhatsApp's one-argument form; Messenger mark_seen), WhatsApp native contextual replies and code-fence normalization, and guarded downloads with a per-hop host and credential policy.
Upstream changes
18d4a230 feat(chat): add mark as read support (#820) — chat@4.38.0 — WhatsApp markAsRead(threadIdOrMessageId, messageId?, message?) sends message_id: messageId ?? threadIdOrMessageId, throws AdapterError("WhatsApp mark as read failed") on falsy success; Messenger markAsRead(threadId, _messageId) POSTs sender_action: "mark_seen" to me/messages. Core half: [4.41/C6] Thread.reply, Thread.mark_as_read, post_ephemeral options #200.
83ede7ea feat(chat): add message reply support (#819) — chat@4.38.0 — WhatsApp postMessage delegates to send(threadId, message, replyId?); reply(threadId, messageId, message) adds context: {message_id} to the first outgoing message only (first text chunk, first media item, or the interactive). Core half: [4.41/C6] Thread.reply, Thread.mark_as_read, post_ephemeral options #200.
7c269653 fix(adapters): restrict attachment credentials to trusted hosts (#859) — chat@4.39.0 — WhatsApp part: isWhatsAppMediaUrl accepts the exact configured Graph origin, or https with no port on fbcdn.net/fbsbx.com (or subdomains); otherwise NetworkError("Refusing to send the access token to an untrusted media URL") before the token is sent.
b6fa24c6 fix(adapters): guard attachment downloads across slack, discord, telegram, and whatsapp (#865) — chat@4.39.0 — WhatsApp part: downloadMedia step 2 uses shared downloadAttachment with a per-hop header policy (token only to policy hosts, re-checked every redirect), the 25 MB cap and the timeout.
src/chat_sdk/adapters/whatsapp/adapter.py:1093-1102mark_as_read(self, message_id) has no success check; grep -rn 'mark_as_read' tests finds no test.
Messenger has no mark_as_read (grep -n 'mark_seen\|mark_as_read' on messenger/adapter.py finds nothing); start_typing (messenger/adapter.py:626-636) shows the sender_action pattern.
WhatsApp has no reply; post_message (:751-783), _send_single_text_message (:785), _send_text_message (:823), _send_interactive_message (:839) take no reply id.
whatsapp/format_converter.py:82-89to_ast runs parse_markdown(self._from_whatsapp_format(platform_text)): markup inside fences is rewritten and the first code line can be lost.
adapter.py:680-750download_media has a Python-only suffix allowlist (facebook.com, fbcdn.net, fbsbx.com, whatsapp.net, whatsapp.com) with no exact-Graph-origin allowance and no port check; step 2 session.get(download_url, headers={"Authorization": ...}) follows redirects with aiohttp defaults, and the read is unbounded with no timeout.
WhatsApp mark_as_read → (self, thread_id_or_message_id: str, message_id: str | None = None, message: Message | None = None) -> None, matching the [4.41/C6] Thread.reply, Thread.mark_as_read, post_ephemeral options #200 hook shape. Send message_id if message_id is not None else thread_id_or_message_id; raise AdapterError("WhatsApp mark as read failed", "whatsapp") unless response.get("success") is True.
Messenger mark_as_read(self, thread_id, message_id=None, message=None): resolve recipient_id via _resolve_thread_id and POST sender_action: "mark_seen" through _graph_api_fetch("me/messages", ...) as start_typing does.
WhatsApp replies. Refactor post_message into async _send(thread_id, message, reply_id=None) and add async def reply(self, thread_id, message_id, message). Thread reply_id through _send_text_message (chunk 0 only), _send_single_text_message, _send_interactive_message and the [4.41/W3] WhatsApp: outbound files/media, CTA URL LinkButton, no duplicate card title #238 media path (remaining_id cleared after the first send). Add "context": {"message_id": reply_id} only when reply_id is truthy.
WhatsApp downloads. Add _WHATSAPP_MEDIA_HOSTS = ("fbcdn.net", "fbsbx.com") and _is_whatsapp_media_url(url, graph_api_url) (exact Graph origin, or https, no port, allowed host or subdomain). Check the step-1 URL before step 2. Step 2 calls the [4.41/SH1] Shared guarded downloader (redirect policy, byte cap, timeout, credential host binding) #204 downloader with adapter="whatsapp", hosts = media hosts plus the Graph hostname, and a per-hop header callback that re-checks _is_whatsapp_media_url before attaching Authorization. Wrap non-NetworkError failures as NetworkError("whatsapp", f"Failed to download media {media_id}").
Docs. Update the WhatsApp/Messenger rows in docs/UPSTREAM_SYNC.md: record dropping facebook.com / whatsapp.* from the allowlist for upstream parity (recommended default) or keeping them as a divergence, and remove the [4.41/MS1] Messenger: guard attachment downloads #234 missing-DNS-check note.
Back-compat shim.await adapter.mark_as_read("wamid.X") must keep working (upstream test "preserves the adapter-level message id signature"). Use is not None, not or, so an explicit "" message id is not replaced.
success check. Upstream's !response.success is truthiness. Recommended: is True (fails closed on "true" strings), recorded in the sync doc; bool(...) is the documented alternative.
Reply context.if reply_id: matches upstream (empty string = no context). Clear remaining_id after the first send of any kind, including a leading text sent before media.
Credentials per hop. Compute Authorization per hop in the callback, never once on the session. The Graph allowance is an exact origin match (scheme, host, port), not a suffix match.
Rehydration.download_media is also reached via rehydrate_attachment (adapter.py:656-678); the rebuilt closure must use the same guarded path.
Tests
Upstream WhatsApp index.test.ts / markdown.test.ts and Messenger index.test.ts / fetch.test.ts are not fidelity-mapped. Port into tests/test_whatsapp_*.py / tests/test_messenger_*.py:
WhatsApp describe("markAsRead"): "marks an inbound message as read", "preserves the adapter-level message id signature", "rejects an unsuccessful API response".
Messenger describe("markAsRead"): "sends the mark_seen sender action".
WhatsApp describe("reply"): "adds contextual reply data to text messages", "adds contextual reply data only to the first split message", "adds contextual reply data to interactive cards", "adds reply context only to the first media message".
WhatsApp markdown.test.ts: "preserves code starting immediately after the opening fence", "does not rewrite bold or strikethrough inside fenced code", "keeps an unpaired ``` as literal text", "separates a fence from surrounding text on the same line".
WhatsApp describe("downloadMedia"): "rejects untrusted media URL %s", "downloads media from trusted Meta URL %s", "downloads media from the configured Graph origin", "refuses to send the token to off-policy redirect %s".
Python-specific: a rehydrated WhatsApp attachment takes the guarded path. AsyncMock for Graph/Send calls, an injected fake transport for downloads, no sleeps.
CHANGELOG entry under an "Unreleased (4.41 wave)" heading.
Consumer-visible changes called out: WhatsApp mark_as_read now raises on success: false; the WhatsApp media host allowlist narrows if the recommendation is adopted.
Dependencies
Blocked by #200, #193, #204. Soft ordering (not index blockers): lands after #234 (replaces its inline guard) and ideally after #238 (reply context on media); if #238 has not landed, "adds reply context only to the first media message" moves to #238.
Metadata
Effort: M (200-700 LOC)
Consumer impact: low. None for Slack/Teams; WhatsApp/Messenger get new APIs, stricter downloads, and mark_as_read raises on failure.
Summary
Wire WhatsApp and Messenger into the new #200/#193 core APIs and move their downloads onto the #204 guarded downloader:
mark_as_read(thread_id, message_id)(back-compat shim for WhatsApp's one-argument form; Messengermark_seen), WhatsApp native contextual replies and code-fence normalization, and guarded downloads with a per-hop host and credential policy.Upstream changes
18d4a230feat(chat): add mark as read support (#820) — chat@4.38.0 — WhatsAppmarkAsRead(threadIdOrMessageId, messageId?, message?)sendsmessage_id: messageId ?? threadIdOrMessageId, throwsAdapterError("WhatsApp mark as read failed")on falsysuccess; MessengermarkAsRead(threadId, _messageId)POSTssender_action: "mark_seen"tome/messages. Core half: [4.41/C6] Thread.reply, Thread.mark_as_read, post_ephemeral options #200.83ede7eafeat(chat): add message reply support (#819) — chat@4.38.0 — WhatsApppostMessagedelegates tosend(threadId, message, replyId?);reply(threadId, messageId, message)addscontext: {message_id}to the first outgoing message only (first text chunk, first media item, or the interactive). Core half: [4.41/C6] Thread.reply, Thread.mark_as_read, post_ephemeral options #200.e71bfeadfix(slack): preserve first line of incoming code blocks (#843) — chat@4.39.0 — WhatsApp part:toAstrunsnormalizeCodeFences(markdown, {convertText: fromWhatsAppFormat}), so the line after an opening fence stays code and WhatsApp markup converts only outside fences. Helper: [4.41/C2b] Shared text utilities: bare-mention scanner, code-fence helpers, to_plain_text structural whitespace #193.7c269653fix(adapters): restrict attachment credentials to trusted hosts (#859) — chat@4.39.0 — WhatsApp part:isWhatsAppMediaUrlaccepts the exact configured Graph origin, or https with no port onfbcdn.net/fbsbx.com(or subdomains); otherwiseNetworkError("Refusing to send the access token to an untrusted media URL")before the token is sent.b6fa24c6fix(adapters): guard attachment downloads across slack, discord, telegram, and whatsapp (#865) — chat@4.39.0 — WhatsApp part:downloadMediastep 2 uses shareddownloadAttachmentwith a per-hop header policy (token only to policy hosts, re-checked every redirect), the 25 MB cap and the timeout.153bd964fix(messenger): guard attachment downloads (#856) — chat@4.39.0 — ported inline by [4.41/MS1] Messenger: guard attachment downloads #234; moved onto the shared helper here.Current Python behavior
src/chat_sdk/adapters/whatsapp/adapter.py:1093-1102mark_as_read(self, message_id)has nosuccesscheck;grep -rn 'mark_as_read' testsfinds no test.mark_as_read(grep -n 'mark_seen\|mark_as_read'onmessenger/adapter.pyfinds nothing);start_typing(messenger/adapter.py:626-636) shows thesender_actionpattern.reply;post_message(:751-783),_send_single_text_message(:785),_send_text_message(:823),_send_interactive_message(:839) take no reply id.whatsapp/format_converter.py:82-89to_astrunsparse_markdown(self._from_whatsapp_format(platform_text)): markup inside fences is rewritten and the first code line can be lost.adapter.py:680-750download_mediahas a Python-only suffix allowlist (facebook.com,fbcdn.net,fbsbx.com,whatsapp.net,whatsapp.com) with no exact-Graph-origin allowance and no port check; step 2session.get(download_url, headers={"Authorization": ...})follows redirects with aiohttp defaults, and the read is unbounded with no timeout._download_attachment: as left by [4.41/MS1] Messenger: guard attachment downloads #234.Scope
mark_as_read→(self, thread_id_or_message_id: str, message_id: str | None = None, message: Message | None = None) -> None, matching the [4.41/C6] Thread.reply, Thread.mark_as_read, post_ephemeral options #200 hook shape. Sendmessage_id if message_id is not None else thread_id_or_message_id; raiseAdapterError("WhatsApp mark as read failed", "whatsapp")unlessresponse.get("success") is True.mark_as_read(self, thread_id, message_id=None, message=None): resolverecipient_idvia_resolve_thread_idand POSTsender_action: "mark_seen"through_graph_api_fetch("me/messages", ...)asstart_typingdoes.post_messageintoasync _send(thread_id, message, reply_id=None)and addasync def reply(self, thread_id, message_id, message). Threadreply_idthrough_send_text_message(chunk 0 only),_send_single_text_message,_send_interactive_messageand the [4.41/W3] WhatsApp: outbound files/media, CTA URL LinkButton, no duplicate card title #238 media path (remaining_idcleared after the first send). Add"context": {"message_id": reply_id}only whenreply_idis truthy.format_converter.pyto_astusesnormalize_code_fences(text, convert_text=self._from_whatsapp_format)fromchat_sdk.shared, as ported by [4.41/C2b] Shared text utilities: bare-mention scanner, code-fence helpers, to_plain_text structural whitespace #193._WHATSAPP_MEDIA_HOSTS = ("fbcdn.net", "fbsbx.com")and_is_whatsapp_media_url(url, graph_api_url)(exact Graph origin, or https, no port, allowed host or subdomain). Check the step-1 URL before step 2. Step 2 calls the [4.41/SH1] Shared guarded downloader (redirect policy, byte cap, timeout, credential host binding) #204 downloader withadapter="whatsapp",hosts= media hosts plus the Graph hostname, and a per-hop header callback that re-checks_is_whatsapp_media_urlbefore attachingAuthorization. Wrap non-NetworkErrorfailures asNetworkError("whatsapp", f"Failed to download media {media_id}").hosts=("fbsbx.com", "fbcdn.net"), no credentials), keeping the error texts.docs/UPSTREAM_SYNC.md: record droppingfacebook.com/whatsapp.*from the allowlist for upstream parity (recommended default) or keeping them as a divergence, and remove the [4.41/MS1] Messenger: guard attachment downloads #234 missing-DNS-check note.Out of scope
Thread.reply/Thread.mark_as_readand the hook contract: [4.41/C6] Thread.reply, Thread.mark_as_read, post_ephemeral options #200. Thenormalize_code_fenceshelper: [4.41/C2b] Shared text utilities: bare-mention scanner, code-fence helpers, to_plain_text structural whitespace #193. The downloader: [4.41/SH1] Shared guarded downloader (redirect policy, byte cap, timeout, credential host binding) #204.get_user: Add MessengerAdapter.get_user (sibling-adapter consistency) #132.Porting notes
await adapter.mark_as_read("wamid.X")must keep working (upstream test"preserves the adapter-level message id signature"). Useis not None, notor, so an explicit""message id is not replaced.successcheck. Upstream's!response.successis truthiness. Recommended:is True(fails closed on"true"strings), recorded in the sync doc;bool(...)is the documented alternative.if reply_id:matches upstream (empty string = no context). Clearremaining_idafter the first send of any kind, including a leading text sent before media.Authorizationper hop in the callback, never once on the session. The Graph allowance is an exact origin match (scheme, host, port), not a suffix match.download_mediais also reached viarehydrate_attachment(adapter.py:656-678); the rebuilt closure must use the same guarded path.Tests
Upstream WhatsApp
index.test.ts/markdown.test.tsand Messengerindex.test.ts/fetch.test.tsare not fidelity-mapped. Port intotests/test_whatsapp_*.py/tests/test_messenger_*.py:describe("markAsRead"):"marks an inbound message as read","preserves the adapter-level message id signature","rejects an unsuccessful API response".describe("markAsRead"):"sends the mark_seen sender action".describe("reply"):"adds contextual reply data to text messages","adds contextual reply data only to the first split message","adds contextual reply data to interactive cards","adds reply context only to the first media message".markdown.test.ts:"preserves code starting immediately after the opening fence","does not rewrite bold or strikethrough inside fenced code","keeps an unpaired ``` as literal text","separates a fence from surrounding text on the same line".describe("downloadMedia"):"rejects untrusted media URL %s","downloads media from trusted Meta URL %s","downloads media from the configured Graph origin","refuses to send the token to off-policy redirect %s".fetch.test.ts: the [4.41/MS1] Messenger: guard attachment downloads #234 tests keep passing unchanged (no duplicates).AsyncMockfor Graph/Send calls, an injected fake transport for downloads, no sleeps.Acceptance criteria
thread.mark_as_read()([4.41/C6] Thread.reply, Thread.mark_as_read, post_ephemeral options #200) works on WhatsApp and Messenger, and the single-argument WhatsApp call still works.thread.reply()([4.41/C6] Thread.reply, Thread.mark_as_read, post_ephemeral options #200) on WhatsApp threads sendscontext.message_idon exactly one outgoing message.docs/UPSTREAM_SYNC.mdupdated (allowlist delta,successstrictness).mark_as_readnow raises onsuccess: false; the WhatsApp media host allowlist narrows if the recommendation is adopted.Dependencies
Blocked by #200, #193, #204. Soft ordering (not index blockers): lands after #234 (replaces its inline guard) and ideally after #238 (reply context on media); if #238 has not landed,
"adds reply context only to the first media message"moves to #238.Metadata
mark_as_readraises on failure.sync/4.41-w4Part of #184.