From 96f6915c8e4ceb64983ce4f372a3b102805b70a8 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 03:14:53 +0200 Subject: [PATCH] feat: a whatsapp resource for WhatsApp Business connections --- README.md | 1 + src/fopost/__init__.py | 22 ++ src/fopost/client.py | 2 + src/fopost/models.py | 115 +++++++++ src/fopost/resources/__init__.py | 2 + src/fopost/resources/whatsapp.py | 429 +++++++++++++++++++++++++++++++ tests/test_models.py | 4 +- tests/test_whatsapp.py | 62 +++++ 8 files changed, 635 insertions(+), 2 deletions(-) create mode 100644 src/fopost/resources/whatsapp.py create mode 100644 tests/test_whatsapp.py diff --git a/README.md b/README.md index 828b424..9453d9d 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,7 @@ except FopostError as err: | `inbox` | `list`, `threads`, `conversations`, `unread_count`, `accounts`, `platforms`, `mark_thread_read`, `refresh`, `update`, `edit_comment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `pin`, `unpin`, `react`, `start_conversation`, `set_typing`, `list_approvals`, `approve_reply`, `reject_reply` | | `ads` | `list`, `external`, `boostable`, `connections`, `sources`, `authorize_meta`, `delete_connection`, `boost`, `create`, `refresh`, `set_status`, `delete`, `audiences`, `create_audience`, `search_targeting`, `lead_forms`, `create_lead_form`, `leads`, `account_tree`, `create_campaign`, `get_campaign`, `update_campaign`, `delete_campaign`, `duplicate_campaign`, `create_ad_set`, `get_ad_set`, `update_ad_set`, `delete_ad_set`, `duplicate_ad_set`, `create_network_ad`, `get_network_ad`, `update_network_ad`, `delete_network_ad`, `duplicate_network_ad`, `bulk_set_status`, `creatives`, `create_creative`, `get_creative`, `delete_creative`, `get_audience`, `update_audience`, `delete_audience`, `add_audience_users`, `estimate_reach`, `insights`, `ad_insights`, `get_lead_form`, `archive_lead_form`, `leads_feed`, `lead_pages`, `subscribe_lead_page`, `unsubscribe_lead_page` | | `validate` | `post`, `length`, `media` | +| `whatsapp` | `get_profile`, `update_profile`, `request_display_name`, `set_username`, `list_templates`, `list_template_library`, `get_template`, `create_template`, `import_template`, `update_template`, `delete_template`, `list_groups`, `create_group`, `get_group`, `update_group`, `delete_group`, `get_group_invite_link`, `reset_group_invite_link`, `remove_group_participants`, `list_blocked`, `block_users`, `unblock_users`, `get_commerce_settings`, `update_commerce_settings`, `link_catalog`, `list_flows`, `get_flow`, `create_flow`, `update_flow`, `delete_flow`, `upload_flow_json`, `publish_flow`, `deprecate_flow`, `list_flow_responses`, `get_encryption_key_status`, `set_encryption_key`, `get_account_events`, `list_sandbox_sessions`, `create_sandbox_session` | For an endpoint the SDK does not wrap yet, `client.request` sends an authenticated call and hands back the decoded body: diff --git a/src/fopost/__init__.py b/src/fopost/__init__.py index 6ec2553..2ebcb7a 100644 --- a/src/fopost/__init__.py +++ b/src/fopost/__init__.py @@ -107,6 +107,17 @@ ValidateMediaResult, ValidatePlatformCheck, ValidatePostResult, + WhatsappBlockResult, + WhatsappCommerceSettings, + WhatsappEncryptionKeyStatus, + WhatsappFlow, + WhatsappFlowJsonResult, + WhatsappFlowResponse, + WhatsappFlowValidationError, + WhatsappGroup, + WhatsappProfile, + WhatsappSandboxSession, + WhatsappTemplate, Workspace, ) @@ -176,6 +187,17 @@ "RewriteVariant", "SlackChannel", "SlackIdentity", + "WhatsappTemplate", + "WhatsappProfile", + "WhatsappGroup", + "WhatsappBlockResult", + "WhatsappCommerceSettings", + "WhatsappFlow", + "WhatsappFlowValidationError", + "WhatsappFlowJsonResult", + "WhatsappFlowResponse", + "WhatsappEncryptionKeyStatus", + "WhatsappSandboxSession", "SlackMember", "SocialAccount", "TargetingOption", diff --git a/src/fopost/client.py b/src/fopost/client.py index 348a888..232782f 100644 --- a/src/fopost/client.py +++ b/src/fopost/client.py @@ -19,6 +19,7 @@ MediaResource, PostsResource, ValidateResource, + WhatsappResource, WorkspacesResource, ) @@ -73,6 +74,7 @@ def __init__( self.inbox = InboxResource(self._http) self.ads = AdsResource(self._http) self.validate = ValidateResource(self._http) + self.whatsapp = WhatsappResource(self._http) @property def base_url(self) -> str: diff --git a/src/fopost/models.py b/src/fopost/models.py index 7a0b4ec..57f927a 100644 --- a/src/fopost/models.py +++ b/src/fopost/models.py @@ -94,6 +94,17 @@ "LeadsFeedPage", "NetworkAd", "ReachEstimate", + "WhatsappTemplate", + "WhatsappProfile", + "WhatsappGroup", + "WhatsappBlockResult", + "WhatsappCommerceSettings", + "WhatsappFlow", + "WhatsappFlowValidationError", + "WhatsappFlowJsonResult", + "WhatsappFlowResponse", + "WhatsappEncryptionKeyStatus", + "WhatsappSandboxSession", ] #: Every platform the API can publish to. Model fields stay plain `str`, so a @@ -130,6 +141,7 @@ "nostr", "whop", "skool", + "whatsapp", ) Platform = Literal[ @@ -164,6 +176,7 @@ "nostr", "whop", "skool", + "whatsapp", ] PostStatus = Literal[ @@ -999,3 +1012,105 @@ class ValidateMediaResult(FopostModel): size: int | None = None mime_type: str | None = None type: str | None = None + + +# ─── WhatsApp Business ──────────────────────────────────────────── +# +# The platform owns these resources, so nothing here is a cached copy: a +# template's status is whatever the platform assigned it. + + +class WhatsappTemplate(FopostModel): + id: str + name: str + language: str + category: str + #: The review status the platform assigned: APPROVED, PENDING, REJECTED, … + status: str + rejected_reason: str | None = None + components: list[Any] = [] + quality_score: str | None = None + + +class WhatsappProfile(FopostModel): + about: str | None = None + address: str | None = None + description: str | None = None + email: str | None = None + vertical: str | None = None + websites: list[str] = [] + profile_picture_url: str | None = None + display_name: str | None = None + #: The platform's review state for the display name. + display_name_status: str | None = None + username: str | None = None + quality_rating: str | None = None + messaging_limit_tier: str | None = None + + +class WhatsappGroup(FopostModel): + id: str + subject: str + description: str | None = None + participant_count: int | None = None + invite_link: str | None = None + created_at: datetime | None = None + + +class WhatsappBlockResult(FopostModel): + blocked: list[str] = [] + unblocked: list[str] = [] + failed: list[str] = [] + + +class WhatsappCommerceSettings(FopostModel): + cart_enabled: bool | None = None + catalog_visible: bool | None = None + catalog_id: str | None = None + + +class WhatsappFlowValidationError(FopostModel): + error: str + message: str + + +class WhatsappFlow(FopostModel): + id: str + name: str + status: str + categories: list[str] = [] + validation_errors: list[WhatsappFlowValidationError] = [] + endpoint_uri: str | None = None + json_version: str | None = None + preview_url: str | None = None + preview_expires_at: datetime | None = None + + +class WhatsappFlowJsonResult(FopostModel): + success: bool + validation_errors: list[WhatsappFlowValidationError] = [] + + +class WhatsappFlowResponse(FopostModel): + message_id: str + wa_id: str | None = None + flow_token: str | None = None + answers: dict[str, Any] = {} + responded_at: datetime | None = None + + +class WhatsappEncryptionKeyStatus(FopostModel): + """Only whether a key is registered; the key itself never comes back.""" + + has_key: bool + signature_status: str | None = None + + +class WhatsappSandboxSession(FopostModel): + id: str + status: str + #: The last four digits only; the number itself is never stored. + phone_number_last4: str + invited_at: datetime + activated_at: datetime | None = None + expires_at: datetime diff --git a/src/fopost/resources/__init__.py b/src/fopost/resources/__init__.py index 8a0ad04..f885cc2 100644 --- a/src/fopost/resources/__init__.py +++ b/src/fopost/resources/__init__.py @@ -7,6 +7,7 @@ from .media import MediaResource from .posts import PostsResource from .validate import ValidateResource +from .whatsapp import WhatsappResource from .workspaces import WorkspacesResource __all__ = [ @@ -19,5 +20,6 @@ "MediaResource", "PostsResource", "ValidateResource", + "WhatsappResource", "WorkspacesResource", ] diff --git a/src/fopost/resources/whatsapp.py b/src/fopost/resources/whatsapp.py new file mode 100644 index 0000000..608e5ca --- /dev/null +++ b/src/fopost/resources/whatsapp.py @@ -0,0 +1,429 @@ +"""``client.whatsapp`` — a WhatsApp Business connection. + +The platform owns templates, flows, the business profile and the commerce +settings, so every method here is a live read or write against the customer's +own WhatsApp Business Account. Nothing is cached, and all of it answers 503 +until WhatsApp is set up on the deployment. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +from .._http import unwrap +from ..models import ( + WhatsappBlockResult, + WhatsappCommerceSettings, + WhatsappEncryptionKeyStatus, + WhatsappFlow, + WhatsappFlowJsonResult, + WhatsappFlowResponse, + WhatsappGroup, + WhatsappProfile, + WhatsappSandboxSession, + WhatsappTemplate, +) +from ._base import UNSET, Resource, drop_unset, parse_list + +__all__ = ["WhatsappResource"] + + +class WhatsappResource(Resource): + # ─── Profile ────────────────────────────────────────────────── + + def get_profile(self, account_id: str) -> WhatsappProfile: + return WhatsappProfile.model_validate( + unwrap(self._http.get(f"/accounts/{account_id}/whatsapp/profile")) + ) + + def update_profile( + self, + account_id: str, + *, + about: str | None | Any = UNSET, + address: str | None | Any = UNSET, + description: str | None | Any = UNSET, + vertical: str | None | Any = UNSET, + websites: Sequence[str] | None | Any = UNSET, + profile_picture_media_id: str | None | Any = UNSET, + ) -> WhatsappProfile: + body = drop_unset( + { + "about": about, + "address": address, + "description": description, + "vertical": vertical, + "websites": list(websites) if isinstance(websites, Sequence) else websites, + "profile_picture_media_id": profile_picture_media_id, + } + ) + return WhatsappProfile.model_validate( + unwrap( + self._http.request("PATCH", f"/accounts/{account_id}/whatsapp/profile", json=body) + ) + ) + + def request_display_name(self, account_id: str, display_name: str) -> dict[str, Any]: + """A review, not a write: the number keeps its old name until it passes.""" + body = unwrap( + self._http.post( + f"/accounts/{account_id}/whatsapp/profile/display-name", + {"display_name": display_name}, + ) + ) + return body if isinstance(body, dict) else {} + + def set_username(self, account_id: str, username: str) -> WhatsappProfile: + return WhatsappProfile.model_validate( + unwrap( + self._http.put( + f"/accounts/{account_id}/whatsapp/profile/username", {"username": username} + ) + ) + ) + + # ─── Templates ──────────────────────────────────────────────── + + def list_templates( + self, account_id: str, *, after: str | None = None + ) -> list[WhatsappTemplate]: + return parse_list( + WhatsappTemplate, + unwrap(self._http.get(f"/accounts/{account_id}/whatsapp/templates", {"after": after})), + ) + + def list_template_library( + self, account_id: str, *, search: str | None = None + ) -> list[dict[str, Any]]: + """The platform's pre-written templates, for adapting instead of drafting.""" + body = unwrap( + self._http.get(f"/accounts/{account_id}/whatsapp/templates/library", {"search": search}) + ) + return list(body) if isinstance(body, list) else [] + + def get_template(self, account_id: str, template_id: str) -> WhatsappTemplate: + return WhatsappTemplate.model_validate( + unwrap(self._http.get(f"/accounts/{account_id}/whatsapp/templates/{template_id}")) + ) + + def create_template( + self, + account_id: str, + *, + name: str, + language: str, + category: str, + components: Sequence[Mapping[str, Any]], + allow_category_change: bool | None | Any = UNSET, + ) -> WhatsappTemplate: + """Files a template for review; the result carries the status it was given.""" + body = drop_unset( + { + "name": name, + "language": language, + "category": category, + "components": [dict(c) for c in components], + "allow_category_change": allow_category_change, + } + ) + return WhatsappTemplate.model_validate( + unwrap(self._http.post(f"/accounts/{account_id}/whatsapp/templates", body)) + ) + + def import_template( + self, + account_id: str, + *, + library_template_name: str, + name: str, + language: str, + category: str, + library_template_button_inputs: Sequence[Mapping[str, Any]] | None | Any = UNSET, + ) -> WhatsappTemplate: + inputs = ( + [dict(i) for i in library_template_button_inputs] + if isinstance(library_template_button_inputs, Sequence) + else library_template_button_inputs + ) + body = drop_unset( + { + "library_template_name": library_template_name, + "name": name, + "language": language, + "category": category, + "library_template_button_inputs": inputs, + } + ) + return WhatsappTemplate.model_validate( + unwrap(self._http.post(f"/accounts/{account_id}/whatsapp/templates/import", body)) + ) + + def update_template( + self, + account_id: str, + template_id: str, + *, + category: str | None | Any = UNSET, + components: Sequence[Mapping[str, Any]] | None | Any = UNSET, + ) -> WhatsappTemplate: + mapped = [dict(c) for c in components] if isinstance(components, Sequence) else components + body = drop_unset({"category": category, "components": mapped}) + return WhatsappTemplate.model_validate( + unwrap( + self._http.request( + "PATCH", f"/accounts/{account_id}/whatsapp/templates/{template_id}", json=body + ) + ) + ) + + def delete_template(self, account_id: str, template_id: str, *, name: str) -> None: + """The name is required: it is what the platform deletes by.""" + from urllib.parse import quote + + self._http.delete( + f"/accounts/{account_id}/whatsapp/templates/{template_id}?name={quote(name)}" + ) + + # ─── Groups ─────────────────────────────────────────────────── + + def list_groups(self, account_id: str) -> list[WhatsappGroup]: + return parse_list( + WhatsappGroup, unwrap(self._http.get(f"/accounts/{account_id}/whatsapp/groups")) + ) + + def create_group( + self, account_id: str, *, subject: str, description: str | None | Any = UNSET + ) -> WhatsappGroup: + """Participation is invite-only: send the invite link, there is no add.""" + body = drop_unset({"subject": subject, "description": description}) + return WhatsappGroup.model_validate( + unwrap(self._http.post(f"/accounts/{account_id}/whatsapp/groups", body)) + ) + + def get_group(self, account_id: str, group_id: str) -> WhatsappGroup: + return WhatsappGroup.model_validate( + unwrap(self._http.get(f"/accounts/{account_id}/whatsapp/groups/{group_id}")) + ) + + def update_group( + self, + account_id: str, + group_id: str, + *, + subject: str | None | Any = UNSET, + description: str | None | Any = UNSET, + ) -> WhatsappGroup: + body = drop_unset({"subject": subject, "description": description}) + return WhatsappGroup.model_validate( + unwrap( + self._http.request( + "PATCH", f"/accounts/{account_id}/whatsapp/groups/{group_id}", json=body + ) + ) + ) + + def delete_group(self, account_id: str, group_id: str) -> None: + self._http.delete(f"/accounts/{account_id}/whatsapp/groups/{group_id}") + + def get_group_invite_link(self, account_id: str, group_id: str) -> str | None: + body = unwrap( + self._http.get(f"/accounts/{account_id}/whatsapp/groups/{group_id}/invite-link") + ) + return body.get("inviteLink") if isinstance(body, dict) else None + + def reset_group_invite_link(self, account_id: str, group_id: str) -> str | None: + """Issues a new link and invalidates the old one.""" + body = unwrap( + self._http.post(f"/accounts/{account_id}/whatsapp/groups/{group_id}/invite-link") + ) + return body.get("inviteLink") if isinstance(body, dict) else None + + def remove_group_participants( + self, account_id: str, group_id: str, users: Sequence[str] + ) -> None: + self._http.delete( + f"/accounts/{account_id}/whatsapp/groups/{group_id}/participants", + json={"users": list(users)}, + ) + + # ─── Blocking ───────────────────────────────────────────────── + + def list_blocked(self, account_id: str, *, after: str | None = None) -> list[str]: + body = unwrap(self._http.get(f"/accounts/{account_id}/whatsapp/block", {"after": after})) + return [str(u) for u in body] if isinstance(body, list) else [] + + def block_users(self, account_id: str, users: Sequence[str]) -> WhatsappBlockResult: + return WhatsappBlockResult.model_validate( + unwrap( + self._http.post(f"/accounts/{account_id}/whatsapp/block", {"users": list(users)}) + ) + ) + + def unblock_users(self, account_id: str, users: Sequence[str]) -> WhatsappBlockResult: + return WhatsappBlockResult.model_validate( + unwrap( + self._http.delete( + f"/accounts/{account_id}/whatsapp/block", json={"users": list(users)} + ) + ) + ) + + # ─── Commerce ───────────────────────────────────────────────── + + def get_commerce_settings(self, account_id: str) -> WhatsappCommerceSettings: + return WhatsappCommerceSettings.model_validate( + unwrap(self._http.get(f"/accounts/{account_id}/whatsapp/commerce")) + ) + + def update_commerce_settings( + self, + account_id: str, + *, + cart_enabled: bool | None | Any = UNSET, + catalog_visible: bool | None | Any = UNSET, + ) -> WhatsappCommerceSettings: + body = drop_unset({"is_cart_enabled": cart_enabled, "is_catalog_visible": catalog_visible}) + return WhatsappCommerceSettings.model_validate( + unwrap( + self._http.request("PATCH", f"/accounts/{account_id}/whatsapp/commerce", json=body) + ) + ) + + def link_catalog(self, account_id: str, catalog_id: str) -> WhatsappCommerceSettings: + return WhatsappCommerceSettings.model_validate( + unwrap( + self._http.post( + f"/accounts/{account_id}/whatsapp/commerce/catalog", + {"catalog_id": catalog_id}, + ) + ) + ) + + # ─── Flows ──────────────────────────────────────────────────── + + def list_flows(self, account_id: str) -> list[WhatsappFlow]: + return parse_list( + WhatsappFlow, unwrap(self._http.get(f"/accounts/{account_id}/whatsapp/flows")) + ) + + def get_flow(self, account_id: str, flow_id: str) -> WhatsappFlow: + return WhatsappFlow.model_validate( + unwrap(self._http.get(f"/accounts/{account_id}/whatsapp/flows/{flow_id}")) + ) + + def create_flow( + self, + account_id: str, + *, + name: str, + categories: Sequence[str], + endpoint_uri: str | None | Any = UNSET, + clone_flow_id: str | None | Any = UNSET, + ) -> WhatsappFlow: + body = drop_unset( + { + "name": name, + "categories": list(categories), + "endpoint_uri": endpoint_uri, + "clone_flow_id": clone_flow_id, + } + ) + return WhatsappFlow.model_validate( + unwrap(self._http.post(f"/accounts/{account_id}/whatsapp/flows", body)) + ) + + def update_flow( + self, + account_id: str, + flow_id: str, + *, + name: str | None | Any = UNSET, + categories: Sequence[str] | None | Any = UNSET, + endpoint_uri: str | None | Any = UNSET, + ) -> WhatsappFlow: + mapped = list(categories) if isinstance(categories, Sequence) else categories + body = drop_unset({"name": name, "categories": mapped, "endpoint_uri": endpoint_uri}) + return WhatsappFlow.model_validate( + unwrap( + self._http.request( + "PATCH", f"/accounts/{account_id}/whatsapp/flows/{flow_id}", json=body + ) + ) + ) + + def delete_flow(self, account_id: str, flow_id: str) -> None: + """Drafts only; a published flow is deprecated instead.""" + self._http.delete(f"/accounts/{account_id}/whatsapp/flows/{flow_id}") + + def upload_flow_json( + self, account_id: str, flow_id: str, flow_json: Mapping[str, Any] + ) -> WhatsappFlowJsonResult: + """The platform answers with its validation errors rather than refusing.""" + return WhatsappFlowJsonResult.model_validate( + unwrap( + self._http.put( + f"/accounts/{account_id}/whatsapp/flows/{flow_id}/json", + {"flow_json": dict(flow_json)}, + ) + ) + ) + + def publish_flow(self, account_id: str, flow_id: str) -> WhatsappFlow: + return WhatsappFlow.model_validate( + unwrap(self._http.post(f"/accounts/{account_id}/whatsapp/flows/{flow_id}/publish")) + ) + + def deprecate_flow(self, account_id: str, flow_id: str) -> WhatsappFlow: + return WhatsappFlow.model_validate( + unwrap(self._http.post(f"/accounts/{account_id}/whatsapp/flows/{flow_id}/deprecate")) + ) + + def list_flow_responses(self, account_id: str) -> list[WhatsappFlowResponse]: + return parse_list( + WhatsappFlowResponse, + unwrap(self._http.get(f"/accounts/{account_id}/whatsapp/flows/responses")), + ) + + def get_encryption_key_status(self, account_id: str) -> WhatsappEncryptionKeyStatus: + return WhatsappEncryptionKeyStatus.model_validate( + unwrap(self._http.get(f"/accounts/{account_id}/whatsapp/flows/encryption-key")) + ) + + def set_encryption_key( + self, account_id: str, business_public_key: str + ) -> WhatsappEncryptionKeyStatus: + """The public half only; the private half stays with the customer.""" + return WhatsappEncryptionKeyStatus.model_validate( + unwrap( + self._http.put( + f"/accounts/{account_id}/whatsapp/flows/encryption-key", + {"business_public_key": business_public_key}, + ) + ) + ) + + # ─── Account state and sandbox ──────────────────────────────── + + def get_account_events(self, account_id: str) -> dict[str, Any]: + body = unwrap(self._http.get(f"/accounts/{account_id}/whatsapp/events")) + return body if isinstance(body, dict) else {} + + def list_sandbox_sessions(self, *, workspace_id: str) -> list[WhatsappSandboxSession]: + return parse_list( + WhatsappSandboxSession, + unwrap(self._http.get("/whatsapp/sandbox/sessions", {"workspaceId": workspace_id})), + ) + + def create_sandbox_session( + self, *, workspace_id: str, phone_number: str + ) -> WhatsappSandboxSession: + """Sends a template from the platform-owned test number; needs the publish scope.""" + return WhatsappSandboxSession.model_validate( + unwrap( + self._http.post( + "/whatsapp/sandbox/sessions", + {"workspaceId": workspace_id, "phoneNumber": phone_number}, + ) + ) + ) diff --git a/tests/test_models.py b/tests/test_models.py index 230784e..ca550ac 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,8 +7,8 @@ def test_the_platform_literal_and_tuple_agree() -> None: assert tuple(get_args(Platform)) == PLATFORMS - assert len(PLATFORMS) == 31 - assert len(set(PLATFORMS)) == 31 + assert len(PLATFORMS) == 32 + assert len(set(PLATFORMS)) == 32 def test_the_list_covers_the_platforms_the_api_returns() -> None: diff --git a/tests/test_whatsapp.py b/tests/test_whatsapp.py new file mode 100644 index 0000000..43a36ea --- /dev/null +++ b/tests/test_whatsapp.py @@ -0,0 +1,62 @@ +"""The WhatsApp resource calls the paths the API serves, and nothing else.""" + +from __future__ import annotations + +import httpx +import respx + +from tests.conftest import BASE_URL + + +def _ok(payload: object) -> httpx.Response: + return httpx.Response(200, json={"data": payload}) + + +@respx.mock +def test_a_template_create_returns_the_review_status_the_platform_gave_it(client) -> None: # type: ignore[no-untyped-def] + route = respx.post(f"{BASE_URL}/accounts/a1/whatsapp/templates").mock( + return_value=_ok( + { + "id": "tpl-1", + "name": "order_shipped", + "language": "en_US", + "category": "UTILITY", + "status": "PENDING", + "rejected_reason": None, + "components": [], + "quality_score": None, + } + ) + ) + template = client.whatsapp.create_template( + "a1", + name="order_shipped", + language="en_US", + category="UTILITY", + components=[{"type": "BODY", "text": "On its way."}], + ) + + assert route.called + # Nothing marks a template approved but the platform. + assert template.status == "PENDING" + assert template.name == "order_shipped" + + +@respx.mock +def test_the_sandbox_session_never_carries_the_whole_number(client) -> None: # type: ignore[no-untyped-def] + respx.post(f"{BASE_URL}/whatsapp/sandbox/sessions").mock( + return_value=_ok( + { + "id": "ses-1", + "status": "invited", + "phone_number_last4": "4567", + "invited_at": "2026-09-20T10:00:00Z", + "activated_at": None, + "expires_at": "2026-09-21T10:00:00Z", + } + ) + ) + session = client.whatsapp.create_sandbox_session(workspace_id="ws", phone_number="+15551234567") + + assert session.phone_number_last4 == "4567" + assert not hasattr(session, "phone_number")