Skip to content

Commit

Permalink
[server] allow custom server implementations
Browse files Browse the repository at this point in the history
  • Loading branch information
david-lev committed May 30, 2024
1 parent f8b9737 commit 24e3b89
Show file tree
Hide file tree
Showing 5 changed files with 118 additions and 35 deletions.
3 changes: 3 additions & 0 deletions docs/source/content/client/client_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ Client Reference
.. automethod:: WhatsApp.get_flows
.. automethod:: WhatsApp.get_flow_assets
.. automethod:: WhatsApp.register_phone_number
.. automethod:: WhatsApp.webhook_update_handler
.. automethod:: WhatsApp.webhook_challenge_handler
.. automethod:: WhatsApp.get_flow_request_handler
22 changes: 10 additions & 12 deletions pywa/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,6 @@
from .utils import FastAPI, Flask
from .server import Server

_MISSING: object | None = object()
"""A sentinel value to indicate a missing value to distinguish from ``None``."""

_DEFAULT_WORKERS = min(32, (os.cpu_count() or 0) + 4)
"""The default number of workers to use for handling incoming updates."""
Expand All @@ -75,7 +73,7 @@ def __init__(
| float
| Literal[utils.Version.GRAPH_API] = utils.Version.GRAPH_API,
session: requests.Session | None = None,
server: Flask | FastAPI | None = None,
server: Flask | FastAPI | None = utils.MISSING,
webhook_endpoint: str = "/",
verify_token: str | None = None,
filter_updates: bool = True,
Expand Down Expand Up @@ -263,7 +261,7 @@ def add_handlers(self, *handlers: Handler | FlowRequestHandler):
Args:
handlers: The handlers to add.
"""
if self._server is None:
if self._server is utils.MISSING:
raise ValueError(
"You must initialize the WhatsApp client with an web server"
" (Flask or FastAPI) in order to handle incoming updates."
Expand Down Expand Up @@ -1529,13 +1527,13 @@ def set_business_public_key(

def update_business_profile(
self,
about: str | None = _MISSING,
address: str | None = _MISSING,
description: str | None = _MISSING,
email: str | None = _MISSING,
profile_picture_handle: str | None = _MISSING,
industry: Industry | None = _MISSING,
websites: Iterable[str] | None = _MISSING,
about: str | None = utils.MISSING,
address: str | None = utils.MISSING,
description: str | None = utils.MISSING,
email: str | None = utils.MISSING,
profile_picture_handle: str | None = utils.MISSING,
industry: Industry | None = utils.MISSING,
websites: Iterable[str] | None = utils.MISSING,
) -> bool:
"""
Update the business profile of the WhatsApp Business account.
Expand Down Expand Up @@ -1584,7 +1582,7 @@ def update_business_profile(
"vertical": industry,
"websites": websites,
}.items()
if value is not _MISSING
if value is not utils.MISSING
}
return self.api.update_business_profile(data)["success"]

Expand Down
104 changes: 90 additions & 14 deletions pywa/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,11 @@ def __init__(
continue_handling: bool,
skip_duplicate_updates: bool,
):
if server is None:
self._server = None
return
self._server = server
self._verify_token = verify_token
if server is utils.MISSING:
return
self._server_type = utils.ServerType.from_app(server)
self._verify_token = verify_token
self._executor = ThreadPoolExecutor(max_workers, thread_name_prefix="Handler")
self._loop = asyncio.get_event_loop()
self._webhook_endpoint = webhook_endpoint
Expand Down Expand Up @@ -146,7 +145,37 @@ def __init__(
)

