Skip to content

Commit

Permalink
[utils] adding flow_request_media_decryptor function to decrypt med…
Browse files Browse the repository at this point in the history
…ias from flow requests
  • Loading branch information
david-lev committed Jun 14, 2024
1 parent 2aab41b commit 5f4aa86
Show file tree
Hide file tree
Showing 8 changed files with 235 additions and 3 deletions.
4 changes: 4 additions & 0 deletions docs/source/content/flows/flow_types.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,7 @@ Flow Types
.. autofunction:: default_flow_request_decryptor

.. autofunction:: default_flow_response_encryptor

.. autofunction:: flow_request_media_decryptor_sync

.. autofunction:: flow_request_media_decryptor_async
29 changes: 29 additions & 0 deletions docs/source/content/flows/overview.rst
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ A flow is collection of screens containing components. screens can exchange data
Flow can be static; all the components settings are predefined and no interaction is required from your server.
Or it can be dynamic; your server can respond to screen actions and determine the next screen to display (or close the flow) and the data to provide to it.

.. note::

WhatsApp just `announced <https://developers.facebook.com/docs/whatsapp/flows/changelogs#june-11th--2024-release>`_ on **Conditional Component Rendering**.
This feature allows you to conditionally render components based on the data that is available in the flow!

- See :class:`If` and :class:`Switch` components.

.. note::

WORK IN PROGRESS
Expand Down Expand Up @@ -667,6 +674,15 @@ what screen to open next or complete the flow.

If you want example of more complex flow, you can check out the `Sign up Flow Example <../examples/sign_up_flow.html>`_.


.. note::

If you using :class:`PhotoPicker` or :class:`DocumentPicker` components, and handling requests containing their data, you need
to decrypt the files. ``pywa`` provides a helper function to decrypt the files:

- Syncronous: :func:`~pywa.utils.flow_request_media_decryptor_sync`
- Asyncronous: :func:`~pywa.utils.flow_request_media_decryptor_async`

Getting Flow Completion message
-------------------------------

Expand Down Expand Up @@ -695,6 +711,19 @@ Here is how to listen to flow completion request:
The .response attribute is the payload you sent when you completed the flow.

.. note::

if you using :class:`PhotoPicker` or :class:`DocumentPicker` components, you will receive the files inside the flow completion .response.
You can constract them into pywa media objects using one of :class:`Image`, :class:`Video`, :class:`Audio`, :class:`Document` classes:

.. code-block:: python
:linenos:
from pywa.types import Image
image = Image.from_flow_completion(flow.response["image"])
image.download("path/to/save")
.. toctree::
flow_json
Expand Down
1 change: 1 addition & 0 deletions docs/source/content/types/media.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Media
.. autoproperty:: BaseMedia.extension
.. automethod:: BaseMedia.get_media_url
.. automethod:: BaseMedia.download
.. automethod:: BaseMedia.from_flow_completion

----------------

Expand Down
2 changes: 1 addition & 1 deletion pywa/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
SectionRow,
FlowButton,
)
from .media import MediaUrlResponse
from .media import MediaUrlResponse, Audio, Document, Image, Sticker, Video
from .message import Message
from .message_status import (
Conversation,
Expand Down
22 changes: 22 additions & 0 deletions pywa/types/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,28 @@ def download(
**kwargs,
)

@classmethod
def from_flow_completion(cls, client: WhatsApp, media: dict[str, str]) -> BaseMedia:
"""
Create a media object from the media dict returned by the flow completion.
Example:
>>> from pywa import WhatsApp, types
>>> wa = WhatsApp()
>>> @wa.on_flow_completion()
... def on_flow_completion(_: WhatsApp, flow: types.FlowCompletion):
... img = types.Image.from_flow_completion(client=wa, media=flow.response['media'])
... img.download()
Args:
client: The WhatsApp client.
media: The media dict returned by the flow completion.
Returns:
The media object (Image, Video, Sticker, Document, Audio).
"""
return cls.from_dict(media, _client=client)


@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
class Image(BaseMedia):
Expand Down
153 changes: 152 additions & 1 deletion pywa/utils.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
from __future__ import annotations

import base64
import functools
import json
import base64
import hashlib
import hmac
import dataclasses
import enum
import importlib
import warnings
from typing import Any, Callable, Protocol, TypeAlias


import httpx
import requests

HUB_VT = "hub.verify_token"
"""The key for the verify token in the query parameters of the webhook get request."""
HUB_CH = "hub.challenge"
Expand Down Expand Up @@ -276,6 +282,151 @@ def default_flow_response_encryptor(response: dict, aes_key: bytes, iv: bytes) -
).decode("utf-8")


