From f3ba8e5c466db5c6fac6d67f0f63f0182cb4dd20 Mon Sep 17 00:00:00 2001 From: David Lev Date: Thu, 19 Oct 2023 02:30:14 +0300 Subject: [PATCH 1/3] [webhook] allowing to register callback url --- pywa/api.py | 69 ++++++++++++++++++++++ pywa/client.py | 150 ++++++++++++++++++++++++++++++++++++++++------- pywa/handlers.py | 77 ++++++++++++------------ pywa/webhook.py | 116 ------------------------------------ 4 files changed, 240 insertions(+), 172 deletions(-) delete mode 100644 pywa/webhook.py diff --git a/pywa/api.py b/pywa/api.py index 6b11a100..0f25c998 100644 --- a/pywa/api.py +++ b/pywa/api.py @@ -62,6 +62,75 @@ def _make_request( raise WhatsAppError.from_response(status_code=res.status_code, error=res.json()["error"]) return res.json() + def get_app_access_token(self, app_id: int, app_secret: str) -> dict[str, str]: + """ + Get an access token for an app. + + Return example:: + + { + 'access_token': 'xyzxyzxyz', + 'token_type': 'bearer' + } + + + Args: + app_id: The ID of the app. + app_secret: The secret of the app. + + Returns: + The access token and its type. + + """ + return self._make_request( + method="GET", + endpoint="/oauth/access_token", + params={ + "grant_type": "client_credentials", + "client_id": app_id, + "client_secret": app_secret + } + ) + + def set_callback_url( + self, + app_id: int, + app_access_token: str, + callback_url: str, + verify_token: str, + fields: tuple[str, ...], + ) -> dict[str, bool]: + """ + Set the callback URL for the webhook. + + Return example:: + + { + 'success': True + } + + Args: + app_id: The ID of the app. + app_access_token: The access token of the app (from ``get_app_access_token``). + callback_url: The URL to set. + verify_token: The verify token to challenge the webhook with. + fields: The fields to subscribe to. + + Returns: + The success of the operation. + """ + return self._make_request( + method="POST", + endpoint=f"/{app_id}/subscriptions", + params={ + "object": "whatsapp_business_account", + "callback_url": callback_url, + "verify_token": verify_token, + "fields": fields, + "access_token": app_access_token + } + ) + def send_text_message( self, to: str, diff --git a/pywa/client.py b/pywa/client.py index 9051c9fa..d771c4a2 100644 --- a/pywa/client.py +++ b/pywa/client.py @@ -4,18 +4,26 @@ __all__ = ["WhatsApp"] +import collections import hashlib +import logging import mimetypes import os +import threading import requests -from typing import Iterable, BinaryIO +from typing import Iterable, BinaryIO, Callable, Any +from pywa import utils from pywa.api import WhatsAppCloudApi +from pywa.types.base_update import BaseUpdate from pywa.utils import Flask, FastAPI -from pywa.webhook import Webhook -from pywa.handlers import Handler, HandlerDecorators # noqa +from pywa.handlers import ( + Handler, HandlerDecorators, + RawUpdateHandler, CallbackButtonHandler, CallbackSelectionHandler, MessageHandler, MessageStatusHandler +) from pywa.types import ( Button, SectionList, Message, Contact, MediaUrlResponse, - ProductsSection, BusinessProfile, Industry, CommerceSettings, NewTemplate, Template, TemplateResponse, ButtonUrl + ProductsSection, BusinessProfile, Industry, CommerceSettings, NewTemplate, Template, TemplateResponse, ButtonUrl, + MessageType ) @@ -65,6 +73,14 @@ def _resolve_media_param( class WhatsApp(HandlerDecorators): + __slots__ = ( + 'phone_id', + 'filter_updates', + 'business_account_id', + 'api', + 'handlers', + ) + # noinspection PyMissingConstructor def __init__( self, @@ -75,12 +91,16 @@ def __init__( session: requests.Session | None = None, server: Flask | FastAPI | None = None, webhook_endpoint: str = "/", + callback_url: str | None = None, + fields: Iterable[str] | None = None, + app_id: int | None = None, + app_secret: str | None = None, verify_token: str | None = None, filter_updates: bool = True, business_account_id: str | int | None = None, ) -> None: """ - Initialize the WhatsApp client. + The WhatsApp client. - Full documentation on `pywa.readthedocs.io `_. Example without webhook: @@ -113,13 +133,19 @@ def __init__( session across multiple WhatsApp clients!) server: The Flask or FastAPI app instance to use for the webhook. webhook_endpoint: The endpoint to listen for incoming messages (default: ``/``). + callback_url: The callback URL to register (optional). + fields: The fields to register for the callback URL (optional, if not provided, all supported fields will be + registered). + app_id: The ID of the WhatsApp App (optional, required when registering a ``callback_url``). + app_secret: The secret of the WhatsApp App (optional, required when registering a ``callback_url``). verify_token: The verify token of the registered webhook (Required when ``server`` is provided). - filter_updates: Whether to filter out updates that not sent to this phone number (default: ``True``, does - not apply to raw updates). + filter_updates: Whether to filter out user updates that not sent to this phone_id (default: ``True``, does + not apply to raw updates or updates that are not user-related). business_account_id: The business account ID of the WhatsApp account (optional, required for some API - methods). + methods). """ self.phone_id = str(phone_id) + self.filter_updates = filter_updates self.business_account_id = str(business_account_id) if business_account_id is not None else None self.api = WhatsAppCloudApi( phone_id=self.phone_id, @@ -128,18 +154,102 @@ def __init__( base_url=base_url, api_version=float(api_version), ) + self.handlers = None if server is not None: if verify_token is None: raise ValueError("When listening for incoming messages, a verify token must be provided.") - self.webhook = Webhook( - wa_client=self, - server=server, - verify_token=verify_token, - webhook_endpoint=webhook_endpoint, - filter_updates=filter_updates, - ) - else: - self.webhook = None + self.handlers: dict[type[Handler] | None, list[Callable[[WhatsApp, BaseUpdate | dict], Any]]] \ + = collections.defaultdict(list) + + hub_vt = "hub.verify_token" + hub_ch = "hub.challenge" + + if utils.is_flask_app(server): + import flask + + @server.route(webhook_endpoint, methods=["GET"]) + def verify(): + if flask.request.args.get(hub_vt) == verify_token: + return flask.request.args.get(hub_ch), 200 + return "Error, invalid verification token", 403 + + @server.route(webhook_endpoint, methods=["POST"]) + def webhook(): + threading.Thread(target=self._call_handlers, args=(flask.request.json,)).start() + return "ok", 200 + + elif utils.is_fastapi_app(server): + import fastapi + + @server.get(webhook_endpoint) + def verify( + vt: str = fastapi.Query(..., alias=hub_vt), + ch: str = fastapi.Query(..., alias=hub_ch) + ): + if vt == verify_token: + return fastapi.Response(content=ch, status_code=200) + return fastapi.Response(content="Error, invalid verification token", status_code=403) + + @server.post(webhook_endpoint) + def webhook(payload: dict = fastapi.Body(...)): + threading.Thread(target=self._call_handlers, args=(payload,)).start() + return fastapi.Response(content="ok", status_code=200) + + else: + raise ValueError("The server must be a Flask or FastAPI app.") + + if callback_url is not None: + if app_id is None or app_secret is None: + raise ValueError("When providing a callback URL, an app ID and app secret must be provided.") + + def register_callback(): + app_access_token = self.api.get_app_access_token(app_id=app_id, app_secret=app_secret) + if not self.api.set_callback_url( + app_id=app_id, + app_access_token=app_access_token['access_token'], + callback_url=f'{callback_url}/{webhook_endpoint}', + verify_token=verify_token, + fields=tuple(fields or Handler.__fields_to_subclasses__().keys()), + )['success']: + raise ValueError("Failed to register callback URL.") + threading.Thread(target=register_callback).start() + + def _call_handlers(self, update: dict) -> None: + """Call the handlers for the given update.""" + handler = self._get_handler(update=update) + for func in self.handlers[handler]: + # noinspection PyCallingNonCallable + func(self, handler.__update_constructor__(self, update)) + for raw_update_func in self.handlers[RawUpdateHandler]: + raw_update_func(self, update) + + def _get_handler(self, update: dict) -> type[Handler] | None: + """Get the handler for the given update.""" + field = update["entry"][0]["changes"][0]["field"] + value = update["entry"][0]["changes"][0]["value"] + + # The `messages` field needs to be handled differently because it can be a message, button, selection, or status + # This check must return handler or None *BEFORE* getting the handler from the dict!! + if field == 'messages': + if not self.filter_updates or (value["metadata"]["phone_number_id"] == self.phone_id): + if 'messages' in value: + match value["messages"][0]["type"]: + case MessageType.INTERACTIVE: + if (_type := value["messages"][0]["interactive"]["type"]) == "button_reply": # button + return CallbackButtonHandler + elif _type == "list_reply": # selection + return CallbackSelectionHandler + logging.warning("PyWa Webhook: Unknown interactive message type: %s" % _type) + case MessageType.BUTTON: # button (quick reply from template) + return CallbackButtonHandler + case _: # message + return MessageHandler + elif 'statuses' in value: # status + return MessageStatusHandler + logging.warning("PyWa Webhook: Unknown message type: %s" % value) + return None + + return Handler.__fields_to_subclasses__().get(field) def __str__(self) -> str: return f"WhatApp(phone_id={self.phone_id!r})" @@ -162,11 +272,11 @@ def add_handlers(self, *handlers: Handler): ... CallbackButtonHandler(print_message), ... ) """ - if self.webhook is None: + if self.handlers is None: raise ValueError("You must initialize the WhatsApp client with an web server" - " (Flask or FastAPI) in order to handle incoming messages.") + " (Flask or FastAPI) in order to handle incoming updates.") for handler in handlers: - self.webhook.handlers[handler.__class__].append(handler) + self.handlers[handler.__class__].append(handler) def send_message( self, diff --git a/pywa/handlers.py b/pywa/handlers.py index 8c717880..2bb9e522 100644 --- a/pywa/handlers.py +++ b/pywa/handlers.py @@ -116,7 +116,7 @@ class Handler(abc.ABC): @property @abc.abstractmethod - def __field_name__(self) -> str: + def __field_name__(self) -> str | None: """ The field name of the webhook update https://developers.facebook.com/docs/graph-api/webhooks/reference/whatsapp-business-account @@ -142,15 +142,20 @@ def __call__(self, wa: WhatsApp, data: Any): if all((f(wa, data) for f in self.filters)): self.handler(wa, data) - def __hash__(self) -> int: - """Return the hash of the handler field name.""" - return hash(self.__field_name__) - @staticmethod @functools.cache def __fields_to_subclasses__() -> dict[str, Handler]: - """Return a dict of all the subclasses of `Handler` with their field name as the key.""" - return {h.__field_name__: h for h in Handler.__subclasses__()} + """ + Return a dict of all the subclasses of `Handler` with their field name as the key. + (e.g. ``{'messages': MessageHandler}``) + + **IMPORTANT:** This function is for internal use only, DO NOT USE IT to get the available handlers + (use ``Handler.__subclasses__()`` instead). + + **IMPORTANT:** This function is cached, so if you subclass `Handler` after calling this function, the new class + will not be included in the returned dict. + """ + return {h.__field_name__: h for h in Handler.__subclasses__() if h.__field_name__ is not None} def __str__(self) -> str: return f"{self.__class__.__name__}(handler={self.handler!r}, filters={self.filters!r})" @@ -178,13 +183,13 @@ class MessageHandler(Handler): *filters: The filters to apply to the handler (gets a :class:`pywa.WhatsApp` instance and a :class:`pywa.types.Message` and returns a :class:`bool`) """ - __field_name__ = "messages:msg" + __field_name__ = 'messages' __update_constructor__ = Message.from_update def __init__( - self, - handler: Callable[[WhatsApp, Message], Any], - *filters: Callable[[WhatsApp, Message], bool] + self, + handler: Callable[[WhatsApp, Message], Any], + *filters: Callable[[WhatsApp, Message], bool] ): super().__init__(handler, *filters) @@ -211,15 +216,15 @@ class CallbackButtonHandler(Handler): factory_before_filters: Whether to apply the factory before the filters (default: ``False``. If ``True``, the filters will get the callback data after the factory is applied). """ - __field_name__ = "messages:btn" + __field_name__ = 'messages' __update_constructor__ = CallbackButton.from_update def __init__( - self, - handler: Callable[[WhatsApp, CallbackButton], Any], - *filters: Callable[[WhatsApp, CallbackButton], bool], - factory: CallbackDataFactoryT = str, - factory_before_filters: bool = False + self, + handler: Callable[[WhatsApp, CallbackButton], Any], + *filters: Callable[[WhatsApp, CallbackButton], bool], + factory: CallbackDataFactoryT = str, + factory_before_filters: bool = False ): self.factory, self.factory_filter = _resolve_callback_data_factory(factory) self.factory_before_filters = factory_before_filters @@ -255,15 +260,15 @@ class CallbackSelectionHandler(Handler): factory_before_filters: Whether to apply the factory before the filters (default: ``False``. If ``True``, the filters will get the callback data after the factory is applied). """ - __field_name__ = "messages:sel" + __field_name__ = 'messages' __update_constructor__ = CallbackSelection.from_update def __init__( - self, - handler: Callable[[WhatsApp, CallbackSelection], Any], - *filters: Callable[[WhatsApp, CallbackSelection], bool], - factory: CallbackDataFactoryT = str, - factory_before_filters: bool = False + self, + handler: Callable[[WhatsApp, CallbackSelection], Any], + *filters: Callable[[WhatsApp, CallbackSelection], bool], + factory: CallbackDataFactoryT = str, + factory_before_filters: bool = False ): self.factory, self.factory_filter = _resolve_callback_data_factory(factory) self.factory_before_filters = factory_before_filters @@ -297,13 +302,13 @@ class MessageStatusHandler(Handler): *filters: The filters to apply to the handler (gets a :class:`pywa.WhatsApp` instance and a :class:`pywa.types.MessageStatus` and returns a :class:`bool`) """ - __field_name__ = "messages:status" + __field_name__ = 'messages' __update_constructor__ = MessageStatus.from_update def __init__( - self, - handler: Callable[[WhatsApp, MessageStatus], Any], - *filters: Callable[[WhatsApp, MessageStatus], bool] + self, + handler: Callable[[WhatsApp, MessageStatus], Any], + *filters: Callable[[WhatsApp, MessageStatus], bool] ): super().__init__(handler, *filters) @@ -331,13 +336,13 @@ class TemplateStatusHandler(Handler): *filters: The filters to apply to the handler (gets a :class:`pywa.WhatsApp` instance and a :class:`pywa.types.TemplateStatus` and returns a :class:`bool`) """ - __field_name__ = "message_template_status_update" + __field_name__ = 'message_template_status_update' __update_constructor__ = TemplateStatus.from_update def __init__( - self, - handler: Callable[[WhatsApp, TemplateStatus], Any], - *filters: Callable[[WhatsApp, TemplateStatus], bool] + self, + handler: Callable[[WhatsApp, TemplateStatus], Any], + *filters: Callable[[WhatsApp, TemplateStatus], bool] ): super().__init__(handler, *filters) @@ -361,19 +366,19 @@ class RawUpdateHandler(Handler): *filters: The filters to apply to the handler (gets a :class:`pywa.WhatsApp` instance and a :class:`dict` and returns a :class:`bool`) """ - __field_name__ = "raw" + __field_name__ = None __update_constructor__ = lambda _, data: data # noqa def __init__( - self, - handler: Callable[[WhatsApp, dict], Any], - *filters: Callable[[WhatsApp, dict], bool] + self, + handler: Callable[[WhatsApp, dict], Any], + *filters: Callable[[WhatsApp, dict], bool] ): super().__init__(handler, *filters) class HandlerDecorators: - def __init__(self): + def __init__(self: WhatsApp): raise TypeError("This class cannot be instantiated.") def on_raw_update( diff --git a/pywa/webhook.py b/pywa/webhook.py deleted file mode 100644 index b36c29d4..00000000 --- a/pywa/webhook.py +++ /dev/null @@ -1,116 +0,0 @@ -from __future__ import annotations - -"""The webhook module contains the Webhook class, which is used to register a webhook to listen for incoming -messages.""" - -import logging -import collections -import threading -from typing import TYPE_CHECKING, Callable, Any, Type -from pywa.types.base_update import BaseUpdate -from pywa import utils -from pywa.handlers import ( - Handler, # noqa - MessageHandler, CallbackButtonHandler, CallbackSelectionHandler, RawUpdateHandler, MessageStatusHandler -) -from pywa.types import MessageType -from pywa.utils import Flask, FastAPI - -if TYPE_CHECKING: - from pywa import WhatsApp - -__all__ = ["Webhook"] - - -class Webhook: - """Register a webhook to listen for incoming messages.""" - - def __init__( - self, - wa_client: WhatsApp, - server: Flask | FastAPI, - verify_token: str, - webhook_endpoint: str, - filter_updates: bool - ): - self.handlers: dict[Type[Handler] | None, list[Callable[[WhatsApp, BaseUpdate | dict], Any]]] \ - = collections.defaultdict(list) - self.wa_client = wa_client - self.server = server - self.verify_token = verify_token - self.webhook_endpoint = webhook_endpoint - self.filter_updates = filter_updates - - hub_vt = "hub.verify_token" - hub_ch = "hub.challenge" - - if utils.is_flask_app(self.server): - import flask - - @self.server.route(self.webhook_endpoint, methods=["GET"]) - def verify_token(): - if flask.request.args.get(hub_vt) == self.verify_token: - return flask.request.args.get(hub_ch), 200 - return "Error, invalid verification token", 403 - - @self.server.route(self.webhook_endpoint, methods=["POST"]) - def webhook(): - threading.Thread(target=self.call_handlers, args=(flask.request.json,)).start() - return "ok", 200 - - elif utils.is_fastapi_app(self.server): - import fastapi - - @self.server.get(self.webhook_endpoint) - def verify_token( - token: str = fastapi.Query(..., alias=hub_vt), - challenge: str = fastapi.Query(..., alias=hub_ch) - ): - if token == self.verify_token: - return fastapi.Response(content=challenge, status_code=200) - return fastapi.Response(content="Error, invalid verification token", status_code=403) - - @self.server.post(self.webhook_endpoint) - def webhook(payload: dict = fastapi.Body(...)): - threading.Thread(target=self.call_handlers, args=(payload,)).start() - return fastapi.Response(content="ok", status_code=200) - - else: - raise ValueError("The server must be a Flask or FastAPI app.") - - def call_handlers(self, update: dict) -> None: - """Call the handlers for the given update.""" - handler = self._get_handler(update=update) - for func in self.handlers[handler]: - # noinspection PyCallingNonCallable - func(self.wa_client, handler.__update_constructor__(self.wa_client, update)) - for raw_update_func in self.handlers[RawUpdateHandler]: - raw_update_func(self.wa_client, update) - - def _get_handler(self, update: dict) -> Type[Handler] | None: - """Get the handler for the given update.""" - field = update["entry"][0]["changes"][0]["field"] - value = update["entry"][0]["changes"][0]["value"] - - # The `messages` field needs to be handled differently because it can be a message, button, selection, or status - if field == 'messages' and ( - not self.filter_updates or (value["metadata"]["phone_number_id"] == self.wa_client.phone_id) - ): - if 'messages' in value: - match value["messages"][0]["type"]: - case MessageType.INTERACTIVE: - if (_type := value["messages"][0]["interactive"]["type"]) == "button_reply": # button - return CallbackButtonHandler - elif _type == "list_reply": # selection - return CallbackSelectionHandler - logging.warning("PyWa Webhook: Unknown interactive message type: %s" % _type) - case MessageType.BUTTON: # button (quick reply from template) - return CallbackButtonHandler - case _: # message - return MessageHandler - - elif 'statuses' in value: # status - return MessageStatusHandler - else: - logging.warning("PyWa Webhook: Unknown message type: %s" % value) - return Handler.__fields_to_subclasses__().get(field) From 7b1481e6302c570485fd66c82809303ef76aa0d9 Mon Sep 17 00:00:00 2001 From: David Lev Date: Thu, 19 Oct 2023 21:23:45 +0300 Subject: [PATCH 2/3] [webhook] allowing to specify `verify_timeout` for the callback url registration --- pywa/api.py | 2 +- pywa/client.py | 234 ++++++++++++++--------------------------------- pywa/handlers.py | 2 + pywa/webhook.py | 143 +++++++++++++++++++++++++++++ 4 files changed, 214 insertions(+), 167 deletions(-) create mode 100644 pywa/webhook.py diff --git a/pywa/api.py b/pywa/api.py index 0f25c998..bebb4418 100644 --- a/pywa/api.py +++ b/pywa/api.py @@ -126,7 +126,7 @@ def set_callback_url( "object": "whatsapp_business_account", "callback_url": callback_url, "verify_token": verify_token, - "fields": fields, + "fields": ",".join(fields), "access_token": app_access_token } ) diff --git a/pywa/client.py b/pywa/client.py index d771c4a2..bbf9920a 100644 --- a/pywa/client.py +++ b/pywa/client.py @@ -4,98 +4,41 @@ __all__ = ["WhatsApp"] -import collections import hashlib -import logging import mimetypes import os -import threading import requests -from typing import Iterable, BinaryIO, Callable, Any -from pywa import utils +from typing import Iterable, BinaryIO from pywa.api import WhatsAppCloudApi -from pywa.types.base_update import BaseUpdate -from pywa.utils import Flask, FastAPI -from pywa.handlers import ( - Handler, HandlerDecorators, - RawUpdateHandler, CallbackButtonHandler, CallbackSelectionHandler, MessageHandler, MessageStatusHandler -) +from pywa.handlers import Handler, HandlerDecorators # noqa from pywa.types import ( Button, SectionList, Message, Contact, MediaUrlResponse, - ProductsSection, BusinessProfile, Industry, CommerceSettings, NewTemplate, Template, TemplateResponse, ButtonUrl, - MessageType + ProductsSection, BusinessProfile, Industry, CommerceSettings, NewTemplate, Template, TemplateResponse, ButtonUrl ) - - -def _resolve_keyboard_param(keyboard: Iterable[Button] | ButtonUrl | SectionList) -> tuple[str, dict]: - """ - Resolve keyboard parameters to a type and an action dict. - """ - if isinstance(keyboard, SectionList): - return "list", keyboard.to_dict() - elif isinstance(keyboard, ButtonUrl): - return "cta_url", keyboard.to_dict() - else: - return "button", {"buttons": tuple(b.to_dict() for b in keyboard)} - - -def _resolve_media_param( - wa: WhatsApp, - media: str | bytes | BinaryIO, - mime_type: str | None, - filename: str | None, -) -> tuple[bool, str]: - """ - Internal method to resolve media parameters. Returns a tuple of (is_url, media_id_or_url). - """ - if isinstance(media, str): - if media.startswith(("https://", "http://")): - return True, media - elif not os.path.isfile(media) and media.isdigit(): - return False, media # assume it's a media ID - else: # assume it's a file path - if not (mt := mimetypes.guess_type(media)[0] or mime_type): - raise ValueError(f"Could not determine the mime type of the file {media!r}. Please provide a mime type.") - return False, wa.upload_media( - media=media, - mime_type=mt, - filename=filename or os.path.basename(media) - ) - else: - if not mime_type or not filename: - msg = "When sending media as bytes or a file object a {} must be provided." - raise ValueError(msg.format("mime_type and filename" if not mime_type and not filename else - ("mime_type" if not mime_type else "filename"))) - return False, wa.api.upload_media(media=media, mime_type=mime_type, filename=filename)['id'] - +from pywa.utils import Flask, FastAPI +from pywa.webhook import Webhook _MISSING = object() -class WhatsApp(HandlerDecorators): - __slots__ = ( - 'phone_id', - 'filter_updates', - 'business_account_id', - 'api', - 'handlers', - ) +class WhatsApp(Webhook, HandlerDecorators): # noinspection PyMissingConstructor def __init__( self, phone_id: str | int, token: str, - base_url: str = "https://graph.facebook.com", + base_url: str = 'https://graph.facebook.com', api_version: float | int = 18.0, session: requests.Session | None = None, server: Flask | FastAPI | None = None, - webhook_endpoint: str = "/", + webhook_endpoint: str = '/', callback_url: str | None = None, fields: Iterable[str] | None = None, app_id: int | None = None, app_secret: str | None = None, verify_token: str | None = None, + verify_timeout: int | None = None, filter_updates: bool = True, business_account_id: str | int | None = None, ) -> None: @@ -132,8 +75,10 @@ def __init__( session: The session to use for requests (default: new ``requests.Session()``, Do not use the same session across multiple WhatsApp clients!) server: The Flask or FastAPI app instance to use for the webhook. - webhook_endpoint: The endpoint to listen for incoming messages (default: ``/``). - callback_url: The callback URL to register (optional). + webhook_endpoint: The endpoint to listen for incoming messages (if you using the server for another purpose + and you're already using the ``/`` endpoint, you can change it to something else). + callback_url: The callback URL to register (optional, only if you want pywa to register the callback URL for + you). fields: The fields to register for the callback URL (optional, if not provided, all supported fields will be registered). app_id: The ID of the WhatsApp App (optional, required when registering a ``callback_url``). @@ -154,102 +99,17 @@ def __init__( base_url=base_url, api_version=float(api_version), ) - self.handlers = None - if server is not None: - if verify_token is None: - raise ValueError("When listening for incoming messages, a verify token must be provided.") - self.handlers: dict[type[Handler] | None, list[Callable[[WhatsApp, BaseUpdate | dict], Any]]] \ - = collections.defaultdict(list) - - hub_vt = "hub.verify_token" - hub_ch = "hub.challenge" - - if utils.is_flask_app(server): - import flask - - @server.route(webhook_endpoint, methods=["GET"]) - def verify(): - if flask.request.args.get(hub_vt) == verify_token: - return flask.request.args.get(hub_ch), 200 - return "Error, invalid verification token", 403 - - @server.route(webhook_endpoint, methods=["POST"]) - def webhook(): - threading.Thread(target=self._call_handlers, args=(flask.request.json,)).start() - return "ok", 200 - - elif utils.is_fastapi_app(server): - import fastapi - - @server.get(webhook_endpoint) - def verify( - vt: str = fastapi.Query(..., alias=hub_vt), - ch: str = fastapi.Query(..., alias=hub_ch) - ): - if vt == verify_token: - return fastapi.Response(content=ch, status_code=200) - return fastapi.Response(content="Error, invalid verification token", status_code=403) - - @server.post(webhook_endpoint) - def webhook(payload: dict = fastapi.Body(...)): - threading.Thread(target=self._call_handlers, args=(payload,)).start() - return fastapi.Response(content="ok", status_code=200) - - else: - raise ValueError("The server must be a Flask or FastAPI app.") - - if callback_url is not None: - if app_id is None or app_secret is None: - raise ValueError("When providing a callback URL, an app ID and app secret must be provided.") - - def register_callback(): - app_access_token = self.api.get_app_access_token(app_id=app_id, app_secret=app_secret) - if not self.api.set_callback_url( - app_id=app_id, - app_access_token=app_access_token['access_token'], - callback_url=f'{callback_url}/{webhook_endpoint}', - verify_token=verify_token, - fields=tuple(fields or Handler.__fields_to_subclasses__().keys()), - )['success']: - raise ValueError("Failed to register callback URL.") - threading.Thread(target=register_callback).start() - - def _call_handlers(self, update: dict) -> None: - """Call the handlers for the given update.""" - handler = self._get_handler(update=update) - for func in self.handlers[handler]: - # noinspection PyCallingNonCallable - func(self, handler.__update_constructor__(self, update)) - for raw_update_func in self.handlers[RawUpdateHandler]: - raw_update_func(self, update) - - def _get_handler(self, update: dict) -> type[Handler] | None: - """Get the handler for the given update.""" - field = update["entry"][0]["changes"][0]["field"] - value = update["entry"][0]["changes"][0]["value"] - - # The `messages` field needs to be handled differently because it can be a message, button, selection, or status - # This check must return handler or None *BEFORE* getting the handler from the dict!! - if field == 'messages': - if not self.filter_updates or (value["metadata"]["phone_number_id"] == self.phone_id): - if 'messages' in value: - match value["messages"][0]["type"]: - case MessageType.INTERACTIVE: - if (_type := value["messages"][0]["interactive"]["type"]) == "button_reply": # button - return CallbackButtonHandler - elif _type == "list_reply": # selection - return CallbackSelectionHandler - logging.warning("PyWa Webhook: Unknown interactive message type: %s" % _type) - case MessageType.BUTTON: # button (quick reply from template) - return CallbackButtonHandler - case _: # message - return MessageHandler - elif 'statuses' in value: # status - return MessageStatusHandler - logging.warning("PyWa Webhook: Unknown message type: %s" % value) - return None - - return Handler.__fields_to_subclasses__().get(field) + self._handlers = None + super().__init__( + server=server, + webhook_endpoint=webhook_endpoint, + callback_url=callback_url, + fields=fields, + app_id=app_id, + app_secret=app_secret, + verify_token=verify_token, + verify_timeout=verify_timeout, + ) def __str__(self) -> str: return f"WhatApp(phone_id={self.phone_id!r})" @@ -272,11 +132,11 @@ def add_handlers(self, *handlers: Handler): ... CallbackButtonHandler(print_message), ... ) """ - if self.handlers is None: + if self._handlers is None: raise ValueError("You must initialize the WhatsApp client with an web server" " (Flask or FastAPI) in order to handle incoming updates.") for handler in handlers: - self.handlers[handler.__class__].append(handler) + self._handlers[handler.__class__].append(handler) def send_message( self, @@ -1403,3 +1263,45 @@ def send_template( template=template.to_dict(is_header_url=is_url), reply_to_message_id=reply_to_message_id, )['messages'][0]['id'] + + +def _resolve_keyboard_param(keyboard: Iterable[Button] | ButtonUrl | SectionList) -> tuple[str, dict]: + """ + Resolve keyboard parameters to a type and an action dict. + """ + if isinstance(keyboard, SectionList): + return "list", keyboard.to_dict() + elif isinstance(keyboard, ButtonUrl): + return "cta_url", keyboard.to_dict() + else: + return "button", {"buttons": tuple(b.to_dict() for b in keyboard)} + + +def _resolve_media_param( + wa: WhatsApp, + media: str | bytes | BinaryIO, + mime_type: str | None, + filename: str | None, +) -> tuple[bool, str]: + """ + Internal method to resolve media parameters. Returns a tuple of (is_url, media_id_or_url). + """ + if isinstance(media, str): + if media.startswith(("https://", "http://")): + return True, media + elif not os.path.isfile(media) and media.isdigit(): + return False, media # assume it's a media ID + else: # assume it's a file path + if not (mt := mimetypes.guess_type(media)[0] or mime_type): + raise ValueError(f"Could not determine the mime type of the file {media!r}. Please provide a mime type.") + return False, wa.upload_media( + media=media, + mime_type=mt, + filename=filename or os.path.basename(media) + ) + else: + if not mime_type or not filename: + msg = "When sending media as bytes or a file object a {} must be provided." + raise ValueError(msg.format("mime_type and filename" if not mime_type and not filename else + ("mime_type" if not mime_type else "filename"))) + return False, wa.api.upload_media(media=media, mime_type=mime_type, filename=filename)['id'] diff --git a/pywa/handlers.py b/pywa/handlers.py index 2bb9e522..b44cfa25 100644 --- a/pywa/handlers.py +++ b/pywa/handlers.py @@ -378,6 +378,8 @@ def __init__( 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.") diff --git a/pywa/webhook.py b/pywa/webhook.py new file mode 100644 index 00000000..27a6094c --- /dev/null +++ b/pywa/webhook.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +"""This module contains the Webhook class, which is used to set up a webhook for receiving incoming messages.""" + +__all__ = [ + 'Webhook' +] + +import collections +import threading +import time +import logging +from pywa.types import MessageType +from typing import Iterable, Callable, Any, TYPE_CHECKING +from pywa import utils +from pywa.errors import WhatsAppError +from pywa.handlers import Handler, CallbackButtonHandler, CallbackSelectionHandler, MessageHandler, \ + MessageStatusHandler, RawUpdateHandler +from pywa.types.base_update import BaseUpdate +from pywa.utils import Flask, FastAPI + +if TYPE_CHECKING: + from pywa.client import WhatsApp + +_VERIFY_TIMEOUT_SEC = 6 + + +class Webhook: + """This class is used by the :class:`WhatsApp` client to set up a webhook for receiving incoming messages.""" + + def __init__( + self: WhatsApp, + server: Flask | FastAPI | None = None, + webhook_endpoint: str = '/', + callback_url: str | None = None, + fields: Iterable[str] | None = None, + app_id: int | None = None, + app_secret: str | None = None, + verify_token: str | None = None, + verify_timeout: int | None = None, + ): + if server is not None: + if verify_token is None: + raise ValueError("When listening for incoming messages, a verify token must be provided.") + self._handlers: dict[type[Handler] | None, list[Callable[[WhatsApp, BaseUpdate | dict], Any]]] \ + = collections.defaultdict(list) + + hub_vt = "hub.verify_token" + hub_ch = "hub.challenge" + + if utils.is_flask_app(server): + import flask + + @server.route(webhook_endpoint, methods=["GET"]) + def challenge(): + if flask.request.args.get(hub_vt) == verify_token: + return flask.request.args.get(hub_ch), 200 + return "Error, invalid verification token", 403 + + @server.route(webhook_endpoint, methods=["POST"]) + def webhook(): + threading.Thread(target=self._call_handlers, args=(flask.request.json,)).start() + return "ok", 200 + + elif utils.is_fastapi_app(server): + import fastapi + + @server.get(webhook_endpoint) + def challenge( + vt: str = fastapi.Query(..., alias=hub_vt), + ch: str = fastapi.Query(..., alias=hub_ch) + ): + if vt == verify_token: + return fastapi.Response(content=ch, status_code=200) + return fastapi.Response(content="Error, invalid verification token", status_code=403) + + @server.post(webhook_endpoint) + def webhook(payload: dict = fastapi.Body(...)): + threading.Thread(target=self._call_handlers, args=(payload,)).start() + return fastapi.Response(content="ok", status_code=200) + + else: + raise ValueError("The server must be a Flask or FastAPI app.") + + if callback_url is not None: + if app_id is None or app_secret is None: + raise ValueError("When providing a callback URL, an app ID and app secret must be provided.") + + # This is a non-blocking function that registers the callback URL. + # It must be called after the server is running so that the challenge can be verified. + def register_callback_url(): + if verify_timeout is not None and verify_timeout > _VERIFY_TIMEOUT_SEC: + time.sleep(verify_timeout - _VERIFY_TIMEOUT_SEC) + try: + app_access_token = self.api.get_app_access_token(app_id=app_id, app_secret=app_secret) + if not self.api.set_callback_url( + app_id=app_id, + app_access_token=app_access_token['access_token'], + callback_url=f'{callback_url}/{webhook_endpoint}', + verify_token=verify_token, + fields=tuple(fields or Handler.__fields_to_subclasses__().keys()), + )['success']: + raise ValueError("Failed to register callback URL.") + except WhatsAppError as e: + raise ValueError("Failed to register callback URL. Error: %s" % e) + threading.Thread(target=register_callback_url).start() + + def _call_handlers(self: WhatsApp, update: dict) -> None: + """Call the handlers for the given update.""" + handler = self._get_handler(update=update) + for func in self._handlers[handler]: + # noinspection PyCallingNonCallable + func(self, handler.__update_constructor__(self, update)) + for raw_update_func in self._handlers[RawUpdateHandler]: + raw_update_func(self, update) + + def _get_handler(self: WhatsApp, update: dict) -> type[Handler] | None: + """Get the handler for the given update.""" + field = update["entry"][0]["changes"][0]["field"] + value = update["entry"][0]["changes"][0]["value"] + + # The `messages` field needs to be handled differently because it can be a message, button, selection, or status + # This check must return handler or None *BEFORE* getting the handler from the dict!! + if field == 'messages': + if not self.filter_updates or (value["metadata"]["phone_number_id"] == self.phone_id): + if 'messages' in value: + match value["messages"][0]["type"]: + case MessageType.INTERACTIVE: + if (_type := value["messages"][0]["interactive"]["type"]) == "button_reply": # button + return CallbackButtonHandler + elif _type == "list_reply": # selection + return CallbackSelectionHandler + logging.warning("PyWa Webhook: Unknown interactive message type: %s" % _type) + case MessageType.BUTTON: # button (quick reply from template) + return CallbackButtonHandler + case _: # message + return MessageHandler + elif 'statuses' in value: # status + return MessageStatusHandler + logging.warning("PyWa Webhook: Unknown message type: %s" % value) + return None + + return Handler.__fields_to_subclasses__().get(field) From a22a3cdf261be78a6e5f901d6ad70d2dbe8150fe Mon Sep 17 00:00:00 2001 From: David Lev Date: Thu, 19 Oct 2023 22:44:08 +0300 Subject: [PATCH 3/3] [webhook] better error messages --- pywa/webhook.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/pywa/webhook.py b/pywa/webhook.py index 27a6094c..accc24b6 100644 --- a/pywa/webhook.py +++ b/pywa/webhook.py @@ -40,8 +40,11 @@ def __init__( verify_timeout: int | None = None, ): if server is not None: - if verify_token is None: - raise ValueError("When listening for incoming messages, a verify token must be provided.") + if not verify_token: + raise ValueError( + "When listening for incoming messages, a verify token must be provided.\n>> The verify token can " + "be any string. It is used to challenge the webhook endpoint to verify that the endpoint is valid." + ) self._handlers: dict[type[Handler] | None, list[Callable[[WhatsApp, BaseUpdate | dict], Any]]] \ = collections.defaultdict(list) @@ -84,7 +87,11 @@ def webhook(payload: dict = fastapi.Body(...)): if callback_url is not None: if app_id is None or app_secret is None: - raise ValueError("When providing a callback URL, an app ID and app secret must be provided.") + raise ValueError( + "When registering a callback URL, the app ID and app secret must be provided.\n>> See here how " + "to get them: " + "https://developers.facebook.com/docs/development/create-an-app/app-dashboard/basic-settings/" + ) # This is a non-blocking function that registers the callback URL. # It must be called after the server is running so that the challenge can be verified. @@ -102,7 +109,7 @@ def register_callback_url(): )['success']: raise ValueError("Failed to register callback URL.") except WhatsAppError as e: - raise ValueError("Failed to register callback URL. Error: %s" % e) + raise ValueError(f"Failed to register callback URL. Error: {e}") threading.Thread(target=register_callback_url).start() def _call_handlers(self: WhatsApp, update: dict) -> None: