diff --git a/docs/source/content/updates/common_methods.rst b/docs/source/content/updates/common_methods.rst index 3468993..31ee3e9 100644 --- a/docs/source/content/updates/common_methods.rst +++ b/docs/source/content/updates/common_methods.rst @@ -7,6 +7,6 @@ Common methods :members: id, raw, timestamp, stop_handling, continue_handling .. autoclass:: BaseUserUpdate() - :members: sender, message_id_to_reply, + :members: sender, recipient, message_id_to_reply, reply_text, reply_image, reply_video, reply_audio, reply_document, reply_location, reply_contact, reply_sticker, reply_template, reply_catalog, reply_product, reply_products, react, unreact, mark_as_read diff --git a/docs/source/content/updates/overview.rst b/docs/source/content/updates/overview.rst index 7416013..274aed6 100644 --- a/docs/source/content/updates/overview.rst +++ b/docs/source/content/updates/overview.rst @@ -100,7 +100,9 @@ All user-related-updates have common methods and properties: * - Method / Property - Description * - :attr:`~BaseUserUpdate.sender` - - The user id who sent the update + - The phone id who sent the update + * - :attr:`~BaseUserUpdate.recipient` + - The phone id who received the update * - :attr:`~BaseUserUpdate.message_id_to_reply` - The message id to reply to * - :meth:`~BaseUserUpdate.reply_text` diff --git a/pywa/api.py b/pywa/api.py index 22bcf6c..4f28430 100644 --- a/pywa/api.py +++ b/pywa/api.py @@ -17,13 +17,11 @@ class WhatsAppCloudApi: def __init__( self, - phone_id: str, token: str, session: requests.Session, base_url: str, api_version: float, ): - self.phone_id = phone_id self._session = self._setup_session(session, token) self._base_url = f"{base_url}/v{api_version}" @@ -42,7 +40,7 @@ def _setup_session(session, token: str) -> requests.Session: return session def __str__(self) -> str: - return f"WhatsAppCloudApi(phone_id={self.phone_id!r})" + return f"WhatsAppCloudApi(session={self._session})" def __repr__(self) -> str: return self.__str__() @@ -180,6 +178,7 @@ def set_phone_callback_url( self, callback_url: str, verify_token: str, + phone_id: str, ) -> dict[str, bool]: """ Set an alternate callback URL on the business phone number. @@ -187,6 +186,7 @@ def set_phone_callback_url( - Read more at `developers.facebook.com `_. Args: + phone_id: The ID of the phone number to set the callback URL on. callback_url: The URL to set. verify_token: The verify token to challenge the webhook with. @@ -195,7 +195,7 @@ def set_phone_callback_url( """ return self._make_request( method="POST", - endpoint=f"/{self.phone_id}/", + endpoint=f"/{phone_id}/", json={ "webhook_configuration": { "override_callback_uri": callback_url, @@ -206,6 +206,7 @@ def set_phone_callback_url( def set_business_public_key( self, + phone_id: str, public_key: str, ) -> dict[str, bool]: """ @@ -220,6 +221,7 @@ def set_business_public_key( } Args: + phone_id: The ID of the phone number to set the public key on. public_key: The public key to set. Returns: @@ -227,12 +229,13 @@ def set_business_public_key( """ return self._make_request( method="POST", - endpoint=f"/{self.phone_id}/whatsapp_business_encryption", + endpoint=f"/{phone_id}/whatsapp_business_encryption", data={"business_public_key": public_key}, ) def upload_media( self, + phone_id: str, media: bytes, mime_type: str, filename: str, @@ -249,6 +252,7 @@ def upload_media( } Args: + phone_id: The ID of the phone number to upload the media to. media: media bytes or open(path, 'rb') object mime_type: The type of the media file filename: The name of the media file @@ -257,7 +261,7 @@ def upload_media( """ return self._make_request( method="POST", - endpoint=f"/{self.phone_id}/media", + endpoint=f"/{phone_id}/media", files=[("file", (filename, media, mime_type))], data={"messaging_product": "whatsapp"}, ) @@ -332,7 +336,6 @@ def send_raw_request(self, method: str, endpoint: str, **kwargs) -> Any: Send a raw request to WhatsApp Cloud API. - Use this method if you want to send a request that is not yet supported by pywa. - - The endpoint can contain path parameters (e.g. ``/{phone_id}/messages/``). only ``phone_id`` is supported. - Every request will automatically include the ``Authorization`` and ``Content-Type`` headers. you can override them by passing them in ``kwargs`` (headers={...}). Args: @@ -359,12 +362,13 @@ def send_raw_request(self, method: str, endpoint: str, **kwargs) -> Any: """ return self._make_request( method=method, - endpoint=endpoint.format(phone_id=self.phone_id), + endpoint=endpoint, **kwargs, ) def send_message( self, + sender: str, to: str, typ: str, msg: dict[str, str | list[str]] | tuple[dict], @@ -377,6 +381,7 @@ def send_message( - Read more at `developers.facebook.com `_. Args: + sender: The phone id to send the message from. to: The phone number to send the message to. typ: The type of the message (e.g. ``text``, ``image``, etc.). msg: The message object to send. @@ -399,12 +404,15 @@ def send_message( data["biz_opaque_callback_data"] = biz_opaque_callback_data return self._make_request( method="POST", - endpoint=f"/{self.phone_id}/messages", + endpoint=f"/{sender}/messages", json=data, ) def register_phone_number( - self, pin: str, data_localization_region: str = None + self, + phone_id: str, + pin: str, + data_localization_region: str = None, ) -> dict[str, bool]: """ Register a phone number. @@ -416,13 +424,18 @@ def register_phone_number( 'success': True, } + Args: + phone_id: The ID of the phone number to register. + pin: The pin to register the phone number with. + data_localization_region: The region to localize the data (Value must be a 2-letter ISO 3166 country code (e.g. IN) indicating the country where you want data-at-rest to be stored). + Returns: The success of the operation. """ return self._make_request( method="POST", - endpoint=f"/{self.phone_id}/register", + endpoint=f"/{phone_id}/register", json={ "messaging_product": "whatsapp", "pin": pin, @@ -434,7 +447,11 @@ def register_phone_number( }, ) - def mark_message_as_read(self, message_id: str) -> dict[str, bool]: + def mark_message_as_read( + self, + phone_id: str, + message_id: str, + ) -> dict[str, bool]: """ Mark a message as read. @@ -447,6 +464,7 @@ def mark_message_as_read(self, message_id: str) -> dict[str, bool]: } Args: + phone_id: The ID of the phone number that message belongs to. message_id: The ID of the message to mark as read. Returns: @@ -454,7 +472,7 @@ def mark_message_as_read(self, message_id: str) -> dict[str, bool]: """ return self._make_request( method="POST", - endpoint=f"/{self.phone_id}/messages", + endpoint=f"/{phone_id}/messages", json={ "messaging_product": "whatsapp", "status": "read", @@ -463,7 +481,9 @@ def mark_message_as_read(self, message_id: str) -> dict[str, bool]: ) def get_business_phone_number( - self, fields: tuple[str, ...] | None = None + self, + phone_id: str, + fields: tuple[str, ...] | None = None, ) -> dict[str, Any]: """ Get the business phone number. @@ -483,6 +503,7 @@ def get_business_phone_number( } Args: + phone_id: The ID of the phone number to get. fields: The fields to get. Returns: @@ -490,12 +511,13 @@ def get_business_phone_number( """ return self._make_request( method="GET", - endpoint=f"/{self.phone_id}", + endpoint=f"/{phone_id}", params={"fields": ",".join(fields)} if fields else None, ) def update_conversational_automation( self, + phone_id: str, enable_welcome_message: bool | None = None, prompts: tuple[dict] | None = None, commands: str | None = None, @@ -512,6 +534,7 @@ def update_conversational_automation( } Args: + phone_id: The ID of the phone number to update. enable_welcome_message: Enable the welcome message. prompts: The prompts (ice breakers) to set. commands: The commands to set. @@ -521,7 +544,7 @@ def update_conversational_automation( """ return self._make_request( method="POST", - endpoint=f"/{self.phone_id}/conversational_automation", + endpoint=f"/{phone_id}/conversational_automation", params={ k: v for k, v in { @@ -535,6 +558,7 @@ def update_conversational_automation( def get_business_profile( self, + phone_id: str, fields: tuple[str, ...] | None = None, ) -> dict[str, list[dict[str, str | list[str]]]]: """ @@ -561,16 +585,17 @@ def get_business_profile( } Args: + phone_id: The ID of the phone number to get. fields: The fields to get. """ return self._make_request( method="GET", - endpoint=f"/{self.phone_id}/whatsapp_business_profile", + endpoint=f"/{phone_id}/whatsapp_business_profile", params={"fields": ",".join(fields)} if fields else None, ) def update_business_profile( - self, data: dict[str, str | list[str]] + self, phone_id: str, data: dict[str, str | list[str]] ) -> dict[str, bool]: """ Update the business profile. @@ -578,6 +603,7 @@ def update_business_profile( - Read more at `developers.facebook.com `_. Args: + phone_id: The ID of the phone number to update. data: The data to update the business profile with. Return example:: @@ -589,11 +615,11 @@ def update_business_profile( data.update(messaging_product="whatsapp") return self._make_request( method="POST", - endpoint=f"/{self.phone_id}/whatsapp_business_profile", + endpoint=f"/{phone_id}/whatsapp_business_profile", json=data, ) - def get_commerce_settings(self) -> dict[str, list[dict]]: + def get_commerce_settings(self, phone_id: str) -> dict[str, list[dict]]: """ Get the commerce settings of the business catalog. @@ -610,19 +636,30 @@ def get_commerce_settings(self) -> dict[str, list[dict]]: } ] } + + Args: + phone_id: The ID of the phone number to get. + + Returns: + The commerce settings of the business catalog. """ return self._make_request( method="GET", - endpoint=f"/{self.phone_id}/whatsapp_commerce_settings", + endpoint=f"/{phone_id}/whatsapp_commerce_settings", ) - def update_commerce_settings(self, data: dict) -> dict[str, bool]: + def update_commerce_settings( + self, + phone_id: str, + data: dict, + ) -> dict[str, bool]: """ Change the commerce settings of the business catalog. - Read more at `developers.facebook.com `_. Args: + phone_id: The ID of the phone number to update. data: The data to update the commerce settings with. Return example:: @@ -633,7 +670,7 @@ def update_commerce_settings(self, data: dict) -> dict[str, bool]: """ return self._make_request( method="POST", - endpoint=f"/{self.phone_id}/whatsapp_commerce_settings", + endpoint=f"/{phone_id}/whatsapp_commerce_settings", params=data, ) diff --git a/pywa/client.py b/pywa/client.py index 4bef46e..defb762 100644 --- a/pywa/client.py +++ b/pywa/client.py @@ -66,8 +66,8 @@ class WhatsApp(Server, HandlerDecorators): def __init__( self, - phone_id: str | int, - token: str, + phone_id: str | int | None = None, + token: str = None, base_url: str = "https://graph.facebook.com", api_version: str | int @@ -121,7 +121,8 @@ def __init__( >>> flask_app.run(port=8000) Args: - phone_id: The Phone number ID (Not the phone number itself, the ID can be found in the App dashboard). + phone_id: The Phone number ID to send messages from (if you manage multiple WhatsApp business accounts + (e.g. partner solutions), you can specify the phone ID when sending messages, optional). token: The token of the WhatsApp business account (In production, you should `use permanent token `_). base_url: The base URL of the WhatsApp API (Do not change unless you know what you're doing). @@ -158,9 +159,10 @@ def __init__( continue_handling: Whether to continue handling updates after a handler has been found (default: ``True``). skip_duplicate_updates: Whether to skip duplicate updates (default: ``True``). """ - if not phone_id or not token: - raise ValueError("phone_id and token must be provided.") - + if not token: + raise ValueError( + "`token` must be provided in order to use the WhatsApp client." + ) try: utils.Version.GRAPH_API.validate_min_version(str(api_version)) except ValueError: @@ -171,8 +173,11 @@ def __init__( stacklevel=2, ) - self._phone_id = str(phone_id) - self.filter_updates = filter_updates + self.phone_id = str(phone_id) if phone_id is not None else None + if phone_id is None: + self.filter_updates = False + else: + self.filter_updates = filter_updates self.business_account_id = ( str(business_account_id) if business_account_id is not None else None ) @@ -209,7 +214,6 @@ def _setup_api( api_version: float, ) -> None: self.api = WhatsAppCloudApi( - phone_id=self.phone_id, token=token, session=session or requests.Session(), base_url=base_url, @@ -222,17 +226,6 @@ def __str__(self) -> str: def __repr__(self) -> str: return self.__str__() - @property - def phone_id(self) -> str: - """The phone ID of the WhatsApp account.""" - return self._phone_id - - @phone_id.setter - def phone_id(self, value: str | int) -> None: - """Update the phone ID in API calls.""" - self._phone_id = str(value) - self.api.phone_id = self._phone_id - @property def token(self) -> str: """The token of the WhatsApp account.""" @@ -299,8 +292,8 @@ def add_handlers(self, *handlers: Handler): """ if self._server is utils.MISSING: raise ValueError( - "You must initialize the WhatsApp client with an web server" - " (Flask or FastAPI) in order to handle incoming updates." + "You must initialize the WhatsApp client with an web app" + " (Flask or FastAPI or custom server by setting `server` to None) in order to handle incoming updates." ) for handler in handlers: if isinstance(handler, FlowRequestHandler): @@ -374,6 +367,7 @@ def send_message( reply_to_message_id: str | None = None, keyboard: None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send a message to a WhatsApp user. @@ -485,10 +479,12 @@ def send_message( reply_to_message_id: The message ID to reply to (optional). keyboard: Deprecated and will be removed in a future version, use ``buttons`` instead. tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent message. """ + sender = _resolve_phone_id_param(self, sender, "sender") if keyboard is not None: buttons = keyboard warnings.simplefilter("always", DeprecationWarning) @@ -498,9 +494,9 @@ def send_message( category=DeprecationWarning, stacklevel=2, ) - if not buttons: return self.api.send_message( + sender=sender, to=str(to), typ=MessageType.TEXT, msg={"body": text, "preview_url": preview_url}, @@ -509,6 +505,7 @@ def send_message( )["messages"][0]["id"] typ, kb = _resolve_buttons_param(buttons) return self.api.send_message( + sender=sender, to=str(to), typ=MessageType.INTERACTIVE, msg=_get_interactive_msg( @@ -540,6 +537,7 @@ def send_image( reply_to_message_id: str | None = None, mime_type: str | None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send an image to a WhatsApp user. @@ -568,11 +566,12 @@ def send_image( or file path that does not have an extension). body: Deprecated and will be removed in a future version, use ``caption`` instead. tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent image message. """ - + sender = _resolve_phone_id_param(self, sender, "sender") if body is not None: caption = body warnings.simplefilter("always", DeprecationWarning) @@ -582,9 +581,9 @@ def send_image( category=DeprecationWarning, stacklevel=2, ) - is_url, image = _resolve_media_param( wa=self, + phone_id=sender, media=image, mime_type=mime_type, media_type=MessageType.IMAGE, @@ -592,6 +591,7 @@ def send_image( ) if not buttons: return self.api.send_message( + sender=sender, to=str(to), typ=MessageType.IMAGE, msg=_get_media_msg( @@ -607,6 +607,7 @@ def send_image( ) typ, kb = _resolve_buttons_param(buttons) return self.api.send_message( + sender=sender, to=str(to), typ=MessageType.INTERACTIVE, msg=_get_interactive_msg( @@ -636,6 +637,7 @@ def send_video( reply_to_message_id: str | None = None, mime_type: str | None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send a video to a WhatsApp user. @@ -665,11 +667,12 @@ def send_video( or file path that does not have an extension). body: Deprecated and will be removed in a future version, use ``caption`` instead. tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent video. """ - + sender = _resolve_phone_id_param(self, sender, "sender") if body is not None: caption = body warnings.simplefilter("always", DeprecationWarning) @@ -679,16 +682,17 @@ def send_video( category=DeprecationWarning, stacklevel=2, ) - is_url, video = _resolve_media_param( wa=self, media=video, mime_type=mime_type, media_type=MessageType.VIDEO, filename=None, + phone_id=sender, ) if not buttons: return self.api.send_message( + sender=sender, to=str(to), typ=MessageType.VIDEO, msg=_get_media_msg( @@ -704,6 +708,7 @@ def send_video( ) typ, kb = _resolve_buttons_param(buttons) return self.api.send_message( + sender=sender, to=str(to), typ=MessageType.INTERACTIVE, msg=_get_interactive_msg( @@ -734,6 +739,7 @@ def send_document( reply_to_message_id: str | None = None, mime_type: str | None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send a document to a WhatsApp user. @@ -765,11 +771,13 @@ def send_document( object, or file path that does not have an extension). body: Deprecated and will be removed in a future version, use ``caption`` instead. tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent document. """ + sender = _resolve_phone_id_param(self, sender, "sender") if body is not None: caption = body warnings.simplefilter("always", DeprecationWarning) @@ -779,16 +787,17 @@ def send_document( category=DeprecationWarning, stacklevel=2, ) - is_url, document = _resolve_media_param( wa=self, media=document, mime_type=mime_type, filename=filename, media_type=None, + phone_id=sender, ) if not buttons: return self.api.send_message( + sender=sender, to=str(to), typ=MessageType.DOCUMENT, msg=_get_media_msg( @@ -803,12 +812,13 @@ def send_document( raise ValueError( "A caption must be provided when sending a document with buttons." ) - type_, kb = _resolve_buttons_param(buttons) + typ, kb = _resolve_buttons_param(buttons) return self.api.send_message( + sender=sender, to=str(to), typ=MessageType.INTERACTIVE, msg=_get_interactive_msg( - typ=type_, + typ=typ, action=kb, header={ "type": MessageType.DOCUMENT, @@ -830,6 +840,7 @@ def send_audio( audio: str | pathlib.Path | bytes | BinaryIO, mime_type: str | None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send an audio file to a WhatsApp user. @@ -848,18 +859,23 @@ def send_audio( mime_type: The mime type of the audio file (optional, required when sending an audio file as bytes or a file object, or file path that does not have an extension). tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent audio file. """ + + sender = _resolve_phone_id_param(self, sender, "sender") is_url, audio = _resolve_media_param( wa=self, media=audio, mime_type=mime_type, media_type=MessageType.AUDIO, filename=None, + phone_id=sender, ) return self.api.send_message( + sender=sender, to=str(to), typ=MessageType.AUDIO, msg=_get_media_msg( @@ -875,6 +891,7 @@ def send_sticker( sticker: str | pathlib.Path | bytes | BinaryIO, mime_type: str | None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send a sticker to a WhatsApp user. @@ -895,18 +912,23 @@ def send_sticker( mime_type: The mime type of the sticker (optional, required when sending a sticker as bytes or a file object, or file path that does not have an extension). tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent message. """ + + sender = _resolve_phone_id_param(self, sender, "sender") is_url, sticker = _resolve_media_param( wa=self, media=sticker, mime_type=mime_type, filename=None, media_type=MessageType.STICKER, + phone_id=sender, ) return self.api.send_message( + sender=sender, to=str(to), typ=MessageType.STICKER, msg=_get_media_msg( @@ -922,6 +944,7 @@ def send_reaction( emoji: str, message_id: str, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ React to a message with an emoji. @@ -947,12 +970,14 @@ def send_reaction( emoji: The emoji to react with. message_id: The message ID to react to. tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the reaction (You can't use this message id to remove the reaction or perform any other action on it. instead, use the message ID of the message you reacted to). """ return self.api.send_message( + sender=_resolve_phone_id_param(self, sender, "sender"), to=str(to), typ=MessageType.REACTION, msg={"emoji": emoji, "message_id": message_id}, @@ -964,6 +989,7 @@ def remove_reaction( to: str | int, message_id: str, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Remove reaction from a message. @@ -987,12 +1013,14 @@ def remove_reaction( to: The phone ID of the WhatsApp user. message_id: The message ID to remove the reaction from. tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the reaction (You can't use this message id to re-react or perform any other action on it. instead, use the message ID of the message you unreacted to). """ return self.api.send_message( + sender=_resolve_phone_id_param(self, sender, "sender"), to=str(to), typ=MessageType.REACTION, msg={"emoji": "", "message_id": message_id}, @@ -1007,6 +1035,7 @@ def send_location( name: str | None = None, address: str | None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send a location to a WhatsApp user. @@ -1029,11 +1058,13 @@ def send_location( name: The name of the location (optional). address: The address of the location (optional). tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent location. """ return self.api.send_message( + sender=_resolve_phone_id_param(self, sender, "sender"), to=str(to), typ=MessageType.LOCATION, msg={ @@ -1046,7 +1077,11 @@ def send_location( )["messages"][0]["id"] def request_location( - self, to: str | int, text: str, tracker: CallbackDataT | None = None + self, + to: str | int, + text: str, + tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send a text message with button to request the user's location. @@ -1055,11 +1090,13 @@ def request_location( to: The phone ID of the WhatsApp user. text: The text to send with the button. tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent message. """ return self.api.send_message( + sender=_resolve_phone_id_param(self, sender, "sender"), to=str(to), typ=MessageType.INTERACTIVE, msg=_get_interactive_msg( @@ -1076,6 +1113,7 @@ def send_contact( contact: Contact | Iterable[Contact], reply_to_message_id: str | None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send a contact/s to a WhatsApp user. @@ -1099,11 +1137,13 @@ def send_contact( contact: The contact/s to send. reply_to_message_id: The message ID to reply to (optional). tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent message. """ return self.api.send_message( + sender=_resolve_phone_id_param(self, sender, "sender"), to=str(to), typ=MessageType.CONTACTS, msg=tuple(c.to_dict() for c in contact) @@ -1121,6 +1161,7 @@ def send_catalog( thumbnail_product_sku: str | None = None, reply_to_message_id: str | None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send the business catalog to a WhatsApp user. @@ -1143,11 +1184,13 @@ def send_catalog( not provided, the first item in the catalog will be used). reply_to_message_id: The message ID to reply to (optional). tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent message. """ return self.api.send_message( + sender=_resolve_phone_id_param(self, sender, "sender"), to=str(to), typ=MessageType.INTERACTIVE, msg=_get_interactive_msg( @@ -1180,6 +1223,7 @@ def send_product( footer: str | None = None, reply_to_message_id: str | None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send a product from a business catalog to a WhatsApp user. @@ -1206,11 +1250,13 @@ def send_product( footer: Text to appear in the footer of the message (optional, up to 60 characters). reply_to_message_id: The message ID to reply to (optional). tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent message. """ return self.api.send_message( + sender=_resolve_phone_id_param(self, sender, "sender"), to=str(to), typ=MessageType.INTERACTIVE, msg=_get_interactive_msg( @@ -1236,6 +1282,7 @@ def send_products( footer: str | None = None, reply_to_message_id: str | None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send products from a business catalog to a WhatsApp user. @@ -1274,11 +1321,13 @@ def send_products( footer: Text to appear in the footer of the message (optional, up to 60 characters). reply_to_message_id: The message ID to reply to (optional). tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent message. """ return self.api.send_message( + sender=_resolve_phone_id_param(self, sender, "sender"), to=str(to), typ=MessageType.INTERACTIVE, msg=_get_interactive_msg( @@ -1301,6 +1350,7 @@ def send_products( def mark_message_as_read( self, message_id: str, + sender: str | int | None = None, ) -> bool: """ Mark a message as read. @@ -1313,11 +1363,15 @@ def mark_message_as_read( Args: message_id: The message ID to mark as read. + sender: The phone ID (optional, if not provided, the client's phone ID will be used). Returns: Whether the message was marked as read. """ - return self.api.mark_message_as_read(message_id=message_id)["success"] + return self.api.mark_message_as_read( + phone_id=_resolve_phone_id_param(self, sender, "sender"), + message_id=message_id, + )["success"] def upload_media( self, @@ -1325,6 +1379,7 @@ def upload_media( mime_type: str | None = None, filename: str | None = None, dl_session: requests.Session | None = None, + phone_id: str | None = None, ) -> str: """ Upload media to WhatsApp servers. @@ -1343,6 +1398,7 @@ def upload_media( filename: The file name of the media (required if media is bytes). dl_session: A requests session to use when downloading the media from a URL (optional, if not provided, a new session will be created). + phone_id: The phone ID to upload the media to (optional, if not provided, the client's phone ID will be used). Returns: The media ID. @@ -1353,6 +1409,8 @@ def upload_media( - If provided ``media`` is URL and the URL is invalid or media cannot be downloaded. - If provided ``media`` is bytes and ``filename`` or ``mime_type`` is not provided. """ + phone_id = _resolve_phone_id_param(self, phone_id, "phone_id") + if isinstance(media, (str, pathlib.Path)): if (path := pathlib.Path(media)).is_file(): file, filename, mime_type = ( @@ -1383,6 +1441,7 @@ def upload_media( if mime_type is None: raise ValueError("`mime_type` is required if media is bytes") return self.api.upload_media( + phone_id=phone_id, filename=filename, media=file, mime_type=mime_type, @@ -1459,7 +1518,10 @@ def download_media( f.write(content) return path - def get_business_phone_number(self) -> BusinessPhoneNumber: + def get_business_phone_number( + self, + phone_id: str | None = None, + ) -> BusinessPhoneNumber: """ Get the phone number of the WhatsApp Business account. @@ -1468,14 +1530,18 @@ def get_business_phone_number(self) -> BusinessPhoneNumber: >>> wa = WhatsApp(...) >>> wa.get_business_phone_number() + Args: + phone_id: The phone ID to get the phone number from (optional, if not provided, the client's phone ID will be used). + Returns: The phone number object. """ return BusinessPhoneNumber.from_dict( data=self.api.get_business_phone_number( + phone_id=_resolve_phone_id_param(self, phone_id, "phone_id"), fields=tuple( field.name for field in dataclasses.fields(BusinessPhoneNumber) - ) + ), ) ) @@ -1484,6 +1550,7 @@ def update_conversational_automation( enable_chat_opened: bool, ice_breakers: Iterable[str] | None = None, commands: Iterable[Command] | None = None, + phone_id: str | None = None, ) -> bool: """ Update the conversational automation settings of the WhatsApp Business account. @@ -1499,17 +1566,22 @@ def update_conversational_automation( first time you chat with a user. For example, `Plan a trip` or `Create a workout plan`. commands: Commands are text strings that WhatsApp users can see by typing a forward slash in a message thread with your business. + phone_id: The phone ID to update the conversational automation settings for (optional, if not provided, the client's phone ID will be used). Returns: Whether the conversational automation settings were updated. """ return self.api.update_conversational_automation( + phone_id=_resolve_phone_id_param(self, phone_id, "phone_id"), enable_welcome_message=enable_chat_opened, prompts=tuple(ice_breakers) if ice_breakers else None, commands=json.dumps([c.to_dict() for c in commands]) if commands else None, )["success"] - def get_business_profile(self) -> BusinessProfile: + def get_business_profile( + self, + phone_id: str | None = None, + ) -> BusinessProfile: """ Get the business profile of the WhatsApp Business account. @@ -1518,11 +1590,15 @@ def get_business_profile(self) -> BusinessProfile: >>> wa = WhatsApp(...) >>> wa.get_business_profile() + Args: + phone_id: The phone ID to get the business profile from (optional, if not provided, the client's phone ID will be used). + Returns: The business profile. """ return BusinessProfile.from_dict( data=self.api.get_business_profile( + phone_id=_resolve_phone_id_param(self, phone_id, "phone_id"), fields=( "about", "address", @@ -1531,19 +1607,21 @@ def get_business_profile(self) -> BusinessProfile: "profile_picture_url", "websites", "vertical", - ) + ), )["data"][0] ) def set_business_public_key( self, public_key: str, + phone_id: str | None = None, ) -> bool: """ Set the business public key of the WhatsApp Business account (required for end-to-end encryption in flows) Args: public_key: An public 2048-bit RSA Key in PEM format. + phone_id: The phone ID to set the business public key for (optional, if not provided, the client's phone ID will be used). Example: @@ -1556,7 +1634,10 @@ def set_business_public_key( Returns: Whether the business public key was set. """ - return self.api.set_business_public_key(public_key=public_key)["success"] + return self.api.set_business_public_key( + phone_id=_resolve_phone_id_param(self, phone_id, "phone_id"), + public_key=public_key, + )["success"] def update_business_profile( self, @@ -1567,6 +1648,7 @@ def update_business_profile( profile_picture_handle: str | None = utils.MISSING, industry: Industry | None = utils.MISSING, websites: Iterable[str] | None = utils.MISSING, + phone_id: str | None = None, ) -> bool: """ Update the business profile of the WhatsApp Business account. @@ -1600,6 +1682,7 @@ def update_business_profile( websites: The URLs associated with the business. For instance, a website, Facebook Page, or Instagram. (You must include the ``http://`` or ``https://`` portion of the URL. There is a maximum of 2 websites with a maximum of 256 characters each.) + phone_id: The phone ID to update the business profile for (optional, if not provided, the client's phone ID will be used). Returns: Whether the business profile was updated. @@ -1617,9 +1700,14 @@ def update_business_profile( }.items() if value is not utils.MISSING } - return self.api.update_business_profile(data)["success"] + return self.api.update_business_profile( + phone_id=_resolve_phone_id_param(self, phone_id, "phone_id"), data=data + )["success"] - def get_commerce_settings(self) -> CommerceSettings: + def get_commerce_settings( + self, + phone_id: str | None = None, + ) -> CommerceSettings: """ Get the commerce settings of the WhatsApp Business account. @@ -1632,13 +1720,16 @@ def get_commerce_settings(self) -> CommerceSettings: The commerce settings. """ return CommerceSettings.from_dict( - data=self.api.get_commerce_settings()["data"][0] + data=self.api.get_commerce_settings( + phone_id=_resolve_phone_id_param(self, phone_id, "phone_id"), + )["data"][0] ) def update_commerce_settings( self, is_catalog_visible: bool = None, is_cart_enabled: bool = None, + phone_id: str | None = None, ) -> bool: """ Update the commerce settings of the WhatsApp Business account. @@ -1654,6 +1745,7 @@ def update_commerce_settings( Args: is_catalog_visible: Whether the catalog is visible (optional). is_cart_enabled: Whether the cart is enabled (optional). + phone_id: The phone ID to update the commerce settings for (optional, if not provided, the client's phone ID will be used). Returns: Whether the commerce settings were updated. @@ -1671,28 +1763,15 @@ def update_commerce_settings( } if not data: raise ValueError("At least one argument must be provided") - return self.api.update_commerce_settings(data)["success"] - - @staticmethod - def _validate_waba_id_provided(func) -> Callable: - """Internal decorator to validate the waba id is provided.""" - - @functools.wraps(func) - def wrapper(self, *args, **kwargs): - if self.business_account_id is None: - raise ValueError( - f"You must provide the WhatsApp business account ID when using the `{func.__name__}` method.\n" - ">> You can provide it when initializing the client or by setting the `business_account_id` attr." - ) - return func(self, *args, **kwargs) - - return wrapper + return self.api.update_commerce_settings( + phone_id=_resolve_phone_id_param(self, phone_id, "phone_id"), data=data + )["success"] - @_validate_waba_id_provided def create_template( self, template: NewTemplate, placeholder: tuple[str, str] | None = None, + waba_id: str | None = None, ) -> TemplateResponse: """ `'Create Templates' on developers.facebook.com @@ -1759,13 +1838,14 @@ def create_template( Args: template: The template to create. placeholder: The placeholders start & end (optional, default: ``('{', '}')``)). + waba_id: The WhatsApp Business account ID (Overrides the client's business account ID). Returns: The template created response. containing the template ID, status and category. """ return TemplateResponse( **self.api.create_template( - waba_id=self.business_account_id, + waba_id=_resolve_waba_id_param(self, waba_id), template=template.to_dict(placeholder=placeholder), ) ) @@ -1776,6 +1856,7 @@ def send_template( template: Template, reply_to_message_id: str | None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send a template to a WhatsApp user. @@ -1823,6 +1904,7 @@ def send_template( template: The template to send. reply_to_message_id: The message ID to reply to (optional). tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent template. @@ -1830,6 +1912,7 @@ def send_template( Raises: """ + sender = _resolve_phone_id_param(self, sender, "sender") is_url = None match type(template.header): case Template.Image: @@ -1839,6 +1922,7 @@ def send_template( mime_type=template.header.mime_type, media_type=MessageType.IMAGE, filename=None, + phone_id=sender, ) case Template.Document: is_url, template.header.document = _resolve_media_param( @@ -1847,6 +1931,7 @@ def send_template( mime_type="application/pdf", # the only supported mime type in template's document header filename=template.header.filename, media_type=None, + phone_id=sender, ) case Template.Video: is_url, template.header.video = _resolve_media_param( @@ -1855,8 +1940,10 @@ def send_template( mime_type=template.header.mime_type, media_type=MessageType.VIDEO, filename=None, + phone_id=sender, ) return self.api.send_message( + sender=sender, to=str(to), typ="template", msg=template.to_dict(is_header_url=is_url), @@ -1864,13 +1951,13 @@ def send_template( biz_opaque_callback_data=_resolve_tracker_param(tracker), )["messages"][0]["id"] - @_validate_waba_id_provided def create_flow( self, name: str, categories: Iterable[FlowCategory | str], clone_flow_id: str | None = None, endpoint_uri: str | None = None, + waba_id: str | None = None, ) -> str: """ Create a flow. @@ -1886,6 +1973,7 @@ def create_flow( clone_flow_id: The flow ID to clone (optional). endpoint_uri: The URL of the FlowJSON Endpoint. Starting from Flow 3.0 this property should be specified only gere. Do not provide this field if you are cloning a Flow with version below 3.0. + waba_id: The WhatsApp Business account ID (Overrides the client's business account ID). Example: @@ -1907,7 +1995,7 @@ def create_flow( categories=tuple(map(str, categories)), clone_flow_id=clone_flow_id, endpoint_uri=endpoint_uri, - waba_id=self.business_account_id, + waba_id=_resolve_waba_id_param(self, waba_id), )["id"] def update_flow_metadata( @@ -2116,10 +2204,10 @@ def get_flow( client=self, ) - @_validate_waba_id_provided def get_flows( self, invalidate_preview: bool = True, + waba_id: str | None = None, ) -> tuple[FlowDetails, ...]: """ Get the details of all flows belonging to the WhatsApp Business account. @@ -2128,6 +2216,7 @@ def get_flows( Args: invalidate_preview: Whether to invalidate the preview (optional, default: True). + waba_id: The WhatsApp Business account ID (Overrides the client's business account ID). Returns: The details of all flows. @@ -2135,7 +2224,7 @@ def get_flows( return tuple( FlowDetails.from_dict(data=data, client=self) for data in self.api.get_flows( - waba_id=self.business_account_id, + waba_id=_resolve_waba_id_param(self, waba_id), fields=_get_flow_fields(invalidate_preview=invalidate_preview), )["data"] ) @@ -2161,7 +2250,10 @@ def get_flow_assets( ) def register_phone_number( - self, pin: int | str, data_localization_region: str | None = None + self, + pin: int | str, + data_localization_region: str | None = None, + phone_id: str | None = None, ) -> bool: """ Register a Business Phone Number @@ -2183,13 +2275,15 @@ def register_phone_number( business phone number. Value must be a 2-letter ISO 3166 country code (e.g. ``IN``) indicating the country where you want data-at-rest to be stored. + phone_id: The phone ID to register (optional, if not provided, the client's phone ID will be used). Returns: The success of the registration. """ - return self.api.register_phone_number( - pin=str(pin), data_localization_region=data_localization_region + phone_id=_resolve_phone_id_param(self, phone_id, "phone_id"), + pin=str(pin), + data_localization_region=data_localization_region, )["success"] @@ -2226,6 +2320,7 @@ def _resolve_media_param( MessageType.IMAGE, MessageType.VIDEO, MessageType.AUDIO, MessageType.STICKER ] | None, + phone_id: str, ) -> tuple[bool, str]: """ Internal method to resolve the ``media`` parameter. Returns a tuple of (``is_url``, ``media_id_or_url``). @@ -2237,6 +2332,7 @@ def _resolve_media_param( return False, media # assume it's a media ID # assume its bytes or a file path return False, wa.upload_media( + phone_id=phone_id, media=media, mime_type=mime_type, filename=_media_types_default_filenames.get(media_type, filename), @@ -2248,6 +2344,28 @@ def _resolve_tracker_param(tracker: CallbackDataT | None) -> str | None: return tracker.to_str() if isinstance(tracker, CallbackData) else tracker +def _resolve_phone_id_param(wa: WhatsApp, phone_id: str | None, arg_name: str) -> str: + """Internal method to resolve the `phone_id` parameter.""" + if phone_id is not None: + return phone_id + if wa.phone_id is not None: + return wa.phone_id + raise ValueError( + f"When initializing WhatsApp without phone_id, {arg_name} must be provided." + ) + + +def _resolve_waba_id_param(wa: WhatsApp, waba_id: str | None) -> str: + """Internal method to resolve the `waba_id` parameter.""" + if waba_id is not None: + return waba_id + if wa.business_account_id is not None: + return wa.business_account_id + raise ValueError( + "When initializing WhatsApp without business_account_id, waba_id must be provided." + ) + + def _get_interactive_msg( typ: InteractiveType, action: dict[str, Any], diff --git a/pywa/server.py b/pywa/server.py index f281438..bc04bbe 100644 --- a/pywa/server.py +++ b/pywa/server.py @@ -247,36 +247,34 @@ async def my_webhook_handler(req: web.Request) -> web.Response: def _register_routes(self: "WhatsApp") -> None: match self._server_type: case utils.ServerType.FLASK: - import flask - - if utils.is_installed("asgiref"): # flask[async] - _logger.info("Using Flask with ASGI") - - @self._server.route(self._webhook_endpoint, methods=["GET"]) - @utils.rename_func(f"({self.phone_id})") - async def flask_challenge() -> tuple[str, int]: - return await self.webhook_challenge_handler( - vt=flask.request.args.get(utils.HUB_VT), - ch=flask.request.args.get(utils.HUB_CH), - ) - - @self._server.route(self._webhook_endpoint, methods=["POST"]) - @utils.rename_func(f"({self.phone_id})") - async def flask_webhook() -> tuple[str, int]: - return await self.webhook_update_handler(flask.request.json) - - else: # flask + if not utils.is_installed("asgiref"): # flask[async] raise ValueError( "Flask with ASGI is required to handle incoming updates asynchronously. Please install " """the `asgiref` package (`pip install "flask[async]"` / `pip install "asgiref"`)""" ) + _logger.info("Using Flask with ASGI") + import flask + + @self._server.route(self._webhook_endpoint, methods=["GET"]) + @utils.rename_func(f"({self._webhook_endpoint})") + async def flask_challenge() -> tuple[str, int]: + return await self.webhook_challenge_handler( + vt=flask.request.args.get(utils.HUB_VT), + ch=flask.request.args.get(utils.HUB_CH), + ) + + @self._server.route(self._webhook_endpoint, methods=["POST"]) + @utils.rename_func(f"({self._webhook_endpoint})") + async def flask_webhook() -> tuple[str, int]: + return await self.webhook_update_handler(flask.request.json) + case utils.ServerType.FASTAPI: _logger.info("Using FastAPI") import fastapi @self._server.get(self._webhook_endpoint) - @utils.rename_func(f"({self.phone_id})") + @utils.rename_func(f"({self._webhook_endpoint})") async def fastapi_challenge(req: fastapi.Request) -> fastapi.Response: content, status_code = await self.webhook_challenge_handler( vt=req.query_params.get(utils.HUB_VT), @@ -285,7 +283,7 @@ async def fastapi_challenge(req: fastapi.Request) -> fastapi.Response: return fastapi.Response(content=content, status_code=status_code) @self._server.post(self._webhook_endpoint) - @utils.rename_func(f"({self.phone_id})") + @utils.rename_func(f"({self._webhook_endpoint})") async def fastapi_webhook(req: fastapi.Request) -> fastapi.Response: content, status_code = await self.webhook_update_handler( await req.json() @@ -365,36 +363,37 @@ def _get_handler(self: "WhatsApp", update: dict) -> type[Handler] | None: # The `messages` field needs to be handled differently because it can be a message, button, selection, or status # This check must return handler or None *BEFORE* getting the handler from the dict!! if field == "messages": - if not self.filter_updates or ( - value["metadata"]["phone_number_id"] == self.phone_id + if self.filter_updates and ( + value["metadata"]["phone_number_id"] != self.phone_id ): - if "messages" in value: - msg_type = value["messages"][0]["type"] - if msg_type == MessageType.INTERACTIVE: - try: - interactive_type = value["messages"][0]["interactive"][ - "type" - ] - except KeyError: # value with errors, when a user tries to send the interactive msg again - return MessageHandler - if ( - handler := _INTERACTIVE_TYPES.get(interactive_type) - ) is not None: - return handler - _logger.warning( - "Webhook ('%s'): Unknown interactive message type: %s. Falling back to MessageHandler.", - self._webhook_endpoint, - interactive_type, - ) - return _MESSAGE_TYPES.get(msg_type, MessageHandler) - - elif "statuses" in value: # status - return MessageStatusHandler - _logger.warning( - "Webhook ('%s'): Unknown message type: %s", - self._webhook_endpoint, - value, - ) + return None + + if "messages" in value: + msg_type = value["messages"][0]["type"] + if msg_type == MessageType.INTERACTIVE: + try: + interactive_type = value["messages"][0]["interactive"]["type"] + except KeyError: # value with errors, when a user tries to send the interactive msg again + return MessageHandler + if ( + handler := _INTERACTIVE_TYPES.get(interactive_type) + ) is not None: + return handler + _logger.warning( + "Webhook ('%s'): Unknown interactive message type: %s. Falling back to MessageHandler.", + self._webhook_endpoint, + interactive_type, + ) + return _MESSAGE_TYPES.get(msg_type, MessageHandler) + + elif "statuses" in value: # status + return MessageStatusHandler + + _logger.warning( + "Webhook ('%s'): Unknown message type: %s", + self._webhook_endpoint, + value, + ) return None # noinspection PyProtectedMember @@ -555,19 +554,19 @@ def _register_flow_endpoint_callback( match self._server_type: case utils.ServerType.FLASK: - import flask - - if utils.is_installed("asgiref"): - - @self._server.route(endpoint, methods=["POST"]) - @utils.rename_func(f"({endpoint})") - async def flask_flow() -> tuple[str, int]: - return await callback_wrapper(flask.request.json) - else: + if not utils.is_installed("asgiref"): raise ValueError( "Flask with ASGI is required to handle incoming updates asynchronously. Please install " """the `asgiref` package (`pip install "flask[async]"` / `pip install "asgiref"`)""" ) + + import flask + + @self._server.route(endpoint, methods=["POST"]) + @utils.rename_func(f"({endpoint})") + async def flask_flow() -> tuple[str, int]: + return await callback_wrapper(flask.request.json) + case utils.ServerType.FASTAPI: import fastapi diff --git a/pywa/types/base_update.py b/pywa/types/base_update.py index b8ae984..18fd750 100644 --- a/pywa/types/base_update.py +++ b/pywa/types/base_update.py @@ -178,11 +178,19 @@ def from_user(self) -> User: ... @property def sender(self) -> str: """ - The WhatsApp ID of the sender. + The WhatsApp ID of the sender who sent the message. - Shortcut for ``.from_user.wa_id``. """ return self.from_user.wa_id + @property + def recipient(self) -> str: + """ + The WhatsApp ID which the message was sent to. + - Shortcut for ``.metadata.phone_number_id``. + """ + return self.metadata.phone_number_id + @property def message_id_to_reply(self) -> str: """ @@ -285,6 +293,7 @@ def reply_text( The ID of the sent reply. """ return self._client.send_message( + sender=self.recipient, to=self.sender, text=text, header=header, @@ -340,6 +349,7 @@ def reply_image( The ID of the sent reply. """ return self._client.send_image( + sender=self.recipient, to=self.sender, image=image, caption=caption, @@ -394,6 +404,7 @@ def reply_video( The ID of the sent reply. """ return self._client.send_video( + sender=self.recipient, to=self.sender, video=video, caption=caption, @@ -451,6 +462,7 @@ def reply_document( The ID of the sent reply. """ return self._client.send_document( + sender=self.recipient, to=self.sender, document=document, filename=filename, @@ -489,6 +501,7 @@ def reply_audio( The ID of the sent message. """ return self._client.send_audio( + sender=self.recipient, to=self.sender, audio=audio, mime_type=mime_type, @@ -523,6 +536,7 @@ def reply_sticker( The ID of the sent reply. """ return self._client.send_sticker( + sender=self.recipient, to=self.sender, sticker=sticker, mime_type=mime_type, @@ -562,6 +576,7 @@ def reply_location( The ID of the sent reply. """ return self._client.send_location( + sender=self.recipient, to=self.sender, latitude=latitude, longitude=longitude, @@ -603,6 +618,7 @@ def reply_contact( The ID of the sent reply. """ return self._client.send_contact( + sender=self.recipient, to=self.sender, contact=contact, reply_to_message_id=self.message_id_to_reply if quote else None, @@ -626,6 +642,7 @@ def react(self, emoji: str, tracker: CallbackDataT | None = None) -> str: The ID of the sent reaction. """ return self._client.send_reaction( + sender=self.recipient, to=self.sender, emoji=emoji, message_id=self.message_id_to_reply, @@ -648,7 +665,10 @@ def unreact(self, tracker: CallbackDataT | None = None) -> str: The ID of the sent unreaction. """ return self._client.remove_reaction( - to=self.sender, message_id=self.message_id_to_reply, tracker=tracker + sender=self.recipient, + to=self.sender, + message_id=self.message_id_to_reply, + tracker=tracker, ) def reply_catalog( @@ -683,6 +703,7 @@ def reply_catalog( The ID of the sent reply. """ return self._client.send_catalog( + sender=self.recipient, to=self.sender, body=body, footer=footer, @@ -719,6 +740,7 @@ def reply_product( The ID of the sent reply. """ return self._client.send_product( + sender=self.recipient, to=self.sender, catalog_id=catalog_id, sku=sku, @@ -779,6 +801,7 @@ def reply_products( The ID of the sent reply. """ return self._client.send_products( + sender=self.recipient, to=self.sender, catalog_id=catalog_id, product_sections=product_sections, @@ -847,6 +870,7 @@ def reply_template( """ return self._client.send_template( + sender=self.recipient, to=self.sender, template=template, reply_to_message_id=quote if quote else None, @@ -861,4 +885,6 @@ def mark_as_read(self) -> bool: Returns: Whether it was successful. """ - return self._client.mark_message_as_read(message_id=self.message_id_to_reply) + return self._client.mark_message_as_read( + sender=self.recipient, message_id=self.message_id_to_reply + ) diff --git a/pywa/types/message.py b/pywa/types/message.py index 56a6a1f..d5616e7 100644 --- a/pywa/types/message.py +++ b/pywa/types/message.py @@ -240,6 +240,7 @@ def copy( reply_to_message_id: str = None, keyboard: None = None, tracker: str | None = None, + sender: str | int | None = None, ) -> str: """ Send the message to another user. @@ -259,6 +260,7 @@ def copy( preview_url: Whether to show a preview of the URL in the message (if any). keyboard: Deprecated and will be removed in a future version, use ``buttons`` instead. tracker: The track data of the message. + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The ID of the sent message. @@ -270,6 +272,7 @@ def copy( match self.type: case MessageType.TEXT: return self._client.send_message( + sender=sender, to=to, text=self.text, preview_url=preview_url, @@ -282,6 +285,7 @@ def copy( ) case MessageType.DOCUMENT: return self._client.send_document( + sender=sender, to=to, document=self.document.id, filename=self.document.filename, @@ -294,6 +298,7 @@ def copy( ) case MessageType.IMAGE: return self._client.send_image( + sender=sender, to=to, image=self.image.id, caption=self.caption, @@ -305,6 +310,7 @@ def copy( ) case MessageType.VIDEO: return self._client.send_video( + sender=sender, to=to, video=self.video.id, caption=self.caption, @@ -316,10 +322,11 @@ def copy( ) case MessageType.STICKER: return self._client.send_sticker( - to=to, sticker=self.sticker.id, tracker=tracker + sender=sender, to=to, sticker=self.sticker.id, tracker=tracker ) case MessageType.LOCATION: return self._client.send_location( + sender=sender, to=to, latitude=self.location.latitude, longitude=self.location.longitude, @@ -329,10 +336,11 @@ def copy( ) case MessageType.AUDIO: return self._client.send_audio( - to=to, audio=self.audio.id, tracker=tracker + sender=sender, to=to, audio=self.audio.id, tracker=tracker ) case MessageType.CONTACTS: return self._client.send_contact( + sender=sender, to=to, contact=self.contacts, reply_to_message_id=reply_to_message_id, @@ -344,6 +352,7 @@ def copy( "You need to provide `reply_to_message_id` in order to `copy` a reaction" ) return self._client.send_reaction( + sender=sender, to=to, message_id=reply_to_message_id, emoji=self.reaction.emoji or "", @@ -351,6 +360,7 @@ def copy( case MessageType.ORDER: if len(self.order.products) == 1: return self._client.send_product( + sender=sender, to=to, catalog_id=self.order.catalog_id, sku=self.order.products[0].sku, @@ -359,6 +369,7 @@ def copy( reply_to_message_id=reply_to_message_id, ) return self._client.send_products( + sender=sender, to=to, catalog_id=self.order.catalog_id, product_sections=( @@ -374,6 +385,7 @@ def copy( ) case MessageType.SYSTEM: return self._client.send_message( + sender=sender, to=to, text=self.system.body, header=header, diff --git a/pywa_async/api.py b/pywa_async/api.py index 46f12ed..3fd24d9 100644 --- a/pywa_async/api.py +++ b/pywa_async/api.py @@ -16,7 +16,6 @@ class WhatsAppCloudApiAsync(WhatsAppCloudApi): def __init__( self, - phone_id: str, token: str, session: httpx.AsyncClient, session_sync: httpx.Client, @@ -24,7 +23,6 @@ def __init__( api_version: float, ): super().__init__( - phone_id=phone_id, token=token, session=session, # noqa base_url=base_url, @@ -33,7 +31,7 @@ def __init__( self._session_sync = self._setup_session(session_sync, token) def __str__(self): - return f"WhatsAppCloudApiAsync(phone_id={self.phone_id!r})" + return f"WhatsAppCloudApiAsync(session={self._session!r})" async def _make_request(self, method: str, endpoint: str, **kwargs) -> dict | list: """ @@ -176,6 +174,7 @@ async def set_phone_callback_url( self, callback_url: str, verify_token: str, + phone_id: str, ) -> dict[str, bool]: """ Set an alternate callback URL on the business phone number. @@ -183,6 +182,7 @@ async def set_phone_callback_url( - Read more at `developers.facebook.com `_. Args: + phone_id: The ID of the phone number to set the callback URL on. callback_url: The URL to set. verify_token: The verify token to challenge the webhook with. @@ -191,7 +191,7 @@ async def set_phone_callback_url( """ return await self._make_request( method="POST", - endpoint=f"/{self.phone_id}/", + endpoint=f"/{phone_id}/", json={ "webhook_configuration": { "override_callback_uri": callback_url, @@ -202,6 +202,7 @@ async def set_phone_callback_url( async def set_business_public_key( self, + phone_id: str, public_key: str, ) -> dict[str, bool]: """ @@ -216,6 +217,7 @@ async def set_business_public_key( } Args: + phone_id: The ID of the phone number to set the public key on. public_key: The public key to set. Returns: @@ -223,12 +225,13 @@ async def set_business_public_key( """ return await self._make_request( method="POST", - endpoint=f"/{self.phone_id}/whatsapp_business_encryption", + endpoint=f"/{phone_id}/whatsapp_business_encryption", data={"business_public_key": public_key}, ) async def upload_media( self, + phone_id: str, media: bytes, mime_type: str, filename: str, @@ -245,6 +248,7 @@ async def upload_media( } Args: + phone_id: The ID of the phone number to upload the media to. media: media bytes or open(path, 'rb') object mime_type: The type of the media file filename: The name of the media file @@ -256,7 +260,7 @@ async def upload_media( try: res = await session.request( method="POST", - url=f"{self._base_url}/{self.phone_id}/media", + url=f"{self._base_url}/{phone_id}/media", files={ "file": (filename, media, mime_type), "messaging_product": (None, "whatsapp"), @@ -341,7 +345,6 @@ async def send_raw_request(self, method: str, endpoint: str, **kwargs) -> Any: Send a raw request to WhatsApp Cloud API. - Use this method if you want to send a request that is not yet supported by pywa. - - The endpoint can contain path parameters (e.g. ``/{phone_id}/messages/``). only ``phone_id`` is supported. - Every request will automatically include the ``Authorization`` and ``Content-Type`` headers. you can override them by passing them in ``kwargs`` (headers={...}). Args: @@ -368,12 +371,13 @@ async def send_raw_request(self, method: str, endpoint: str, **kwargs) -> Any: """ return await self._make_request( method=method, - endpoint=endpoint.format(phone_id=self.phone_id), + endpoint=endpoint, **kwargs, ) async def send_message( self, + sender: str, to: str, typ: str, msg: dict[str, str | list[str]] | tuple[dict], @@ -386,6 +390,7 @@ async def send_message( - Read more at `developers.facebook.com `_. Args: + sender: The phone id to send the message from. to: The phone number to send the message to. typ: The type of the message (e.g. ``text``, ``image``, etc.). msg: The message object to send. @@ -408,12 +413,15 @@ async def send_message( data["biz_opaque_callback_data"] = biz_opaque_callback_data return await self._make_request( method="POST", - endpoint=f"/{self.phone_id}/messages", + endpoint=f"/{sender}/messages", json=data, ) async def register_phone_number( - self, pin: str, data_localization_region: str = None + self, + phone_id: str, + pin: str, + data_localization_region: str = None, ) -> dict[str, bool]: """ Register a phone number. @@ -425,13 +433,18 @@ async def register_phone_number( 'success': True, } + Args: + phone_id: The ID of the phone number to register. + pin: The pin to register the phone number with. + data_localization_region: The region to localize the data (Value must be a 2-letter ISO 3166 country code (e.g. IN) indicating the country where you want data-at-rest to be stored). + Returns: The success of the operation. """ return await self._make_request( method="POST", - endpoint=f"/{self.phone_id}/register", + endpoint=f"/{phone_id}/register", json={ "messaging_product": "whatsapp", "pin": pin, @@ -443,7 +456,11 @@ async def register_phone_number( }, ) - async def mark_message_as_read(self, message_id: str) -> dict[str, bool]: + async def mark_message_as_read( + self, + phone_id: str, + message_id: str, + ) -> dict[str, bool]: """ Mark a message as read. @@ -456,6 +473,7 @@ async def mark_message_as_read(self, message_id: str) -> dict[str, bool]: } Args: + phone_id: The ID of the phone number that message belongs to. message_id: The ID of the message to mark as read. Returns: @@ -463,7 +481,7 @@ async def mark_message_as_read(self, message_id: str) -> dict[str, bool]: """ return await self._make_request( method="POST", - endpoint=f"/{self.phone_id}/messages", + endpoint=f"/{phone_id}/messages", json={ "messaging_product": "whatsapp", "status": "read", @@ -472,7 +490,9 @@ async def mark_message_as_read(self, message_id: str) -> dict[str, bool]: ) async def get_business_phone_number( - self, fields: tuple[str, ...] | None = None + self, + phone_id: str, + fields: tuple[str, ...] | None = None, ) -> dict[str, Any]: """ Get the business phone number. @@ -492,6 +512,7 @@ async def get_business_phone_number( } Args: + phone_id: The ID of the phone number to get. fields: The fields to get. Returns: @@ -499,12 +520,13 @@ async def get_business_phone_number( """ return await self._make_request( method="GET", - endpoint=f"/{self.phone_id}", + endpoint=f"/{phone_id}", params={"fields": ",".join(fields)} if fields else None, ) async def update_conversational_automation( self, + phone_id: str, enable_welcome_message: bool | None = None, prompts: tuple[dict] | None = None, commands: str | None = None, @@ -521,6 +543,7 @@ async def update_conversational_automation( } Args: + phone_id: The ID of the phone number to update. enable_welcome_message: Enable the welcome message. prompts: The prompts (ice breakers) to set. commands: The commands to set. @@ -530,7 +553,7 @@ async def update_conversational_automation( """ return await self._make_request( method="POST", - endpoint=f"/{self.phone_id}/conversational_automation", + endpoint=f"/{phone_id}/conversational_automation", params={ k: v for k, v in { @@ -544,6 +567,7 @@ async def update_conversational_automation( async def get_business_profile( self, + phone_id: str, fields: tuple[str, ...] | None = None, ) -> dict[str, list[dict[str, str | list[str]]]]: """ @@ -570,16 +594,17 @@ async def get_business_profile( } Args: + phone_id: The ID of the phone number to get. fields: The fields to get. """ return await self._make_request( method="GET", - endpoint=f"/{self.phone_id}/whatsapp_business_profile", + endpoint=f"/{phone_id}/whatsapp_business_profile", params={"fields": ",".join(fields)} if fields else None, ) async def update_business_profile( - self, data: dict[str, str | list[str]] + self, phone_id: str, data: dict[str, str | list[str]] ) -> dict[str, bool]: """ Update the business profile. @@ -587,6 +612,7 @@ async def update_business_profile( - Read more at `developers.facebook.com `_. Args: + phone_id: The ID of the phone number to update. data: The data to update the business profile with. Return example:: @@ -598,11 +624,11 @@ async def update_business_profile( data.update(messaging_product="whatsapp") return await self._make_request( method="POST", - endpoint=f"/{self.phone_id}/whatsapp_business_profile", + endpoint=f"/{phone_id}/whatsapp_business_profile", json=data, ) - async def get_commerce_settings(self) -> dict[str, list[dict]]: + async def get_commerce_settings(self, phone_id: str) -> dict[str, list[dict]]: """ Get the commerce settings of the business catalog. @@ -619,19 +645,30 @@ async def get_commerce_settings(self) -> dict[str, list[dict]]: } ] } + + Args: + phone_id: The ID of the phone number to get. + + Returns: + The commerce settings of the business catalog. """ return await self._make_request( method="GET", - endpoint=f"/{self.phone_id}/whatsapp_commerce_settings", + endpoint=f"/{phone_id}/whatsapp_commerce_settings", ) - async def update_commerce_settings(self, data: dict) -> dict[str, bool]: + async def update_commerce_settings( + self, + phone_id: str, + data: dict, + ) -> dict[str, bool]: """ Change the commerce settings of the business catalog. - Read more at `developers.facebook.com `_. Args: + phone_id: The ID of the phone number to update. data: The data to update the commerce settings with. Return example:: @@ -642,7 +679,7 @@ async def update_commerce_settings(self, data: dict) -> dict[str, bool]: """ return await self._make_request( method="POST", - endpoint=f"/{self.phone_id}/whatsapp_commerce_settings", + endpoint=f"/{phone_id}/whatsapp_commerce_settings", params=data, ) diff --git a/pywa_async/client.py b/pywa_async/client.py index 320e967..91f4487 100644 --- a/pywa_async/client.py +++ b/pywa_async/client.py @@ -9,6 +9,8 @@ WhatsApp as _WhatsApp, _resolve_buttons_param, _resolve_tracker_param, + _resolve_waba_id_param, + _resolve_phone_id_param, _get_interactive_msg, _get_media_msg, _get_flow_fields, @@ -89,8 +91,8 @@ class WhatsApp(_WhatsApp): def __init__( self, - phone_id: str | int, - token: str, + phone_id: str | int | None = None, + token: str = None, *, session: httpx.AsyncClient | None = None, session_sync: httpx.Client | None = None, @@ -152,7 +154,8 @@ def __init__( uvicorn main:fastapi_app --reload Args: - phone_id: The Phone number ID (Not the phone number itself, the ID can be found in the App dashboard). + phone_id: The Phone number ID to send messages from (if you manage multiple WhatsApp business accounts + (e.g. partner solutions), you can specify the phone ID when sending messages, optional). token: The token of the WhatsApp business account (In production, you should `use permanent token `_). base_url: The base URL of the WhatsApp API (Do not change unless you know what you're doing). @@ -239,7 +242,6 @@ def _setup_api( api_version: float, ) -> None: self.api = WhatsAppCloudApiAsync( - phone_id=self.phone_id, token=token, session=session or httpx.AsyncClient(), session_sync=self._session_sync or httpx.Client(), @@ -268,6 +270,7 @@ async def send_message( reply_to_message_id: str | None = None, keyboard: None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send a message to a WhatsApp user. @@ -379,10 +382,12 @@ async def send_message( reply_to_message_id: The message ID to reply to (optional). keyboard: Deprecated and will be removed in a future version, use ``buttons`` instead. tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent message. """ + sender = _resolve_phone_id_param(self, sender, "sender") if keyboard is not None: buttons = keyboard warnings.simplefilter("always", DeprecationWarning) @@ -392,10 +397,10 @@ async def send_message( category=DeprecationWarning, stacklevel=2, ) - if not buttons: return ( await self.api.send_message( + sender=sender, to=str(to), typ=MessageType.TEXT, msg={"body": text, "preview_url": preview_url}, @@ -406,6 +411,7 @@ async def send_message( typ, kb = _resolve_buttons_param(buttons) return ( await self.api.send_message( + sender=sender, to=str(to), typ=MessageType.INTERACTIVE, msg=_get_interactive_msg( @@ -438,6 +444,7 @@ async def send_image( reply_to_message_id: str | None = None, mime_type: str | None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send an image to a WhatsApp user. @@ -466,11 +473,12 @@ async def send_image( or file path that does not have an extension). body: Deprecated and will be removed in a future version, use ``caption`` instead. tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent image message. """ - + sender = _resolve_phone_id_param(self, sender, "sender") if body is not None: caption = body warnings.simplefilter("always", DeprecationWarning) @@ -480,9 +488,9 @@ async def send_image( category=DeprecationWarning, stacklevel=2, ) - is_url, image = await _resolve_media_param( wa=self, + phone_id=sender, media=image, mime_type=mime_type, media_type=MessageType.IMAGE, @@ -491,6 +499,7 @@ async def send_image( if not buttons: return ( await self.api.send_message( + sender=sender, to=str(to), typ=MessageType.IMAGE, msg=_get_media_msg( @@ -508,6 +517,7 @@ async def send_image( typ, kb = _resolve_buttons_param(buttons) return ( await self.api.send_message( + sender=sender, to=str(to), typ=MessageType.INTERACTIVE, msg=_get_interactive_msg( @@ -538,6 +548,7 @@ async def send_video( reply_to_message_id: str | None = None, mime_type: str | None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send a video to a WhatsApp user. @@ -567,11 +578,12 @@ async def send_video( or file path that does not have an extension). body: Deprecated and will be removed in a future version, use ``caption`` instead. tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent video. """ - + sender = _resolve_phone_id_param(self, sender, "sender") if body is not None: caption = body warnings.simplefilter("always", DeprecationWarning) @@ -581,17 +593,18 @@ async def send_video( category=DeprecationWarning, stacklevel=2, ) - is_url, video = await _resolve_media_param( wa=self, media=video, mime_type=mime_type, media_type=MessageType.VIDEO, filename=None, + phone_id=sender, ) if not buttons: return ( await self.api.send_message( + sender=sender, to=str(to), typ=MessageType.VIDEO, msg=_get_media_msg( @@ -609,6 +622,7 @@ async def send_video( typ, kb = _resolve_buttons_param(buttons) return ( await self.api.send_message( + sender=sender, to=str(to), typ=MessageType.INTERACTIVE, msg=_get_interactive_msg( @@ -640,6 +654,7 @@ async def send_document( reply_to_message_id: str | None = None, mime_type: str | None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send a document to a WhatsApp user. @@ -671,11 +686,13 @@ async def send_document( object, or file path that does not have an extension). body: Deprecated and will be removed in a future version, use ``caption`` instead. tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent document. """ + sender = _resolve_phone_id_param(self, sender, "sender") if body is not None: caption = body warnings.simplefilter("always", DeprecationWarning) @@ -685,17 +702,18 @@ async def send_document( category=DeprecationWarning, stacklevel=2, ) - is_url, document = await _resolve_media_param( wa=self, media=document, mime_type=mime_type, filename=filename, media_type=None, + phone_id=sender, ) if not buttons: return ( await self.api.send_message( + sender=sender, to=str(to), typ=MessageType.DOCUMENT, msg=_get_media_msg( @@ -711,13 +729,14 @@ async def send_document( raise ValueError( "A caption must be provided when sending a document with buttons." ) - type_, kb = _resolve_buttons_param(buttons) + typ, kb = _resolve_buttons_param(buttons) return ( await self.api.send_message( + sender=sender, to=str(to), typ=MessageType.INTERACTIVE, msg=_get_interactive_msg( - typ=type_, + typ=typ, action=kb, header={ "type": MessageType.DOCUMENT, @@ -740,6 +759,7 @@ async def send_audio( audio: str | pathlib.Path | bytes | BinaryIO, mime_type: str | None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send an audio file to a WhatsApp user. @@ -758,19 +778,24 @@ async def send_audio( mime_type: The mime type of the audio file (optional, required when sending an audio file as bytes or a file object, or file path that does not have an extension). tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent audio file. """ + + sender = _resolve_phone_id_param(self, sender, "sender") is_url, audio = await _resolve_media_param( wa=self, media=audio, mime_type=mime_type, media_type=MessageType.AUDIO, filename=None, + phone_id=sender, ) return ( await self.api.send_message( + sender=sender, to=str(to), typ=MessageType.AUDIO, msg=_get_media_msg( @@ -787,6 +812,7 @@ async def send_sticker( sticker: str | pathlib.Path | bytes | BinaryIO, mime_type: str | None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send a sticker to a WhatsApp user. @@ -807,19 +833,24 @@ async def send_sticker( mime_type: The mime type of the sticker (optional, required when sending a sticker as bytes or a file object, or file path that does not have an extension). tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent message. """ + + sender = _resolve_phone_id_param(self, sender, "sender") is_url, sticker = await _resolve_media_param( wa=self, media=sticker, mime_type=mime_type, filename=None, media_type=MessageType.STICKER, + phone_id=sender, ) return ( await self.api.send_message( + sender=sender, to=str(to), typ=MessageType.STICKER, msg=_get_media_msg( @@ -836,6 +867,7 @@ async def send_reaction( emoji: str, message_id: str, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ React to a message with an emoji. @@ -861,6 +893,7 @@ async def send_reaction( emoji: The emoji to react with. message_id: The message ID to react to. tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the reaction (You can't use this message id to remove the reaction or perform any other @@ -868,6 +901,7 @@ async def send_reaction( """ return ( await self.api.send_message( + sender=_resolve_phone_id_param(self, sender, "sender"), to=str(to), typ=MessageType.REACTION, msg={"emoji": emoji, "message_id": message_id}, @@ -880,6 +914,7 @@ async def remove_reaction( to: str | int, message_id: str, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Remove reaction from a message. @@ -903,6 +938,7 @@ async def remove_reaction( to: The phone ID of the WhatsApp user. message_id: The message ID to remove the reaction from. tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the reaction (You can't use this message id to re-react or perform any other action on it. @@ -910,6 +946,7 @@ async def remove_reaction( """ return ( await self.api.send_message( + sender=_resolve_phone_id_param(self, sender, "sender"), to=str(to), typ=MessageType.REACTION, msg={"emoji": "", "message_id": message_id}, @@ -925,6 +962,7 @@ async def send_location( name: str | None = None, address: str | None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send a location to a WhatsApp user. @@ -947,12 +985,14 @@ async def send_location( name: The name of the location (optional). address: The address of the location (optional). tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent location. """ return ( await self.api.send_message( + sender=_resolve_phone_id_param(self, sender, "sender"), to=str(to), typ=MessageType.LOCATION, msg={ @@ -966,7 +1006,11 @@ async def send_location( )["messages"][0]["id"] async def request_location( - self, to: str | int, text: str, tracker: CallbackDataT | None = None + self, + to: str | int, + text: str, + tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send a text message with button to request the user's location. @@ -975,12 +1019,14 @@ async def request_location( to: The phone ID of the WhatsApp user. text: The text to send with the button. tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent message. """ return ( await self.api.send_message( + sender=_resolve_phone_id_param(self, sender, "sender"), to=str(to), typ=MessageType.INTERACTIVE, msg=_get_interactive_msg( @@ -998,13 +1044,14 @@ async def send_contact( contact: Contact | Iterable[Contact], reply_to_message_id: str | None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send a contact/s to a WhatsApp user. Example: - >>> from pywa_async.types import Contact + >>> from pywa.types import Contact >>> wa = WhatsApp(...) >>> wa.send_contact( ... to='1234567890', @@ -1021,12 +1068,14 @@ async def send_contact( contact: The contact/s to send. reply_to_message_id: The message ID to reply to (optional). tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent message. """ return ( await self.api.send_message( + sender=_resolve_phone_id_param(self, sender, "sender"), to=str(to), typ=MessageType.CONTACTS, msg=tuple(c.to_dict() for c in contact) @@ -1045,6 +1094,7 @@ async def send_catalog( thumbnail_product_sku: str | None = None, reply_to_message_id: str | None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send the business catalog to a WhatsApp user. @@ -1067,12 +1117,14 @@ async def send_catalog( not provided, the first item in the catalog will be used). reply_to_message_id: The message ID to reply to (optional). tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent message. """ return ( await self.api.send_message( + sender=_resolve_phone_id_param(self, sender, "sender"), to=str(to), typ=MessageType.INTERACTIVE, msg=_get_interactive_msg( @@ -1106,6 +1158,7 @@ async def send_product( footer: str | None = None, reply_to_message_id: str | None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send a product from a business catalog to a WhatsApp user. @@ -1132,12 +1185,14 @@ async def send_product( footer: Text to appear in the footer of the message (optional, up to 60 characters). reply_to_message_id: The message ID to reply to (optional). tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent message. """ return ( await self.api.send_message( + sender=_resolve_phone_id_param(self, sender, "sender"), to=str(to), typ=MessageType.INTERACTIVE, msg=_get_interactive_msg( @@ -1164,6 +1219,7 @@ async def send_products( footer: str | None = None, reply_to_message_id: str | None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send products from a business catalog to a WhatsApp user. @@ -1171,7 +1227,7 @@ async def send_products( Example: - >>> from pywa_async.types import ProductsSection + >>> from pywa.types import ProductsSection >>> wa = WhatsApp(...) >>> wa.send_products( ... to='1234567890', @@ -1202,12 +1258,14 @@ async def send_products( footer: Text to appear in the footer of the message (optional, up to 60 characters). reply_to_message_id: The message ID to reply to (optional). tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent message. """ return ( await self.api.send_message( + sender=_resolve_phone_id_param(self, sender, "sender"), to=str(to), typ=MessageType.INTERACTIVE, msg=_get_interactive_msg( @@ -1231,6 +1289,7 @@ async def send_products( async def mark_message_as_read( self, message_id: str, + sender: str | int | None = None, ) -> bool: """ Mark a message as read. @@ -1243,11 +1302,17 @@ async def mark_message_as_read( Args: message_id: The message ID to mark as read. + sender: The phone ID (optional, if not provided, the client's phone ID will be used). Returns: Whether the message was marked as read. """ - return (await self.api.mark_message_as_read(message_id=message_id))["success"] + return ( + await self.api.mark_message_as_read( + phone_id=_resolve_phone_id_param(self, sender, "sender"), + message_id=message_id, + ) + )["success"] async def upload_media( self, @@ -1255,6 +1320,7 @@ async def upload_media( mime_type: str | None = None, filename: str | None = None, dl_session: httpx.AsyncClient | None = None, + phone_id: str | None = None, ) -> str: """ Upload media to WhatsApp servers. @@ -1271,8 +1337,9 @@ async def upload_media( media: The media to upload (can be a URL, bytes, or a file path). mime_type: The MIME type of the media (required if media is bytes or a file path). filename: The file name of the media (required if media is bytes). - dl_session: A httpx session to use when downloading the media from a URL (optional, if not provided, a + dl_session: A requests session to use when downloading the media from a URL (optional, if not provided, a new session will be created). + phone_id: The phone ID to upload the media to (optional, if not provided, the client's phone ID will be used). Returns: The media ID. @@ -1283,6 +1350,8 @@ async def upload_media( - If provided ``media`` is URL and the URL is invalid or media cannot be downloaded. - If provided ``media`` is bytes and ``filename`` or ``mime_type`` is not provided. """ + phone_id = _resolve_phone_id_param(self, phone_id, "phone_id") + if isinstance(media, (str, pathlib.Path)): if (path := pathlib.Path(media)).is_file(): file, filename, mime_type = ( @@ -1316,6 +1385,7 @@ async def upload_media( raise ValueError("`mime_type` is required if media is bytes") return ( await self.api.upload_media( + phone_id=phone_id, filename=filename, media=file, mime_type=mime_type, @@ -1393,7 +1463,10 @@ async def download_media( f.write(content) return path - async def get_business_phone_number(self) -> BusinessPhoneNumber: + async def get_business_phone_number( + self, + phone_id: str | None = None, + ) -> BusinessPhoneNumber: """ Get the phone number of the WhatsApp Business account. @@ -1402,16 +1475,18 @@ async def get_business_phone_number(self) -> BusinessPhoneNumber: >>> wa = WhatsApp(...) >>> wa.get_business_phone_number() + Args: + phone_id: The phone ID to get the phone number from (optional, if not provided, the client's phone ID will be used). + Returns: The phone number object. """ return BusinessPhoneNumber.from_dict( - data=( - await self.api.get_business_phone_number( - fields=tuple( - field.name for field in dataclasses.fields(BusinessPhoneNumber) - ) - ) + data=await self.api.get_business_phone_number( + phone_id=_resolve_phone_id_param(self, phone_id, "phone_id"), + fields=tuple( + field.name for field in dataclasses.fields(BusinessPhoneNumber) + ), ) ) @@ -1420,6 +1495,7 @@ async def update_conversational_automation( enable_chat_opened: bool, ice_breakers: Iterable[str] | None = None, commands: Iterable[Command] | None = None, + phone_id: str | None = None, ) -> bool: """ Update the conversational automation settings of the WhatsApp Business account. @@ -1435,12 +1511,14 @@ async def update_conversational_automation( first time you chat with a user. For example, `Plan a trip` or `Create a workout plan`. commands: Commands are text strings that WhatsApp users can see by typing a forward slash in a message thread with your business. + phone_id: The phone ID to update the conversational automation settings for (optional, if not provided, the client's phone ID will be used). Returns: Whether the conversational automation settings were updated. """ return ( await self.api.update_conversational_automation( + phone_id=_resolve_phone_id_param(self, phone_id, "phone_id"), enable_welcome_message=enable_chat_opened, prompts=tuple(ice_breakers) if ice_breakers else None, commands=json.dumps([c.to_dict() for c in commands]) @@ -1449,7 +1527,10 @@ async def update_conversational_automation( ) )["success"] - async def get_business_profile(self) -> BusinessProfile: + async def get_business_profile( + self, + phone_id: str | None = None, + ) -> BusinessProfile: """ Get the business profile of the WhatsApp Business account. @@ -1458,12 +1539,16 @@ async def get_business_profile(self) -> BusinessProfile: >>> wa = WhatsApp(...) >>> wa.get_business_profile() + Args: + phone_id: The phone ID to get the business profile from (optional, if not provided, the client's phone ID will be used). + Returns: The business profile. """ return BusinessProfile.from_dict( data=( await self.api.get_business_profile( + phone_id=_resolve_phone_id_param(self, phone_id, "phone_id"), fields=( "about", "address", @@ -1472,7 +1557,7 @@ async def get_business_profile(self) -> BusinessProfile: "profile_picture_url", "websites", "vertical", - ) + ), ) )["data"][0] ) @@ -1480,12 +1565,14 @@ async def get_business_profile(self) -> BusinessProfile: async def set_business_public_key( self, public_key: str, + phone_id: str | None = None, ) -> bool: """ Set the business public key of the WhatsApp Business account (required for end-to-end encryption in flows) Args: public_key: An public 2048-bit RSA Key in PEM format. + phone_id: The phone ID to set the business public key for (optional, if not provided, the client's phone ID will be used). Example: @@ -1498,9 +1585,12 @@ async def set_business_public_key( Returns: Whether the business public key was set. """ - return (await self.api.set_business_public_key(public_key=public_key))[ - "success" - ] + return ( + await self.api.set_business_public_key( + phone_id=_resolve_phone_id_param(self, phone_id, "phone_id"), + public_key=public_key, + ) + )["success"] async def update_business_profile( self, @@ -1511,13 +1601,14 @@ async def update_business_profile( profile_picture_handle: str | None = utils.MISSING, industry: Industry | None = utils.MISSING, websites: Iterable[str] | None = utils.MISSING, + phone_id: str | None = None, ) -> bool: """ Update the business profile of the WhatsApp Business account. Example: - >>> from pywa_async.types import Industry + >>> from pywa.types import Industry >>> wa = WhatsApp(...) >>> wa.update_business_profile( ... about='This is a test business', @@ -1544,6 +1635,7 @@ async def update_business_profile( websites: The URLs associated with the business. For instance, a website, Facebook Page, or Instagram. (You must include the ``http://`` or ``https://`` portion of the URL. There is a maximum of 2 websites with a maximum of 256 characters each.) + phone_id: The phone ID to update the business profile for (optional, if not provided, the client's phone ID will be used). Returns: Whether the business profile was updated. @@ -1561,9 +1653,16 @@ async def update_business_profile( }.items() if value is not utils.MISSING } - return (await self.api.update_business_profile(data))["success"] + return ( + await self.api.update_business_profile( + phone_id=_resolve_phone_id_param(self, phone_id, "phone_id"), data=data + ) + )["success"] - async def get_commerce_settings(self) -> CommerceSettings: + async def get_commerce_settings( + self, + phone_id: str | None = None, + ) -> CommerceSettings: """ Get the commerce settings of the WhatsApp Business account. @@ -1576,13 +1675,18 @@ async def get_commerce_settings(self) -> CommerceSettings: The commerce settings. """ return CommerceSettings.from_dict( - data=(await self.api.get_commerce_settings())["data"][0] + data=( + await self.api.get_commerce_settings( + phone_id=_resolve_phone_id_param(self, phone_id, "phone_id"), + ) + )["data"][0] ) async def update_commerce_settings( self, is_catalog_visible: bool = None, is_cart_enabled: bool = None, + phone_id: str | None = None, ) -> bool: """ Update the commerce settings of the WhatsApp Business account. @@ -1598,6 +1702,7 @@ async def update_commerce_settings( Args: is_catalog_visible: Whether the catalog is visible (optional). is_cart_enabled: Whether the cart is enabled (optional). + phone_id: The phone ID to update the commerce settings for (optional, if not provided, the client's phone ID will be used). Returns: Whether the commerce settings were updated. @@ -1615,28 +1720,17 @@ async def update_commerce_settings( } if not data: raise ValueError("At least one argument must be provided") - return (await self.api.update_commerce_settings(data))["success"] - - @staticmethod - def _validate_waba_id_provided(func) -> Callable: - """Internal decorator to validate the waba id is provided.""" - - @functools.wraps(func) - async def wrapper(self, *args, **kwargs): - if self.business_account_id is None: - raise ValueError( - f"You must provide the WhatsApp business account ID when using the `{func.__name__}` method.\n" - ">> You can provide it when initializing the client or by setting the `business_account_id` attr." - ) - return await func(self, *args, **kwargs) - - return wrapper + return ( + await self.api.update_commerce_settings( + phone_id=_resolve_phone_id_param(self, phone_id, "phone_id"), data=data + ) + )["success"] - @_validate_waba_id_provided async def create_template( self, template: NewTemplate, placeholder: tuple[str, str] | None = None, + waba_id: str | None = None, ) -> TemplateResponse: """ `'Create Templates' on developers.facebook.com @@ -1658,7 +1752,7 @@ async def create_template( Example: - >>> from pywa_async.types import NewTemplate as NewTemp + >>> from pywa.types import NewTemplate as NewTemp >>> wa = WhatsApp(...) >>> wa.create_template( ... template=NewTemp( @@ -1679,7 +1773,7 @@ async def create_template( Example for Authentication Template: - >>> from pywa_async.types import NewTemplate as NewTemp + >>> from pywa.types import NewTemplate as NewTemp >>> wa = WhatsApp(...) >>> wa.create_template( ... template=NewTemp( @@ -1703,6 +1797,7 @@ async def create_template( Args: template: The template to create. placeholder: The placeholders start & end (optional, default: ``('{', '}')``)). + waba_id: The WhatsApp Business account ID (Overrides the client's business account ID). Returns: The template created response. containing the template ID, status and category. @@ -1710,7 +1805,7 @@ async def create_template( return TemplateResponse( **( await self.api.create_template( - waba_id=self.business_account_id, + waba_id=_resolve_waba_id_param(self, waba_id), template=template.to_dict(placeholder=placeholder), ) ) @@ -1722,6 +1817,7 @@ async def send_template( template: Template, reply_to_message_id: str | None = None, tracker: CallbackDataT | None = None, + sender: str | int | None = None, ) -> str: """ Send a template to a WhatsApp user. @@ -1730,7 +1826,7 @@ async def send_template( Example: - >>> from pywa_async.types import Template as Temp + >>> from pywa.types import Template as Temp >>> wa = WhatsApp(...) >>> wa.send_template( ... to='1234567890', @@ -1753,7 +1849,7 @@ async def send_template( Example for Authentication Template: - >>> from pywa_async.types import Template as Temp + >>> from pywa.types import Template as Temp >>> wa = WhatsApp(...) >>> wa.send_template( ... to='1234567890', @@ -1769,6 +1865,7 @@ async def send_template( template: The template to send. reply_to_message_id: The message ID to reply to (optional). tracker: The data to track the message with (optional, up to 512 characters, for complex data You can use :class:`CallbackData`). + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The message ID of the sent template. @@ -1776,6 +1873,7 @@ async def send_template( Raises: """ + sender = _resolve_phone_id_param(self, sender, "sender") is_url = None match type(template.header): case Template.Image: @@ -1785,6 +1883,7 @@ async def send_template( mime_type=template.header.mime_type, media_type=MessageType.IMAGE, filename=None, + phone_id=sender, ) case Template.Document: is_url, template.header.document = await _resolve_media_param( @@ -1793,6 +1892,7 @@ async def send_template( mime_type="application/pdf", # the only supported mime type in template's document header filename=template.header.filename, media_type=None, + phone_id=sender, ) case Template.Video: is_url, template.header.video = await _resolve_media_param( @@ -1801,9 +1901,11 @@ async def send_template( mime_type=template.header.mime_type, media_type=MessageType.VIDEO, filename=None, + phone_id=sender, ) return ( await self.api.send_message( + sender=sender, to=str(to), typ="template", msg=template.to_dict(is_header_url=is_url), @@ -1812,13 +1914,13 @@ async def send_template( ) )["messages"][0]["id"] - @_validate_waba_id_provided async def create_flow( self, name: str, categories: Iterable[FlowCategory | str], clone_flow_id: str | None = None, endpoint_uri: str | None = None, + waba_id: str | None = None, ) -> str: """ Create a flow. @@ -1834,10 +1936,11 @@ async def create_flow( clone_flow_id: The flow ID to clone (optional). endpoint_uri: The URL of the FlowJSON Endpoint. Starting from Flow 3.0 this property should be specified only gere. Do not provide this field if you are cloning a Flow with version below 3.0. + waba_id: The WhatsApp Business account ID (Overrides the client's business account ID). Example: - >>> from pywa_async.types.flows import FlowCategory + >>> from pywa.types.flows import FlowCategory >>> wa = WhatsApp(...) >>> wa.create_flow( ... name='Feedback', @@ -1856,7 +1959,7 @@ async def create_flow( categories=tuple(map(str, categories)), clone_flow_id=clone_flow_id, endpoint_uri=endpoint_uri, - waba_id=self.business_account_id, + waba_id=_resolve_waba_id_param(self, waba_id), ) )["id"] @@ -1888,14 +1991,15 @@ async def update_flow_metadata( ... flow_id='1234567890', ... name='Feedback', ... categories=[FlowCategory.SURVEY, FlowCategory.OTHER], - ... endpoint_uri='https://my-api-server/feedback_flow' + ... endpoint_uri='https://my-api-server/feedback_flow', + ... application_id=1234567890, ... ) Returns: Whether the flow was updated. Raises: - ValueError: If neither ``name``, ``categories`` or ``endpoint_uri`` are provided. + ValueError: If neither of the arguments is provided. """ if not any((name, categories, endpoint_uri, application_id)): raise ValueError("At least one argument must be provided") @@ -2069,10 +2173,10 @@ async def get_flow( client=self, ) - @_validate_waba_id_provided async def get_flows( self, invalidate_preview: bool = True, + waba_id: str | None = None, ) -> tuple[FlowDetails, ...]: """ Get the details of all flows belonging to the WhatsApp Business account. @@ -2081,6 +2185,7 @@ async def get_flows( Args: invalidate_preview: Whether to invalidate the preview (optional, default: True). + waba_id: The WhatsApp Business account ID (Overrides the client's business account ID). Returns: The details of all flows. @@ -2089,7 +2194,7 @@ async def get_flows( FlowDetails.from_dict(data=data, client=self) for data in ( await self.api.get_flows( - waba_id=self.business_account_id, + waba_id=_resolve_waba_id_param(self, waba_id), fields=_get_flow_fields(invalidate_preview=invalidate_preview), ) )["data"] @@ -2118,7 +2223,10 @@ async def get_flow_assets( ) async def register_phone_number( - self, pin: int | str, data_localization_region: str | None = None + self, + pin: int | str, + data_localization_region: str | None = None, + phone_id: str | None = None, ) -> bool: """ Register a Business Phone Number @@ -2140,14 +2248,16 @@ async def register_phone_number( business phone number. Value must be a 2-letter ISO 3166 country code (e.g. ``IN``) indicating the country where you want data-at-rest to be stored. + phone_id: The phone ID to register (optional, if not provided, the client's phone ID will be used). Returns: The success of the registration. """ - return ( await self.api.register_phone_number( - pin=str(pin), data_localization_region=data_localization_region + phone_id=_resolve_phone_id_param(self, phone_id, "phone_id"), + pin=str(pin), + data_localization_region=data_localization_region, ) )["success"] @@ -2161,6 +2271,7 @@ async def _resolve_media_param( MessageType.IMAGE, MessageType.VIDEO, MessageType.AUDIO, MessageType.STICKER ] | None, + phone_id: str, ) -> tuple[bool, str]: """ Internal method to resolve the ``media`` parameter. Returns a tuple of (``is_url``, ``media_id_or_url``). @@ -2172,6 +2283,7 @@ async def _resolve_media_param( return False, media # assume it's a media ID # assume its bytes or a file path return False, await wa.upload_media( + phone_id=phone_id, media=media, mime_type=mime_type, filename=_media_types_default_filenames.get(media_type, filename), diff --git a/pywa_async/types/base_update.py b/pywa_async/types/base_update.py index d14cc72..6cddafe 100644 --- a/pywa_async/types/base_update.py +++ b/pywa_async/types/base_update.py @@ -126,6 +126,7 @@ async def reply_text( The ID of the sent reply. """ return await self._client.send_message( + sender=self.recipient, to=self.sender, text=text, header=header, @@ -181,6 +182,7 @@ async def reply_image( The ID of the sent reply. """ return await self._client.send_image( + sender=self.recipient, to=self.sender, image=image, caption=caption, @@ -235,6 +237,7 @@ async def reply_video( The ID of the sent reply. """ return await self._client.send_video( + sender=self.recipient, to=self.sender, video=video, caption=caption, @@ -292,6 +295,7 @@ async def reply_document( The ID of the sent reply. """ return await self._client.send_document( + sender=self.recipient, to=self.sender, document=document, filename=filename, @@ -330,6 +334,7 @@ async def reply_audio( The ID of the sent message. """ return await self._client.send_audio( + sender=self.recipient, to=self.sender, audio=audio, mime_type=mime_type, @@ -364,6 +369,7 @@ async def reply_sticker( The ID of the sent reply. """ return await self._client.send_sticker( + sender=self.recipient, to=self.sender, sticker=sticker, mime_type=mime_type, @@ -403,6 +409,7 @@ async def reply_location( The ID of the sent reply. """ return await self._client.send_location( + sender=self.recipient, to=self.sender, latitude=latitude, longitude=longitude, @@ -444,6 +451,7 @@ async def reply_contact( The ID of the sent reply. """ return await self._client.send_contact( + sender=self.recipient, to=self.sender, contact=contact, reply_to_message_id=self.message_id_to_reply if quote else None, @@ -467,6 +475,7 @@ async def react(self, emoji: str, tracker: CallbackDataT | None = None) -> str: The ID of the sent reaction. """ return await self._client.send_reaction( + sender=self.recipient, to=self.sender, emoji=emoji, message_id=self.message_id_to_reply, @@ -489,7 +498,10 @@ async def unreact(self, tracker: CallbackDataT | None = None) -> str: The ID of the sent unreaction. """ return await self._client.remove_reaction( - to=self.sender, message_id=self.message_id_to_reply, tracker=tracker + sender=self.recipient, + to=self.sender, + message_id=self.message_id_to_reply, + tracker=tracker, ) async def reply_catalog( @@ -524,6 +536,7 @@ async def reply_catalog( The ID of the sent reply. """ return await self._client.send_catalog( + sender=self.recipient, to=self.sender, body=body, footer=footer, @@ -560,6 +573,7 @@ async def reply_product( The ID of the sent reply. """ return await self._client.send_product( + sender=self.recipient, to=self.sender, catalog_id=catalog_id, sku=sku, @@ -620,6 +634,7 @@ async def reply_products( The ID of the sent reply. """ return await self._client.send_products( + sender=self.recipient, to=self.sender, catalog_id=catalog_id, product_sections=product_sections, @@ -688,6 +703,7 @@ async def reply_template( """ return await self._client.send_template( + sender=self.recipient, to=self.sender, template=template, reply_to_message_id=quote if quote else None, @@ -703,5 +719,5 @@ async def mark_as_read(self) -> bool: Whether it was successful. """ return await self._client.mark_message_as_read( - message_id=self.message_id_to_reply + sender=self.recipient, message_id=self.message_id_to_reply ) diff --git a/pywa_async/types/message.py b/pywa_async/types/message.py index 8ceb980..098d24e 100644 --- a/pywa_async/types/message.py +++ b/pywa_async/types/message.py @@ -138,6 +138,7 @@ async def copy( reply_to_message_id: str = None, keyboard: None = None, tracker: str | None = None, + sender: str | int | None = None, ) -> str: """ Send the message to another user. @@ -157,6 +158,7 @@ async def copy( preview_url: Whether to show a preview of the URL in the message (if any). keyboard: Deprecated and will be removed in a future version, use ``buttons`` instead. tracker: The track data of the message. + sender: The phone ID to send the message from (optional, overrides the client's phone ID). Returns: The ID of the sent message. @@ -168,6 +170,7 @@ async def copy( match self.type: case MessageType.TEXT: return await self._client.send_message( + sender=sender, to=to, text=self.text, preview_url=preview_url, @@ -180,6 +183,7 @@ async def copy( ) case MessageType.DOCUMENT: return await self._client.send_document( + sender=sender, to=to, document=self.document.id, filename=self.document.filename, @@ -192,6 +196,7 @@ async def copy( ) case MessageType.IMAGE: return await self._client.send_image( + sender=sender, to=to, image=self.image.id, caption=self.caption, @@ -203,6 +208,7 @@ async def copy( ) case MessageType.VIDEO: return await self._client.send_video( + sender=sender, to=to, video=self.video.id, caption=self.caption, @@ -214,10 +220,11 @@ async def copy( ) case MessageType.STICKER: return await self._client.send_sticker( - to=to, sticker=self.sticker.id, tracker=tracker + sender=sender, to=to, sticker=self.sticker.id, tracker=tracker ) case MessageType.LOCATION: return await self._client.send_location( + sender=sender, to=to, latitude=self.location.latitude, longitude=self.location.longitude, @@ -227,10 +234,11 @@ async def copy( ) case MessageType.AUDIO: return await self._client.send_audio( - to=to, audio=self.audio.id, tracker=tracker + sender=sender, to=to, audio=self.audio.id, tracker=tracker ) case MessageType.CONTACTS: return await self._client.send_contact( + sender=sender, to=to, contact=self.contacts, reply_to_message_id=reply_to_message_id, @@ -242,6 +250,7 @@ async def copy( "You need to provide `reply_to_message_id` in order to `copy` a reaction" ) return await self._client.send_reaction( + sender=sender, to=to, message_id=reply_to_message_id, emoji=self.reaction.emoji or "", @@ -249,6 +258,7 @@ async def copy( case MessageType.ORDER: if len(self.order.products) == 1: return await self._client.send_product( + sender=sender, to=to, catalog_id=self.order.catalog_id, sku=self.order.products[0].sku, @@ -257,6 +267,7 @@ async def copy( reply_to_message_id=reply_to_message_id, ) return await self._client.send_products( + sender=sender, to=to, catalog_id=self.order.catalog_id, product_sections=( @@ -272,6 +283,7 @@ async def copy( ) case MessageType.SYSTEM: return await self._client.send_message( + sender=sender, to=to, text=self.system.body, header=header, diff --git a/tests/test_client.py b/tests/test_client.py index 1f69b38..302bc18 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -19,10 +19,7 @@ wa = WhatsApp(phone_id=PHONE_ID, token=TOKEN) -def test_wa_without_phone_id_or_token(): - with pytest.raises(ValueError): - WhatsApp(phone_id="", token="123") - +def test_wa_without_token(): with pytest.raises(ValueError): WhatsApp(phone_id="123", token="")