def _download_cdn_file_sync(
session: requests.Session | httpx.Client, url: str
) -> bytes:
response = session.get(url)
response.raise_for_status()
return response.content


async def _download_cdn_file_async(session: httpx.AsyncClient, url: str) -> bytes:
response = await session.get(url)
response.raise_for_status()
return response.content


def flow_request_media_decryptor_sync(
encrypted_media: dict[str, str | dict[str, str]],
dl_session: requests.Session | httpx.Client | None = None,
) -> tuple[str, str, bytes]:
"""
Decrypt the encrypted media file from the flow request.
- Read more at `developers.facebook.com <https://developers.facebook.com/docs/whatsapp/flows/reference/flowjson/components/media_upload#endpoint>`_.
- This implementation requires ``cryptography`` to be installed. To install it, run ``pip3 install 'pywa[cryptography]'`` or ``pip3 install cryptography``.
- This implementation is synchronous. Use this if your flow request callback is synchronous, otherwise use :func:`flow_request_media_decryptor_async`.
Example:
>>> from pywa import WhatsApp, utils, types
>>> wa = WhatsApp(...)
>>> @wa.on_flow_request("/media-upload")
... def on_media_upload_request(_: WhatsApp, flow: types.FlowRequest) -> types.FlowResponse | None:
... encrypted_media = flow.data["driver_license"][0]
... media_id, filename, decrypted_data = utils.flow_request_media_decryptor_sync(encrypted_media)
... with open(filename, "wb") as file:
... file.write(decrypted_data)
... return types.FlowResponse(...)
Args:
encrypted_media (dict): encrypted media data from the flow request (see example above).
dl_session (requests.Session | httpx.Client): download session. Optional.
Returns:
tuple[str, str, bytes]
- media_id (str): media ID
- filename (str): media filename
- decrypted_data (bytes): decrypted media file
Raises:
ValueError: If any of the hash verifications fail.
"""
cdn_file = _download_cdn_file_sync(
dl_session or httpx.Client(), encrypted_media["cdn_url"]
)
return (
encrypted_media["media_id"],
encrypted_media["file_name"],
_flow_request_media_decryptor(cdn_file, encrypted_media["encryption_metadata"]),
)


