From f0c42c376551e0bf830f4d59a1a32481f47c7565 Mon Sep 17 00:00:00 2001 From: WOUTER DURNEZ Date: Mon, 13 Jul 2026 11:44:45 +0200 Subject: [PATCH 1/2] [callback] treat missing message context as no reply_to_message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhatsApp does not always include the context field on incoming template quick-reply button presses (observed in production on Cloud API webhooks that also carry the new user_id/from_user_id fields). CallbackButton.from_update and CallbackSelection.from_update index msg["context"] unconditionally, so such updates raise KeyError: 'context' and are dropped by the server's update constructor — the handler never fires and, since 200 is returned, Meta never redelivers. Parse context the same way Message.from_update already does: treat it as optional and set reply_to_message=None when absent. Co-Authored-By: Claude Fable 5 --- pywa/types/callback.py | 18 ++++++--- pywa_async/types/callback.py | 4 +- tests/data/updates/callback_button.json | 43 +++++++++++++++++++++ tests/data/updates/callback_selection.json | 45 ++++++++++++++++++++++ tests/test_updates.py | 23 +++++++++-- 5 files changed, 122 insertions(+), 11 deletions(-) diff --git a/pywa/types/callback.py b/pywa/types/callback.py index 3eee0953..d483c472 100644 --- a/pywa/types/callback.py +++ b/pywa/types/callback.py @@ -258,14 +258,14 @@ class CallbackButton(BaseUserUpdate, Generic[_CallbackDataT]): type: The message type (:class:`MessageType.INTERACTIVE` for :class:`~pywa.types.callback.Button` presses or :class:`MessageType.BUTTON` for :class:`~pywa.types.templates.QuickReplyButton` clicks). from_user: The user who sent the message. timestamp: The timestamp when the message was sent (in UTC). - reply_to_message: The message to which this callback button is a reply to. + reply_to_message: The message to which this callback button is a reply to (``None`` when WhatsApp omits the ``context`` field). data: The data of the button (the ``callback_data`` parameter you provided in :class:`~pywa.types.callback.Button` or :class:`~pywa.types.templates.QuickReplyButton`). title: The title of the button. shared_data: Shared data between handlers. """ type: MessageType - reply_to_message: ReplyToMessage + reply_to_message: ReplyToMessage | None data: _CallbackDataT title: str @@ -286,6 +286,7 @@ def from_update(cls, client: "WhatsApp", update: RawUpdate) -> "CallbackButton": data = msg["button"]["payload"] case _: raise ValueError(f"Invalid message type {msg_type}") + context = msg.get("context", {}) return cls( _client=client, raw=update, @@ -295,7 +296,9 @@ def from_update(cls, client: "WhatsApp", update: RawUpdate) -> "CallbackButton": type=MessageType(msg_type), from_user=client._usr_cls.from_contact(value["contacts"][0], client=client), timestamp=helpers.timestamp_to_datetime(msg["timestamp"]), - reply_to_message=ReplyToMessage.from_dict(msg["context"]), + reply_to_message=ReplyToMessage.from_dict(context) + if context.get("id") + else None, data=data, title=title, ) @@ -354,14 +357,14 @@ class CallbackSelection(BaseUserUpdate, Generic[_CallbackDataT]): type: The message type (always :class:`MessageType.INTERACTIVE`). from_user: The user who sent the message. timestamp: The timestamp when the message was sent (in UTC). - reply_to_message: The message to which this callback selection is a reply to. + reply_to_message: The message to which this callback selection is a reply to (``None`` when WhatsApp omits the ``context`` field). data: The data of the selection (the ``callback_data`` parameter you provided in :class:`~pywa.types.callback.SectionRow`). title: The title of the selection. description: The description of the selection (optional). """ type: MessageType - reply_to_message: ReplyToMessage + reply_to_message: ReplyToMessage | None data: _CallbackDataT title: str description: str | None @@ -374,6 +377,7 @@ def from_update(cls, client: "WhatsApp", update: RawUpdate) -> "CallbackSelectio msg = (value := (entry := update["entry"][0])["changes"][0]["value"])[ "messages" ][0] + context = msg.get("context", {}) return cls( _client=client, raw=update, @@ -383,7 +387,9 @@ def from_update(cls, client: "WhatsApp", update: RawUpdate) -> "CallbackSelectio type=MessageType(msg["type"]), from_user=client._usr_cls.from_contact(value["contacts"][0], client=client), timestamp=helpers.timestamp_to_datetime(msg["timestamp"]), - reply_to_message=ReplyToMessage.from_dict(msg["context"]), + reply_to_message=ReplyToMessage.from_dict(context) + if context.get("id") + else None, data=msg["interactive"]["list_reply"]["id"], title=msg["interactive"]["list_reply"]["title"], description=msg["interactive"]["list_reply"].get("description"), diff --git a/pywa_async/types/callback.py b/pywa_async/types/callback.py index bf5b3a74..0e6e5d8c 100644 --- a/pywa_async/types/callback.py +++ b/pywa_async/types/callback.py @@ -46,7 +46,7 @@ class CallbackButton(BaseUserUpdateAsync, _CallbackButton[_CallbackDataT]): type: The message type (:class:`MessageType.INTERACTIVE` for :class:`~pywa.types.callback.Button` presses or :class:`MessageType.BUTTON` for :class:`~pywa.types.templates.QuickReplyButton` clicks). from_user: The user who sent the message. timestamp: The timestamp when the message was sent (in UTC). - reply_to_message: The message to which this callback button is a reply to. + reply_to_message: The message to which this callback button is a reply to (``None`` when WhatsApp omits the ``context`` field). data: The data of the button (the ``callback_data`` parameter you provided in :class:`~pywa.types.callback.Button` or :class:`~pywa.types.templates.QuickReplyButton`). title: The title of the button. shared_data: Shared data between handlers. @@ -95,7 +95,7 @@ class CallbackSelection(BaseUserUpdateAsync, _CallbackSelection[_CallbackDataT]) type: The message type (always :class:`MessageType.INTERACTIVE`). from_user: The user who sent the message. timestamp: The timestamp when the message was sent (in UTC). - reply_to_message: The message to which this callback selection is a reply to. + reply_to_message: The message to which this callback selection is a reply to (``None`` when WhatsApp omits the ``context`` field). data: The data of the selection (the ``callback_data`` parameter you provided in :class:`~pywa.types.callback.SectionRow`). title: The title of the selection. description: The description of the selection (optional). diff --git a/tests/data/updates/callback_button.json b/tests/data/updates/callback_button.json index bc69fa45..6928a60c 100644 --- a/tests/data/updates/callback_button.json +++ b/tests/data/updates/callback_button.json @@ -93,5 +93,48 @@ ] } ] + }, + "quick_reply_without_context": { + "object": "whatsapp_business_account", + "entry": [ + { + "id": "5467539754836534", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "972123456789", + "phone_number_id": "1122334455667" + }, + "contacts": [ + { + "profile": { + "name": "Test Name" + }, + "wa_id": "972987654321", + "user_id": "US.13491208655302741918", + "parent_user_id": "US.ENT.11815799212886844830" + } + ], + "messages": [ + { + "from": "972987654321", + "from_user_id": "US.13491208655302741918", + "id": "wamid.xsidjx", + "timestamp": "1698269027", + "type": "button", + "button": { + "payload": "callback_data", + "text": "title" + } + } + ] + }, + "field": "messages" + } + ] + } + ] } } diff --git a/tests/data/updates/callback_selection.json b/tests/data/updates/callback_selection.json index 439a1e88..15ab6eb6 100644 --- a/tests/data/updates/callback_selection.json +++ b/tests/data/updates/callback_selection.json @@ -97,5 +97,50 @@ ] } ] + }, + "callback_without_context": { + "object": "whatsapp_business_account", + "entry": [ + { + "id": "1234567890987654321", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "972123456789", + "phone_number_id": "1122334455667" + }, + "contacts": [ + { + "profile": { + "name": "Test Name" + }, + "wa_id": "972987654321", + "user_id": "US.13491208655302741918", + "parent_user_id": "US.ENT.11815799212886844830" + } + ], + "messages": [ + { + "from": "972987654321", + "id": "wamid.zyzxyw", + "timestamp": "1698266905", + "type": "interactive", + "interactive": { + "type": "list_reply", + "list_reply": { + "id": "callback_data", + "title": "title" + } + } + } + ] + }, + "field": "messages" + } + ] + } + ] } } diff --git a/tests/test_updates.py b/tests/test_updates.py index 59edce54..9da62f26 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -66,12 +66,29 @@ "media_with_url": [lambda m: m.media.url is not None], }, "callback_button": { - "button": [lambda b: b.type == MessageType.INTERACTIVE], - "quick_reply": [lambda b: b.type == MessageType.BUTTON], + "button": [ + lambda b: b.type == MessageType.INTERACTIVE, + lambda b: b.reply_to_message is not None, + ], + "quick_reply": [ + lambda b: b.type == MessageType.BUTTON, + lambda b: b.reply_to_message is not None, + ], + "quick_reply_without_context": [ + lambda b: b.type == MessageType.BUTTON, + lambda b: b.reply_to_message is None, + ], }, "callback_selection": { - "callback": [lambda s: s.data is not None], + "callback": [ + lambda s: s.data is not None, + lambda s: s.reply_to_message is not None, + ], "description": [lambda s: s.description is not None], + "callback_without_context": [ + lambda s: s.data is not None, + lambda s: s.reply_to_message is None, + ], }, "message_status": { "sent": [lambda s: s.status == MessageStatusType.SENT], From fbaadb69bdb9bddba5021f539ad2a3fd8898e6e0 Mon Sep 17 00:00:00 2001 From: WOUTER DURNEZ Date: Mon, 13 Jul 2026 16:07:04 +0200 Subject: [PATCH 2/2] [callback] narrow missing-context handling to CallbackButton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CallbackSelection only ever parses interactive list_reply payloads, which always carry the context field — only the button payload type (template QuickReplyButton clicks) omits it. Revert the defensive context parsing and synthetic fixture for CallbackSelection, and document on CallbackButton.reply_to_message (sync and async) that it is None specifically for template quick-reply clicks. Co-Authored-By: Claude Fable 5 --- pywa/types/callback.py | 11 ++---- pywa_async/types/callback.py | 4 +- tests/data/updates/callback_selection.json | 45 ---------------------- tests/test_updates.py | 9 +---- 4 files changed, 7 insertions(+), 62 deletions(-) diff --git a/pywa/types/callback.py b/pywa/types/callback.py index d483c472..94bcf9d3 100644 --- a/pywa/types/callback.py +++ b/pywa/types/callback.py @@ -258,7 +258,7 @@ class CallbackButton(BaseUserUpdate, Generic[_CallbackDataT]): type: The message type (:class:`MessageType.INTERACTIVE` for :class:`~pywa.types.callback.Button` presses or :class:`MessageType.BUTTON` for :class:`~pywa.types.templates.QuickReplyButton` clicks). from_user: The user who sent the message. timestamp: The timestamp when the message was sent (in UTC). - reply_to_message: The message to which this callback button is a reply to (``None`` when WhatsApp omits the ``context`` field). + reply_to_message: The message to which this callback button is a reply to. ``None`` when a template :class:`~pywa.types.templates.QuickReplyButton` is clicked (WhatsApp omits the ``context`` field for template ``button`` messages). data: The data of the button (the ``callback_data`` parameter you provided in :class:`~pywa.types.callback.Button` or :class:`~pywa.types.templates.QuickReplyButton`). title: The title of the button. shared_data: Shared data between handlers. @@ -357,14 +357,14 @@ class CallbackSelection(BaseUserUpdate, Generic[_CallbackDataT]): type: The message type (always :class:`MessageType.INTERACTIVE`). from_user: The user who sent the message. timestamp: The timestamp when the message was sent (in UTC). - reply_to_message: The message to which this callback selection is a reply to (``None`` when WhatsApp omits the ``context`` field). + reply_to_message: The message to which this callback selection is a reply to. data: The data of the selection (the ``callback_data`` parameter you provided in :class:`~pywa.types.callback.SectionRow`). title: The title of the selection. description: The description of the selection (optional). """ type: MessageType - reply_to_message: ReplyToMessage | None + reply_to_message: ReplyToMessage data: _CallbackDataT title: str description: str | None @@ -377,7 +377,6 @@ def from_update(cls, client: "WhatsApp", update: RawUpdate) -> "CallbackSelectio msg = (value := (entry := update["entry"][0])["changes"][0]["value"])[ "messages" ][0] - context = msg.get("context", {}) return cls( _client=client, raw=update, @@ -387,9 +386,7 @@ def from_update(cls, client: "WhatsApp", update: RawUpdate) -> "CallbackSelectio type=MessageType(msg["type"]), from_user=client._usr_cls.from_contact(value["contacts"][0], client=client), timestamp=helpers.timestamp_to_datetime(msg["timestamp"]), - reply_to_message=ReplyToMessage.from_dict(context) - if context.get("id") - else None, + reply_to_message=ReplyToMessage.from_dict(msg["context"]), data=msg["interactive"]["list_reply"]["id"], title=msg["interactive"]["list_reply"]["title"], description=msg["interactive"]["list_reply"].get("description"), diff --git a/pywa_async/types/callback.py b/pywa_async/types/callback.py index 0e6e5d8c..cdfae938 100644 --- a/pywa_async/types/callback.py +++ b/pywa_async/types/callback.py @@ -46,7 +46,7 @@ class CallbackButton(BaseUserUpdateAsync, _CallbackButton[_CallbackDataT]): type: The message type (:class:`MessageType.INTERACTIVE` for :class:`~pywa.types.callback.Button` presses or :class:`MessageType.BUTTON` for :class:`~pywa.types.templates.QuickReplyButton` clicks). from_user: The user who sent the message. timestamp: The timestamp when the message was sent (in UTC). - reply_to_message: The message to which this callback button is a reply to (``None`` when WhatsApp omits the ``context`` field). + reply_to_message: The message to which this callback button is a reply to. ``None`` when a template :class:`~pywa.types.templates.QuickReplyButton` is clicked (WhatsApp omits the ``context`` field for template ``button`` messages). data: The data of the button (the ``callback_data`` parameter you provided in :class:`~pywa.types.callback.Button` or :class:`~pywa.types.templates.QuickReplyButton`). title: The title of the button. shared_data: Shared data between handlers. @@ -95,7 +95,7 @@ class CallbackSelection(BaseUserUpdateAsync, _CallbackSelection[_CallbackDataT]) type: The message type (always :class:`MessageType.INTERACTIVE`). from_user: The user who sent the message. timestamp: The timestamp when the message was sent (in UTC). - reply_to_message: The message to which this callback selection is a reply to (``None`` when WhatsApp omits the ``context`` field). + reply_to_message: The message to which this callback selection is a reply to. data: The data of the selection (the ``callback_data`` parameter you provided in :class:`~pywa.types.callback.SectionRow`). title: The title of the selection. description: The description of the selection (optional). diff --git a/tests/data/updates/callback_selection.json b/tests/data/updates/callback_selection.json index 15ab6eb6..439a1e88 100644 --- a/tests/data/updates/callback_selection.json +++ b/tests/data/updates/callback_selection.json @@ -97,50 +97,5 @@ ] } ] - }, - "callback_without_context": { - "object": "whatsapp_business_account", - "entry": [ - { - "id": "1234567890987654321", - "changes": [ - { - "value": { - "messaging_product": "whatsapp", - "metadata": { - "display_phone_number": "972123456789", - "phone_number_id": "1122334455667" - }, - "contacts": [ - { - "profile": { - "name": "Test Name" - }, - "wa_id": "972987654321", - "user_id": "US.13491208655302741918", - "parent_user_id": "US.ENT.11815799212886844830" - } - ], - "messages": [ - { - "from": "972987654321", - "id": "wamid.zyzxyw", - "timestamp": "1698266905", - "type": "interactive", - "interactive": { - "type": "list_reply", - "list_reply": { - "id": "callback_data", - "title": "title" - } - } - } - ] - }, - "field": "messages" - } - ] - } - ] } } diff --git a/tests/test_updates.py b/tests/test_updates.py index 9da62f26..f7f4f3fd 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -80,15 +80,8 @@ ], }, "callback_selection": { - "callback": [ - lambda s: s.data is not None, - lambda s: s.reply_to_message is not None, - ], + "callback": [lambda s: s.data is not None], "description": [lambda s: s.description is not None], - "callback_without_context": [ - lambda s: s.data is not None, - lambda s: s.reply_to_message is None, - ], }, "message_status": { "sent": [lambda s: s.status == MessageStatusType.SENT],