async def webhook_challenge_handler(self, vt: str, ch: str) -> tuple[str, int]:
"""The challenge function that is called when the callback URL is registered."""
"""
Handle the verification challenge from the webhook manually.
- Use this function only if you are using a custom server (e.g. Django, aiohttp, etc.).
Example:
.. code-block:: python
from aiohttp import web
from pywa import WhatsApp, utils
wa = WhatsApp(..., server=None)
async def my_challenge_handler(req: web.Request) -> web.Response:
challenge, status_code = await wa.webhook_challenge_handler(
vt=req.query[utils.HUB_VT],
ch=req.query[utils.HUB_CH],
)
return web.Response(text=challenge, status=status_code)
app = web.Application()
app.add_routes([web.get("/my_webhook", my_challenge_handler)])
Args:
vt: The verify token param (utils.HUB_VT).
ch: The challenge param (utils.HUB_CH).
Returns:
A tuple containing the challenge and the status code.
"""
if vt == self._verify_token:
_logger.info(
"Webhook ('%s') passed the verification challenge",
Expand All @@ -162,7 +191,30 @@ async def webhook_challenge_handler(self, vt: str, ch: str) -> tuple[str, int]:
return "Error, invalid verification token", 403

async def webhook_update_handler(self, update: dict) -> tuple[str, int]:
"""The webhook function that is called when an update is received."""
"""
Handle the incoming update from the webhook manually.
- Use this function only if you are using a custom server (e.g. Django, aiohttp, etc.).
Example:
.. code-block:: python
from aiohttp import web
from pywa import WhatsApp
wa = WhatsApp(..., server=None)
async def my_webhook_handler(req: web.Request) -> web.Response:
res, status_code = await wa.webhook_update_handler(await req.json())
return web.Response(text=res, status=status_code)
Args:
update: The incoming update from the webhook.
Returns:
A tuple containing the response and the status code.
"""
update_id: str | None = None
_logger.debug(
"Webhook ('%s') received an update: %s",
Expand All @@ -187,21 +239,19 @@ async def webhook_update_handler(self, update: dict) -> tuple[str, int]:
return "ok", 200

def _register_routes(self: "WhatsApp") -> None:
hub_vt = "hub.verify_token"
hub_ch = "hub.challenge"

match self._server_type:
case utils.ServerType.FLASK:
import flask

if utils.is_installed("asgiref"): # flask[async]
_logger.info("Using Flask with ASGI")

@self._server.route(self._webhook_endpoint, methods=["GET"])
@utils.rename_func(f"({self.phone_id})")
async def flask_challenge() -> tuple[str, int]:
return await self.webhook_challenge_handler(
vt=flask.request.args.get(hub_vt),
ch=flask.request.args.get(hub_ch),
vt=flask.request.args.get(utils.HUB_VT),
ch=flask.request.args.get(utils.HUB_CH),
)

@self._server.route(self._webhook_endpoint, methods=["POST"])
Expand All @@ -210,14 +260,15 @@ async def flask_webhook() -> tuple[str, int]:
return await self.webhook_update_handler(flask.request.json)

else: # flask
_logger.info("Using Flask with WSGI")

@self._server.route(self._webhook_endpoint, methods=["GET"])
@utils.rename_func(f"({self.phone_id})")
def flask_challenge() -> tuple[str, int]:
return self._loop.run_until_complete(
self.webhook_challenge_handler(
vt=flask.request.args.get(hub_vt),
ch=flask.request.args.get(hub_ch),
vt=flask.request.args.get(utils.HUB_VT),
ch=flask.request.args.get(utils.HUB_CH),
)
)

Expand All @@ -229,13 +280,15 @@ def flask_webhook() -> tuple[str, int]:
)

case utils.ServerType.FASTAPI:
_logger.info("Using FastAPI")
import fastapi

@self._server.get(self._webhook_endpoint)
@utils.rename_func(f"({self.phone_id})")
async def fastapi_challenge(req: fastapi.Request) -> fastapi.Response:
content, status_code = await self.webhook_challenge_handler(
vt=req.query_params.get(hub_vt), ch=req.query_params.get(hub_ch)
vt=req.query_params.get(utils.HUB_VT),
ch=req.query_params.get(utils.HUB_CH),
)
return fastapi.Response(content=content, status_code=status_code)

Expand All @@ -246,6 +299,9 @@ async def fastapi_webhook(req: fastapi.Request) -> fastapi.Response:
await req.json()
)
return fastapi.Response(content=content, status_code=status_code)
case None:
_logger.info("Using a custom server")

