From a917ceeb35b3e1f34f35305cf746af87edd8172f Mon Sep 17 00:00:00 2001 From: David Lev Date: Wed, 15 Jan 2025 23:50:16 +0200 Subject: [PATCH 1/2] [flows] `flow_name` instead of `flow_id` while sending. `flow_token` is now optional --- pywa/types/callback.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/pywa/types/callback.py b/pywa/types/callback.py index 53aad1f..778c4d8 100644 --- a/pywa/types/callback.py +++ b/pywa/types/callback.py @@ -517,20 +517,19 @@ class FlowButton: Attributes: title: Text on the CTA button. e.g ``SignUp``, Up to 20 characters, no emojis) - flow_id: Unique ID of the Flow provided by WhatsApp. - flow_token: Flow token generated by the business to serve as an identifier for data exchange. + flow_id: Unique ID of the Flow provided by WhatsApp (You can provide either ``flow_id`` or ``flow_name``). + flow_token: Flow token generated by the business to serve as an identifier (Default value: ``unused``) flow_message_version: Version of the flow message. Default is the latest version. - flow_action_type: Type of action to be performed when the user clicks on the CTA button. - flow_action_screen: The ID of the first Screen. - Required when ``flow_action_type`` is ``FlowActionType.NAVIGATE`` (default). - flow_action_payload: The payload to send when the user clicks on the button - (optional, only when ``flow_action_type`` is ``FlowActionType.NAVIGATE``). + flow_action_type: Type of action to be performed when the user clicks on the button. + flow_action_screen: The ID of the screen to navigate to. Required when ``flow_action_type`` is ``FlowActionType.NAVIGATE`` (default). + flow_action_payload: The data to provide to the navigation screen, if the screen requires it. mode: The mode of the flow. ``FlowStatus.PUBLISHED`` (default) or ``FlowStatus.DRAFT`` (for testing). + flow_name: Name of the Flow provided by WhatsApp (You can provide either ``flow_id`` or ``flow_name``). """ title: str - flow_id: str | int - flow_token: str + flow_id: str | int | None = None + flow_token: str | None = None flow_action_type: ( Literal[FlowActionType.NAVIGATE, FlowActionType.DATA_EXCHANGE] | None ) = None @@ -540,6 +539,7 @@ class FlowButton: utils.Version.FLOW_MSG ) mode: Literal[FlowStatus.PUBLISHED, FlowStatus.DRAFT] = FlowStatus.PUBLISHED + flow_name: str | None = None def __post_init__(self): utils.Version.FLOW_MSG.validate_min_version(str(self.flow_message_version)) @@ -550,6 +550,12 @@ def __post_init__(self): raise ValueError( "flow_action_screen cannot be None when flow_action_type is FlowActionType.NAVIGATE" ) + if (not self.flow_id and not self.flow_name) or ( + self.flow_id and self.flow_name + ): + raise ValueError( + "Either flow_id or flow_name must be provided, but not both." + ) def to_dict(self) -> dict: return { @@ -558,7 +564,8 @@ def to_dict(self) -> dict: "mode": self.mode.lower(), "flow_message_version": str(self.flow_message_version), "flow_token": self.flow_token, - "flow_id": self.flow_id, + "flow_id" if self.flow_id else "flow_name": self.flow_id + or self.flow_name, "flow_cta": self.title, **( {"flow_action": str(self.flow_action_type)} From c56e2ef2947dc75f8359fa196d7df58d35178ba1 Mon Sep 17 00:00:00 2001 From: David Lev Date: Sat, 18 Jan 2025 21:19:16 +0200 Subject: [PATCH 2/2] [client] allowing to create flow with flow json and publish it with one request --- pywa/_helpers.py | 2 +- pywa/api.py | 39 +++++++++++++++++++++++-- pywa/client.py | 60 ++++++++++++++++++++++++++++++--------- pywa/types/flows.py | 29 +++++++++++++++++++ pywa_async/api.py | 39 +++++++++++++++++++++++-- pywa_async/client.py | 49 ++++++++++++++++++++++++++------ pywa_async/types/flows.py | 1 + tests/test_client.py | 5 ++++ 8 files changed, 196 insertions(+), 28 deletions(-) diff --git a/pywa/_helpers.py b/pywa/_helpers.py index 7040473..f2fc080 100644 --- a/pywa/_helpers.py +++ b/pywa/_helpers.py @@ -162,7 +162,7 @@ def resolve_flow_json_param( to_dump = flow_json else: raise TypeError( - "`flow_json` must be a FlowJSON object, dict, json string, json file path or json bytes" + f"`flow_json` must be a FlowJSON object, dict, json string, json file path or json bytes. not {type(flow_json)}" ) if to_dump is not None: diff --git a/pywa/api.py b/pywa/api.py index 732e6ce..4b438fd 100644 --- a/pywa/api.py +++ b/pywa/api.py @@ -796,6 +796,8 @@ def create_flow( categories: tuple[str, ...], clone_flow_id: str | None = None, endpoint_uri: str | None = None, + flow_json: str = None, + publish: bool = None, ) -> dict[str, str]: """ Create or clone a flow. @@ -808,18 +810,49 @@ def create_flow( categories: The categories of the flow. clone_flow_id: The ID of the flow to clone. endpoint_uri: The endpoint URI of the flow. + flow_json: Flow's JSON encoded as string. + publish: Whether to publish the flow. Only works if ``flow_json`` is also provided with valid Flow JSON. Return example:: { - "id": "" + "id": "" + "success": True, + "validation_errors": [ + { + "error": "INVALID_PROPERTY_VALUE" , + "error_type": "FLOW_JSON_ERROR", + "message": "Invalid value found for property 'type'.", + "line_start": 10, + "line_end": 10, + "column_start": 21, + "column_end": 34, + "pointers": [ + { + "line_start": 10, + "line_end": 10, + "column_start": 21, + "column_end": 34, + "path": "screens [0].layout.children[0].type" + } + ] + } + ] } """ data = { "name": name, "categories": categories, - **({"clone_flow_id": clone_flow_id} if clone_flow_id else {}), - **({"endpoint_uri": endpoint_uri} if endpoint_uri else {}), + **{ + k: v + for k, v in { + "clone_flow_id": clone_flow_id, + "endpoint_uri": endpoint_uri, + "flow_json": flow_json, + "publish": publish, + }.items() + if v is not None + }, } return self._make_request( method="POST", diff --git a/pywa/client.py b/pywa/client.py index 1718eb7..14e0d7b 100644 --- a/pywa/client.py +++ b/pywa/client.py @@ -64,6 +64,7 @@ FlowDetails, FlowValidationError, FlowAsset, + CreatedFlow, ) from .types.sent_message import SentMessage, SentTemplate from .types.others import InteractiveType @@ -2011,6 +2012,7 @@ def send_template( from_phone_id=sender, ) + # fmt: off def create_flow( self, name: str, @@ -2018,45 +2020,77 @@ def create_flow( clone_flow_id: str | None = None, endpoint_uri: str | None = None, waba_id: str | int | None = None, - ) -> str: + flow_json: FlowJSON | dict | str | pathlib.Path | bytes | BinaryIO | None = None, + publish: bool | None = None, + *, + return_only_id: bool = True, + ) -> CreatedFlow | str: """ Create a flow. + For backward compatibility, when ``flow_json`` is not provided, the method will return the ID of the created flow. + Set ``return_only_id=False`` to return the created flow object instead. + - This method requires the WhatsApp Business account ID to be provided when initializing the client. - - New Flows are created in :class:`FlowStatus.DRAFT` status. + - New Flows are created in :class:`FlowStatus.DRAFT` status unless ``flow_json`` is provided and ``publish`` is True. - To update the flow json, use :py:func:`~pywa.client.WhatsApp.update_flow`. - To send a flow, use :py:func:`~pywa.client.WhatsApp.send_flow`. Args: - name: The name of the flow. + name: The name of the flow (must be unique, can be used later to update and send the flow). categories: The categories of the flow. + flow_json: The JSON of the flow (optional, if provided, the flow will be created with the provided JSON). + publish: Whether to publish the flow after creating it, only works if ``flow_json`` is provided. clone_flow_id: The flow ID to clone (optional). endpoint_uri: The URL of the FlowJSON Endpoint. Starting from Flow 3.0 this property should be specified only gere. Do not provide this field if you are cloning a Flow with version below 3.0. waba_id: The WhatsApp Business account ID (Overrides the client's business account ID). + return_only_id: Only for backward compatibility. Switch to False to return the created flow object. ignored when flow_json provided. Example: - >>> from pywa.types.flows import FlowCategory + >>> from pywa.types.flows import * >>> wa = WhatsApp(...) >>> wa.create_flow( ... name='Feedback', - ... categories=[FlowCategory.SURVEY, FlowCategory.OTHER] + ... categories=[FlowCategory.SURVEY, FlowCategory.OTHER], + ... flow_json=FlowJSON(...), + ... publish=True, ... ) Returns: - The flow ID. + The created flow or the ID of the created flow (if ``return_only_id`` is True). Raises: FlowBlockedByIntegrity: If you can't create a flow because of integrity issues. """ - return self.api.create_flow( - name=name, - categories=tuple(map(str, categories)), - clone_flow_id=clone_flow_id, - endpoint_uri=endpoint_uri, - waba_id=helpers.resolve_waba_id_param(self, waba_id), - )["id"] + if return_only_id: + if flow_json: + return_only_id = False + else: + warnings.warn( + "The `return_only_id` argument is for backward compatibility and will be removed in a future version.\n" + ">>> Set `return_only_id=False` and access the `.id` attribute of the returned object instead.", + DeprecationWarning, + stacklevel=2, + ) + + created = CreatedFlow.from_dict( + self.api.create_flow( + name=name, + categories=tuple(map(str, categories)), + clone_flow_id=clone_flow_id, + endpoint_uri=endpoint_uri, + waba_id=helpers.resolve_waba_id_param(self, waba_id), + flow_json=helpers.resolve_flow_json_param(flow_json) + if flow_json + else None, + publish=publish, + ) + ) + if return_only_id: + return created.id + return created def update_flow_metadata( self, diff --git a/pywa/types/flows.py b/pywa/types/flows.py index b7d6302..7d0bd37 100644 --- a/pywa/types/flows.py +++ b/pywa/types/flows.py @@ -56,6 +56,7 @@ "FlowPreview", "FlowValidationError", "FlowAsset", + "CreatedFlow", "FlowJSON", "Screen", "ScreenData", @@ -916,6 +917,34 @@ def from_dict(cls, data: dict): ) +@dataclasses.dataclass(frozen=True, slots=True) +class CreatedFlow: + """ + Represents a created flow. + + Attributes: + id: The ID of the created flow. + success: Whether the flow json is valid (Only if created with flow json). + validation_errors: The validation errors of the flow json. Only available if success is ``False``. + """ + + id: str + success: bool + validation_errors: tuple[FlowValidationError, ...] | None + + @classmethod + def from_dict(cls, data: dict): + return cls( + id=data["id"], + success=data.get("success", True), + validation_errors=tuple( + FlowValidationError.from_dict(e) + for e in data.get("validation_errors", []) + ) + or None, + ) + + _UNDERSCORE_FIELDS = { "routing_model", "data_api_version", diff --git a/pywa_async/api.py b/pywa_async/api.py index fdc0a22..79c75cd 100644 --- a/pywa_async/api.py +++ b/pywa_async/api.py @@ -786,6 +786,8 @@ async def create_flow( categories: tuple[str, ...], clone_flow_id: str | None = None, endpoint_uri: str | None = None, + flow_json: str = None, + publish: bool = None, ) -> dict[str, str]: """ Create or clone a flow. @@ -798,18 +800,49 @@ async def create_flow( categories: The categories of the flow. clone_flow_id: The ID of the flow to clone. endpoint_uri: The endpoint URI of the flow. + flow_json: Flow's JSON encoded as string. + publish: Whether to publish the flow. Only works if ``flow_json`` is also provided with valid Flow JSON. Return example:: { - "id": "" + "id": "" + "success": True, + "validation_errors": [ + { + "error": "INVALID_PROPERTY_VALUE" , + "error_type": "FLOW_JSON_ERROR", + "message": "Invalid value found for property 'type'.", + "line_start": 10, + "line_end": 10, + "column_start": 21, + "column_end": 34, + "pointers": [ + { + "line_start": 10, + "line_end": 10, + "column_start": 21, + "column_end": 34, + "path": "screens [0].layout.children[0].type" + } + ] + } + ] } """ data = { "name": name, "categories": categories, - **({"clone_flow_id": clone_flow_id} if clone_flow_id else {}), - **({"endpoint_uri": endpoint_uri} if endpoint_uri else {}), + **{ + k: v + for k, v in { + "clone_flow_id": clone_flow_id, + "endpoint_uri": endpoint_uri, + "flow_json": flow_json, + "publish": publish, + }.items() + if v is not None + }, } return await self._make_request( method="POST", diff --git a/pywa_async/client.py b/pywa_async/client.py index 89709e3..03d89d2 100644 --- a/pywa_async/client.py +++ b/pywa_async/client.py @@ -12,6 +12,7 @@ import mimetypes import os import pathlib +import warnings from types import ModuleType from typing import BinaryIO, Iterable, Literal @@ -56,6 +57,7 @@ FlowDetails, FlowValidationError, FlowAsset, + CreatedFlow, ) from .types.others import InteractiveType from .types.sent_message import SentMessage, SentTemplate @@ -1790,6 +1792,7 @@ async def send_template( from_phone_id=sender, ) + # fmt: off async def create_flow( self, name: str, @@ -1797,47 +1800,77 @@ async def create_flow( clone_flow_id: str | None = None, endpoint_uri: str | None = None, waba_id: str | int | None = None, - ) -> str: + flow_json: FlowJSON | dict | str | pathlib.Path | bytes | BinaryIO | None = None, + publish: bool | None = None, + *, + return_only_id: bool = True, + ) -> CreatedFlow | str: """ Create a flow. + For backward compatibility, when ``flow_json`` is not provided, the method will return the ID of the created flow. + Set ``return_only_id=False`` to return the created flow object instead. + - This method requires the WhatsApp Business account ID to be provided when initializing the client. - - New Flows are created in :class:`FlowStatus.DRAFT` status. + - New Flows are created in :class:`FlowStatus.DRAFT` status unless ``flow_json`` is provided and ``publish`` is True. - To update the flow json, use :py:func:`~pywa.client.WhatsApp.update_flow`. - To send a flow, use :py:func:`~pywa.client.WhatsApp.send_flow`. Args: - name: The name of the flow. + name: The name of the flow (must be unique, can be used later to update and send the flow). categories: The categories of the flow. + flow_json: The JSON of the flow (optional, if provided, the flow will be created with the provided JSON). + publish: Whether to publish the flow after creating it, only works if ``flow_json`` is provided. clone_flow_id: The flow ID to clone (optional). endpoint_uri: The URL of the FlowJSON Endpoint. Starting from Flow 3.0 this property should be specified only gere. Do not provide this field if you are cloning a Flow with version below 3.0. waba_id: The WhatsApp Business account ID (Overrides the client's business account ID). + return_only_id: Only for backward compatibility. Switch to False to return the created flow object. ignored when flow_json provided. Example: - >>> from pywa.types.flows import FlowCategory + >>> from pywa.types.flows import * >>> wa = WhatsApp(...) >>> wa.create_flow( ... name='Feedback', - ... categories=[FlowCategory.SURVEY, FlowCategory.OTHER] + ... categories=[FlowCategory.SURVEY, FlowCategory.OTHER], + ... flow_json=FlowJSON(...), + ... publish=True, ... ) Returns: - The flow ID. + The created flow or the ID of the created flow (if ``return_only_id`` is True). Raises: FlowBlockedByIntegrity: If you can't create a flow because of integrity issues. """ - return ( + if return_only_id: + if flow_json: + return_only_id = False + else: + warnings.warn( + "The `return_only_id` argument is for backward compatibility and will be removed in a future version.\n" + ">>> Set `return_only_id=False` and access the `.id` attribute of the returned object instead.", + DeprecationWarning, + stacklevel=2, + ) + + created = CreatedFlow.from_dict( await self.api.create_flow( name=name, categories=tuple(map(str, categories)), clone_flow_id=clone_flow_id, endpoint_uri=endpoint_uri, waba_id=helpers.resolve_waba_id_param(self, waba_id), + flow_json=helpers.resolve_flow_json_param(flow_json) + if flow_json + else None, + publish=publish, ) - )["id"] + ) + if return_only_id: + return created.id + return created async def update_flow_metadata( self, diff --git a/pywa_async/types/flows.py b/pywa_async/types/flows.py index 74ef376..47443a1 100644 --- a/pywa_async/types/flows.py +++ b/pywa_async/types/flows.py @@ -18,6 +18,7 @@ "FlowPreview", "FlowValidationError", "FlowAsset", + "CreatedFlow", "FlowJSON", "Screen", "ScreenData", diff --git a/tests/test_client.py b/tests/test_client.py index 97230a2..0df8318 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -777,3 +777,8 @@ def test_mark_message_as_read(api, wa): phone_id=PHONE_ID, message_id=MSG_ID, ) + + +def test_created_flow(api, wa): + with pytest.warns(DeprecationWarning): + wa.create_flow(name="flow", categories=[], waba_id=123)