diff --git a/CHANGELOG.md b/CHANGELOG.md index 46b29f0f..5802f5b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,14 @@ > NOTE: pywa follows the [semver](https://semver.org/) versioning standard. -#### 4.3.0 (2026-08-07) **Latest** +#### 4.3.1 (2026-08-17) **Latest** + +- [callback] treat missing message context as no `reply_to_message` +- [types] deprecate `caller` and `callee` properties as part of BSUID changes +- [sent_update] better recipient type resolving +- [enums] adding copy-paste-compatible repr for `UploadedBy` and `UserIdentifier` + +#### 4.3.0 (2026-08-07) - [filters] make `Filter` class generic - [signups] adding support for creating listing and updating signups diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..8835556e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,35 @@ +# Security Policy + +## Supported Versions + +Currently, only the **4.x** release line is supported with security updates. + +Versions prior to 4.0 are no longer supported due to the `BSUID` changes that introduced breaking changes to the older +codebase. We strongly encourage all users to upgrade to the latest 4.x release. + +| Version | Supported | +|---------|--------------------| +| 4.x.x | :white_check_mark: | +| < 4.0.0 | :x: | + +## Reporting a Vulnerability + +We take the security of **pywa** seriously. If you discover a security vulnerability, please do not disclose it +publicly. + +**How to report:** +Please use GitHub's Private Vulnerability Reporting feature to ensure the issue remains confidential until a fix is +released. + +1. Go to the **Security** tab of this repository and click **Report a vulnerability**. +2. Provide a clear description of the issue and steps to reproduce it. + +Alternatively, you can email reports directly to **david@davidlev.dev**. + +**What to expect:** + +* You will receive an acknowledgment of your report within 48 hours. +* We will use the private advisory to communicate, verify the issue, and work on a patch. +* Once the vulnerability is patched and published, you will be credited for the discovery. + +Please avoid opening public GitHub issues for sensitive security matters. diff --git a/pywa/__init__.py b/pywa/__init__.py index acb6685c..6a660cb4 100644 --- a/pywa/__init__.py +++ b/pywa/__init__.py @@ -9,6 +9,6 @@ from pywa.client import WhatsApp from pywa.utils import Version -__version__ = "4.3.0" +__version__ = "4.3.1" __author__ = "David Lev" __license__ = "MIT" diff --git a/pywa/server.py b/pywa/server.py index ddabaf18..1729b54b 100644 --- a/pywa/server.py +++ b/pywa/server.py @@ -287,13 +287,13 @@ def _register_routes(self: "WhatsApp") -> None: match self._server_type: case utils.CustomServerType.STARLETTE: - _logger.info("Using Starlette") + _logger.debug("Using Starlette") helpers.register_routes_starlette(wa=self) case utils.CustomServerType.FASTAPI: - _logger.info("Using FastAPI") + _logger.debug("Using FastAPI") helpers.register_routes_fastapi(wa=self) case utils.CustomServerType.FLASK: - _logger.info("Using Flask") + _logger.debug("Using Flask") helpers.register_routes_flask(wa=self) case _: raise ValueError( diff --git a/pywa/types/base_update.py b/pywa/types/base_update.py index 8861b14c..66d26ce3 100644 --- a/pywa/types/base_update.py +++ b/pywa/types/base_update.py @@ -1315,9 +1315,9 @@ class BaseUserUpdate(BaseUpdate, _ClientShortcuts, abc.ABC): """The WhatsApp Business Account ID that the update was sent to.""" @property - def sender(self) -> str | None: + def sender(self) -> None: """ - Deprecated. Use :py:attr:`~pywa.types.others.User.wa_id` instead. + Deprecated. Use ``update.from_user.wa_id`` instead. """ warnings.warn( "Deprecated. Use `update.from_user.wa_id` instead.", @@ -1327,9 +1327,9 @@ def sender(self) -> str | None: return self.from_user.wa_id @property - def recipient(self) -> str: + def recipient(self) -> None: """ - Deprecated. Use :py:attr:`~pywa.types.others.Metadata.phone_number_id` instead. + Deprecated. Use ``update.metadata.phone_number_id`` instead. """ warnings.warn( "Deprecated. Use `update.metadata.phone_number_id` instead.", diff --git a/pywa/types/callback.py b/pywa/types/callback.py index 3eee0953..94bcf9d3 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 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. """ 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, ) diff --git a/pywa/types/calls.py b/pywa/types/calls.py index fb926ef3..f06e9f91 100644 --- a/pywa/types/calls.py +++ b/pywa/types/calls.py @@ -42,10 +42,11 @@ import dataclasses import datetime +import warnings from typing import TYPE_CHECKING, Generic from .. import _helpers as helpers -from ..errors import WhatsAppError +from ..errors import PywaDeprecationWarning, WhatsAppError from .base_update import BaseUpdate, BaseUserUpdate, RawUpdate, _ClientShortcuts # noqa from .callback import CallbackData, _CallbackDataT from .others import ( @@ -67,8 +68,6 @@ class _CallShortcuts: id: str _client: WhatsApp - _internal_sender: str - _internal_recipient: str @property def message_id_to_reply(self) -> str: @@ -76,16 +75,33 @@ def message_id_to_reply(self) -> str: raise ValueError("You cannot use `message_id_to_reply` to quote a call update.") @property - def caller(self) -> str: + def caller(self) -> None: """ - The WhatsApp ID of the business phone number that initiated the call. + Deprecated. Use ``call.from_user.wa_id`` instead. """ - return self._internal_recipient + warnings.warn( + "Deprecated. Use `call.chat.id` instead.", + PywaDeprecationWarning, + stacklevel=2, + ) + return ( + self.from_user.wa_id if hasattr(self, "from_user") else self.from_phone_id # ty:ignore[unresolved-attribute] + ) @property - def callee(self) -> str: - """The WhatsApp ID of the user that received the call.""" - return self._internal_sender + def callee(self) -> None: + """ + Deprecated. Use ``call.metadata.phone_number_id`` instead. + """ + warnings.warn( + "Deprecated. Use `call.metadata.phone_number_id` instead.", + PywaDeprecationWarning, + stacklevel=2, + ) + + return ( + self.metadata.phone_number_id if hasattr(self, "metadata") else self.chat.id # ty:ignore[unresolved-attribute] + ) def pre_accept(self, *, sdp: SessionDescription) -> SuccessResult: """ diff --git a/pywa/types/media.py b/pywa/types/media.py index ea7723b6..5be2ceb4 100644 --- a/pywa/types/media.py +++ b/pywa/types/media.py @@ -43,6 +43,9 @@ class UploadedBy(enum.Enum): BUSINESS = BUSINESS_UPLOADS_EXPIRATION_DAYS USER = USER_UPLOADS_EXPIRATION_DAYS + def __repr__(self) -> str: + return f"UploadedBy.{self.name}" + class _MediaActions: _client: WhatsApp diff --git a/pywa/types/sent_update.py b/pywa/types/sent_update.py index 06e42448..3caceba3 100644 --- a/pywa/types/sent_update.py +++ b/pywa/types/sent_update.py @@ -69,12 +69,12 @@ def from_recipient(cls, recipient: str | int) -> RecipientType: if _BSUID_RE.fullmatch(s): return cls.BSUID - if re.sub(_PHONE_CLEAN_RE, "", s).isdigit(): - return RecipientType.PHONE_NUMBER - - if re.match(_PARENT_BSUID_RE, s): + if _PARENT_BSUID_RE.fullmatch(s): return cls.PARENT_BSUID + if _PHONE_CLEAN_RE.sub("", s).isdigit(): + return cls.PHONE_NUMBER + return cls.GROUP_ID def to_chat_type(self) -> ChatType: @@ -171,24 +171,24 @@ class SentMessage(_SentUpdate, _PinUnpinActions): ) @property - def recipient(self) -> str: - """Deprecated. Use :py:attr:`~pywa.types.SentMessage.chat.id` / :py:attr:`~pywa.types.SentMessage.to` instead.""" + def recipient(self) -> None: + """Deprecated. Use ``sent.chat.id`` or ``sent.to`` instead.""" warnings.warn( "Deprecated. Use `sent.chat.id` or `sent.to` instead.", PywaDeprecationWarning, stacklevel=2, ) - return self._internal_sender + return self.chat.id @property - def sender(self) -> str: - """Deprecated. Use :py:attr:`~pywa.types.SentMessage.from_phone_id` instead.""" + def sender(self) -> None: + """Deprecated. Use ``sent.from_phone_id`` instead.""" warnings.warn( "Deprecated. Use `sent.from_phone_id` instead.", PywaDeprecationWarning, stacklevel=2, ) - return self._internal_recipient + return self.from_phone_id @classmethod def from_sent_update( diff --git a/pywa/utils.py b/pywa/utils.py index a04e07f9..213e05ac 100644 --- a/pywa/utils.py +++ b/pywa/utils.py @@ -416,6 +416,9 @@ class UserIdentifier(enum.Enum): def user_attr(self) -> str: return self.value + def __repr__(self) -> str: + return f"UserIdentifier.{self.name}" + def start_ngrok_tunnel( *, diff --git a/pywa_async/types/callback.py b/pywa_async/types/callback.py index bf5b3a74..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. + 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. 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/test_types.py b/tests/test_types.py index 8e235d92..3c1b7576 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -312,10 +312,10 @@ def test_initiated_call(): recipient_type=RecipientType.WA_ID, callee="16506666666", ) - assert c.caller == wa.phone_id + assert c.from_phone_id == wa.phone_id assert c.chat.id == "16506666666" assert c.to == "16506666666" - assert c.callee == "16506666666" + assert c.chat.id == "16506666666" assert c == InitiatedCall( _client=wa, _recipient_type=RecipientType.WA_ID, diff --git a/tests/test_updates.py b/tests/test_updates.py index 59edce54..f7f4f3fd 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -66,8 +66,18 @@ "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],