case _:
raise ValueError(
f"The `server` must be one of {utils.ServerType.protocols_names()}"
Expand Down Expand Up @@ -416,6 +472,26 @@ def get_flow_request_handler(
request_decryptor: utils.FlowRequestDecryptor | None,
response_encryptor: utils.FlowResponseEncryptor | None,
) -> Callable[[dict], Coroutine[Any, Any, tuple[str, int]]]:
"""
Get a function that handles the incoming flow requests.
- Use this function only if you are using a custom server (e.g. Django, aiohttp, etc.), else use the
:meth:`WhatsApp.on_flow_request` decorator.
Args:
endpoint: The endpoint to listen to (The endpoint uri you set to the flow. e.g ``/feedback_flow``).
callback: The callback function to call when a flow request is received.
acknowledge_errors: Whether to acknowledge errors (The return value of the callback will be ignored, and
pywa will acknowledge the error automatically).
handle_health_check: Whether to handle health checks (The callback will not be called for health checks).
private_key: The private key to use to decrypt the requests (Override the global ``business_private_key``).
private_key_password: The password to use to decrypt the private key (Override the global ``business_private_key_password``).
request_decryptor: The function to use to decrypt the requests (Override the global ``flows_request_decryptor``)
response_encryptor: The function to use to encrypt the responses (Override the global ``flows_response_encryptor``)
Returns:
A function that handles the incoming flow request and returns (response, status_code).
"""
private_key = private_key or self._private_key
private_key_password = private_key_password or self._private_key_password
if not private_key:
Expand Down
7 changes: 7 additions & 0 deletions pywa/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@
import warnings
from typing import Any, Callable, Protocol, TypeAlias

HUB_VT = "hub.verify_token"
"""The key for the verify token in the query parameters of the webhook get request."""
HUB_CH = "hub.challenge"
"""The key for the challenge in the query parameters of the webhook get request."""
MISSING: object | None = object()
"""A sentinel value to indicate a missing value to distinguish from ``None``."""


class FastAPI(Protocol):
"""Protocol for the `FastAPI <https://fastapi.tiangolo.com/>`_ app."""
Expand Down
17 changes: 8 additions & 9 deletions pywa_async/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from pywa.client import (
WhatsApp as _WhatsApp,
_DEFAULT_WORKERS,
_MISSING,
_resolve_buttons_param,
_resolve_tracker_param,
_get_interactive_msg,
Expand Down Expand Up @@ -1539,13 +1538,13 @@ async def set_business_public_key(

async def update_business_profile(
self,
about: str | None = _MISSING,
address: str | None = _MISSING,
description: str | None = _MISSING,
email: str | None = _MISSING,
profile_picture_handle: str | None = _MISSING,
industry: Industry | None = _MISSING,
websites: Iterable[str] | None = _MISSING,
about: str | None = utils.MISSING,
address: str | None = utils.MISSING,
description: str | None = utils.MISSING,
email: str | None = utils.MISSING,
profile_picture_handle: str | None = utils.MISSING,
industry: Industry | None = utils.MISSING,
websites: Iterable[str] | None = utils.MISSING,
) -> bool:
"""
Update the business profile of the WhatsApp Business account.
Expand Down Expand Up @@ -1594,7 +1593,7 @@ async def update_business_profile(
"vertical": industry,
"websites": websites,
}.items()
if value is not _MISSING
if value is not utils.MISSING
}
return (await self.api.update_business_profile(data))["success"]

Expand Down

0 comments on commit 24e3b89

Please sign in to comment.