async def flow_request_media_decryptor_async(
encrypted_media: dict[str, str | dict[str, str]],
dl_session: httpx.AsyncClient | None = None,
) -> tuple[str, str, bytes]:
"""
Decrypt the encrypted media file from the flow request.
- Read more at `developers.facebook.com <https://developers.facebook.com/docs/whatsapp/flows/reference/flowjson/components/media_upload#endpoint>`_.
- This implementation requires ``cryptography`` to be installed. To install it, run ``pip3 install 'pywa[cryptography]'`` or ``pip3 install cryptography``.
- This implementation is asynchronous. Use this if your flow request callback is asynchronous, otherwise use :func:`flow_request_media_decryptor_sync`.
Example:
>>> from pywa import WhatsApp, utils, types
>>> wa = WhatsApp(...)
>>> @wa.on_flow_request("/media-upload")
... async def on_media_upload_request(_: WhatsApp, flow: types.FlowRequest) -> types.FlowResponse | None:
... encrypted_media = flow.data["driver_license"][0]
... media_id, filename, decrypted_data = await utils.flow_request_media_decryptor_async(encrypted_media)
... with open(filename, "wb") as file:
... file.write(decrypted_data)
... return types.FlowResponse(...)
Args:
encrypted_media (dict): encrypted media data from the flow request (see example above).
dl_session (httpx.AsyncClient): download session. Optional.
Returns:
tuple[str, str, bytes]
- media_id (str): media ID
- filename (str): media filename
- decrypted_data (bytes): decrypted media file
Raises:
ValueError: If any of the hash verifications fail.
"""
cdn_file = await _download_cdn_file_async(
dl_session or httpx.AsyncClient(), encrypted_media["cdn_url"]
)
return (
encrypted_media["media_id"],
encrypted_media["file_name"],
_flow_request_media_decryptor(cdn_file, encrypted_media["encryption_metadata"]),
)


def _flow_request_media_decryptor(
cdn_file: bytes, encryption_metadata: dict[str, str]
) -> bytes:
"""The actual implementation of the media decryption."""
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.padding import PKCS7
from cryptography.hazmat.backends import default_backend

ciphertext = cdn_file[:-10]
sha256 = hashlib.sha256(cdn_file)
calculated_hash = base64.b64encode(sha256.digest()).decode()
if calculated_hash != encryption_metadata["encrypted_hash"]:
raise ValueError("CDN file hash verification failed")
if (
hmac.new(
base64.b64decode(encryption_metadata["hmac_key"]),
base64.b64decode(encryption_metadata["iv"]) + ciphertext,
hashlib.sha256,
).digest()[:10]
!= cdn_file[-10:]
):
raise ValueError("HMAC verification failed")
decryptor = Cipher(
algorithms.AES(base64.b64decode(encryption_metadata["encryption_key"])),
modes.CBC(base64.b64decode(encryption_metadata["iv"])),
backend=default_backend(),
).decryptor()
decrypted_data = decryptor.update(ciphertext) + decryptor.finalize()
unpadder = PKCS7(128).unpadder()
decrypted_data = unpadder.update(decrypted_data) + unpadder.finalize()
sha256 = hashlib.sha256(decrypted_data)
if (
base64.b64encode(sha256.digest()).decode()
!= encryption_metadata["plaintext_hash"]
):
raise ValueError("Decrypted data hash verification failed")
return decrypted_data


def rename_func(extended_with: str) -> Callable:
"""Rename function to avoid conflicts when registering the same function multiple times."""

Expand Down
2 changes: 1 addition & 1 deletion pywa_async/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
SectionRow,
FlowButton,
)
from .media import MediaUrlResponse
from .media import MediaUrlResponse, Audio, Document, Image, Sticker, Video
from .message import Message
from .message_status import (
Conversation,
Expand Down
25 changes: 25 additions & 0 deletions pywa_async/types/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,31 @@ async def download(
**kwargs,
)

@classmethod
def from_flow_completion(
cls, client: WhatsApp, media: dict[str, str]
) -> BaseMediaAsync:
"""
Create a media object from the media dict returned by the flow completion.
Example:
>>> from pywa_async import WhatsApp, types
>>> wa = WhatsApp()
>>> @wa.on_flow_completion()
... async def on_flow_completion(_: WhatsApp, flow: types.FlowCompletion):
... img = types.Image.from_flow_completion(client=wa, media=flow.response['media'])
... await img.download()
Args:
client: The WhatsApp client.
media: The media dict returned by the flow completion.
Returns:
The media object (Image, Video, Sticker, Document, Audio).
"""
# noinspection PyUnresolvedReferences
return cls.from_dict(media, _client=client)


@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
class Image(BaseMediaAsync, _Image):
Expand Down

0 comments on commit 5f4aa86

Please sign in to comment.