From e9f6181e63f3f7e0f95894c5e15176224726d208 Mon Sep 17 00:00:00 2001 From: David Lev <42866208+david-lev@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:40:34 +0300 Subject: [PATCH 1/8] [docs] improvements --- docs/source/content/client/overview.rst | 7 +- docs/source/content/examples/demo-bots.rst | 76 ++++--------- docs/source/content/examples/interactive.rst | 89 ++++++++------- docs/source/content/examples/overview.rst | 8 +- docs/source/content/filters/overview.rst | 28 ++--- docs/source/content/flows/overview.rst | 91 +++++++--------- docs/source/content/getting-started.rst | 54 +++++---- docs/source/content/handlers/overview.rst | 37 ++++--- docs/source/content/listeners/overview.rst | 109 ++++++++++++------- docs/source/content/updates/overview.rst | 4 +- pywa/filters.py | 4 +- pywa/types/base_update.py | 2 +- pywa_async/types/base_update.py | 2 +- 13 files changed, 257 insertions(+), 254 deletions(-) diff --git a/docs/source/content/client/overview.rst b/docs/source/content/client/overview.rst index 9313d08b..a4e3a7c1 100644 --- a/docs/source/content/client/overview.rst +++ b/docs/source/content/client/overview.rst @@ -13,7 +13,7 @@ Its **three main responsibilities** are: 3. **Managing resources** — templates, flows, profiles, and other business-related settings. .. tip:: - :class: note + :class: tip Pywa provides **two types of clients**: @@ -43,6 +43,9 @@ Its **three main responsibilities** are: await msg.reply("Hello!") For optimal type checking, ensure that **all** your imports come from the same package—either ``pywa`` or ``pywa_async``. + +To help you navigate the API, the client's methods are grouped below by functionality. Most of these methods return type-safe objects representing API responses, which you can then manipulate or reply to directly. + .. autoclass:: WhatsApp() :members: __init__ @@ -227,7 +230,7 @@ Create, update, and manage message templates: * - :meth:`~WhatsApp.create_template` - Create a new template * - :meth:`~WhatsApp.upsert_authentication_template` - - Bulk create or update authentication templates + - Create or update multiple authentication templates in a single request * - :meth:`~WhatsApp.get_templates` - Retrieve all templates * - :meth:`~WhatsApp.get_template` diff --git a/docs/source/content/examples/demo-bots.rst b/docs/source/content/examples/demo-bots.rst index c23b4f53..31518b52 100644 --- a/docs/source/content/examples/demo-bots.rst +++ b/docs/source/content/examples/demo-bots.rst @@ -1,8 +1,7 @@ 🤖 Demo Bots ============ -This page contains some examples of bots you can create using pywa. -Every example is a complete working bot that you can run on your own server. +This page contains complete, working examples of bots you can create using pywa. 👋 Hello Bot -------------- @@ -12,15 +11,11 @@ This is a simple bot that welcomes the user when they send a message. .. code-block:: python :linenos: - import flask # pip3 install flask from pywa import WhatsApp, types - flask_app = flask.Flask(__name__) - wa = WhatsApp( phone_id='your_phone_number', token='your_token', - server=flask_app, verify_token='xyzxyz', ) @@ -29,8 +24,7 @@ This is a simple bot that welcomes the user when they send a message. msg.react('👋') msg.reply(f'Hello {msg.from_user.name}!') - # Run the server - flask_app.run() + # Run the server with `pywa dev` 📝 Echo Bot @@ -42,15 +36,11 @@ This is a simple bot that echoes back the user's message. .. code-block:: python :linenos: - import flask # pip3 install flask from pywa import WhatsApp, types - flask_app = flask.Flask(__name__) - wa = WhatsApp( phone_id='your_phone_number', token='your_token', - server=flask_app, verify_token='xyzxyz', ) @@ -59,11 +49,9 @@ This is a simple bot that echoes back the user's message. try: msg.copy(to=msg.sender, reply_to_message_id=msg.message_id_to_reply) except ValueError: - msg.reply_text("I can't echo this message") - - # Run the server - flask_app.run() + msg.reply("I can't echo this message") + # Run the server with `pywa dev` ⬆️ Url Uploader Bot @@ -74,16 +62,11 @@ This is a simple bot that uploads files from URLs. .. code-block:: python :linenos: - import flask # pip3 install flask from pywa import WhatsApp, types, filters, errors - from pywa.types import Message, MessageStatus - - flask_app = flask.Flask(__name__) wa = WhatsApp( phone_id='your_phone_number', token='your_token', - server=flask_app, verify_token='xyzxyz', ) @@ -94,10 +77,9 @@ This is a simple bot that uploads files from URLs. # When a file fails to download/upload, the bot will reply with an error message. @wa.on_message_status(filters.failed_with(errors.MediaDownloadError, errors.MediaUploadError)) def on_media_download_error(_: WhatsApp, status: types.MessageStatus): - status.reply_text(f"I can't download/upload this file: {status.error.details}") + status.reply(f"I can't download/upload this file: {status.error.details}") - # Run the server - flask_app.run() + # Run the server with `pywa dev` 🔢 Calculator WhatsApp Bot @@ -115,15 +97,11 @@ Usage: .. code-block:: python import re - import flask # pip3 install flask from pywa import WhatsApp, types, filters - flask_app = flask.Flask(__name__) - wa = WhatsApp( phone_id='your_phone_number', token='your_token', - server=flask_app, verify_token='xyzxyz', ) @@ -153,9 +131,7 @@ Usage: return msg.reply(f'{a} {op} {b} = *{result}*') - # Run the server - flask_app.run() - + # Run the server with `pywa dev` 🌐 Translator Bot ----------------- @@ -166,17 +142,14 @@ A simple WhatsApp bot that translates text messages to other languages. :linenos: import logging - import flask # pip3 install flask import googletrans # pip3 install googletrans==4.0.0-rc1 from pywa import WhatsApp, types, filters - flask_app = flask.Flask(__name__) translator = googletrans.Translator() wa = WhatsApp( phone_id='your_phone_number', token='your_token', - server=flask_app, verify_token='xyzxyz', ) @@ -199,7 +172,7 @@ A simple WhatsApp bot that translates text messages to other languages. @wa.on_message(filters.text) def offer_translation(_: WhatsApp, msg: types.Message): - msg_id = msg.reply_text( + msg_id = msg.reply( text='Choose language to translate to:', buttons=types.SectionList( button_title='🌐 Choose Language', @@ -238,7 +211,7 @@ A simple WhatsApp bot that translates text messages to other languages. original_text = MESSAGE_ID_TO_TEXT[sel.reply_to_message.message_id] except KeyError: # If the bot was restarted, the message ID is no longer valid. sel.react('❌') - sel.reply_text( + sel.reply( text='Original message not found. Please send a new message.' ) return @@ -246,19 +219,18 @@ A simple WhatsApp bot that translates text messages to other languages. translated = translator.translate(original_text, dest=lang_code) except Exception as e: sel.react('❌') - sel.reply_text( + sel.reply( text='An error occurred. Please try again.' ) logging.exception(e) return - sel.reply_text( + sel.reply( text=f"Translated to {translated.dest}:\n{translated.text}" ) - # Run the server - flask_app.run() + # Run the server with `pywa dev` 🖼 Random image bot @@ -270,16 +242,11 @@ This example shows how to create a simple bot that replies with a random image f .. code-block:: python :linenos: - import requests - import flask from pywa import WhatsApp, types - flask_app = flask.Flask(__name__) - wa = WhatsApp( phone_id='your_phone_number', token='your_token', - server=flask_app, verify_token='xyzxyz', ) @@ -291,8 +258,7 @@ This example shows how to create a simple bot that replies with a random image f buttons=types.ButtonUrl(title='Unsplash', url='https://unsplash.com') ) - # Run the server - flask_app.run() + # Run the server with `pywa dev` 📸 Remove background from image @@ -303,16 +269,13 @@ This example shows how to create a bot that removes the background from an image .. code-block:: python :linenos: - import requests - import flask + import logging + import httpx from pywa import WhatsApp, types - flask_app = flask.Flask(__name__) - wa = WhatsApp( phone_id='your_phone_number', token='your_token', - server=flask_app, verify_token='xyzxyz', ) @@ -324,7 +287,7 @@ This example shows how to create a bot that removes the background from an image files = {'image_file': original_img} data = {'size': 'auto'} headers = {'X-Api-Key': REMOVEBG_API_KEY} - response = requests.post(url, files=files, data=data, headers=headers) + response = httpx.post(url, files=files, data=data, headers=headers) response.raise_for_status() return response.content @@ -334,8 +297,8 @@ This example shows how to create a bot that removes the background from an image try: original_img = msg.image.download(in_memory=True) image = get_removed_bg_image(original_img) - except requests.HTTPError as e: - msg.reply_text(f"A error occurred") + except httpx.HTTPError as e: + msg.reply("An error occurred") logging.exception(e) return msg.reply_image( @@ -344,5 +307,4 @@ This example shows how to create a bot that removes the background from an image mime_type='image/png', # when sending bytes, you must specify the mime type ) - # Run the server - flask_app.run() + # Run the server with `pywa dev` diff --git a/docs/source/content/examples/interactive.rst b/docs/source/content/examples/interactive.rst index e7bc59d2..f0560b04 100644 --- a/docs/source/content/examples/interactive.rst +++ b/docs/source/content/examples/interactive.rst @@ -1,33 +1,24 @@ -Sending Interactive Messages -============================ +📲 Sending Interactive Messages +=============================== +Interactive messages let you present structured options to your users — such as selection lists or quick-reply buttons — instead of relying on them to type plain text. + +.. note:: + + These examples focus on **sending** interactive messages. To learn how to listen for and process the user's choice when they tap a button or select an option, check out the `Handlers Overview <../handlers/overview.html>`_ and `Filters Overview <../filters/overview.html>`_ guides. Send a message with selection keyboard -------------------------------------- -- You can use selection keyboard only with text messages. +- You can use selection keyboards only with text messages. - The maximum number of section rows is 10. -.. sidebar:: 📱 ScreenShots - - .. tip:: How the message displayed in WhatsApp: - - .. image:: ../../../../_static/examples/selection-message.webp - :alt: Selection message example - :width: 90% - - .. tip:: How the keyboard displayed in WhatsApp: - - .. image:: ../../../../_static/examples/selection-keyboard.webp - :alt: Selection keyboard example - :width: 90% .. code-block:: python :caption: Color selection message :linenos: - from pywa import WhatsApp - from pywa.types import SectionList, Section, SectionRow + from pywa import WhatsApp, types wa = WhatsApp(phone_id='972123456789', token='xxxxx') recipient = '972987654321' @@ -37,43 +28,43 @@ Send a message with selection keyboard header='Select your favorite color', text='Tap a button to select your favorite color:', footer='⚡ Powered by PyWa', - buttons=SectionList( + buttons=types.SectionList( button_title='Colors', sections=[ - Section( + types.Section( title='Popular Colors', rows=[ - SectionRow( + types.SectionRow( title='🟥 Red', callback_data='color:red', description='The color of blood', ), - SectionRow( + types.SectionRow( title='🟩 Green', callback_data='color:green', description='The color of grass', ), - SectionRow( + types.SectionRow( title='🟦 Blue', callback_data='color:blue', description='The color of the sky', ) ], ), - Section( + types.Section( title='Other Colors', rows=[ - SectionRow( + types.SectionRow( title='🟧 Orange', callback_data='color:orange', description='The color of an orange', ), - SectionRow( + types.SectionRow( title='🟪 Purple', callback_data='color:purple', description='The color of a grape', ), - SectionRow( + types.SectionRow( title='🟨 Yellow', callback_data='color:yellow', description='The color of the sun', @@ -84,28 +75,35 @@ Send a message with selection keyboard ) ) +**How it looks on WhatsApp:** +.. list-table:: + :widths: 50 50 + :align: center -Send a message with buttons keyboard ------------------------------------- - -- You can attach up to 3 buttons to a message. + * - .. figure:: ../../../../_static/examples/selection-message.webp + :alt: Selection message example + :align: center + :width: 90% -.. sidebar:: 📱 ScreenShots + *Interactive message view* + - .. figure:: ../../../../_static/examples/selection-keyboard.webp + :alt: Selection keyboard example + :align: center + :width: 90% - .. tip:: How the message displayed in WhatsApp: + *Selection menu view* - .. image:: ../../../../_static/examples/buttons-message.webp - :alt: Buttons message example - :width: 90% +Send a message with buttons keyboard +------------------------------------ +- You can attach up to 3 buttons to a message. .. code-block:: python :caption: YouTube video info message :linenos: - from pywa import WhatsApp - from pywa.types import Button + from pywa import WhatsApp, types wa = WhatsApp(phone_id='972123456789', token='xxxxx') @@ -118,8 +116,17 @@ Send a message with buttons keyboard caption='Chandler Jokes | Friends • 2.9M views • 1 year ago', footer='⚡ Powered by PyWa', buttons=[ - Button(title='⬇️ Download', callback_data=f'dl:{requested_vid_id}'), - Button(title='💬 Comments', callback_data=f'cmnts:{requested_vid_id}'), - Button(title='🎬 Info', callback_data=f'info:{requested_vid_id}'), + types.Button(title='⬇️ Download', callback_data=f'dl:{requested_vid_id}'), + types.Button(title='💬 Comments', callback_data=f'cmnts:{requested_vid_id}'), + types.Button(title='🎬 Info', callback_data=f'info:{requested_vid_id}'), ] ) + +**How it looks on WhatsApp:** + +.. figure:: ../../../../_static/examples/buttons-message.webp + :alt: Buttons message example + :align: center + :width: 60% + + *Interactive buttons view* diff --git a/docs/source/content/examples/overview.rst b/docs/source/content/examples/overview.rst index b304269d..4577cb20 100644 --- a/docs/source/content/examples/overview.rst +++ b/docs/source/content/examples/overview.rst @@ -1,9 +1,11 @@ 💡 Examples -============== +=========== -.. warning:: +Welcome to the examples gallery! Here you can find practical code recipes and complete bot implementations showcasing various features of PyWa. - The examples are outdated. please read the documentation for the latest features and usage. +.. note:: + + Some of these examples use older patterns. For the latest recommended design patterns and best practices, please refer to the main guides (like `Get Started <../getting-started.html>`_ and `Handlers <../handlers/overview.html>`_). .. toctree:: template diff --git a/docs/source/content/filters/overview.rst b/docs/source/content/filters/overview.rst index 77b7bae7..462d85b1 100644 --- a/docs/source/content/filters/overview.rst +++ b/docs/source/content/filters/overview.rst @@ -3,13 +3,14 @@ .. currentmodule:: pywa.filters -Filters are used by handlers to decide whether an update should be handled or ignored. +Filters are conditions that decide whether an incoming update should be handled or ignored. +Pass them to any handler and pywa will only call your callback when the filter matches. -The library provides several built-in filters, available in the :mod:`pywa.filters` module. +The library ships with a wide range of built-in filters in the :mod:`pywa.filters` module, +and you can write your own in a single function. ------------------ Basic Usage ------------------ +----------- .. code-block:: python :emphasize-lines: 5, 10 @@ -26,11 +27,10 @@ Basic Usage buttons=[types.Button("Click me!", "click")] ) - @wa.on_callback(filters.matches("click")) + @wa.on_callback_button(filters.matches("click")) def handle_click(wa: WhatsApp, clb: types.CallbackButton): clb.reply("You clicked me!") ------------------ Combining Filters ----------------- @@ -68,14 +68,11 @@ Filters can be combined with logical operators: filters.matches("hello", "hi") ------------------ Custom Filters ------------------ - -You can define your own filters by writing a function that takes the client and the update, and returns a boolean. +-------------- -If the function returns ``True`` → the handler will process the update. -If it returns ``False`` → the update will be ignored. +Write a function that accepts the client and the update, and returns a boolean. +``True`` means the handler runs; ``False`` means the update is skipped. .. note:: @@ -97,14 +94,13 @@ If it returns ``False`` → the update will be ignored. def messages_without_xyz(wa: WhatsApp, msg: types.Message): msg.reply("You said something without xyz!") - # Or with lambda: - @wa.on_message(filters.new(lambda _, msg: msg.text and "xyz" not in msg.text)) + # Or inline with a lambda — combine with built-in filters: + @wa.on_message(filters.text & filters.new(lambda _, msg: "xyz" not in msg.text)) def messages_without_xyz(wa: WhatsApp, msg: types.Message): msg.reply("You said something without xyz!") ------------------ Built-in Filters ------------------ +---------------- .. toctree:: diff --git a/docs/source/content/flows/overview.rst b/docs/source/content/flows/overview.rst index 15aba6a6..d970f501 100644 --- a/docs/source/content/flows/overview.rst +++ b/docs/source/content/flows/overview.rst @@ -3,24 +3,20 @@ .. currentmodule:: pywa.types.flows -PyWa has a built-in support for WhatsApp Flows, which allows you to create structured interactions with your users. +PyWa has built-in support for WhatsApp Flows, allowing you to create rich, structured interactions with your users directly within WhatsApp. -From `developers.facebook.com `_: +.. image:: ../../../../_static/guides/flows-new.webp + :alt: WhatsApp Flows + :width: 100% - .. image:: ../../../../_static/guides/flows-new.webp - :alt: WhatsApp Flows - :width: 100% +Flows let your users perform complex tasks — such as booking appointments, browsing products, or completing sign-up forms — without leaving the chat interface. - WhatsApp Flows is a way to build structured interactions for business messaging. With Flows, businesses can define, configure, and customize messages with rich interactions that give customers more structure in the way they communicate. +Working with WhatsApp Flows in PyWa involves four main steps: - You can use Flows to book appointments, browse products, collect customer feedback, get new sales leads, or anything else where structured communication is more natural or comfortable for your customers. - -The Flows are separated into 4 parts: - -- Creating Flow -- Sending Flow -- Handling Flow requests and responding to them (Only for dynamic flows) -- Getting Flow Completion +- Creating the Flow +- Sending the Flow +- Handling Flow requests and responding to them (for dynamic flows) +- Receiving the Flow Completion update Creating Flow ------------- @@ -287,10 +283,10 @@ Or getting all the flows with :meth:`~pywa.client.WhatsApp.get_flows`: print(flow) -To test your flow you need to sent it: +To test your flow, you need to send it to a user. -Sending Flow ------------- +Sending Flows +------------- .. currentmodule:: pywa.types.callback @@ -361,64 +357,53 @@ The ``.response`` attribute is the payload you sent when you completed the flow. img.download() -Handling Flow requests +Handling Flow Requests ---------------------- -This is when things get interesting. WhatsApp Flows can be dynamic, which means that you can handle user actions and respond to them in real-time from your server. - +WhatsApp Flows can be dynamic, allowing your server to handle user actions and respond in real-time. For example, you can validate inputs, transition to new screens based on business logic, or complete the interaction dynamically. -.. note:: - - Since the requests and responses can contain sensitive data, such as passwords and other personal information, - all the requests and responses are encrypted using the `WhatsApp Business Encryption `_. +.. important:: - Before you continue, you need to sign and upload the business public key. - First you need to generate a private key and a public key: + Because dynamic flow requests and responses contain sensitive data, Meta requires them to be encrypted using `WhatsApp Business Encryption `_. - Generate a public and private RSA key pair by typing in the following command: + Before handling dynamic flows, you must generate and upload an RSA key pair: - >>> openssl genrsa -des3 -out private.pem 2048 + 1. **Generate a private key** (you will be prompted to set a password): + .. code-block:: bash - This generates 2048-bit RSA key pair encrypted with a password you provided and is written to a file. + openssl genrsa -des3 -out private.pem 2048 - Next, you need to export the RSA Public Key to a file. + 2. **Export the public key** from your private key: - >>> openssl rsa -in private.pem -outform PEM -pubout -out public.pem + .. code-block:: bash + openssl rsa -in private.pem -outform PEM -pubout -out public.pem - This exports the RSA Public Key to a file. + 3. **Upload the public key** to Meta using the :meth:`~pywa.client.WhatsApp.set_business_public_key` method: - Once you have the public key, you can upload it using the :meth:`~pywa.client.WhatsApp.set_business_public_key` method. + .. code-block:: python + :linenos: - .. code-block:: python - :linenos: + from pywa import WhatsApp - from pywa import WhatsApp - - wa = WhatsApp(...) - - wa.set_business_public_key(open("public.pem").read()) - - Every request need to be decrypted using the private key. so you need to provide it when you create the :class:`WhatsApp` object: - - .. code-block:: python - :linenos: + wa = WhatsApp(...) + wa.set_business_public_key(open("public.pem").read()) - from pywa import WhatsApp + 4. **Provide the private key** to your :class:`WhatsApp` client initialization: - wa = WhatsApp(..., business_private_key=open("private.pem").read()) + .. code-block:: python + :linenos: - Now you are ready to handle the requests. + from pywa import WhatsApp - Just one more thing, the default decryption & encryption implementation is using the `cryptography `_ library, - So you need to install it: + wa = WhatsApp(..., business_private_key=open("private.pem").read()) - >>> pip3 install cryptography + 5. **Install the required dependencies** (pywa uses the `cryptography `_ library for decryption/encryption): - Or when installing PyWa: + .. code-block:: bash - >>> pip3 install "pywa[cryptography]" + pip3 install "pywa[cryptography]" Let's see an example of a dynamic flow: diff --git a/docs/source/content/getting-started.rst b/docs/source/content/getting-started.rst index a2f1ec6a..e29254d7 100644 --- a/docs/source/content/getting-started.rst +++ b/docs/source/content/getting-started.rst @@ -1,6 +1,10 @@ ⚙️ Get Started =============== +This page walks you through installing pywa, creating a WhatsApp app on Meta, +and sending your first message. If you already have your **Phone ID** and **Token**, +jump straight to `Send a Message <#id2>`_. + ⬇️ Installation --------------- @@ -32,9 +36,9 @@ ================================ Create a WhatsApp Application ------------------------------ +------------------------------ -You already have an app? Skip to `Setup the App <#id1>`_. +Already have a Facebook App? `Jump to Setup the App → <#id1>`_ To use the WhatsApp Cloud API, you need a Facebook App. If you don't have a Facebook Developer account, `register here `_. @@ -51,7 +55,7 @@ If you don't have a Facebook Developer account, `register here `_. +Already have your **Phone ID** and **Token**? `Jump to Send a Message → <#id2>`_ -7. In the left menu (under **Products**), expand **WhatsApp** and click **API Setup**. +6. In the left menu (under **Products**), expand **WhatsApp** and click **API Setup**. .. toggle:: @@ -103,7 +107,7 @@ You already have your **Phone ID** and **Token**? Skip to `Send a Message <#id2> .. attention:: - If you haven’t connected a real phone number, you can use a test number provided by Meta. + If you haven't connected a real phone number, you can use a test number provided by Meta. You can send messages to up to 5 allowed numbers. Add them in the **Manage phone number list**. .. toggle:: @@ -118,7 +122,7 @@ You already have your **Phone ID** and **Token**? Skip to `Send a Message <#id2> Send a Message -------------- -Now you have your ``phone_id`` and ``token``. You can send messages: +Now that you have your ``phone_id`` and ``token``, you're ready to send messages: .. code-block:: python @@ -129,13 +133,13 @@ Now you have your ``phone_id`` and ``token``. You can send messages: token='YOUR_TOKEN' # from API Setup ) -.. code-block:: python - + # Send a text message wa.send_message( to='PHONE_NUMBER_TO_SEND_TO', text='Hi! This message was sent from pywa!' ) + # Send an image wa.send_image( to='PHONE_NUMBER_TO_SEND_TO', image='https://www.rd.com/wp-content/uploads/2021/04/GettyImages-1053735888-scaled.jpg' @@ -143,8 +147,6 @@ Now you have your ``phone_id`` and ``token``. You can send messages: .. note:: - - The ``to`` parameter must include country code, e.g., ``+972123456789`` or ``16315551234``. - Read more about `phone number formats here `_. - For **Test Numbers**, add recipients to the allowed numbers list. - Free-form messages can only be received if the recipient messaged your number in the last 24h. See `WhatsApp policy `_. @@ -154,13 +156,19 @@ Now you have your ``phone_id`` and ``token``. You can send messages: Quick Start ----------- -Here’s a quick overview of the ``pywa`` package: - -- `WhatsApp `_: Core client to send/receive messages, manage profile/business settings, and register callbacks. -- `Handlers `_: Register callbacks to handle incoming updates (messages, callbacks, and more). -- `Listeners `_: Listen for incoming user updates. -- `Filters `_: Filter and handle specific updates, e.g., text messages containing “Hello”. -- `Updates `_: Explore different update types, their attributes, and usage. -- `Flows `_: Create, update, and send flows. -- `Errors `_: Learn about package errors and how to handle them. -- `Examples `_: See practical usage examples. +Here's a brief overview of the main pywa concepts — click any link to dive deeper: + +- `WhatsApp `_: The core client. Use it to send messages, manage your business + profile, and register handlers. Everything starts here. +- `Handlers `_: Register callbacks that fire when a user sends a message, + presses a button, or triggers any other update. The backbone of every bot. +- `Listeners `_: Wait for a user's *next* reply inline, without registering + a separate handler. Perfect for step-by-step conversations. +- `Filters `_: Composable conditions that decide which updates a handler + processes. Combine them with ``&``, ``|``, and ``~`` like Python expressions. +- `Updates `_: All the types of incoming events — messages, button clicks, + delivery statuses, flow completions, and more. +- `Flows `_: Build rich, multi-screen interactive WhatsApp experiences entirely + in Python. +- `Errors `_: How pywa surfaces API errors and how to handle them gracefully. +- `Examples `_: Complete, runnable bots and code snippets to get inspired. diff --git a/docs/source/content/handlers/overview.rst b/docs/source/content/handlers/overview.rst index d14f8fd0..ca6f5d46 100644 --- a/docs/source/content/handlers/overview.rst +++ b/docs/source/content/handlers/overview.rst @@ -597,7 +597,7 @@ If you pass a custom FastAPI or Flask server, pywa does not run it for you. Handler Order and Flow ---------------------- -By default, pywa stops after the first handler that matches an update. +By default, pywa stops after the first handler whose **filter** matches an update. .. code-block:: python :caption: main.py @@ -609,14 +609,14 @@ By default, pywa stops after the first handler that matches an update. @wa.on_message def first(client: WhatsApp, msg: types.Message): - print("first") - # No later message handlers run for this update. + print("first") # <-- runs, then stops. second() is never called. @wa.on_message def second(client: WhatsApp, msg: types.Message): - print("second") + print("second") # <-- never reached, because first() matched first. -Handlers run in registration order unless you set ``priority``. Higher priority runs earlier. +Handlers run in registration order unless you set ``priority``. +**Higher priority number runs first** — a handler with ``priority=2`` runs before one with ``priority=1``. .. code-block:: python :caption: main.py @@ -734,19 +734,28 @@ Update route: With manual framework integration, you are responsible for returning the right response format for your framework and for running the server. -What pywa runs --------------- +.. note:: -When you use ``pywa dev``, ``pywa run``, or :meth:`~pywa.client.WhatsApp.run`, pywa creates a -small Starlette app and runs it with Uvicorn. + Regardless of how you run pywa (``pywa dev``, ``pywa run``, or :meth:`~pywa.client.WhatsApp.run`), + it creates a small Starlette app backed by Uvicorn and registers two routes on ``webhook_endpoint``: -That app registers: + - ``GET`` — answers WhatsApp's verification challenge. + - ``POST`` — receives and dispatches incoming webhook updates. -- A ``GET`` route on ``webhook_endpoint`` for WhatsApp's verification challenge. -- A ``POST`` route on ``webhook_endpoint`` for incoming webhook updates. + When you pass a FastAPI or Flask ``server``, pywa registers the same routes on that app instead. + For any other framework, use the :ref:`manual helper methods ` above. -For supported custom servers, pywa registers equivalent routes on the FastAPI or Flask app you -pass to ``server``. For other frameworks, use the manual helper methods above. +.. tip:: + + **Common best practices:** + + - Always add a filter (e.g., ``filters.text``) to message handlers that read ``msg.text`` + so the handler is never called with ``None``. + - Avoid long blocking operations inside synchronous handlers — they block the entire event loop. + Use threads or switch to ``pywa_async`` for async handlers. + - Use ``priority`` sparingly. Explicit filters are usually cleaner than execution ordering. + - Use ``shared_data`` on the update object to pass context between chained handlers + instead of global state. .. toctree:: handler_decorators diff --git a/docs/source/content/listeners/overview.rst b/docs/source/content/listeners/overview.rst index 3fd33f53..a51857ea 100644 --- a/docs/source/content/listeners/overview.rst +++ b/docs/source/content/listeners/overview.rst @@ -1,16 +1,31 @@ 📥 Listeners -================== +============ .. currentmodule:: pywa.types.sent_update -When handling updates, most of the time you ask the user for input (e.g. a reply, text, button press, etc.). This is where listeners come in. -With listeners, you can create an `inline` handler that waits for a specific user input and returns the result. +When your bot needs more than a single reply — for example, asking a follow-up question or +collecting a sequence of inputs — listeners let you pause execution and wait for the user's +next message, right inside the same handler function. No extra handler needed. +.. warning:: + + **Limitations and Resource Safety Warnings:** + + * **Multi-worker environments**: Listening is **not supported** when running the server with multiple workers (e.g., ``pywa run --workers > 1``), as workers do not share in-memory listener states. + * **Memory Leak Risk**: Listening without a ``timeout`` is highly discouraged. If the user never responds, the listener remains in memory indefinitely. Always specify a reasonable ``timeout``. + * **Thread Pool Exhaustion (Synchronous Clients)**: In synchronous pywa (not ``pywa_async``, each active listener blocks a worker thread. If you run pywa synchronously with ASGI frameworks like FastAPI or Starlette, active listeners can quickly exhaust the AnyIO thread pool (default is 40). **If the limit is reached, your server will freeze and drop incoming webhooks.** + + *Actionable mitigations:* + + 1. **(Recommended)** Migrate to the asynchronous client (``pywa_async``) for fully non-blocking listeners. + 2. Enforce strict, shorter ``timeout`` durations on all listeners to free up threads faster. + 3. Increase the AnyIO thread limit by adjusting ``pywa.server.ANYIO_THREADS_LIMIT`` to a higher value. Listening -_________ +--------- -In this example, we will create a listener that waits for the user to send their age. The listener will wait for a text-digit message from the user and then reply with a message based on the age provided. +In this example, we wait for the user to send their age. The listener checks that the reply +is a text message containing only digits, then branches based on the value. .. code-block:: python :linenos: @@ -23,28 +38,29 @@ In this example, we will create a listener that waits for the user to send their @wa.on_message(filters.command("start")) def start(client: WhatsApp, msg: types.Message): sent = msg.reply("Hello! How old are you?") - age_reply: Message = sent.wait_for_reply( + age_reply: types.Message = sent.wait_for_reply( filters=filters.text & filters.new(lambda _, m: m.text.isdigit()) ) age = int(age_reply.text) if age < 18: age_reply.reply("You are too young to use this service.") - # Handle the case when the user is too young else: age_reply.reply("Welcome! You can now use the service.") - # Handle the case when the user is old enough .. role:: python(code) :language: python -In the example above, we storing the sent message in the variable ``sent``. Then, we used the :meth:`~SentMessage.wait_for_reply` method to create a listener that waits for a reply from the user. The listener will wait for a message that matches the filter :python:`filters.text & filters.new(lambda _, m: m.text.isdigit())`, which means it will wait for a text message that contains only digits. -When the user sends a message that matches the filter, the listener will return the message as a :class:`~pywa.types.Message` object, which we store in the variable ``age_reply``. We then convert the text of the message to an integer and check if the user is old enough to use the service. - +:meth:`~SentMessage.wait_for_reply` blocks execution until the user sends a message that +matches the filter — :python:`filters.text & filters.new(lambda _, m: m.text.isdigit())` here. +The matching update is returned as a :class:`~pywa.types.Message` object, so you can read +``age_reply.text``, reply to it, or pass it along to other logic. Canceling -_________ +--------- -Now, listeners are blocking. This means that the code execution will stop until the listener returns a result. However, you can cancel the listener if you want to stop waiting for a reply. For example, you can add a button to the message that the user can press to cancel the listener or you can set a timeout for the listener to stop waiting after a certain period of time. +Listeners block until a matching message arrives. You can also give the user a way out by +providing ``cancelers`` (e.g., a cancel button) or a ``timeout`` in seconds. +If either triggers, a exception is raised — see *Handling cancel and timeout* below. .. code-block:: python :linenos: @@ -64,13 +80,11 @@ Now, listeners are blocking. This means that the code execution will stop until ) ... -In the example above, we added a button to the message that the user can press to cancel the listener. We also set a timeout of 60 seconds for the listener. If the user presses the cancel button or if the listener times out, the listener will stop waiting for a reply and raise an exception. - Handling cancel and timeout -____________________________ +--------------------------- -When a listener is canceled or times out, it raises an exception. Most of the time, you will want to handle these exceptions to provide a better user experience. PyWa provides two exceptions for this purpose: :class:`~pywa.listeners.ListenerCanceled` and :class:`~pywa.listeners.ListenerTimeout`. -You can use these exceptions to handle the cancel and timeout cases in your code. Let's see an example: +When a listener is canceled or times out, pywa raises :class:`~pywa.listeners.ListenerCanceled` +or :class:`~pywa.listeners.ListenerTimeout` respectively. Catch them to send a helpful response. .. code-block:: python :linenos: @@ -80,7 +94,6 @@ You can use these exceptions to handle the cancel and timeout cases in your code wa = WhatsApp(...) - @wa.on_message(filters.command("start")) def start(_: WhatsApp, msg: types.Message): try: @@ -100,31 +113,50 @@ You can use these exceptions to handle the cancel and timeout cases in your code return ... - -In the example above, we used a try-except block to handle the :class:`~pywa.listeners.ListenerCanceled` and :class:`~pywa.listeners.ListenerTimeout` exceptions. If the user cancels the listener by clicking the cancel button, we send a message to inform them that they canceled the operation. If the listener times out, we send a message to inform the user that they took too long to respond. -If the listener returns a result, we can continue processing the user's input as usual. - - Custom listeners -_________________ +---------------- .. currentmodule:: pywa.client -You can create custom listeners by using the raw :meth:`WhatsApp.listen` method. This method allows you to create a listener that waits for a specific update and returns the result when the update is received. +For advanced use cases, you can use the lower-level :meth:`WhatsApp.listen` method directly. +It lets you specify which sender and update type to wait for, and is what all the shortcuts +(``wait_for_reply``, ``wait_for_click``, etc.) are built on top of. .. attention:: - If the listener did not **use** the update (the update not matched the filters or the cancelers), the update **will be passed to the handlers**. - This means that the update can be processed by other handlers that are registered to handle the same update type. - This behavior changed since version ``3.0.0``, before that - when update was not used by the listener - it was ignored and not passed to the handlers. + If the listener did not **use** the update (the update did not match the filters or the + cancelers), the update **will be passed to the handlers**. + This means that the update can be processed by other handlers registered for the same update type. - If you must prevent the update from being passed to the handlers, call the :meth:`~pywa.types.base_update.BaseUpdate.stop_handling` method on the update inside the filters or the cancelers (it will not affect the listener behavior, just prevent the update from being passed to the handlers). + If you must prevent the update from being passed to the handlers, call + :meth:`~pywa.types.base_update.BaseUpdate.stop_handling` on the update inside the filters + or cancelers (this only affects handler dispatch, not the listener itself). +.. code-block:: python + :linenos: + :emphasize-lines: 7-12 + + from pywa import WhatsApp, types, filters + + wa = WhatsApp(...) + + @wa.on_message(filters.command("confirm")) + def confirm_action(_: WhatsApp, msg: types.Message): + confirmation = wa.listen( + to=msg.sender, + filters=filters.callback_button & filters.matches("yes", "no"), + cancelers=filters.text & filters.matches("cancel"), + timeout=30, + ) + if confirmation.data == "yes": + msg.reply("✅ Confirmed!") + else: + msg.reply("❌ Canceled.") Shortcuts -_________ +--------- -PyWa provides a few shortcuts to create listeners when sending messages. Let's see an example: +PyWa provides several shortcuts to create listeners directly from sent messages: .. code-block:: python :linenos: @@ -136,10 +168,8 @@ PyWa provides a few shortcuts to create listeners when sending messages. Let's s @wa.on_message(filters.command("start")) def start(client: WhatsApp, msg: types.Message): - age: types.Message = m.reply("Hello! How old are you?").wait_for_reply(filters.text) - m.reply(f"You are {age.text} years old") - -In the example above, we used the :meth:`~pywa.types.sent_update.SentMessage.wait_for_reply` method to create a listener that waits for a text reply from the user. + age = msg.reply("Hello! How old are you?").wait_for_reply(filters.text) + msg.reply(f"You are {age.text} years old") .. code-block:: python :linenos: @@ -154,9 +184,10 @@ In the example above, we used the :meth:`~pywa.types.sent_update.SentMessage.wai msg.reply(f"Hello {msg.from_user.name}!").wait_until_delivered() msg.reply("How can I help you?") -In the example above, we used the :meth:`~pywa.types.sent_update.SentMessage.wait_until_delivered` method to create a listener that waits until the message is delivered to the user. - -Other shortcuts are available, such as :meth:`~pywa.types.sent_update.SentMessage.wait_for_click`, :meth:`~pywa.types.sent_update.SentMessage.wait_for_selection`, :meth:`~pywa.types.sent_update.SentMessage.wait_until_read`, :meth:`~pywa.types.sent_update.SentVoiceMessage.wait_until_played`, and more. +Other shortcuts include :meth:`~pywa.types.sent_update.SentMessage.wait_for_click`, +:meth:`~pywa.types.sent_update.SentMessage.wait_for_selection`, +:meth:`~pywa.types.sent_update.SentMessage.wait_until_read`, +:meth:`~pywa.types.sent_update.SentVoiceMessage.wait_until_played`, and more. .. toctree:: diff --git a/docs/source/content/updates/overview.rst b/docs/source/content/updates/overview.rst index a52e0a0e..64129a9e 100644 --- a/docs/source/content/updates/overview.rst +++ b/docs/source/content/updates/overview.rst @@ -6,7 +6,7 @@ Updates are the **incoming events** from the WhatsApp Cloud API. They are sent to your webhook URL and converted by PyWa into type-safe objects that are easy to handle. -In WhatsApp Cloud API, updates are called **fields**, and you need to subscribe to them in order to receive them at your webhook URL. +In the WhatsApp Cloud API, incoming events are grouped into webhook **fields** (such as ``messages`` or ``calls``). To receive these updates, you must configure your webhook URL in the Meta Developer Portal and subscribe to the specific fields your bot needs. ----------------- Supported Fields @@ -165,7 +165,7 @@ All updates share common methods and properties: - Reply with a location message * - :meth:`~BaseUserUpdate.reply_location_request` - Request the user’s location - * :meth:`~BaseUserUpdate.reply_contact_info_request` + * - :meth:`~BaseUserUpdate.reply_contact_info_request` - Request the user’s contact info * - :meth:`~BaseUserUpdate.reply_contact` - Reply with a contact message diff --git a/pywa/filters.py b/pywa/filters.py index 339a8b2e..b6872445 100644 --- a/pywa/filters.py +++ b/pywa/filters.py @@ -27,7 +27,7 @@ "sent_to", "sent_to_me", "from_users", - "no_wa_id", + "without_wa_id", "from_countries", "matches", "contains", @@ -303,7 +303,7 @@ def has_async(self) -> bool: >>> filters.reply """ -no_wa_id = new(lambda _, u: u.from_user.wa_id is None, name="no_wa_id") +without_wa_id = new(lambda _, u: u.from_user.wa_id is None, name="without_wa_id") """ Filter for updates that their sender doesn't have a ``wa_id`` (when the user enables username) """ diff --git a/pywa/types/base_update.py b/pywa/types/base_update.py index 409b8709..3076652c 100644 --- a/pywa/types/base_update.py +++ b/pywa/types/base_update.py @@ -788,7 +788,7 @@ def reply_contact_info_request( Example: >>> wa = WhatsApp(...) - >>> @wa.on_message(filters.no_wa_id, priority=100) + >>> @wa.on_message(filters.without_wa_id, priority=100) ... def callback(_: WhatsApp, msg: Message): ... # check in your db if the user has shared their contact info before, if not, request it: ... msg.reply_contact_info_request( diff --git a/pywa_async/types/base_update.py b/pywa_async/types/base_update.py index 3b7e1d3c..5d6368a7 100644 --- a/pywa_async/types/base_update.py +++ b/pywa_async/types/base_update.py @@ -616,7 +616,7 @@ async def reply_contact_info_request( Example: >>> wa = WhatsApp(...) - >>> @wa.on_message(filters.no_wa_id, priority=100) + >>> @wa.on_message(filters.without_wa_id, priority=100) ... async def callback(_: WhatsApp, msg: Message): ... # check in your db if the user has shared their contact info before, if not, request it: ... await msg.reply_contact_info_request( From 53c65675170f27d45c2702ca90364b694f137286 Mon Sep 17 00:00:00 2001 From: David Lev <42866208+david-lev@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:16:31 +0300 Subject: [PATCH 2/8] [handlers] improve typing and examples, fixes #207 --- docs/source/content/handlers/overview.rst | 70 +- pywa/handlers.py | 1748 +++++++++++++-------- 2 files changed, 1101 insertions(+), 717 deletions(-) diff --git a/docs/source/content/handlers/overview.rst b/docs/source/content/handlers/overview.rst index ca6f5d46..2a3c3904 100644 --- a/docs/source/content/handlers/overview.rst +++ b/docs/source/content/handlers/overview.rst @@ -6,8 +6,8 @@ Handlers are where your bot reacts to incoming WhatsApp updates. In pywa, every incoming webhook update is converted into a typed update object, such as -:class:`~pywa.types.Message`, :class:`~pywa.types.CallbackButton`, or -:class:`~pywa.types.MessageStatus`. You register callback functions for the update types you +:class:`~pywa.types.message.Message`, :class:`~pywa.types.callback.CallbackButton`, or +:class:`~pywa.types.message_status.MessageStatus`. You register callback functions for the update types you care about, and pywa calls the right function when WhatsApp sends an update. The usual workflow is: @@ -15,7 +15,7 @@ The usual workflow is: 1. Create a :class:`~pywa.client.WhatsApp` client. 2. Register handlers with decorators or ``Handler`` objects. 3. Give WhatsApp a public callback URL. -4. Run the app with ``pywa dev`` while developing, or ``pywa run`` when deploying. +4. Run the app with ``pywa dev`` while developing, or ``pywa run`` for production. This guide starts with the day-to-day part: writing handlers. @@ -27,6 +27,7 @@ A handler callback receives the WhatsApp client and the update object. .. code-block:: python :caption: main.py :linenos: + :emphasize-lines: 9-11 from pywa import WhatsApp, filters, types @@ -79,20 +80,24 @@ Use the ``on_...`` decorators on your :class:`~pywa.client.WhatsApp` client. def handle_callback_button(client: WhatsApp, clb: types.CallbackButton): clb.react("❤️") -You can pass filters to many handlers: +You can pass filters to the handlers: .. code-block:: python :caption: main.py :linenos: - :emphasize-lines: 5 + :emphasize-lines: 5, 9 from pywa import WhatsApp, filters, types wa = WhatsApp(...) - @wa.on_message(filters.command("start")) - def start(client: WhatsApp, msg: types.Message): - msg.reply("Welcome!") + @wa.on_message(filters.text) + def handle_text_message(client: WhatsApp, msg: types.Message): + msg.reply(f"You said: {msg.text}") + + @wa.on_message(filters.image | filters.video) + def handle_media_message(client: WhatsApp, msg: types.Message): + msg.reply(f"Thanks for sending a media message.") See the `filters guide <../filters/overview.html>`_ for built-in filters and custom filters. @@ -134,39 +139,48 @@ You can also load modules later: wa.load_handlers_modules(my_handlers) -Using ``Handler`` objects -^^^^^^^^^^^^^^^^^^^^^^^^^ - -For larger projects, or when handlers are created dynamically, wrap callbacks in ``Handler`` -objects and register them with :meth:`~pywa.client.WhatsApp.add_handlers`. +Dynamic Handler Registration +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -.. code-block:: python - :caption: handlers.py - :linenos: +You can register and remove handlers dynamically at runtime instead of declaring them all at startup. This is useful for state-dependent workflows (e.g., toggling a temporary maintenance mode) where handlers are added or removed on the fly. - from pywa import types +To register a handler dynamically, instantiate one of the `Available Handlers` and pass it to :meth:`~pywa.client.WhatsApp.add_handlers`. To stop listening, pass the same handler instance to :meth:`~pywa.client.WhatsApp.remove_handlers`. - def handle_message(client, msg: types.Message): - print(msg.text) - - def handle_callback_button(client, clb: types.CallbackButton): - print(clb.data) +Here is an example demonstrating how to register a high-priority maintenance handler and dynamically remove it: .. code-block:: python :caption: main.py :linenos: - :emphasize-lines: 2, 6-9 + :emphasize-lines: 13-16, 22, 25 - from pywa import WhatsApp, filters, handlers - import handlers as my_handlers + from pywa import WhatsApp, filters, handlers, types wa = WhatsApp(...) - wa.add_handlers( - handlers.MessageHandler(my_handlers.handle_message, filters.text), - handlers.CallbackButtonHandler(my_handlers.handle_callback_button), + admin_filter = filters.from_users("1234567890", "9876543210") + + # Define a high-priority handler callback that intercepts messages during maintenance + def maintenance_callback(client: WhatsApp, msg: types.Message): + msg.reply("🛠️ The bot is currently undergoing maintenance. Please try again later.") + msg.stop_handling() # Prevent other, lower-priority handlers from running + + # Create the handler instance with high priority + maintenance_handler = handlers.MessageHandler( + callback=maintenance_callback, + priority=100, ) + # Handler to turn maintenance mode ON or OFF + @wa.on_message(filters.command("maintenance") & admin_filter) + def enable_maintenance(client: WhatsApp, msg: types.Message): + if msg.text.split("maintenance")[1].strip() == "on": + client.add_handlers(maintenance_handler) + msg.reply("Maintenance mode has been activated.") + else: + client.remove_handlers(maintenance_handler, silent=True) + msg.reply("Maintenance mode has been deactivated.") + + Available Handlers ------------------ diff --git a/pywa/handlers.py b/pywa/handlers.py index 9953a8cb..5082ce49 100644 --- a/pywa/handlers.py +++ b/pywa/handlers.py @@ -80,6 +80,7 @@ TypedDict, TypeVar, cast, + overload, ) from . import _helpers as helpers @@ -221,6 +222,7 @@ class EncryptedFlowRequestType(TypedDict): ) _UpdateType = TypeVar("_UpdateType") +_CallbackT = TypeVar("_CallbackT", bound=Callable) class Handler(Generic[_UpdateType]): @@ -316,7 +318,7 @@ class MessageHandler(Handler[Message]): def __init__( self, callback: _MessageCallback, - filters: Filter = None, + filters: Filter | None = None, priority: int = 0, ): super().__init__(callback=callback, filters=filters, priority=priority) @@ -394,7 +396,7 @@ class CallbackButtonHandler(_FactoryHandler[CallbackButton]): def __init__( self, callback: _CallbackButtonCallback, - filters: Filter = None, + filters: Filter | None = None, factory: type[CallbackData] | None = None, priority: int = 0, ): @@ -432,7 +434,7 @@ class CallbackSelectionHandler(_FactoryHandler[CallbackSelection]): def __init__( self, callback: _CallbackSelectionCallback, - filters: Filter = None, + filters: Filter | None = None, factory: type[CallbackData] | None = None, priority: int = 0, ): @@ -472,7 +474,7 @@ class MessageStatusHandler(_FactoryHandler[MessageStatus]): def __init__( self, callback: _MessageStatusCallback, - filters: Filter = None, + filters: Filter | None = None, factory: type[CallbackData] | None = None, priority: int = 0, ): @@ -507,7 +509,7 @@ class GroupMessageStatusesHandler(Handler[GroupMessageStatuses]): def __init__( self, callback: _GroupMessageStatusesCallback, - filters: Filter = None, + filters: Filter | None = None, priority: int = 0, ): super().__init__(callback=callback, filters=filters, priority=priority) @@ -537,7 +539,7 @@ class PhoneNumberChangeHandler(Handler[PhoneNumberChange]): def __init__( self, callback: _PhoneNumberChangeCallback, - filters: Filter = None, + filters: Filter | None = None, priority: int = 0, ): super().__init__(callback=callback, filters=filters, priority=priority) @@ -567,7 +569,7 @@ class IdentityChangeHandler(Handler[IdentityChange]): def __init__( self, callback: _IdentityChangeCallback, - filters: Filter = None, + filters: Filter | None = None, priority: int = 0, ): super().__init__(callback=callback, filters=filters, priority=priority) @@ -598,7 +600,7 @@ class TemplateStatusUpdateHandler(Handler[TemplateStatusUpdate]): def __init__( self, callback: _TemplateStatusUpdateCallback, - filters: Filter = None, + filters: Filter | None = None, priority: int = 0, ): super().__init__(callback=callback, filters=filters, priority=priority) @@ -629,7 +631,7 @@ class TemplateCategoryUpdateHandler(Handler[TemplateCategoryUpdate]): def __init__( self, callback: _TemplateCategoryUpdateCallback, - filters: Filter = None, + filters: Filter | None = None, priority: int = 0, ): super().__init__(callback=callback, filters=filters, priority=priority) @@ -659,7 +661,7 @@ class TemplateQualityUpdateHandler(Handler[TemplateQualityUpdate]): def __init__( self, callback: _TemplateQualityUpdateCallback, - filters: Filter = None, + filters: Filter | None = None, priority: int = 0, ): super().__init__(callback=callback, filters=filters, priority=priority) @@ -689,7 +691,7 @@ class TemplateComponentsUpdateHandler(Handler[TemplateComponentsUpdate]): def __init__( self, callback: _TemplateComponentsUpdateCallback, - filters: Filter = None, + filters: Filter | None = None, priority: int = 0, ): super().__init__(callback=callback, filters=filters, priority=priority) @@ -719,7 +721,7 @@ class UserMarketingPreferencesHandler(Handler[UserMarketingPreferences]): def __init__( self, callback: _UserMarketingPreferencesCallback, - filters: Filter = None, + filters: Filter | None = None, priority: int = 0, ): super().__init__(callback=callback, filters=filters, priority=priority) @@ -749,7 +751,7 @@ class FlowCompletionHandler(Handler[FlowCompletion]): def __init__( self, callback: _FlowCompletionCallback, - filters: Filter = None, + filters: Filter | None = None, priority: int = 0, ): super().__init__(callback=callback, filters=filters, priority=priority) @@ -779,7 +781,7 @@ class CallConnectHandler(Handler[CallConnect]): def __init__( self, callback: _CallConnectCallback, - filters: Filter = None, + filters: Filter | None = None, priority: int = 0, ): super().__init__(callback=callback, filters=filters, priority=priority) @@ -809,7 +811,7 @@ class CallTerminateHandler(Handler[CallTerminate]): def __init__( self, callback: _CallTerminateCallback, - filters: Filter = None, + filters: Filter | None = None, priority: int = 0, ): super().__init__(callback=callback, filters=filters, priority=priority) @@ -841,7 +843,7 @@ class CallStatusHandler(_FactoryHandler[CallStatus]): def __init__( self, callback: _CallStatusCallback, - filters: Filter = None, + filters: Filter | None = None, factory: type[CallbackData] | None = None, priority: int = 0, ): @@ -877,7 +879,7 @@ class CallPermissionUpdateHandler(Handler[CallPermissionUpdate]): def __init__( self, callback: _CallPermissionUpdateCallback, - filters: Filter = None, + filters: Filter | None = None, priority: int = 0, ): super().__init__(callback=callback, filters=filters, priority=priority) @@ -907,7 +909,7 @@ class EditedMessageHandler(Handler[EditedMessage]): def __init__( self, callback: _EditedMessageCallback, - filters: Filter = None, + filters: Filter | None = None, priority: int = 0, ): super().__init__(callback=callback, filters=filters, priority=priority) @@ -937,7 +939,7 @@ class DeletedMessageHandler(Handler[DeletedMessage]): def __init__( self, callback: _DeletedMessageCallback, - filters: Filter = None, + filters: Filter | None = None, priority: int = 0, ): super().__init__(callback=callback, filters=filters, priority=priority) @@ -967,7 +969,7 @@ class OutgoingMessageHandler(Handler[OutgoingMessage]): def __init__( self, callback: _OutgoingMessageCallback, - filters: Filter = None, + filters: Filter | None = None, priority: int = 0, ): super().__init__(callback=callback, filters=filters, priority=priority) @@ -997,7 +999,7 @@ class OutgoingEditedMessageHandler(Handler[OutgoingEditedMessage]): def __init__( self, callback: _OutgoingEditedMessageCallback, - filters: Filter = None, + filters: Filter | None = None, priority: int = 0, ): super().__init__(callback=callback, filters=filters, priority=priority) @@ -1027,7 +1029,7 @@ class OutgoingDeletedMessageHandler(Handler[OutgoingDeletedMessage]): def __init__( self, callback: _OutgoingDeletedMessageCallback, - filters: Filter = None, + filters: Filter | None = None, priority: int = 0, ): super().__init__(callback=callback, filters=filters, priority=priority) @@ -1057,7 +1059,7 @@ class AccountUpdateHandler(Handler[AccountUpdate]): def __init__( self, callback: _AccountUpdateCallback, - filters: Filter = None, + filters: Filter | None = None, priority: int = 0, ): super().__init__(callback=callback, filters=filters, priority=priority) @@ -1087,7 +1089,7 @@ class RawUpdateHandler(Handler[RawUpdate]): def __init__( self, callback: _RawUpdateCallback, - filters: Filter = None, + filters: Filter | None = None, priority: int = 0, ): super().__init__(callback=callback, filters=filters, priority=priority) @@ -1115,7 +1117,7 @@ def add_handler( callback: _FlowRequestHandlerT, action: FlowRequestActionType, screen: Screen | str | None = None, - filters: Filter = None, + filters: Filter | None = None, ) -> _CallbackWrapperDecorators: ... @abc.abstractmethod @@ -1129,7 +1131,7 @@ def on( *, action: FlowRequestActionType, screen: Screen | str | None = None, - filters: Filter = None, + filters: Filter | None = None, ) -> Callable[[_FlowRequestHandlerT], _FlowRequestHandlerT] | _FlowRequestHandlerT: """ Decorator to help you add more handlers to the same endpoint and split the logic into multiple functions. @@ -1154,7 +1156,7 @@ def decorator(callback: _FlowRequestHandlerT) -> _FlowRequestHandlerT: return decorator def on_init( - self=None, filters: Filter = None, *, call_on_error: bool = False + self=None, filters: Filter | None = None, *, call_on_error: bool = False ) -> Callable[[_FlowRequestHandlerT], _FlowRequestHandlerT]: """ Decorator to add a handler for the :class:`FlowRequestActionType.INIT` action. @@ -1206,7 +1208,7 @@ def deco(callback: _FlowRequestHandlerT) -> _FlowRequestHandlerT: def on_data_exchange( self=None, screen: Screen | str | None = None, - filters: Filter = None, + filters: Filter | None = None, *, call_on_error: bool = False, ) -> Callable[[_FlowRequestHandlerT], _FlowRequestHandlerT]: @@ -1260,7 +1262,7 @@ def on_back( self=None, *, screen: Screen | str | None = None, - filters: Filter = None, + filters: Filter | None = None, ) -> Callable[[_FlowRequestHandlerT], _FlowRequestHandlerT]: """ Decorator to add a handler for the :class:`FlowRequestActionType.BACK` action. @@ -1309,7 +1311,7 @@ def deco(callback: _FlowRequestHandlerT) -> _FlowRequestHandlerT: def on_completion( self=None, - filters: Filter = None, + filters: Filter | None = None, *, priority: int = 0, ) -> Callable[[_FlowCompletionCallback], _FlowCompletionCallback]: @@ -1395,7 +1397,7 @@ def add_handler( callback: _FlowRequestHandlerT, action: FlowRequestActionType, screen: Screen | str | None = None, - filters: Filter = None, + filters: Filter | None = None, ) -> _CallbackWrapperDecorators: self._handlers[ (action, screen.id if isinstance(screen, Screen) else screen) @@ -1419,15 +1421,104 @@ def add_completion_handler( """Indicates that the function is a flow request handler that should be registered.""" +def _handle_on_update( + self: WhatsApp | Filter | Callable | None, + handler_type: type[Handler], + filters: Filter | Callable | None, + priority: int, + **kwargs, +) -> Any: + # Determine if 'self' is the WhatsApp client instance + is_wa_instance = hasattr(self, "add_handlers") + + # Case 1: @wa.on_x (no parentheses) + # self=wa, filters=callback + if is_wa_instance and callable(filters): + self.add_handlers( + handler_type(callback=filters, filters=None, priority=priority, **kwargs) + ) + return filters + + # Case 2: @WhatsApp.on_x (no parentheses) + # self=callback, filters=None + if ( + not is_wa_instance + and callable(self) + and filters is None + and not isinstance(self, Filter) + ): + _register_func_handler( + handler_type=handler_type, + callback=self, + filters=None, + priority=priority, + **kwargs, + ) + return self + + # Case 3: @wa.on_x(...) or @WhatsApp.on_x(...) (with parentheses) + def deco(callback: Callable) -> Callable: + if is_wa_instance: + self.add_handlers( + handler_type( + callback=callback, filters=filters, priority=priority, **kwargs + ) + ) + else: + _register_func_handler( + handler_type=handler_type, + callback=callback, + filters=self + if ( + isinstance(self, Filter) + or (callable(self) and self is not callback) + ) + else filters, + priority=priority, + **kwargs, + ) + return callback + + return deco + + class _HandlerDecorators: """This class is used by the :class:`WhatsApp` client to register handlers using decorators.""" def __init__(self: WhatsApp): raise TypeError("This class cannot be instantiated.") + @overload + def on_raw_update( + self: WhatsApp, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_RawUpdateCallback], _RawUpdateCallback]: ... + + @overload + def on_raw_update( + self: _RawUpdateCallback, + filters: Filter | None = None, + priority: int = 0, + ) -> _RawUpdateCallback: ... + + @overload + def on_raw_update( + self: Filter, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_RawUpdateCallback], _RawUpdateCallback]: ... + + @overload + def on_raw_update( + self: None = None, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_RawUpdateCallback], _RawUpdateCallback]: ... + def on_raw_update( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: WhatsApp | Filter | _RawUpdateCallback | None = None, + filters: Filter | None = None, priority: int = 0, ) -> Callable[[_RawUpdateCallback], _RawUpdateCallback] | _RawUpdateCallback: """ @@ -1438,40 +1529,54 @@ def on_raw_update( Example: + >>> from pywa import WhatsApp, filters >>> wa = WhatsApp(...) - >>> @wa.on_raw_update - ... def raw_update_handler(_: WhatsApp, update: RawUpdate): - ... print(update) + >>> @wa.on_raw_update(filters.webhook_fields("video_calls")) + ... def on_video_call(_: WhatsApp, update: RawUpdate): + ... print(f"New video call! {update}") Args: filters: Filters to apply to the incoming updates. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=RawUpdateHandler, + filters=filters, + priority=priority, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=RawUpdateHandler, - filters=filters, - priority=priority, - ) - ) is not None: - return clb + @overload + def on_message( + self: WhatsApp, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_MessageCallback], _MessageCallback]: ... - def deco(callback: _RawUpdateCallback) -> _RawUpdateCallback: - return _registered_with_parentheses( - self=self, - handler_type=RawUpdateHandler, - callback=callback, - filters=filters, - priority=priority, - ) + @overload + def on_message( + self: _MessageCallback, + filters: Filter | None = None, + priority: int = 0, + ) -> _MessageCallback: ... - return deco + @overload + def on_message( + self: Filter, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_MessageCallback], _MessageCallback]: ... + + @overload + def on_message( + self: None = None, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_MessageCallback], _MessageCallback]: ... def on_message( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: WhatsApp | Filter | _MessageCallback | None = None, + filters: Filter | None = None, priority: int = 0, ) -> Callable[[_MessageCallback], _MessageCallback] | _MessageCallback: """ @@ -1483,40 +1588,57 @@ def on_message( >>> from pywa import WhatsApp, types, filters >>> wa = WhatsApp(...) - >>> @wa.on_message(filters.matches("Hello", "Hi", ignore_case=True)) + >>> @wa.on_message(filters.command("start") | filters.matches("hello", ignore_case=True)) ... def hello_handler(_: WhatsApp, msg: types.Message): ... msg.react("👋") - ... msg.reply_text(text="Hello from PyWa!", quote=True) + ... msg.reply_text(text=f"Hello {msg.from_user.name}! How can I help you?") Args: filters: Filters to apply to the incoming messages. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=MessageHandler, + filters=filters, + priority=priority, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=MessageHandler, - filters=filters, - priority=priority, - ) - ) is not None: - return clb + @overload + def on_callback_button( + self: WhatsApp, + filters: Filter | None = None, + factory: type[CallbackData] | None = None, + priority: int = 0, + ) -> Callable[[_CallbackButtonCallback], _CallbackButtonCallback]: ... - def deco(callback: _MessageCallback) -> _MessageCallback: - return _registered_with_parentheses( - self=self, - handler_type=MessageHandler, - callback=callback, - filters=filters, - priority=priority, - ) + @overload + def on_callback_button( + self: _CallbackButtonCallback, + filters: Filter | None = None, + factory: type[CallbackData] | None = None, + priority: int = 0, + ) -> _CallbackButtonCallback: ... - return deco + @overload + def on_callback_button( + self: Filter, + filters: Filter | None = None, + factory: type[CallbackData] | None = None, + priority: int = 0, + ) -> Callable[[_CallbackButtonCallback], _CallbackButtonCallback]: ... + + @overload + def on_callback_button( + self: None = None, + filters: Filter | None = None, + factory: type[CallbackData] | None = None, + priority: int = 0, + ) -> Callable[[_CallbackButtonCallback], _CallbackButtonCallback]: ... def on_callback_button( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: WhatsApp | Filter | _CallbackButtonCallback | None = None, + filters: Filter | None = None, factory: type[CallbackData] | None = None, priority: int = 0, ) -> ( @@ -1530,44 +1652,63 @@ def on_callback_button( Example: - >>> from pywa import WhatsApp, types, filters + >>> from pywa import WhatsApp, types >>> wa = WhatsApp(...) - >>> @wa.on_callback_button(filters.matches("help")) - ... def help_handler(_: WhatsApp, btn: types.CallbackButton): - ... btn.reply_text(text="What can I help you with?") + >>> class UserData(types.CallbackData): + ... id: int + ... admin: bool + >>> @wa.on_callback_button(factory=UserData) + ... def on_user_click(_: WhatsApp, btn: types.CallbackButton[UserData]): + ... print(f"User {btn.data.id} (admin: {btn.data.admin}) clicked the button") Args: filters: Filters to apply to the incoming callback button presses. factory: The :class:`~pywa.types.callback.CallbackData` subclass to use to construct the callback data. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=CallbackButtonHandler, + filters=filters, + priority=priority, + factory=factory, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=CallbackButtonHandler, - filters=filters, - priority=priority, - factory=factory, - ) - ) is not None: - return clb + @overload + def on_callback_selection( + self: WhatsApp, + filters: Filter | None = None, + factory: type[CallbackData] | None = None, + priority: int = 0, + ) -> Callable[[_CallbackSelectionCallback], _CallbackSelectionCallback]: ... - def deco(callback: _CallbackButtonCallback) -> _CallbackButtonCallback: - return _registered_with_parentheses( - self=self, - handler_type=CallbackButtonHandler, - callback=callback, - filters=filters, - priority=priority, - factory=factory, - ) + @overload + def on_callback_selection( + self: _CallbackSelectionCallback, + filters: Filter | None = None, + factory: type[CallbackData] | None = None, + priority: int = 0, + ) -> _CallbackSelectionCallback: ... - return deco + @overload + def on_callback_selection( + self: Filter, + filters: Filter | None = None, + factory: type[CallbackData] | None = None, + priority: int = 0, + ) -> Callable[[_CallbackSelectionCallback], _CallbackSelectionCallback]: ... + + @overload + def on_callback_selection( + self: None = None, + filters: Filter | None = None, + factory: type[CallbackData] | None = None, + priority: int = 0, + ) -> Callable[[_CallbackSelectionCallback], _CallbackSelectionCallback]: ... def on_callback_selection( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: WhatsApp | Filter | _CallbackSelectionCallback | None = None, + filters: Filter | None = None, factory: type[CallbackData] | None = None, priority: int = 0, ) -> ( @@ -1581,44 +1722,63 @@ def on_callback_selection( Example: - >>> from pywa import WhatsApp, types, filters + >>> from pywa import WhatsApp, types >>> wa = WhatsApp(...) - >>> @wa.on_callback_selection(filters.startswith("id:")) - ... def id_handler(_: WhatsApp, sel: types.CallbackSelection): - ... sel.reply_text(text=f"Your ID is {sel.data.split(':', 1)[1]}") + >>> class ShopData(types.CallbackData): + ... item_id: str + ... price: float + >>> @wa.on_callback_selection(factory=ShopData) + ... def on_shop_selection(_: WhatsApp, sel: types.CallbackSelection[ShopData]): + ... sel.reply_text(text=f"You selected item {sel.data.item_id} which costs {sel.data.price}$") Args: filters: Filters to apply to the incoming callback selections. factory: The :class:`~pywa.types.callback.CallbackData` subclass to use to construct the callback data. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=CallbackSelectionHandler, + filters=filters, + priority=priority, + factory=factory, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=CallbackSelectionHandler, - filters=filters, - priority=priority, - factory=factory, - ) - ) is not None: - return clb + @overload + def on_message_status( + self: WhatsApp, + filters: Filter | None = None, + factory: type[CallbackData] | None = None, + priority: int = 0, + ) -> Callable[[_MessageStatusCallback], _MessageStatusCallback]: ... - def deco(callback: _CallbackSelectionCallback) -> _CallbackSelectionCallback: - return _registered_with_parentheses( - self=self, - handler_type=CallbackSelectionHandler, - callback=callback, - filters=filters, - priority=priority, - factory=factory, - ) + @overload + def on_message_status( + self: _MessageStatusCallback, + filters: Filter | None = None, + factory: type[CallbackData] | None = None, + priority: int = 0, + ) -> _MessageStatusCallback: ... - return deco + @overload + def on_message_status( + self: Filter, + filters: Filter | None = None, + factory: type[CallbackData] | None = None, + priority: int = 0, + ) -> Callable[[_MessageStatusCallback], _MessageStatusCallback]: ... + + @overload + def on_message_status( + self: None = None, + filters: Filter | None = None, + factory: type[CallbackData] | None = None, + priority: int = 0, + ) -> Callable[[_MessageStatusCallback], _MessageStatusCallback]: ... def on_message_status( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: WhatsApp | Filter | _MessageStatusCallback | None = None, + filters: Filter | None = None, factory: type[CallbackData] | None = None, priority: int = 0, ) -> ( @@ -1634,11 +1794,11 @@ def on_message_status( Example: - >>> from pywa import WhatsApp, types, filters + >>> from pywa import WhatsApp, types, filters, errors >>> wa = WhatsApp(...) - >>> @wa.on_message_status(filters.failed) - ... def delivered_handler(client: WhatsApp, status: types.MessageStatus): - ... print(f"Message {status.id} failed to send to {status.from_user.wa_id}: {status.error.message}) + >>> @wa.on_message_status(filters.failed_with(errors.ReEngagementMessage)) + ... def on_re_engagement_failed(_: WhatsApp, status: types.MessageStatus): + ... print(f"Message failed to send to {status.from_user} because 24h passed") Args: @@ -1646,33 +1806,45 @@ def on_message_status( factory: The :class:`~pywa.types.callback.CallbackData` subclass to use to construct the ``tracker`` data. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=MessageStatusHandler, + filters=filters, + priority=priority, + factory=factory, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=MessageStatusHandler, - filters=filters, - priority=priority, - factory=factory, - ) - ) is not None: - return clb + @overload + def on_group_message_statuses( + self: WhatsApp, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_GroupMessageStatusesCallback], _GroupMessageStatusesCallback]: ... - def deco(callback: _MessageStatusCallback) -> _MessageStatusCallback: - return _registered_with_parentheses( - self=self, - handler_type=MessageStatusHandler, - callback=callback, - filters=filters, - priority=priority, - factory=factory, - ) + @overload + def on_group_message_statuses( + self: _GroupMessageStatusesCallback, + filters: Filter | None = None, + priority: int = 0, + ) -> _GroupMessageStatusesCallback: ... - return deco + @overload + def on_group_message_statuses( + self: Filter, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_GroupMessageStatusesCallback], _GroupMessageStatusesCallback]: ... + + @overload + def on_group_message_statuses( + self: None = None, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_GroupMessageStatusesCallback], _GroupMessageStatusesCallback]: ... def on_group_message_statuses( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: WhatsApp | Filter | _GroupMessageStatusesCallback | None = None, + filters: Filter | None = None, priority: int = 0, ) -> ( Callable[[_GroupMessageStatusesCallback], _GroupMessageStatusesCallback] @@ -1689,41 +1861,52 @@ def on_group_message_statuses( >>> from pywa import WhatsApp, types, filters >>> wa = WhatsApp(...) - >>> @wa.on_group_message_statuses - ... def callback(client: WhatsApp, statuses: types.GroupMessageStatuses): - ... for status in statuses: print(f"Message {status.id} to {statuses.group_id} is {status.status}") + >>> @wa.on_group_message_statuses(filters.read) + ... def on_group_read(_: WhatsApp, statuses: types.GroupMessageStatuses): + ... print(f"Message {statuses.id} was read by {len(statuses.statuses)} participants in {statuses.group_id}") Args: filters: Filters to apply to the incoming group message status changes. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=GroupMessageStatusesHandler, + filters=filters, + priority=priority, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=GroupMessageStatusesHandler, - filters=filters, - priority=priority, - ) - ) is not None: - return clb - - def deco( - callback: _GroupMessageStatusesCallback, - ) -> _GroupMessageStatusesCallback: - return _registered_with_parentheses( - self=self, - handler_type=GroupMessageStatusesHandler, - callback=callback, - filters=filters, - priority=priority, - ) + @overload + def on_phone_number_change( + self: WhatsApp, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_PhoneNumberChangeCallback], _PhoneNumberChangeCallback]: ... - return deco + @overload + def on_phone_number_change( + self: _PhoneNumberChangeCallback, + filters: Filter | None = None, + priority: int = 0, + ) -> _PhoneNumberChangeCallback: ... + + @overload + def on_phone_number_change( + self: Filter, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_PhoneNumberChangeCallback], _PhoneNumberChangeCallback]: ... + + @overload + def on_phone_number_change( + self: None = None, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_PhoneNumberChangeCallback], _PhoneNumberChangeCallback]: ... def on_phone_number_change( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: WhatsApp | Filter | _PhoneNumberChangeCallback | None = None, + filters: Filter | None = None, priority: int = 0, ) -> ( Callable[[_PhoneNumberChangeCallback], _PhoneNumberChangeCallback] @@ -1739,38 +1922,51 @@ def on_phone_number_change( >>> from pywa import WhatsApp, types >>> wa = WhatsApp(...) >>> @wa.on_phone_number_change - ... def phone_number_change_handler(client: WhatsApp, phone_number_change: types.PhoneNumberChange): - ... print(f"The user {phone_number_change.from_user.wa_id} just changed their phone number to {phone_number_change.new_phone_number}!") + ... def on_num_change(_: WhatsApp, pnc: types.PhoneNumberChange): + ... print(f"User {pnc.from_user.wa_id} changed to {pnc.new_wa_id}") Args: filters: Filters to apply to the incoming phone number change events. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=PhoneNumberChangeHandler, + filters=filters, + priority=priority, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=PhoneNumberChangeHandler, - filters=filters, - priority=priority, - ) - ) is not None: - return clb + @overload + def on_identity_change( + self: WhatsApp, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_IdentityChangeCallback], _IdentityChangeCallback]: ... - def deco(callback: _PhoneNumberChangeCallback) -> _PhoneNumberChangeCallback: - return _registered_with_parentheses( - self=self, - handler_type=PhoneNumberChangeHandler, - callback=callback, - filters=filters, - priority=priority, - ) + @overload + def on_identity_change( + self: _IdentityChangeCallback, + filters: Filter | None = None, + priority: int = 0, + ) -> _IdentityChangeCallback: ... - return deco + @overload + def on_identity_change( + self: Filter, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_IdentityChangeCallback], _IdentityChangeCallback]: ... + + @overload + def on_identity_change( + self: None = None, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_IdentityChangeCallback], _IdentityChangeCallback]: ... def on_identity_change( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: WhatsApp | Filter | _IdentityChangeCallback | None = None, + filters: Filter | None = None, priority: int = 0, ) -> ( Callable[[_IdentityChangeCallback], _IdentityChangeCallback] @@ -1786,38 +1982,51 @@ def on_identity_change( >>> from pywa import WhatsApp, types >>> wa = WhatsApp(...) >>> @wa.on_identity_change - ... def identity_change_handler(client: WhatsApp, identity_change: types.IdentityChange): - ... print(f"The user {identity_change.from_user.wa_id} just changed their identity!: {identity_change.body}") + ... def on_identity_change(_: WhatsApp, ic: types.IdentityChange): + ... print(f"User {ic.from_user.wa_id} changed identity: {ic.body}") Args: filters: Filters to apply to the incoming identity change events. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=IdentityChangeHandler, + filters=filters, + priority=priority, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=IdentityChangeHandler, - filters=filters, - priority=priority, - ) - ) is not None: - return clb + @overload + def on_template_status_update( + self: WhatsApp, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_TemplateStatusUpdateCallback], _TemplateStatusUpdateCallback]: ... - def deco(callback: _IdentityChangeCallback) -> _IdentityChangeCallback: - return _registered_with_parentheses( - self=self, - handler_type=IdentityChangeHandler, - callback=callback, - filters=filters, - priority=priority, - ) + @overload + def on_template_status_update( + self: _TemplateStatusUpdateCallback, + filters: Filter | None = None, + priority: int = 0, + ) -> _TemplateStatusUpdateCallback: ... - return deco + @overload + def on_template_status_update( + self: Filter, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_TemplateStatusUpdateCallback], _TemplateStatusUpdateCallback]: ... + + @overload + def on_template_status_update( + self: None = None, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_TemplateStatusUpdateCallback], _TemplateStatusUpdateCallback]: ... def on_template_status_update( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: WhatsApp | Filter | _TemplateStatusUpdateCallback | None = None, + filters: Filter | None = None, priority: int = 0, ) -> ( Callable[[_TemplateStatusUpdateCallback], _TemplateStatusUpdateCallback] @@ -1832,41 +2041,58 @@ def on_template_status_update( >>> from pywa import WhatsApp, types, filters >>> wa = WhatsApp(...) - >>> @wa.on_template_status_update - ... def approved_handler(client: WhatsApp, update: types.TemplateStatusUpdate): - ... print(f"Template {update.template_name} just got {update.new_status}!") + >>> @wa.on_template_status_update(filters.template_status_approved) + ... def on_template_approved(_: WhatsApp, update: types.TemplateStatusUpdate): + ... print(f"Template {update.template_name} is now approved and ready to use!") Args: filters: Filters to apply to the incoming template status changes. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=TemplateStatusUpdateHandler, + filters=filters, + priority=priority, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=TemplateStatusUpdateHandler, - filters=filters, - priority=priority, - ) - ) is not None: - return clb - - def deco( - callback: _TemplateStatusUpdateCallback, - ) -> _TemplateStatusUpdateCallback: - return _registered_with_parentheses( - self=self, - handler_type=TemplateStatusUpdateHandler, - callback=callback, - filters=filters, - priority=priority, - ) + @overload + def on_template_category_update( + self: WhatsApp, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[ + [_TemplateCategoryUpdateCallback], _TemplateCategoryUpdateCallback + ]: ... - return deco + @overload + def on_template_category_update( + self: _TemplateCategoryUpdateCallback, + filters: Filter | None = None, + priority: int = 0, + ) -> _TemplateCategoryUpdateCallback: ... + + @overload + def on_template_category_update( + self: Filter, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[ + [_TemplateCategoryUpdateCallback], _TemplateCategoryUpdateCallback + ]: ... + + @overload + def on_template_category_update( + self: None = None, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[ + [_TemplateCategoryUpdateCallback], _TemplateCategoryUpdateCallback + ]: ... def on_template_category_update( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: WhatsApp | Filter | _TemplateCategoryUpdateCallback | None = None, + filters: Filter | None = None, priority: int = 0, ) -> ( Callable[[_TemplateCategoryUpdateCallback], _TemplateCategoryUpdateCallback] @@ -1889,33 +2115,44 @@ def on_template_category_update( filters: Filters to apply to the incoming template category changes. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=TemplateCategoryUpdateHandler, + filters=filters, + priority=priority, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=TemplateCategoryUpdateHandler, - filters=filters, - priority=priority, - ) - ) is not None: - return clb - - def deco( - callback: _TemplateCategoryUpdateCallback, - ) -> _TemplateCategoryUpdateCallback: - return _registered_with_parentheses( - self=self, - handler_type=TemplateCategoryUpdateHandler, - callback=callback, - filters=filters, - priority=priority, - ) - - return deco + @overload + def on_template_quality_update( + self: WhatsApp, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_TemplateQualityUpdateCallback], _TemplateQualityUpdateCallback]: ... + + @overload + def on_template_quality_update( + self: _TemplateQualityUpdateCallback, + filters: Filter | None = None, + priority: int = 0, + ) -> _TemplateQualityUpdateCallback: ... + + @overload + def on_template_quality_update( + self: Filter, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_TemplateQualityUpdateCallback], _TemplateQualityUpdateCallback]: ... + @overload def on_template_quality_update( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: None = None, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_TemplateQualityUpdateCallback], _TemplateQualityUpdateCallback]: ... + + def on_template_quality_update( + self: WhatsApp | Filter | _TemplateQualityUpdateCallback | None = None, + filters: Filter | None = None, priority: int = 0, ) -> ( Callable[[_TemplateQualityUpdateCallback], _TemplateQualityUpdateCallback] @@ -1938,33 +2175,50 @@ def on_template_quality_update( filters: Filters to apply to the incoming template quality changes. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=TemplateQualityUpdateHandler, + filters=filters, + priority=priority, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=TemplateQualityUpdateHandler, - filters=filters, - priority=priority, - ) - ) is not None: - return clb - - def deco( - callback: _TemplateQualityUpdateCallback, - ) -> _TemplateQualityUpdateCallback: - return _registered_with_parentheses( - self=self, - handler_type=TemplateQualityUpdateHandler, - callback=callback, - filters=filters, - priority=priority, - ) + @overload + def on_template_components_update( + self: WhatsApp, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[ + [_TemplateComponentsUpdateCallback], _TemplateComponentsUpdateCallback + ]: ... - return deco + @overload + def on_template_components_update( + self: _TemplateComponentsUpdateCallback, + filters: Filter | None = None, + priority: int = 0, + ) -> _TemplateComponentsUpdateCallback: ... + + @overload + def on_template_components_update( + self: Filter, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[ + [_TemplateComponentsUpdateCallback], _TemplateComponentsUpdateCallback + ]: ... + + @overload + def on_template_components_update( + self: None = None, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[ + [_TemplateComponentsUpdateCallback], _TemplateComponentsUpdateCallback + ]: ... def on_template_components_update( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: WhatsApp | Filter | _TemplateComponentsUpdateCallback | None = None, + filters: Filter | None = None, priority: int = 0, ) -> ( Callable[[_TemplateComponentsUpdateCallback], _TemplateComponentsUpdateCallback] @@ -1987,33 +2241,44 @@ def on_template_components_update( filters: Filters to apply to the incoming template components changes. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=TemplateComponentsUpdateHandler, + filters=filters, + priority=priority, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=TemplateComponentsUpdateHandler, - filters=filters, - priority=priority, - ) - ) is not None: - return clb - - def deco( - callback: _TemplateComponentsUpdateCallback, - ) -> _TemplateComponentsUpdateCallback: - return _registered_with_parentheses( - self=self, - handler_type=TemplateComponentsUpdateHandler, - callback=callback, - filters=filters, - priority=priority, - ) + @overload + def on_flow_completion( + self: WhatsApp, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_FlowCompletionCallback], _FlowCompletionCallback]: ... - return deco + @overload + def on_flow_completion( + self: _FlowCompletionCallback, + filters: Filter | None = None, + priority: int = 0, + ) -> _FlowCompletionCallback: ... + @overload def on_flow_completion( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: Filter, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_FlowCompletionCallback], _FlowCompletionCallback]: ... + + @overload + def on_flow_completion( + self: None = None, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_FlowCompletionCallback], _FlowCompletionCallback]: ... + + def on_flow_completion( + self: WhatsApp | Filter | _FlowCompletionCallback | None = None, + filters: Filter | None = None, priority: int = 0, ) -> ( Callable[[_FlowCompletionCallback], _FlowCompletionCallback] @@ -2026,41 +2291,54 @@ def on_flow_completion( Example: - >>> from pywa import WhatsApp, types + >>> from pywa import WhatsApp, types, filters >>> wa = WhatsApp(...) - >>> @wa.on_flow_completion - ... def flow_handler(client: WhatsApp, flow: types.FlowCompletion): - ... print(f"Flow {flow.token} just got completed!. Flow data: {flow.response}") + >>> @wa.on_flow_completion(filters.startswith("feedback")) # flow.token startswith "filters" + ... def on_feedback_complete(_: WhatsApp, flow: types.FlowCompletion): + ... print(f"User {flow.from_user.name} completed feedback: {flow.body}") Args: filters: Filters to apply to the incoming flow completion. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=FlowCompletionHandler, + filters=filters, + priority=priority, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=FlowCompletionHandler, - filters=filters, - priority=priority, - ) - ) is not None: - return clb + @overload + def on_call_connect( + self: WhatsApp, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_CallConnectCallback], _CallConnectCallback]: ... - def deco(callback: _FlowCompletionCallback) -> _FlowCompletionCallback: - return _registered_with_parentheses( - self=self, - handler_type=FlowCompletionHandler, - callback=callback, - filters=filters, - priority=priority, - ) + @overload + def on_call_connect( + self: _CallConnectCallback, + filters: Filter | None = None, + priority: int = 0, + ) -> _CallConnectCallback: ... - return deco + @overload + def on_call_connect( + self: Filter, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_CallConnectCallback], _CallConnectCallback]: ... + + @overload + def on_call_connect( + self: None = None, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_CallConnectCallback], _CallConnectCallback]: ... def on_call_connect( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: WhatsApp | Filter | _CallConnectCallback | None = None, + filters: Filter | None = None, priority: int = 0, ) -> Callable[[_CallConnectCallback], _CallConnectCallback] | _CallConnectCallback: """ @@ -2070,43 +2348,55 @@ def on_call_connect( Example: - >>> from pywa import WhatsApp, types + >>> from pywa import WhatsApp, types, filters >>> wa = WhatsApp(...) - >>> @wa.on_call_connect - ... def incoming_call_handler(client: WhatsApp, call: types.CallConnect): - ... print(f"You getting an incoming call from {call.from_user.name}") - ... call.accept() + >>> @wa.on_call_connect(filters.incoming_call) + ... def on_call_connect(_: WhatsApp, call: types.CallConnect): + ... print(f"Incoming call connected from {call.from_user.name}") Args: filters: Filters to apply to the incoming call connect. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=CallConnectHandler, + filters=filters, + priority=priority, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=CallConnectHandler, - filters=filters, - priority=priority, - ) - ) is not None: - return clb + @overload + def on_call_terminate( + self: WhatsApp, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_CallTerminateCallback], _CallTerminateCallback]: ... - def deco(callback: _CallConnectCallback) -> _CallConnectCallback: - return _registered_with_parentheses( - self=self, - handler_type=CallConnectHandler, - callback=callback, - filters=filters, - priority=priority, - ) + @overload + def on_call_terminate( + self: _CallTerminateCallback, + filters: Filter | None = None, + priority: int = 0, + ) -> _CallTerminateCallback: ... - return deco + @overload + def on_call_terminate( + self: Filter, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_CallTerminateCallback], _CallTerminateCallback]: ... + @overload def on_call_terminate( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: None = None, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_CallTerminateCallback], _CallTerminateCallback]: ... + + def on_call_terminate( + self: WhatsApp | Filter | _CallTerminateCallback | None = None, + filters: Filter | None = None, priority: int = 0, ) -> ( Callable[[_CallTerminateCallback], _CallTerminateCallback] @@ -2122,38 +2412,55 @@ def on_call_terminate( >>> from pywa import WhatsApp, types >>> wa = WhatsApp(...) >>> @wa.on_call_terminate - ... def on_hangup(client: WhatsApp, call: types.CallTerminate): - ... print(f"The call {call.from_user.name} is terminated") + ... def on_call_terminate(_: WhatsApp, call: types.CallTerminate): + ... print(f"Call with {call.from_user.name} ended. duration: {call.duration}s") Args: filters: Filters to apply to the incoming call terminate. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=CallTerminateHandler, + filters=filters, + priority=priority, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=CallTerminateHandler, - filters=filters, - priority=priority, - ) - ) is not None: - return clb + @overload + def on_call_status( + self: WhatsApp, + filters: Filter | None = None, + factory: type[CallbackData] | None = None, + priority: int = 0, + ) -> Callable[[_CallStatusCallback], _CallStatusCallback]: ... - def deco(callback: _CallTerminateCallback) -> _CallTerminateCallback: - return _registered_with_parentheses( - self=self, - handler_type=CallTerminateHandler, - callback=callback, - filters=filters, - priority=priority, - ) + @overload + def on_call_status( + self: _CallStatusCallback, + filters: Filter | None = None, + factory: type[CallbackData] | None = None, + priority: int = 0, + ) -> _CallStatusCallback: ... - return deco + @overload + def on_call_status( + self: Filter, + filters: Filter | None = None, + factory: type[CallbackData] | None = None, + priority: int = 0, + ) -> Callable[[_CallStatusCallback], _CallStatusCallback]: ... + @overload def on_call_status( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: None = None, + filters: Filter | None = None, + factory: type[CallbackData] | None = None, + priority: int = 0, + ) -> Callable[[_CallStatusCallback], _CallStatusCallback]: ... + + def on_call_status( + self: WhatsApp | Filter | _CallStatusCallback | None = None, + filters: Filter | None = None, factory: type[CallbackData] | None = None, priority: int = 0, ) -> Callable[[_CallStatusCallback], _CallStatusCallback] | _CallStatusCallback: @@ -2164,44 +2471,56 @@ def on_call_status( Example: - >>> from pywa import WhatsApp, types + >>> from pywa import WhatsApp, types, filters >>> wa = WhatsApp(...) - >>> @wa.on_call_status - ... def on_status(client: WhatsApp, call: types.CallStatus): - ... print(f"The call with {call.from_user.name} is {call.status}") + >>> @wa.on_call_status(filters.call_answered) + ... def on_call_answered(_: WhatsApp, call: types.CallStatus): + ... print(f"Call with {call.from_user.name} answered at {call.timestamp}") Args: filters: Filters to apply to the incoming call status. factory: The constructor to use to construct the callback data. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=CallStatusHandler, + filters=filters, + priority=priority, + factory=factory, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=CallStatusHandler, - filters=filters, - priority=priority, - factory=factory, - ) - ) is not None: - return clb + @overload + def on_call_permission_update( + self: WhatsApp, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_CallPermissionUpdateCallback], _CallPermissionUpdateCallback]: ... - def deco(callback: _CallStatusCallback) -> _CallStatusCallback: - return _registered_with_parentheses( - self=self, - handler_type=CallStatusHandler, - callback=callback, - filters=filters, - priority=priority, - factory=factory, - ) + @overload + def on_call_permission_update( + self: _CallPermissionUpdateCallback, + filters: Filter | None = None, + priority: int = 0, + ) -> _CallPermissionUpdateCallback: ... - return deco + @overload + def on_call_permission_update( + self: Filter, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_CallPermissionUpdateCallback], _CallPermissionUpdateCallback]: ... + @overload def on_call_permission_update( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: None = None, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_CallPermissionUpdateCallback], _CallPermissionUpdateCallback]: ... + + def on_call_permission_update( + self: WhatsApp | Filter | _CallPermissionUpdateCallback | None = None, + filters: Filter | None = None, priority: int = 0, ) -> ( Callable[[_CallPermissionUpdateCallback], _CallPermissionUpdateCallback] @@ -2226,32 +2545,50 @@ def on_call_permission_update( filters: Filters to apply to the incoming call permission updates. priority: The priority of the handler (default: ``0``). """ - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=CallPermissionUpdateHandler, - filters=filters, - priority=priority, - ) - ) is not None: - return clb - - def deco( - callback: _CallPermissionUpdateCallback, - ) -> _CallPermissionUpdateCallback: - return _registered_with_parentheses( - self=self, - handler_type=CallPermissionUpdateHandler, - callback=callback, - filters=filters, - priority=priority, - ) + return _handle_on_update( + self=self, + handler_type=CallPermissionUpdateHandler, + filters=filters, + priority=priority, + ) - return deco + @overload + def on_user_marketing_preferences( + self: WhatsApp, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[ + [_UserMarketingPreferencesCallback], _UserMarketingPreferencesCallback + ]: ... + + @overload + def on_user_marketing_preferences( + self: _UserMarketingPreferencesCallback, + filters: Filter | None = None, + priority: int = 0, + ) -> _UserMarketingPreferencesCallback: ... + @overload def on_user_marketing_preferences( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: Filter, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[ + [_UserMarketingPreferencesCallback], _UserMarketingPreferencesCallback + ]: ... + + @overload + def on_user_marketing_preferences( + self: None = None, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[ + [_UserMarketingPreferencesCallback], _UserMarketingPreferencesCallback + ]: ... + + def on_user_marketing_preferences( + self: WhatsApp | Filter | _UserMarketingPreferencesCallback | None = None, + filters: Filter | None = None, priority: int = 0, ) -> ( Callable[[_UserMarketingPreferencesCallback], _UserMarketingPreferencesCallback] @@ -2264,44 +2601,54 @@ def on_user_marketing_preferences( Example: - >>> from pywa import WhatsApp, types + >>> from pywa import WhatsApp, types, filters >>> wa = WhatsApp(...) - >>> @wa.on_user_marketing_preferences - ... def user_marketing_preferences_handler(client: WhatsApp, prefs: types.UserMarketingPreferences): - ... if not prefs: # use boolean context to check if the user wants to stop receiving marketing messages - ... print(f"The user {prefs.from_user.wa_id} wants to stop receiving marketing messages.") + >>> @wa.on_user_marketing_preferences(filters.user_marketing_preferences_stop) + ... def on_marketing_stop(_: WhatsApp, pref: types.UserMarketingPreferences): + ... print(f"User {pref.from_user.wa_id} wants to stop receiving marketing messages") Args: filters: Filters to apply to the incoming user marketing preferences updates. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=UserMarketingPreferencesHandler, + filters=filters, + priority=priority, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=UserMarketingPreferencesHandler, - filters=filters, - priority=priority, - ) - ) is not None: - return clb - - def deco( - callback: _UserMarketingPreferencesCallback, - ) -> _UserMarketingPreferencesCallback: - return _registered_with_parentheses( - self=self, - handler_type=UserMarketingPreferencesHandler, - callback=callback, - filters=filters, - priority=priority, - ) + @overload + def on_edited_message( + self: WhatsApp, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_EditedMessageCallback], _EditedMessageCallback]: ... - return deco + @overload + def on_edited_message( + self: _EditedMessageCallback, + filters: Filter | None = None, + priority: int = 0, + ) -> _EditedMessageCallback: ... + + @overload + def on_edited_message( + self: Filter, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_EditedMessageCallback], _EditedMessageCallback]: ... + + @overload + def on_edited_message( + self: None = None, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_EditedMessageCallback], _EditedMessageCallback]: ... def on_edited_message( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: WhatsApp | Filter | _EditedMessageCallback | None = None, + filters: Filter | None = None, priority: int = 0, ) -> ( Callable[[_EditedMessageCallback], _EditedMessageCallback] @@ -2318,37 +2665,50 @@ def on_edited_message( >>> wa = WhatsApp(...) >>> @wa.on_edited_message(filters.text) ... def edited_message_handler(client: WhatsApp, edited_msg: types.EditedMessage): - ... print(f"The user {edited_msg.from_user.wa_id} just edited their message to: {edited_msg.message.text}") + ... print(f"The user {edited_msg.from_user} just edited their message to: {edited_msg.message.text}") Args: filters: Filters to apply to the incoming edited messages. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=EditedMessageHandler, + filters=filters, + priority=priority, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=EditedMessageHandler, - filters=filters, - priority=priority, - ) - ) is not None: - return clb + @overload + def on_deleted_message( + self: WhatsApp, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_DeletedMessageCallback], _DeletedMessageCallback]: ... - def deco(callback: _EditedMessageCallback) -> _EditedMessageCallback: - return _registered_with_parentheses( - self=self, - handler_type=EditedMessageHandler, - callback=callback, - filters=filters, - priority=priority, - ) + @overload + def on_deleted_message( + self: _DeletedMessageCallback, + filters: Filter | None = None, + priority: int = 0, + ) -> _DeletedMessageCallback: ... - return deco + @overload + def on_deleted_message( + self: Filter, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_DeletedMessageCallback], _DeletedMessageCallback]: ... + @overload def on_deleted_message( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: None = None, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_DeletedMessageCallback], _DeletedMessageCallback]: ... + + def on_deleted_message( + self: WhatsApp | Filter | _DeletedMessageCallback | None = None, + filters: Filter | None = None, priority: int = 0, ) -> ( Callable[[_DeletedMessageCallback], _DeletedMessageCallback] @@ -2364,38 +2724,51 @@ def on_deleted_message( >>> from pywa import WhatsApp, types, filters >>> wa = WhatsApp(...) >>> @wa.on_deleted_message - ... def deleted_message_handler(client: WhatsApp, delete_msg: types.DeletedMessage): - ... print(f"The user {delete_msg.from_user.wa_id} just deleted their message with id {delete_msg.original_message_id}") + ... def on_delete(_: WhatsApp, msg: types.DeletedMessage): + ... print(f"User {msg.from_user.name} revoked a message (ID: {msg.original_message_id})") Args: filters: Filters to apply to the incoming deleted messages. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=DeletedMessageHandler, + filters=filters, + priority=priority, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=DeletedMessageHandler, - filters=filters, - priority=priority, - ) - ) is not None: - return clb + @overload + def on_outgoing_message( + self: WhatsApp, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_OutgoingMessageCallback], _OutgoingMessageCallback]: ... - def deco(callback: _DeletedMessageCallback) -> _DeletedMessageCallback: - return _registered_with_parentheses( - self=self, - handler_type=DeletedMessageHandler, - callback=callback, - filters=filters, - priority=priority, - ) + @overload + def on_outgoing_message( + self: _OutgoingMessageCallback, + filters: Filter | None = None, + priority: int = 0, + ) -> _OutgoingMessageCallback: ... - return deco + @overload + def on_outgoing_message( + self: Filter, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_OutgoingMessageCallback], _OutgoingMessageCallback]: ... + @overload def on_outgoing_message( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: None = None, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_OutgoingMessageCallback], _OutgoingMessageCallback]: ... + + def on_outgoing_message( + self: WhatsApp | Filter | _OutgoingMessageCallback | None = None, + filters: Filter | None = None, priority: int = 0, ) -> ( Callable[[_OutgoingMessageCallback], _OutgoingMessageCallback] @@ -2418,31 +2791,44 @@ def on_outgoing_message( filters: Filters to apply to the outgoing messages. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=OutgoingMessageHandler, + filters=filters, + priority=priority, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=OutgoingMessageHandler, - filters=filters, - priority=priority, - ) - ) is not None: - return clb + @overload + def on_outgoing_edited_message( + self: WhatsApp, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_OutgoingEditedMessageCallback], _OutgoingEditedMessageCallback]: ... - def deco(callback: _OutgoingMessageCallback) -> _OutgoingMessageCallback: - return _registered_with_parentheses( - self=self, - handler_type=OutgoingMessageHandler, - callback=callback, - filters=filters, - priority=priority, - ) + @overload + def on_outgoing_edited_message( + self: _OutgoingEditedMessageCallback, + filters: Filter | None = None, + priority: int = 0, + ) -> _OutgoingEditedMessageCallback: ... - return deco + @overload + def on_outgoing_edited_message( + self: Filter, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_OutgoingEditedMessageCallback], _OutgoingEditedMessageCallback]: ... + + @overload + def on_outgoing_edited_message( + self: None = None, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_OutgoingEditedMessageCallback], _OutgoingEditedMessageCallback]: ... def on_outgoing_edited_message( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: WhatsApp | Filter | _OutgoingEditedMessageCallback | None = None, + filters: Filter | None = None, priority: int = 0, ) -> ( Callable[[_OutgoingEditedMessageCallback], _OutgoingEditedMessageCallback] @@ -2465,33 +2851,50 @@ def on_outgoing_edited_message( filters: Filters to apply to the outgoing message edits. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=OutgoingEditedMessageHandler, + filters=filters, + priority=priority, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=OutgoingEditedMessageHandler, - filters=filters, - priority=priority, - ) - ) is not None: - return clb - - def deco( - callback: _OutgoingEditedMessageCallback, - ) -> _OutgoingEditedMessageCallback: - return _registered_with_parentheses( - self=self, - handler_type=OutgoingEditedMessageHandler, - callback=callback, - filters=filters, - priority=priority, - ) + @overload + def on_outgoing_deleted_message( + self: WhatsApp, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[ + [_OutgoingDeletedMessageCallback], _OutgoingDeletedMessageCallback + ]: ... - return deco + @overload + def on_outgoing_deleted_message( + self: _OutgoingDeletedMessageCallback, + filters: Filter | None = None, + priority: int = 0, + ) -> _OutgoingDeletedMessageCallback: ... + + @overload + def on_outgoing_deleted_message( + self: Filter, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[ + [_OutgoingDeletedMessageCallback], _OutgoingDeletedMessageCallback + ]: ... + + @overload + def on_outgoing_deleted_message( + self: None = None, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[ + [_OutgoingDeletedMessageCallback], _OutgoingDeletedMessageCallback + ]: ... def on_outgoing_deleted_message( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: WhatsApp | Filter | _OutgoingDeletedMessageCallback | None = None, + filters: Filter | None = None, priority: int = 0, ) -> ( Callable[[_OutgoingDeletedMessageCallback], _OutgoingDeletedMessageCallback] @@ -2514,33 +2917,44 @@ def on_outgoing_deleted_message( filters: Filters to apply to the outgoing message deletions. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=OutgoingDeletedMessageHandler, + filters=filters, + priority=priority, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=OutgoingDeletedMessageHandler, - filters=filters, - priority=priority, - ) - ) is not None: - return clb - - def deco( - callback: _OutgoingDeletedMessageCallback, - ) -> _OutgoingDeletedMessageCallback: - return _registered_with_parentheses( - self=self, - handler_type=OutgoingDeletedMessageHandler, - callback=callback, - filters=filters, - priority=priority, - ) + @overload + def on_account_update( + self: WhatsApp, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_AccountUpdateCallback], _AccountUpdateCallback]: ... - return deco + @overload + def on_account_update( + self: _AccountUpdateCallback, + filters: Filter | None = None, + priority: int = 0, + ) -> _AccountUpdateCallback: ... + + @overload + def on_account_update( + self: Filter, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_AccountUpdateCallback], _AccountUpdateCallback]: ... + @overload def on_account_update( - self: WhatsApp | Filter = None, - filters: Filter = None, + self: None = None, + filters: Filter | None = None, + priority: int = 0, + ) -> Callable[[_AccountUpdateCallback], _AccountUpdateCallback]: ... + + def on_account_update( + self: WhatsApp | Filter | _AccountUpdateCallback | None = None, + filters: Filter | None = None, priority: int = 0, ) -> ( Callable[[_AccountUpdateCallback], _AccountUpdateCallback] @@ -2552,37 +2966,53 @@ def on_account_update( - Shortcut for :func:`~pywa.client.WhatsApp.add_handlers` with a :class:`~pywa.handlers.AccountUpdateHandler`. Example: - >>> from pywa import WhatsApp, filters + + >>> from pywa import WhatsApp, types, filters >>> wa = WhatsApp(...) - >>> @wa.on_account_update - ... def account_update_handler(client: WhatsApp, update: types.AccountUpdate): - ... print(f"Your account information just got updated! Updated fields: {update.updated_fields}") + >>> @wa.on_account_update(filters.account_violation) + ... def on_violation(_: WhatsApp, update: types.AccountUpdate): + ... print(f"Violation detected! {update.violation_info}") Args: filters: Filters to apply to the incoming account updates. priority: The priority of the handler (default: ``0``). """ + return _handle_on_update( + self=self, + handler_type=AccountUpdateHandler, + filters=filters, + priority=priority, + ) - if ( - clb := _registered_without_parentheses( - self=self, - handler_type=AccountUpdateHandler, - filters=filters, - priority=priority, - ) - ) is not None: - return clb - - def deco(callback: _AccountUpdateCallback) -> _AccountUpdateCallback: - return _registered_with_parentheses( - self=self, - handler_type=AccountUpdateHandler, - callback=callback, - filters=filters, - priority=priority, - ) + @overload + def on_flow_request( + self: WhatsApp, + endpoint: str, + *, + acknowledge_errors: bool = True, + private_key: str | None = None, + private_key_password: str | None = None, + request_decryptor: utils.FlowRequestDecryptor | None = None, + response_encryptor: utils.FlowResponseEncryptor | None = None, + ) -> Callable[ + [_FlowRequestHandlerT], + FlowRequestCallbackWrapper | FlowRequestHandler, + ]: ... - return deco + @overload + def on_flow_request( + self: str, + endpoint: str = None, + *, + acknowledge_errors: bool = True, + private_key: str | None = None, + private_key_password: str | None = None, + request_decryptor: utils.FlowRequestDecryptor | None = None, + response_encryptor: utils.FlowResponseEncryptor | None = None, + ) -> Callable[ + [_FlowRequestHandlerT], + FlowRequestCallbackWrapper | FlowRequestHandler, + ]: ... def on_flow_request( self: WhatsApp | str = None, @@ -2607,12 +3037,15 @@ def on_flow_request( >>> from pywa import WhatsApp, types >>> wa = WhatsApp(business_private_key='...', ...) >>> @wa.on_flow_request('/feedback_flow') - ... def feedback_flow_handler(_: WhatsApp, req: FlowRequest) -> FlowResponse: - ... ... - - >>> @feedback_flow_handler.on(types.FlowRequestActionType.DATA_EXCHANGE, screen="SURVEY") - ... def survey_data_handler(_: WhatsApp, req: FlowRequest): + ... def feedback_handler(_: WhatsApp, req: types.FlowRequest): ... ... + >>> @feedback_handler.on_init + ... def on_init(_: WhatsApp, req: types.FlowRequest): + ... return req.respond(screen="SURVEY") + >>> @feedback_handler.on_data_exchange(screen="SURVEY") + ... def on_survey(_: WhatsApp, req: types.FlowRequest): + ... print(f"Received rating: {req.data['rating']}") + ... return req.respond(screen="THANKS", data={"message": "We appreciate your feedback!"}) Args: endpoint: The endpoint to listen to (The endpoint uri you set to the flow. e.g ``/feedback_flow``). @@ -2627,93 +3060,30 @@ def on_flow_request( def decorator( callback: _FlowRequestHandlerT, ) -> FlowRequestCallbackWrapper | FlowRequestHandler: - if self is None or isinstance(self, str): - ep = self or endpoint - if not ep: - raise ValueError("The endpoint must be provided.") - handler = FlowRequestHandler( - callback=callback, - endpoint=ep, - acknowledge_errors=acknowledge_errors, - private_key=private_key, - private_key_password=private_key_password, - request_decryptor=request_decryptor, - response_encryptor=response_encryptor, - ) - setattr(handler, _flow_request_handler_attr, None) - return handler - - return self.add_flow_request_handler( - FlowRequestHandler( - callback=callback, - endpoint=endpoint, - acknowledge_errors=acknowledge_errors, - private_key=private_key, - private_key_password=private_key_password, - request_decryptor=request_decryptor, - response_encryptor=response_encryptor, - ) - ) - - return decorator + ep = (self if isinstance(self, str) else endpoint) or endpoint + if not ep: + raise ValueError("The endpoint must be provided.") + handler = FlowRequestHandler( + callback=callback, + endpoint=ep, + acknowledge_errors=acknowledge_errors, + private_key=private_key, + private_key_password=private_key_password, + request_decryptor=request_decryptor, + response_encryptor=response_encryptor, + ) -_handlers_attr = "__pywa_handlers" + if hasattr(self, "add_flow_request_handler"): + return self.add_flow_request_handler(handler) + setattr(handler, _flow_request_handler_attr, True) + return handler -def _registered_without_parentheses( - *, - self: WhatsApp, - handler_type: type[Handler], - filters: Filter, - priority: int, - **kwargs, -) -> Callable | None: - """When the decorator is called without parentheses.""" - if callable(self) and filters is None: # @WhatsApp.on_x - _register_func_handler( - handler_type=handler_type, - callback=self, - filters=None, - priority=priority, - **kwargs, - ) - return self - elif callable(filters): # @wa.on_x - self.add_handlers( - handler_type(callback=filters, filters=None, priority=priority, **kwargs) - ) - return filters - return None + return decorator -def _registered_with_parentheses( - *, - self: WhatsApp, - handler_type: type[Handler], - callback: Callable, - filters: Filter, - priority: int, - **kwargs, -) -> Callable: - """When the decorator is called with parentheses.""" - if self is None or isinstance( - self, Filter - ): # @WhatsApp.on_x(filters=...) | @WhatsApp.on_x(filters.text) - _register_func_handler( - handler_type=handler_type, - callback=callback, - filters=self or filters, - priority=priority, - **kwargs, - ) - else: # @wa.on_x(filters.text) - self.add_handlers( - handler_type( - callback=callback, filters=filters, priority=priority, **kwargs - ) - ) - return callback +_handlers_attr = "__pywa_handlers" def _register_func_handler( @@ -2798,7 +3168,7 @@ def add_handler( callback: _FlowRequestHandlerT, action: FlowRequestActionType, screen: Screen | str | None = None, - filters: Filter = None, + filters: Filter | None = None, ) -> FlowRequestCallbackWrapper: """ Add a handler to the current endpoint. From c5ffb39de1036f6c5c478347df61d16f3eb0577b Mon Sep 17 00:00:00 2001 From: David Lev <42866208+david-lev@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:19:13 +0300 Subject: [PATCH 3/8] [filters] adding `webhook_fields` for raw updates --- pywa/filters.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pywa/filters.py b/pywa/filters.py index b6872445..dd954f30 100644 --- a/pywa/filters.py +++ b/pywa/filters.py @@ -277,6 +277,17 @@ def has_async(self) -> bool: false = new(lambda _, __: False, name="false") """Filter that always returns False.""" + +def webhook_fields(*fields: str) -> Filter: + """ + Filter for raw updates that contain any of the specified fields. + + >>> filters.webhook_fields("messages") + """ + fields = set(fields) + return new(lambda _, r: r.field in fields, name="webhook_fields") + + forwarded = new( lambda _, m: m.forwarded, name="forwarded", From 4f53c030b4857905d94da14fccfebd2adb2b79a6 Mon Sep 17 00:00:00 2001 From: David Lev <42866208+david-lev@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:05:29 +0300 Subject: [PATCH 4/8] [client] annotating `add_handlers` and `remove_handlers` with `Handler[Any]`. fixes #208 --- pywa/client.py | 4 ++-- pywa/handlers.py | 22 ++++------------------ tests/test_handlers.py | 34 +++++++++++++++++----------------- 3 files changed, 23 insertions(+), 37 deletions(-) diff --git a/pywa/client.py b/pywa/client.py index fd6b3c9b..c31b2c51 100644 --- a/pywa/client.py +++ b/pywa/client.py @@ -548,7 +548,7 @@ def add_flow_request_handler( self._register_flow_handler_wrapper(wrapper) return wrapper - def add_handlers(self, *handlers: Handler) -> None: + def add_handlers(self, *handlers: Handler[Any]) -> None: """ Add handlers programmatically instead of using decorators. @@ -573,7 +573,7 @@ def add_handlers(self, *handlers: Handler) -> None: self._handlers[handler.__class__], handler, key=lambda x: -x._priority ) - def remove_handlers(self, *handlers: Handler, silent: bool = False) -> None: + def remove_handlers(self, *handlers: Handler[Any], silent: bool = False) -> None: """ Remove handlers programmatically (not flow handlers). diff --git a/pywa/handlers.py b/pywa/handlers.py index 5082ce49..81ad7239 100644 --- a/pywa/handlers.py +++ b/pywa/handlers.py @@ -1428,11 +1428,9 @@ def _handle_on_update( priority: int, **kwargs, ) -> Any: - # Determine if 'self' is the WhatsApp client instance is_wa_instance = hasattr(self, "add_handlers") # Case 1: @wa.on_x (no parentheses) - # self=wa, filters=callback if is_wa_instance and callable(filters): self.add_handlers( handler_type(callback=filters, filters=None, priority=priority, **kwargs) @@ -1440,13 +1438,7 @@ def _handle_on_update( return filters # Case 2: @WhatsApp.on_x (no parentheses) - # self=callback, filters=None - if ( - not is_wa_instance - and callable(self) - and filters is None - and not isinstance(self, Filter) - ): + if not is_wa_instance and callable(self): _register_func_handler( handler_type=handler_type, callback=self, @@ -1456,24 +1448,18 @@ def _handle_on_update( ) return self - # Case 3: @wa.on_x(...) or @WhatsApp.on_x(...) (with parentheses) def deco(callback: Callable) -> Callable: - if is_wa_instance: + if is_wa_instance: # Case 3: @wa.on_x(...) (with parentheses) self.add_handlers( handler_type( callback=callback, filters=filters, priority=priority, **kwargs ) ) - else: + else: # Case 4: @WhatsApp.on_x(...) (with parentheses) _register_func_handler( handler_type=handler_type, callback=callback, - filters=self - if ( - isinstance(self, Filter) - or (callable(self) and self is not callback) - ) - else filters, + filters=self if (isinstance(self, Filter)) else filters, priority=priority, **kwargs, ) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index df90bdc8..a1881746 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -49,9 +49,9 @@ def test_instance_with_parentheses(): @wa.on_message(filters=filters.text) # @wa.on_x(filters=...) def instance_with_parentheses(_, __): ... - assert ( - wa._handlers[handlers.MessageHandler][0]._callback == instance_with_parentheses - ) + h = wa._handlers[handlers.MessageHandler][0] + assert h._callback == instance_with_parentheses + assert h._filters == filters.text def test_instance_without_parentheses(): @@ -60,10 +60,9 @@ def test_instance_without_parentheses(): @wa.on_message # @wa.on_x def instance_without_parentheses(_, __): ... - assert ( - wa._handlers[handlers.MessageHandler][0]._callback - == instance_without_parentheses - ) + h = wa._handlers[handlers.MessageHandler][0] + assert h._callback == instance_without_parentheses + assert h._filters is None def test_class_with_parentheses_kw(): @@ -74,9 +73,9 @@ def class_with_parentheses_kw(_, __): ... module.__dict__["on_message"] = class_with_parentheses_kw wa = WhatsApp(server=None, verify_token="1234567890", handlers_modules=[module]) - assert ( - wa._handlers[handlers.MessageHandler][0]._callback == class_with_parentheses_kw - ) + h = wa._handlers[handlers.MessageHandler][0] + assert h._callback == class_with_parentheses_kw + assert h._filters == filters.text def test_class_with_parentheses_args(): @@ -87,10 +86,10 @@ def class_with_parentheses_args(_, __): ... module.__dict__["on_message"] = class_with_parentheses_args wa = WhatsApp(server=None, verify_token="1234567890", handlers_modules=[module]) - assert ( - wa._handlers[handlers.MessageHandler][0]._callback - == class_with_parentheses_args - ) + + h = wa._handlers[handlers.MessageHandler][0] + assert h._callback == class_with_parentheses_args + assert h._filters == filters.text def test_class_without_parentheses(): @@ -101,9 +100,10 @@ def class_without_parentheses(_, __): ... module.__dict__["on_message"] = class_without_parentheses wa = WhatsApp(server=None, verify_token="1234567890", handlers_modules=[module]) - assert ( - wa._handlers[handlers.MessageHandler][0]._callback == class_without_parentheses - ) + + h = wa._handlers[handlers.MessageHandler][0] + assert h._callback == class_without_parentheses + assert h._filters is None def test_all_combinations(): From 7259bb940305b610895ad751810b95684318d76d Mon Sep 17 00:00:00 2001 From: David Lev <42866208+david-lev@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:15:31 +0300 Subject: [PATCH 5/8] [cli] improving error handling --- docs/source/content/handlers/overview.rst | 3 +- pywa/cli.py | 108 +++++++++++----------- 2 files changed, 57 insertions(+), 54 deletions(-) diff --git a/docs/source/content/handlers/overview.rst b/docs/source/content/handlers/overview.rst index 2a3c3904..5c549a10 100644 --- a/docs/source/content/handlers/overview.rst +++ b/docs/source/content/handlers/overview.rst @@ -151,7 +151,7 @@ Here is an example demonstrating how to register a high-priority maintenance han .. code-block:: python :caption: main.py :linenos: - :emphasize-lines: 13-16, 22, 25 + :emphasize-lines: 13-16, 23, 26 from pywa import WhatsApp, filters, handlers, types @@ -167,6 +167,7 @@ Here is an example demonstrating how to register a high-priority maintenance han # Create the handler instance with high priority maintenance_handler = handlers.MessageHandler( callback=maintenance_callback, + filters=~admin_filter, # Only non-admins priority=100, ) diff --git a/pywa/cli.py b/pywa/cli.py index 1b70ed9c..4ea05dcf 100644 --- a/pywa/cli.py +++ b/pywa/cli.py @@ -148,45 +148,38 @@ def serve_application( Core function that resolves dependencies and starts the Uvicorn server. """ if not uvicorn: - print( - "❌ Error: Could not import Uvicorn. Please install it using 'pip install \"pywa[server]\"'." + raise PywaCLIException( + "Could not import Uvicorn. Please install it using 'pip install \"pywa[server]\"'." ) - sys.exit(1) if entrypoint and (path or app): - print( - "❌ Error: Cannot use --entrypoint together with a path or --app arguments." + raise PywaCLIException( + "Cannot use --entrypoint together with a path or --app arguments." ) - sys.exit(1) workers = uvicorn_kwargs.get("workers") - try: - if entrypoint: - module_str, _, app_name = entrypoint.partition(":") - if not module_str or not app_name: - raise PywaCLIException("Entrypoint must be in the format 'module:app'") + if entrypoint: + module_str, _, app_name = entrypoint.partition(":") + if not module_str or not app_name: + raise PywaCLIException("Entrypoint must be in the format 'module:app'") - sys_path = pathlib.Path.cwd().resolve() - sys.path.insert(0, str(sys_path)) + sys_path = pathlib.Path.cwd().resolve() + sys.path.insert(0, str(sys_path)) - else: - target_path = path or get_default_path() - if not target_path.exists(): - raise PywaCLIException(f"Target path does not exist: {target_path}") - - module_str, sys_path = resolve_module_path(target_path) - sys.path.insert(0, str(sys_path)) - app_name, client = discover_app_instance(module_str, app) - if client._server is not None: - raise PywaCLIException( - f"The WhatsApp instance assigned to '{app_name}' in '{module_str}.py' is already configured with a {client._server_type.value} server." - ) - client._uvicorn_workers = workers or 1 + else: + target_path = path or get_default_path() + if not target_path.exists(): + raise PywaCLIException(f"Target path does not exist: {target_path}") - except PywaCLIException as e: - print(f"❌ Error: {e}") - sys.exit(1) + module_str, sys_path = resolve_module_path(target_path) + sys.path.insert(0, str(sys_path)) + app_name, client = discover_app_instance(module_str, app) + if client._server is not None: + raise PywaCLIException( + f"The WhatsApp instance assigned to '{app_name}' in '{module_str}.py' is already configured with a {client._server_type.value} server." + ) + client._uvicorn_workers = workers or 1 base_import_string = f"{module_str}:{app_name}" uvicorn_app_string = ( @@ -607,30 +600,39 @@ def main() -> None: # --- EXECUTION --- args = parser.parse_args() - if args.command in ["run", "dev"]: - target_path = pathlib.Path(args.path) if getattr(args, "path", None) else None - app_args = { - "command": args.command, - "path": target_path, - "app": getattr(args, "app", None), - "entrypoint": getattr(args, "entrypoint", None), - } - - exclude_keys = app_args.keys() - uvicorn_kwargs = {k: v for k, v in vars(args).items() if k not in exclude_keys} - - if uvicorn_kwargs.get("reload_dirs"): - uvicorn_kwargs["reload_dirs"] = [ - str(pathlib.Path(d).resolve()) for d in uvicorn_kwargs["reload_dirs"] - ] - - serve_application(**app_args, **uvicorn_kwargs) - - elif args.command == "send": - send_messages(**vars(args)) - - elif args.command == "new": - generate_code(target=args.target, is_async=args.is_async, out_path=args.out) + try: + if args.command in ["run", "dev"]: + target_path = ( + pathlib.Path(args.path) if getattr(args, "path", None) else None + ) + app_args = { + "command": args.command, + "path": target_path, + "app": getattr(args, "app", None), + "entrypoint": getattr(args, "entrypoint", None), + } + + exclude_keys = app_args.keys() + uvicorn_kwargs = { + k: v for k, v in vars(args).items() if k not in exclude_keys + } + + if uvicorn_kwargs.get("reload_dirs"): + uvicorn_kwargs["reload_dirs"] = [ + str(pathlib.Path(d).resolve()) + for d in uvicorn_kwargs["reload_dirs"] + ] + + serve_application(**app_args, **uvicorn_kwargs) + + elif args.command == "send": + send_messages(**vars(args)) + + elif args.command == "new": + generate_code(target=args.target, is_async=args.is_async, out_path=args.out) + except Exception as e: + print(f"❌ Error: {e}") + sys.exit(1) if __name__ == "__main__": From daf74c8ade799e0edf19718b8505a6144ced15a6 Mon Sep 17 00:00:00 2001 From: David Lev <42866208+david-lev@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:35:32 +0300 Subject: [PATCH 6/8] [client] update `request_contact_info` to return `SentContactInfoRequest` to allow `.wait_for_contact_info()` shortcut --- .../filters/account_update_filters.rst | 20 ++++++ docs/source/content/filters/calls_filters.rst | 15 ++++ .../source/content/filters/common_filters.rst | 4 +- .../content/filters/message_filters.rst | 1 + docs/source/content/filters/overview.rst | 4 +- docs/source/content/listeners/overview.rst | 35 ++++++---- docs/source/content/listeners/reference.rst | 1 + docs/source/content/types/others.rst | 3 + pywa/client.py | 5 +- pywa/filters.py | 17 +++++ pywa/types/base_update.py | 3 +- pywa/types/sent_update.py | 60 +++++++++++++++- pywa_async/client.py | 5 +- pywa_async/types/base_update.py | 3 +- pywa_async/types/sent_update.py | 70 ++++++++++++++++++- tests/test_async.py | 31 ++++++++ 16 files changed, 253 insertions(+), 24 deletions(-) create mode 100644 docs/source/content/filters/account_update_filters.rst create mode 100644 docs/source/content/filters/calls_filters.rst diff --git a/docs/source/content/filters/account_update_filters.rst b/docs/source/content/filters/account_update_filters.rst new file mode 100644 index 00000000..1de6c3e1 --- /dev/null +++ b/docs/source/content/filters/account_update_filters.rst @@ -0,0 +1,20 @@ +Account Update Filters +======================== + +.. automodule:: pywa.filters + +.. autoattribute:: pywa.filters.account_deleted +.. autoattribute:: pywa.filters.account_restriction +.. autoattribute:: pywa.filters.account_violation +.. autoattribute:: pywa.filters.ad_account_linked +.. autoattribute:: pywa.filters.auth_intl_price_eligibility_update +.. autoattribute:: pywa.filters.business_primary_location_country_update +.. autoattribute:: pywa.filters.account_disabled +.. autoattribute:: pywa.filters.partner_added +.. autoattribute:: pywa.filters.partner_app_installed +.. autoattribute:: pywa.filters.partner_app_uninstalled +.. autoattribute:: pywa.filters.partner_client_certification_status_update +.. autoattribute:: pywa.filters.partner_removed +.. autoattribute:: pywa.filters.volume_based_pricing_tier_update +.. autoattribute:: pywa.filters.account_offboarded +.. autoattribute:: pywa.filters.account_reconnected diff --git a/docs/source/content/filters/calls_filters.rst b/docs/source/content/filters/calls_filters.rst new file mode 100644 index 00000000..dad471cf --- /dev/null +++ b/docs/source/content/filters/calls_filters.rst @@ -0,0 +1,15 @@ +Calls Filters +============= + +.. automodule:: pywa.filters + +.. autoattribute:: pywa.filters.outgoing_call +.. autoattribute:: pywa.filters.incoming_call +.. autoattribute:: pywa.filters.call_status +.. autoattribute:: pywa.filters.call_answered +.. autoattribute:: pywa.filters.call_rejected +.. autoattribute:: pywa.filters.call_ringing +.. autoattribute:: pywa.filters.call_permission_update +.. autoattribute:: pywa.filters.call_permission_accepted +.. autoattribute:: pywa.filters.call_permission_rejected +.. autoattribute:: pywa.filters.call_terminate diff --git a/docs/source/content/filters/common_filters.rst b/docs/source/content/filters/common_filters.rst index 379d54de..a80d438d 100644 --- a/docs/source/content/filters/common_filters.rst +++ b/docs/source/content/filters/common_filters.rst @@ -26,9 +26,11 @@ Common filters .. autoattribute:: pywa.filters.private .. autoattribute:: pywa.filters.group .. autofunction:: sent_to +.. autofunction:: update_id +.. autofunction:: waba_id .. autoattribute:: pywa.filters.sent_to_me .. autofunction:: from_users -.. autofunction:: no_wa_id +.. autofunction:: without_wa_id .. autofunction:: from_countries .. autofunction:: matches .. autofunction:: contains diff --git a/docs/source/content/filters/message_filters.rst b/docs/source/content/filters/message_filters.rst index b3530e28..b58d21a0 100644 --- a/docs/source/content/filters/message_filters.rst +++ b/docs/source/content/filters/message_filters.rst @@ -33,5 +33,6 @@ Message Filters .. autoattribute:: pywa.filters.current_location .. autofunction:: pywa.filters.location_in_radius .. autoattribute:: pywa.filters.contacts +.. autoattribute:: pywa.filters.contact_info_shared .. autoattribute:: pywa.filters.contacts_has_wa .. autoattribute:: pywa.filters.order diff --git a/docs/source/content/filters/overview.rst b/docs/source/content/filters/overview.rst index 462d85b1..2a8eeb81 100644 --- a/docs/source/content/filters/overview.rst +++ b/docs/source/content/filters/overview.rst @@ -13,7 +13,7 @@ Basic Usage ----------- .. code-block:: python - :emphasize-lines: 5, 10 + :emphasize-lines: 5, 13 from pywa import WhatsApp, types, filters @@ -107,3 +107,5 @@ Built-in Filters ./common_filters ./message_filters ./message_status_filters + ./account_update_filters + ./calls_filters diff --git a/docs/source/content/listeners/overview.rst b/docs/source/content/listeners/overview.rst index a51857ea..7cd28413 100644 --- a/docs/source/content/listeners/overview.rst +++ b/docs/source/content/listeners/overview.rst @@ -88,7 +88,7 @@ or :class:`~pywa.listeners.ListenerTimeout` respectively. Catch them to send a h .. code-block:: python :linenos: - :emphasize-lines: 14-15, 17, 20 + :emphasize-lines: 12-14, 16, 19 from pywa import WhatsApp, types, filters @@ -134,7 +134,7 @@ It lets you specify which sender and update type to wait for, and is what all th .. code-block:: python :linenos: - :emphasize-lines: 7-12 + :emphasize-lines: 8-13 from pywa import WhatsApp, types, filters @@ -142,16 +142,23 @@ It lets you specify which sender and update type to wait for, and is what all th @wa.on_message(filters.command("confirm")) def confirm_action(_: WhatsApp, msg: types.Message): - confirmation = wa.listen( - to=msg.sender, - filters=filters.callback_button & filters.matches("yes", "no"), - cancelers=filters.text & filters.matches("cancel"), - timeout=30, - ) - if confirmation.data == "yes": - msg.reply("✅ Confirmed!") - else: - msg.reply("❌ Canceled.") + try: + confirmation: types.Message = wa.listen( + to=msg.sender, + filters=filters.message & filters.matches("yes", "no"), + cancelers=filters.message & filters.matches("cancel"), + timeout=30, + ) + if confirmation.text == "yes": + confirmation.reply("✅ Confirmed!") + else: + confirmation.reply("❌ Didn't confirm") + except types.ListenerCanceled: + msg.reply("You canceled the operation by clicking the cancel button.") + return + except types.ListenerTimeout: + msg.reply("You took too long to respond. Please try again later.") + return Shortcuts --------- @@ -187,7 +194,9 @@ PyWa provides several shortcuts to create listeners directly from sent messages: Other shortcuts include :meth:`~pywa.types.sent_update.SentMessage.wait_for_click`, :meth:`~pywa.types.sent_update.SentMessage.wait_for_selection`, :meth:`~pywa.types.sent_update.SentMessage.wait_until_read`, -:meth:`~pywa.types.sent_update.SentVoiceMessage.wait_until_played`, and more. +:meth:`~pywa.types.sent_update.SentVoiceMessage.wait_until_played`, +:meth:`~pywa.types.sent_update.SentLocationRequest.wait_for_location`, +:meth:`~pywa.types.sent_update.SentContactInfoRequest.wait_for_contact_info` and more. .. toctree:: diff --git a/docs/source/content/listeners/reference.rst b/docs/source/content/listeners/reference.rst index 29ca4959..466ed719 100644 --- a/docs/source/content/listeners/reference.rst +++ b/docs/source/content/listeners/reference.rst @@ -20,6 +20,7 @@ Listeners reference .. automethod:: SentMessage.wait_for_incoming_voice_call .. automethod:: SentVoiceMessage.wait_until_played .. automethod:: SentLocationRequest.wait_for_location +.. automethod:: SentContactInfoRequest.wait_for_contact_info .. currentmodule:: pywa.types.templates diff --git a/docs/source/content/types/others.rst b/docs/source/content/types/others.rst index 38dd7eea..9b05d555 100644 --- a/docs/source/content/types/others.rst +++ b/docs/source/content/types/others.rst @@ -17,6 +17,9 @@ Others .. autoclass:: SentLocationRequest() :show-inheritance: +.. autoclass:: SentContactInfoRequest + :show-inheritance: + .. autoclass:: SentReaction() :show-inheritance: diff --git a/pywa/client.py b/pywa/client.py index c31b2c51..edda07ec 100644 --- a/pywa/client.py +++ b/pywa/client.py @@ -146,6 +146,7 @@ ) from .types.sent_update import ( InitiatedCall, + SentContactInfoRequest, SentLocationRequest, SentMediaMessage, SentMessage, @@ -1504,7 +1505,7 @@ def request_contact_info( tracker: str | CallbackData | None = None, identity_key_hash: str | None = None, sender: str | int | None = None, - ) -> SentMessage: + ) -> SentContactInfoRequest: """ If a user taps this button, their WhatsApp phone number will be shared in the message thread, and a contacts webhook will be triggered containing the user’s phone number. Note that if a WhatsApp user shares a contact using the share contacts feature in the WhatsApp app instead, the webhook will also include the contact’s vCard. If you are using the contact book feature, their phone number will also be added to your contact book automatically. For businesses that have enabled Local Storage, Meta extracts the user’s phone number from the shared contact card (vCard) and stores it in your contact book on Meta data centers. Only the phone number is extracted and stored; no other vCard data is retained beyond the standard data-in-use period. @@ -1535,7 +1536,7 @@ def request_contact_info( wa=self, value=sender, method_arg="sender", client_arg="phone_id" ) recipient, recipient_type = helpers.resolve_recipient(to) - return SentMessage.from_sent_update( + return SentContactInfoRequest.from_sent_update( client=self, update=self.api.send_message( sender=sender, diff --git a/pywa/filters.py b/pywa/filters.py index dd954f30..dd78acac 100644 --- a/pywa/filters.py +++ b/pywa/filters.py @@ -19,6 +19,7 @@ "private", "group", "update_id", + "waba_id", "forwarded", "forwarded_many_times", "reply", @@ -60,6 +61,7 @@ "current_location", "location_in_radius", "contacts", + "contact_info_shared", "contacts_has_wa", "order", "callback_button", @@ -147,6 +149,7 @@ from .types.message import Message as _Msg from .types.message_status import MessageStatus as _Ms from .types.message_status import MessageStatusType as _Mst +from .types.others import ContactsOrigin as _Cor from .types.others import MessageType as _Mt from .types.system import IdentityChange as _Ic from .types.system import PhoneNumberChange as _Pnc @@ -329,6 +332,15 @@ def update_id(id_: str) -> Filter: return new(lambda _, u: u.id == id_, name="update_id") +def waba_id(id_: str) -> Filter: + """ + Filter for updates that their WABA ID matches the given id. + + >>> waba_id("105102735943269") + """ + return new(lambda _, u: getattr(u, "waba_id", u.id) == id_, name="waba_id") + + def replays_to(*msg_ids: str) -> Filter: """ Filter for messages that reply to any of the given message ids. @@ -812,6 +824,11 @@ def reaction_emojis(*emojis: str) -> Filter: contacts = new(lambda _, m: m.type == _Mt.CONTACTS, name="contacts") """Filter for contacts messages.""" +contact_info_shared = new( + lambda _, m: m.type == _Mt.CONTACTS and m.contacts.origin == _Cor.CONTACT_REQUEST, + name="contact_info_shared", +) +"""Filter for contact info shared messages.""" contacts_has_wa = new( lambda _, m: ( diff --git a/pywa/types/base_update.py b/pywa/types/base_update.py index 3076652c..bbd9381b 100644 --- a/pywa/types/base_update.py +++ b/pywa/types/base_update.py @@ -47,6 +47,7 @@ from .media import Media from .sent_update import ( InitiatedCall, + SentContactInfoRequest, SentLocationRequest, SentMediaMessage, SentMessage, @@ -777,7 +778,7 @@ def reply_contact_info_request( quote: bool = False, tracker: str | CallbackData | None = None, identity_key_hash: str | None = None, - ) -> SentMessage: + ) -> SentContactInfoRequest: """ If a user taps this button, their WhatsApp phone number will be shared in the message thread, and a contacts webhook will be triggered containing the user’s phone number. Note that if a WhatsApp user shares a contact using the share contacts feature in the WhatsApp app instead, the webhook will also include the contact’s vCard. If you are using the contact book feature, their phone number will also be added to your contact book automatically. For businesses that have enabled Local Storage, Meta extracts the user’s phone number from the shared contact card (vCard) and stores it in your contact book on Meta data centers. Only the phone number is extracted and stored; no other vCard data is retained beyond the standard data-in-use period. diff --git a/pywa/types/sent_update.py b/pywa/types/sent_update.py index d9f714d6..de68751f 100644 --- a/pywa/types/sent_update.py +++ b/pywa/types/sent_update.py @@ -5,6 +5,7 @@ "SentMediaMessage", "SentVoiceMessage", "SentLocationRequest", + "SentContactInfoRequest", "SentReaction", "SentTemplate", "SentTemplateStatus", @@ -815,7 +816,7 @@ def start(w: WhatsApp, m: Message): class SentLocationRequest(SentMessage): """ - Represents a location request message that was sent to WhatsApp user/group. + Represents a location request message that was sent to WhatsApp user. Attributes: id: The ID of the message. @@ -876,6 +877,61 @@ def start(w: WhatsApp, m: Message): ) +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) +class SentContactInfoRequest(SentMessage): + """ + Represents a contact information request message that was sent to WhatsApp user. + + Attributes: + id: The ID of the message. + from_phone_id: The phone id of the sender (you). + chat: The chat to which the message was sent. + input: The input of the recipient. + """ + + def wait_for_contact_info( + self, + *, + filters: pywa_filters.Filter = None, + cancelers: pywa_filters.Filter = None, + ignore_updates: bool = True, + timeout: float | None = None, + ) -> Message: + """ + Wait for a contact info shared message. + + Example: + + .. code-block:: python + + @wa.on_message(filters.command("start")) + def start(w: WhatsApp, m: Message): + r = m.reply_contact_info_request(text="Please share your contact",) + contact_message = r.wait_for_contact_info() + r.reply(f"You shared your contact: {contact_message.contacts.first.name}", quote=True) + + Args: + filters: The filters to apply to the contact message. + cancelers: The filters to cancel the listening. + ignore_updates: Whether to ignore user updates that do not pass the filters. + timeout: The time to wait for the contact message. + + Returns: + The contact message. + + Raises: + ListenerTimeout: If the listener timed out. + ListenerCanceled: If the listener was canceled by a filter. + ListenerStopped: If the listener was stopped manually. + """ + return self.wait_for_reply( + filters=(filters or pywa_filters.true) & pywa_filters.contact_info_shared, + cancelers=cancelers, + ignore_updates=ignore_updates, + timeout=timeout, + ) + + @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) class SentReaction(SentMessage): """ @@ -885,7 +941,7 @@ class SentReaction(SentMessage): id: The ID of the reaction. message_id: The ID of the message that was reacted/unreacted to. from_phone_id: The phone id of the sender (you). - chat: The chat to which the message was sent. + chat: The chat to which the reaction was sent. input: The input of the recipient. """ diff --git a/pywa_async/client.py b/pywa_async/client.py index 1b758630..d9d5086f 100644 --- a/pywa_async/client.py +++ b/pywa_async/client.py @@ -153,6 +153,7 @@ ) from .types.sent_update import ( InitiatedCall, + SentContactInfoRequest, SentLocationRequest, SentMediaMessage, SentMessage, @@ -1283,7 +1284,7 @@ async def request_contact_info( tracker: str | CallbackData | None = None, identity_key_hash: str | None = None, sender: str | int | None = None, - ) -> SentMessage: + ) -> SentContactInfoRequest: """ If a user taps this button, their WhatsApp phone number will be shared in the message thread, and a contacts webhook will be triggered containing the user’s phone number. Note that if a WhatsApp user shares a contact using the share contacts feature in the WhatsApp app instead, the webhook will also include the contact’s vCard. If you are using the contact book feature, their phone number will also be added to your contact book automatically. For businesses that have enabled Local Storage, Meta extracts the user’s phone number from the shared contact card (vCard) and stores it in your contact book on Meta data centers. Only the phone number is extracted and stored; no other vCard data is retained beyond the standard data-in-use period. @@ -1314,7 +1315,7 @@ async def request_contact_info( wa=self, value=sender, method_arg="sender", client_arg="phone_id" ) recipient, recipient_type = helpers.resolve_recipient(to) - return SentMessage.from_sent_update( + return SentContactInfoRequest.from_sent_update( client=self, update=await self.api.send_message( sender=sender, diff --git a/pywa_async/types/base_update.py b/pywa_async/types/base_update.py index 5d6368a7..a01c7922 100644 --- a/pywa_async/types/base_update.py +++ b/pywa_async/types/base_update.py @@ -31,6 +31,7 @@ from .chat import Chat from .media import Media from .sent_update import ( + SentContactInfoRequest, SentLocationRequest, SentMediaMessage, SentMessage, @@ -605,7 +606,7 @@ async def reply_contact_info_request( quote: bool = False, tracker: str | CallbackData | None = None, identity_key_hash: str | None = None, - ) -> SentMessage: + ) -> SentContactInfoRequest: """ If a user taps this button, their WhatsApp phone number will be shared in the message thread, and a contacts webhook will be triggered containing the user’s phone number. Note that if a WhatsApp user shares a contact using the share contacts feature in the WhatsApp app instead, the webhook will also include the contact’s vCard. If you are using the contact book feature, their phone number will also be added to your contact book automatically. For businesses that have enabled Local Storage, Meta extracts the user’s phone number from the shared contact card (vCard) and stores it in your contact book on Meta data centers. Only the phone number is extracted and stored; no other vCard data is retained beyond the standard data-in-use period. diff --git a/pywa_async/types/sent_update.py b/pywa_async/types/sent_update.py index 73103d6f..19fbdcec 100644 --- a/pywa_async/types/sent_update.py +++ b/pywa_async/types/sent_update.py @@ -7,6 +7,9 @@ from pywa.types.sent_update import ( InitiatedCall as _InitiatedCall, ) +from pywa.types.sent_update import ( + SentContactInfoRequest as _SentContactInfoRequest, +) from pywa.types.sent_update import ( SentLocationRequest as _SentLocationRequest, ) @@ -49,6 +52,7 @@ "SentMediaMessage", "SentVoiceMessage", "SentLocationRequest", + "SentContactInfoRequest", "SentReaction", "SentTemplate", "SentTemplateStatus", @@ -668,7 +672,7 @@ def start(w: WhatsApp, m: Message): class SentLocationRequest(SentMessage, _SentLocationRequest): """ - Represents a location request message that was sent to WhatsApp user/group. + Represents a location request message that was sent to WhatsApp user. Attributes: id: The ID of the message. @@ -689,6 +693,16 @@ async def wait_for_location( """ Wait for a location message in response to the location request. + Example: + + .. code-block:: python + + @wa.on_message(filters.command("start")) + async def start(w: WhatsApp, m: Message): + r = await m.reply_location_request(text="Please share your location",) + location_message = await r.wait_for_location() + await r.reply(f"You shared your location: {location_message.location}", quote=True) + Args: force_current_location: Whether to only accept current location messages. filters: The filters to apply to the location message. @@ -719,6 +733,60 @@ async def wait_for_location( ) +class SentContactInfoRequest(SentMessage, _SentContactInfoRequest): + """ + Represents a contact information request message that was sent to WhatsApp user. + + Attributes: + id: The ID of the message. + from_phone_id: The phone id of the sender (you). + chat: The chat to which the message was sent. + input: The input of the recipient. + """ + + async def wait_for_contact_info( + self, + *, + filters: pywa_filters.Filter = None, + cancelers: pywa_filters.Filter = None, + ignore_updates: bool = True, + timeout: float | None = None, + ) -> Message: + """ + Wait for a contact info shared message. + + Example: + + .. code-block:: python + + @wa.on_message(filters.command("start")) + async def start(w: WhatsApp, m: Message): + r = await m.reply_contact_info_request(text="Please share your contact",) + contact_message = await r.wait_for_contact_info() + await r.reply(f"You shared your contact: {contact_message.contacts.first.name}", quote=True) + + Args: + filters: The filters to apply to the contact message. + cancelers: The filters to cancel the listening. + ignore_updates: Whether to ignore user updates that do not pass the filters. + timeout: The time to wait for the contact message. + + Returns: + The contact message. + + Raises: + ListenerTimeout: If the listener timed out. + ListenerCanceled: If the listener was canceled by a filter. + ListenerStopped: If the listener was stopped manually. + """ + return await self.wait_for_reply( + filters=(filters or pywa_filters.true) & pywa_filters.contact_info_shared, + cancelers=cancelers, + ignore_updates=ignore_updates, + timeout=timeout, + ) + + class SentReaction(SentMessage, _SentReaction): """ Represents a reaction message that was sent to WhatsApp user/group. diff --git a/tests/test_async.py b/tests/test_async.py index 06e2bd9d..d0629116 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -104,12 +104,23 @@ from pywa.types.sent_update import ( InitiatedCall as InitiatedCallSync, ) +from pywa.types.sent_update import ( + SentContactInfoRequest as SentContactInfoRequestSync, +) +from pywa.types.sent_update import ( + SentLocationRequest as SentLocationRequestSync, +) +from pywa.types.sent_update import ( + SentMediaMessage as SentMediaMessageSync, +) from pywa.types.sent_update import ( SentMessage as SentMessageSync, ) +from pywa.types.sent_update import SentReaction as SentReactionSync from pywa.types.sent_update import ( SentTemplate as SentTemplateSync, ) +from pywa.types.sent_update import SentVoiceMessage as SentVoiceMessageSync from pywa.types.templates import ( CreatedTemplate as CreatedTemplateSync, ) @@ -226,12 +237,27 @@ from pywa_async.types.sent_update import ( InitiatedCall as InitiatedCallAsync, ) +from pywa_async.types.sent_update import ( + SentContactInfoRequest as SentContactInfoRequestAsync, +) +from pywa_async.types.sent_update import ( + SentLocationRequest as SentLocationRequestAsync, +) +from pywa_async.types.sent_update import ( + SentMediaMessage as SentMediaMessageAsync, +) from pywa_async.types.sent_update import ( SentMessage as SentMessageAsync, ) +from pywa_async.types.sent_update import ( + SentReaction as SentReactionAsync, +) from pywa_async.types.sent_update import ( SentTemplate as SentTemplateAsync, ) +from pywa_async.types.sent_update import ( + SentVoiceMessage as SentVoiceMessageAsync, +) from pywa_async.types.templates import ( CreatedTemplate as CreatedTemplateAsync, ) @@ -285,6 +311,11 @@ def overrides() -> list[tuple[type, type]]: (AudioSync, AudioAsync), (StickerSync, StickerAsync), (SentMessageSync, SentMessageAsync), + (SentReactionSync, SentReactionAsync), + (SentLocationRequestSync, SentLocationRequestAsync), + (SentContactInfoRequestSync, SentContactInfoRequestAsync), + (SentMediaMessageSync, SentMediaMessageAsync), + (SentVoiceMessageSync, SentVoiceMessageAsync), (SentTemplateSync, SentTemplateAsync), (InitiatedCallSync, InitiatedCallAsync), (GraphAPISync, GraphAPIAsync), From 6f8abd74f5d6963236ea3e3f07b6e2d1320d8c08 Mon Sep 17 00:00:00 2001 From: David Lev <42866208+david-lev@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:37:44 +0300 Subject: [PATCH 7/8] [version] new version 4.1.0 --- CHANGELOG.md | 52 +++++++++++++++++++++++++++++++++++++++++++++--- pywa/__init__.py | 2 +- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f777cdd8..2846fc1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,55 @@ > NOTE: pywa follows the [semver](https://semver.org/) versioning standard. -#### 4.0.0 (2026-06-09) **Latest** - -WORK IN PROGRESS +#### 4.1.0 (2026-06-16) **Latest** + +- [client] add `archive_templates` and `unarchive_templates` methods for template archival management +- [client] add `force_transfer` option to `set_username` method for username management +- [client] add `request_contact_info` method to request customer contact information +- [listners] add `wait_for_contact_info` method to wait for contact info requests +- [filters] add `webhook_fields` filter for filtering raw updates by fields +- [cli] improve error handling during CLI command execution +- [handlers] improve handler typing and inline documentation examples +- [api] add internal utility methods for filtering `None` values and joining fields + +#### 4.0.0 (2026-06-09) + +- **User Identity & BSUID Readiness**: + - Full support for BSUIDs (Business-Scoped User IDs), parent BSUIDs, usernames, and `country_code`. + - `types.User.wa_id` is now optional (users who enable usernames may no longer expose a phone-number-based WhatsApp + ID). + - `types.User.preferred_id` resolves IDs using the new `WhatsApp(user_identifier_priority=...)` priority + configuration. + - `filters.from_users(...)` now accepts BSUIDs, parent BSUIDs, WA IDs, and formatted phone numbers. + - Phone number change updates now expose BSUID-related fields (`new_user_id`, `new_parent_id`). +- **Groups & Chat-Aware Updates**: + - Full group management support: create, update, delete, fetch groups, manage invite links, handle join requests, + and add/remove participants. + - Incoming messages now expose `msg.chat` (a `Chat` object with `id` and `type`) to distinguish private chats from + groups. + - Added `filters.private`, `filters.group`, and `filters.from_groups(...)`. + - Added `GroupMessageStatusesHandler` and `wa.on_group_message_statuses(...)` for group status updates. + - Sent messages now expose `sent.chat` and support pinning/unpinning. +- **Webhooks, CLI, & Local Development**: + - Added the built-in server workflow: `pywa dev` (auto-reload), `pywa run` (production-style), and + `WhatsApp.run()` (quick scripts). + - Added `utils.start_ngrok_tunnel(...)` for easy local webhook testing. + - Support for custom webhook subscription fields with `utils.WebhookFields` via `webhook_fields`. + - Refactored webhook validation and endpoint registration to work consistently across built-in Starlette app, + FastAPI, Flask, and manual integrations. + - Listeners now warn when no timeout is provided and prevent usage with multiple Uvicorn workers. +- **Messages, Media, Callbacks, & Account Updates**: + - Added `EditedMessage`, `DeletedMessage`, `OutgoingEditedMessage`, and `OutgoingDeletedMessage` updates for + coexistence support. + - Added `AccountUpdate` and related enums for account updates. + - Media objects now store their `caption`, and media upload internals support async pending uploads with + `PendingMedia`. + - Added `ContactInfoRequestButton`, `ContactList`, and carousel message support (`send_carousel`, `reply_carousel`). +- **Business Management & Templates**: + - Retrieve shared/owned WABAs, create/verify phone numbers, and manage usernames (`set_username`, etc.). + - Added WABA settings updates, including `degrees_of_freedom_spec`. + - Enhanced template validation, lookup, parameter introspection (`param_names`), and error reporting. + - Support for `target_waba_id` in `Template.duplicate(...)` and helper states in `CreativeFeaturesSpec`. #### 4.0.0b7 (2026-04-30) diff --git a/pywa/__init__.py b/pywa/__init__.py index a57f2112..fa8d66c6 100644 --- a/pywa/__init__.py +++ b/pywa/__init__.py @@ -9,6 +9,6 @@ from pywa.client import WhatsApp from pywa.utils import Version -__version__ = "4.0.0" +__version__ = "4.1.0" __author__ = "David Lev" __license__ = "MIT" From ed097853fc416faf1d8acf4abbdbb13c46920e70 Mon Sep 17 00:00:00 2001 From: David Lev <42866208+david-lev@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:44:54 +0300 Subject: [PATCH 8/8] [helpers] set media type to text/plain for Starlette responses, fixes #198 --- pywa/_helpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pywa/_helpers.py b/pywa/_helpers.py index 1228cc8b..bfab1575 100644 --- a/pywa/_helpers.py +++ b/pywa/_helpers.py @@ -1110,6 +1110,7 @@ async def _webhook_challenge_handler( return StarletteResponse( content=content, status_code=status, + media_type="text/plain", headers={ "X-Content-Type-Options": "nosniff", },