From 9db9f7a1d5228be0695b3856fd50e06e149dc594 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 01:44:15 +0200 Subject: [PATCH 1/3] feat: contacts, custom fields and per-conversation analytics --- CLAUDE.md | 4 +- README.md | 1 + src/fopost/__init__.py | 18 +++ src/fopost/client.py | 2 + src/fopost/models.py | 100 ++++++++++++++ src/fopost/resources/__init__.py | 2 + src/fopost/resources/contacts.py | 227 +++++++++++++++++++++++++++++++ tests/test_contacts.py | 207 ++++++++++++++++++++++++++++ 8 files changed, 559 insertions(+), 2 deletions(-) create mode 100644 src/fopost/resources/contacts.py create mode 100644 tests/test_contacts.py diff --git a/CLAUDE.md b/CLAUDE.md index 3648dfc..a94e046 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Guidance for Claude Code (claude.ai/code) when working in this repository. `fopost` on PyPI — the official Python client for the FoPost REST API (`fopost.com`). Current version `0.3.0`. It wraps the API's HTTP surface in a namespaced client -(`posts`, `accounts`, `account_groups`, `workspaces`, `labels`, `ai`, `inbox`, `ads`) returning pydantic v2 models. +(`posts`, `accounts`, `account_groups`, `workspaces`, `labels`, `ai`, `inbox`, `contacts`, `ads`) returning pydantic v2 models. Requires Python >= 3.10 (CI matrix: 3.10–3.13). Runtime deps: `httpx>=0.27`, `pydantic>=2.7`. Built with hatchling from `src/fopost`, ships `py.typed`. @@ -47,7 +47,7 @@ src/fopost/ models.py pydantic models, PLATFORMS, POST_STATUSES, Page/PageMeta resources/ _base.py (Resource, parse_list, UNSET, drop_unset) posts.py accounts.py account_groups.py workspaces.py labels.py ai.py - inbox.py ads.py + inbox.py contacts.py ads.py ``` Request flow: a resource method builds a snake_case body/params dict, calls diff --git a/README.md b/README.md index 828b424..e86e8d3 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,7 @@ except FopostError as err: | `media` | `presign`, `complete`, `upload_direct` | | `ai` | `credits`, `generate_caption`, `rewrite`, `repurpose_url` | | `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` | +| `contacts` | `list`, `get`, `create`, `update`, `delete`, `conversations`, `import_csv`, `list_fields`, `create_field`, `update_field`, `delete_field`, `conversation_analytics` | | `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` | diff --git a/src/fopost/__init__.py b/src/fopost/__init__.py index 6ec2553..a12a3fd 100644 --- a/src/fopost/__init__.py +++ b/src/fopost/__init__.py @@ -52,8 +52,17 @@ BoostablePost, BulkAdStatusResult, CaptionResult, + Contact, + ContactChannel, + ContactConversation, + ContactField, + ContactImportResult, + ContactImportSkip, + ContactLabel, ContentBlock, ContentSignal, + ConversationAnalytics, + ConversationAnalyticsRow, Delivery, ExternalAd, FeedLead, @@ -147,6 +156,15 @@ "InboxApproval", "InboxAttachment", "InboxConversation", + "Contact", + "ContactChannel", + "ContactConversation", + "ContactField", + "ContactImportResult", + "ContactImportSkip", + "ContactLabel", + "ConversationAnalytics", + "ConversationAnalyticsRow", "InboxItem", "InboxPlatform", "InboxPostContext", diff --git a/src/fopost/client.py b/src/fopost/client.py index 348a888..9610ddc 100644 --- a/src/fopost/client.py +++ b/src/fopost/client.py @@ -14,6 +14,7 @@ AccountsResource, AdsResource, AiResource, + ContactsResource, InboxResource, LabelsResource, MediaResource, @@ -71,6 +72,7 @@ def __init__( self.media = MediaResource(self._http) self.ai = AiResource(self._http) self.inbox = InboxResource(self._http) + self.contacts = ContactsResource(self._http) self.ads = AdsResource(self._http) self.validate = ValidateResource(self._http) diff --git a/src/fopost/models.py b/src/fopost/models.py index 7a0b4ec..735ec9d 100644 --- a/src/fopost/models.py +++ b/src/fopost/models.py @@ -999,3 +999,103 @@ class ValidateMediaResult(FopostModel): size: int | None = None mime_type: str | None = None type: str | None = None + + +# ─── Contacts ────────────────────────────────────────────────────── + + +class ContactChannel(FopostModel): + """One handle on one network. ``handle`` is lower-cased, no leading @.""" + + platform: str + handle: str + #: The platform's own id for this person, when the network gave us one. + external_id: str | None = None + + +class ContactLabel(FopostModel): + id: str + name: str + color: str | None = None + + +class Contact(FopostModel): + """One person, however many handles they write from.""" + + id: str + display_name: str | None = None + channels: list[ContactChannel] = [] + #: ``inbox``, ``radar`` or ``import`` — what first created the row. + source: str = "inbox" + note: str | None = None + first_seen_at: datetime | None = None + last_seen_at: datetime | None = None + #: Custom field values, keyed by field key. + fields: dict[str, str] = {} + labels: list[ContactLabel] = [] + #: Only on a listing that spans workspaces. + workspace_id: str | None = None + + +class ContactConversation(FopostModel): + """One thread a contact appears in.""" + + #: How the inbox groups it: DM thread id, else root post id, else handle. + key: str + account_id: str + account_username: str | None = None + platform: str + messages: int = 0 + received: int = 0 + sent: int = 0 + last_message_at: datetime | None = None + last_item_id: str | None = None + + +class ContactImportSkip(FopostModel): + row: int + reason: str + + +class ContactImportResult(FopostModel): + created: int = 0 + #: Rows that folded into a contact already on file. + merged: int = 0 + skipped: list[ContactImportSkip] = [] + #: Columns that named neither a reserved field nor a custom field. + unknown_columns: list[str] = [] + + +class ContactField(FopostModel): + """A column the workspace invented.""" + + id: str + #: Lower-case key, also the CSV column header. Fixed once created. + key: str + name: str + #: ``text``, ``number``, ``date``, ``select`` or ``boolean``. + type: str = "text" + #: Allowed values when ``type`` is ``select``. + options: list[str] = [] + position: int = 0 + + +class ConversationAnalyticsRow(FopostModel): + key: str + account_id: str + platform: str + received: int = 0 + sent: int = 0 + answered: int = 0 + open: int = 0 + #: Median minutes to the first reply in this thread. + median_response_minutes: float | None = None + first_message_at: datetime | None = None + last_message_at: datetime | None = None + + +class ConversationAnalytics(FopostModel): + conversations: list[ConversationAnalyticsRow] = [] + total: int = 0 + page: int = 1 + per_page: int = 25 diff --git a/src/fopost/resources/__init__.py b/src/fopost/resources/__init__.py index 8a0ad04..f582aef 100644 --- a/src/fopost/resources/__init__.py +++ b/src/fopost/resources/__init__.py @@ -2,6 +2,7 @@ from .accounts import AccountsResource from .ads import AdsResource from .ai import AiResource +from .contacts import ContactsResource from .inbox import InboxResource from .labels import LabelsResource from .media import MediaResource @@ -14,6 +15,7 @@ "AccountsResource", "AdsResource", "AiResource", + "ContactsResource", "InboxResource", "LabelsResource", "MediaResource", diff --git a/src/fopost/resources/contacts.py b/src/fopost/resources/contacts.py new file mode 100644 index 0000000..ced83e5 --- /dev/null +++ b/src/fopost/resources/contacts.py @@ -0,0 +1,227 @@ +"""``client.contacts`` — the people behind the inbox, and the fields kept about them. + +A contact is one human however many handles they write from. An inbound inbox +item files its author, a reply files whoever you answered, and both fold into +whatever is already on file, so the same person never becomes two rows. +""" + +from __future__ import annotations + +import builtins +from typing import Any + +from .._http import unwrap +from ..models import ( + Contact, + ContactChannel, + ContactConversation, + ContactField, + ContactImportResult, + ConversationAnalytics, + Page, + PageMeta, +) +from ._base import UNSET, Resource, drop_unset, parse_list + +__all__ = ["ContactsResource"] + + +def _channels(channels: list[ContactChannel] | list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + c.model_dump(exclude_none=True) if isinstance(c, ContactChannel) else c for c in channels + ] + + +class ContactsResource(Resource): + def list( + self, + *, + workspace_id: str | None = None, + search: str | None = None, + platform: str | None = None, + source: str | None = None, + page: int = 1, + per_page: int = 25, + ) -> Page[Contact]: + """One page of contacts, most recently active first. + + Omit ``workspace_id`` to span every workspace the key can reach; each + contact then carries ``workspace_id``. + """ + body = self._http.get( + "/contacts", + { + "workspace_id": workspace_id, + "search": search, + "platform": platform, + "source": source, + "page": page, + "per_page": per_page, + }, + ) + items = parse_list(Contact, body.get("data") if isinstance(body, dict) else body) + raw = body.get("pagination") if isinstance(body, dict) else None + meta = PageMeta.model_validate(raw) if isinstance(raw, dict) else PageMeta() + return Page[Contact](items=items, meta=meta) + + def get(self, contact_id: str) -> Contact: + return Contact.model_validate(unwrap(self._http.get(f"/contacts/{contact_id}"))) + + def create( + self, + *, + workspace_id: str, + channels: builtins.list[ContactChannel] | builtins.list[dict[str, Any]], + display_name: str | None = None, + note: str | None = None, + fields: dict[str, str] | None = None, + ) -> Contact: + """Create a contact. + + Folds into the contact that already holds the first channel, so this + cannot duplicate someone the inbox has already met. + """ + body: dict[str, Any] = { + "workspace_id": workspace_id, + "channels": _channels(channels), + } + if display_name is not None: + body["display_name"] = display_name + if note is not None: + body["note"] = note + if fields is not None: + body["fields"] = fields + return Contact.model_validate(unwrap(self._http.post("/contacts", json=body))) + + def update( + self, + contact_id: str, + *, + display_name: str | None | Any = UNSET, + channels: builtins.list[ContactChannel] | builtins.list[dict[str, Any]] | Any = UNSET, + note: str | None | Any = UNSET, + fields: dict[str, str | None] | Any = UNSET, + ) -> Contact: + """Patch a contact. A field set to ``None`` or ``""`` is cleared.""" + body = drop_unset( + { + "display_name": display_name, + "channels": _channels(channels) if channels is not UNSET else UNSET, + "note": note, + "fields": fields, + } + ) + return Contact.model_validate( + unwrap(self._http.request("PATCH", f"/contacts/{contact_id}", json=body)) + ) + + def delete(self, contact_id: str) -> bool: + """Remove a contact. Their messages stay in the inbox and file them again.""" + body = unwrap(self._http.delete(f"/contacts/{contact_id}")) + return bool(body.get("deleted", True)) if isinstance(body, dict) else True + + def conversations( + self, contact_id: str, *, limit: int | None = None + ) -> builtins.list[ContactConversation]: + """The threads this person appears in, newest first.""" + return parse_list( + ContactConversation, + unwrap(self._http.get(f"/contacts/{contact_id}/conversations", {"limit": limit})), + ) + + def import_csv(self, *, workspace_id: str, csv: str) -> ContactImportResult: + """Import from CSV text. + + ``platform`` and ``handle`` are required columns. Any other column is + read as a custom field key, and one matching no field is reported back + in ``unknown_columns`` rather than stored. + """ + return ContactImportResult.model_validate( + unwrap( + self._http.post("/contacts/import", json={"workspace_id": workspace_id, "csv": csv}) + ) + ) + + # ─── Custom fields ───────────────────────────────────────────── + + def list_fields(self, *, workspace_id: str) -> builtins.list[ContactField]: + """The columns this workspace keeps about its contacts, in display order.""" + return parse_list( + ContactField, unwrap(self._http.get("/contacts/fields", {"workspace_id": workspace_id})) + ) + + def create_field( + self, + *, + workspace_id: str, + key: str, + name: str, + type: str = "text", + options: builtins.list[str] | None = None, + ) -> ContactField: + return ContactField.model_validate( + unwrap( + self._http.request( + "POST", + "/contacts/fields", + params={"workspace_id": workspace_id}, + json={ + "key": key, + "name": name, + "type": type, + "options": options or [], + }, + ) + ) + ) + + def update_field( + self, + field_id: str, + *, + name: str | Any = UNSET, + options: builtins.list[str] | Any = UNSET, + position: int | Any = UNSET, + ) -> ContactField: + """The key and the type are fixed once created; the name and options are not.""" + body = drop_unset({"name": name, "options": options, "position": position}) + return ContactField.model_validate( + unwrap(self._http.request("PATCH", f"/contacts/fields/{field_id}", json=body)) + ) + + def delete_field(self, field_id: str) -> bool: + """Remove the field and every answer to it.""" + body = unwrap(self._http.delete(f"/contacts/fields/{field_id}")) + return bool(body.get("deleted", True)) if isinstance(body, dict) else True + + # ─── Per-conversation analytics ──────────────────────────────── + + def conversation_analytics( + self, + *, + workspace_id: str | None = None, + account_id: str | None = None, + days: int | None = None, + sort: str | None = None, + page: int = 1, + per_page: int = 25, + ) -> ConversationAnalytics: + """Volume and median reply time per thread. + + Needs the ``analytics`` scope rather than ``inbox``. + """ + return ConversationAnalytics.model_validate( + unwrap( + self._http.get( + "/analytics/inbox/conversations", + { + "workspace_id": workspace_id, + "accountId": account_id, + "days": days, + "sort": sort, + "page": page, + "per_page": per_page, + }, + ) + ) + ) diff --git a/tests/test_contacts.py b/tests/test_contacts.py new file mode 100644 index 0000000..c1652ac --- /dev/null +++ b/tests/test_contacts.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +from typing import Any + +import httpx +import respx + +from fopost import Fopost +from tests.conftest import BASE_URL + +CONTACT_FIXTURE: dict[str, Any] = { + "id": "con_1", + "display_name": "Ada Okafor", + "channels": [ + {"platform": "instagram", "handle": "adaokafor", "externalId": "178414"}, + {"platform": "x", "handle": "ada_writes", "externalId": None}, + ], + "source": "inbox", + "note": None, + "first_seen_at": "2026-04-02T09:14:00.000Z", + "last_seen_at": "2026-09-18T14:30:00.000Z", + "fields": {"plan_tier": "Pro"}, + "labels": [{"id": "lbl_1", "name": "VIP", "color": "#0070f3"}], +} + + +@respx.mock +def test_list_carries_the_pagination_block(client: Fopost) -> None: + route = respx.get(f"{BASE_URL}/contacts").mock( + return_value=httpx.Response( + 200, + json={ + "data": [CONTACT_FIXTURE], + "pagination": {"page": 2, "per_page": 10, "total": 11}, + }, + ) + ) + + page = client.contacts.list(workspace_id="ws_1", search="ada", page=2, per_page=10) + + assert len(page) == 1 + assert page[0].display_name == "Ada Okafor" + assert page[0].channels[0].external_id == "178414" + assert page.meta.total == 11 + assert dict(route.calls.last.request.url.params) == { + "workspace_id": "ws_1", + "search": "ada", + "page": "2", + "per_page": "10", + } + + +@respx.mock +def test_create_sends_the_wire_names(client: Fopost) -> None: + route = respx.post(f"{BASE_URL}/contacts").mock( + return_value=httpx.Response(201, json={"data": CONTACT_FIXTURE}) + ) + + contact = client.contacts.create( + workspace_id="ws_1", + channels=[{"platform": "x", "handle": "ada_writes"}], + display_name="Ada Okafor", + fields={"plan_tier": "Pro"}, + ) + + assert contact.id == "con_1" + assert route.calls.last.request.read() == ( + b'{"workspace_id":"ws_1","channels":[{"platform":"x","handle":"ada_writes"}],' + b'"display_name":"Ada Okafor","fields":{"plan_tier":"Pro"}}' + ) + + +@respx.mock +def test_update_clears_a_field_with_null_and_omits_what_was_not_passed(client: Fopost) -> None: + route = respx.patch(f"{BASE_URL}/contacts/con_1").mock( + return_value=httpx.Response(200, json={"data": CONTACT_FIXTURE}) + ) + + client.contacts.update("con_1", fields={"region": None}) + + assert route.calls.last.request.read() == b'{"fields":{"region":null}}' + + +@respx.mock +def test_conversations_returns_the_threads_a_contact_appears_in(client: Fopost) -> None: + route = respx.get(f"{BASE_URL}/contacts/con_1/conversations").mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + { + "key": "t_182736", + "account_id": "acc_1", + "account_username": "yourbrand", + "platform": "instagram", + "messages": 14, + "received": 9, + "sent": 5, + "last_message_at": "2026-09-18T14:30:00.000Z", + "last_item_id": "inb_1", + } + ] + }, + ) + ) + + rows = client.contacts.conversations("con_1", limit=10) + + assert len(rows) == 1 + assert rows[0].key == "t_182736" + assert rows[0].received == 9 + assert dict(route.calls.last.request.url.params) == {"limit": "10"} + + +@respx.mock +def test_import_reports_what_merged_and_what_was_skipped(client: Fopost) -> None: + respx.post(f"{BASE_URL}/contacts/import").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "created": 1, + "merged": 2, + "skipped": [{"row": 4, "reason": "platform and handle are both required"}], + "unknownColumns": ["lifetime_value"], + } + }, + ) + ) + + result = client.contacts.import_csv(workspace_id="ws_1", csv="platform,handle\nx,ada_writes") + + assert result.created == 1 + assert result.merged == 2 + assert result.skipped[0].row == 4 + assert result.unknown_columns == ["lifetime_value"] + + +@respx.mock +def test_create_field_puts_the_workspace_on_the_query(client: Fopost) -> None: + route = respx.post(f"{BASE_URL}/contacts/fields").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "id": "fld_1", + "key": "plan_tier", + "name": "Plan Tier", + "type": "select", + "options": ["Free", "Pro"], + "position": 0, + } + }, + ) + ) + + field = client.contacts.create_field( + workspace_id="ws_1", + key="plan_tier", + name="Plan Tier", + type="select", + options=["Free", "Pro"], + ) + + assert field.key == "plan_tier" + assert dict(route.calls.last.request.url.params) == {"workspace_id": "ws_1"} + + +@respx.mock +def test_conversation_analytics_reads_the_analytics_route(client: Fopost) -> None: + route = respx.get(f"{BASE_URL}/analytics/inbox/conversations").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "conversations": [ + { + "key": "t_1", + "accountId": "acc_1", + "platform": "instagram", + "received": 9, + "sent": 5, + "answered": 5, + "open": 1, + "medianResponseMinutes": 47, + "firstMessageAt": None, + "lastMessageAt": None, + } + ], + "total": 128, + "page": 1, + "perPage": 25, + } + }, + ) + ) + + report = client.contacts.conversation_analytics(days=30, sort="slowest") + + assert report.total == 128 + assert report.conversations[0].median_response_minutes == 47 + assert dict(route.calls.last.request.url.params) == { + "days": "30", + "sort": "slowest", + "page": "1", + "per_page": "25", + } From a827b6bdbcefba686a73d943480227d5e1297a41 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 03:15:57 +0200 Subject: [PATCH 2/3] feat: broadcasts and drip sequences Both send into conversations the workspace already has, never a cold DM, and both honour each network's messaging window server-side: a recipient outside it comes back skipped with window_closed rather than attempted, so the number sent is often lower than the audience. --- CLAUDE.md | 4 +- README.md | 2 + src/fopost/__init__.py | 16 ++ src/fopost/client.py | 4 + src/fopost/models.py | 106 +++++++++++ src/fopost/resources/__init__.py | 3 + src/fopost/resources/broadcasts.py | 292 +++++++++++++++++++++++++++++ tests/test_broadcasts.py | 169 +++++++++++++++++ 8 files changed, 594 insertions(+), 2 deletions(-) create mode 100644 src/fopost/resources/broadcasts.py create mode 100644 tests/test_broadcasts.py diff --git a/CLAUDE.md b/CLAUDE.md index a94e046..0b56534 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Guidance for Claude Code (claude.ai/code) when working in this repository. `fopost` on PyPI — the official Python client for the FoPost REST API (`fopost.com`). Current version `0.3.0`. It wraps the API's HTTP surface in a namespaced client -(`posts`, `accounts`, `account_groups`, `workspaces`, `labels`, `ai`, `inbox`, `contacts`, `ads`) returning pydantic v2 models. +(`posts`, `accounts`, `account_groups`, `workspaces`, `labels`, `ai`, `inbox`, `contacts`, `broadcasts`, `sequences`, `ads`) returning pydantic v2 models. Requires Python >= 3.10 (CI matrix: 3.10–3.13). Runtime deps: `httpx>=0.27`, `pydantic>=2.7`. Built with hatchling from `src/fopost`, ships `py.typed`. @@ -47,7 +47,7 @@ src/fopost/ models.py pydantic models, PLATFORMS, POST_STATUSES, Page/PageMeta resources/ _base.py (Resource, parse_list, UNSET, drop_unset) posts.py accounts.py account_groups.py workspaces.py labels.py ai.py - inbox.py contacts.py ads.py + inbox.py contacts.py broadcasts.py ads.py ``` Request flow: a resource method builds a snake_case body/params dict, calls diff --git a/README.md b/README.md index e86e8d3..3955102 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,8 @@ except FopostError as err: | `ai` | `credits`, `generate_caption`, `rewrite`, `repurpose_url` | | `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` | | `contacts` | `list`, `get`, `create`, `update`, `delete`, `conversations`, `import_csv`, `list_fields`, `create_field`, `update_field`, `delete_field`, `conversation_analytics` | +| `broadcasts` | `list`, `get`, `create`, `update`, `delete`, `send`, `cancel`, `recipients` | +| `sequences` | `list`, `get`, `create`, `update`, `delete`, `enroll`, `unenroll`, `enrollments` | | `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` | diff --git a/src/fopost/__init__.py b/src/fopost/__init__.py index a12a3fd..f000362 100644 --- a/src/fopost/__init__.py +++ b/src/fopost/__init__.py @@ -48,8 +48,12 @@ AiCreditBalance, AiCredits, Audience, + AudienceFilter, AudiencesResult, BoostablePost, + Broadcast, + BroadcastCounts, + BroadcastRecipient, BulkAdStatusResult, CaptionResult, Contact, @@ -64,6 +68,7 @@ ConversationAnalytics, ConversationAnalyticsRow, Delivery, + Enrollment, ExternalAd, FeedLead, InboxAccount, @@ -102,6 +107,9 @@ RepurposeResult, RewriteResult, RewriteVariant, + Sequence, + SequenceEnrollmentCounts, + SequenceStep, SlackChannel, SlackIdentity, SlackMember, @@ -156,6 +164,10 @@ "InboxApproval", "InboxAttachment", "InboxConversation", + "AudienceFilter", + "Broadcast", + "BroadcastCounts", + "BroadcastRecipient", "Contact", "ContactChannel", "ContactConversation", @@ -165,6 +177,10 @@ "ContactLabel", "ConversationAnalytics", "ConversationAnalyticsRow", + "Enrollment", + "Sequence", + "SequenceEnrollmentCounts", + "SequenceStep", "InboxItem", "InboxPlatform", "InboxPostContext", diff --git a/src/fopost/client.py b/src/fopost/client.py index 9610ddc..3779ad0 100644 --- a/src/fopost/client.py +++ b/src/fopost/client.py @@ -14,11 +14,13 @@ AccountsResource, AdsResource, AiResource, + BroadcastsResource, ContactsResource, InboxResource, LabelsResource, MediaResource, PostsResource, + SequencesResource, ValidateResource, WorkspacesResource, ) @@ -73,6 +75,8 @@ def __init__( self.ai = AiResource(self._http) self.inbox = InboxResource(self._http) self.contacts = ContactsResource(self._http) + self.broadcasts = BroadcastsResource(self._http) + self.sequences = SequencesResource(self._http) self.ads = AdsResource(self._http) self.validate = ValidateResource(self._http) diff --git a/src/fopost/models.py b/src/fopost/models.py index 735ec9d..a5f32e2 100644 --- a/src/fopost/models.py +++ b/src/fopost/models.py @@ -1099,3 +1099,109 @@ class ConversationAnalytics(FopostModel): total: int = 0 page: int = 1 per_page: int = 25 + + +# ─── Broadcasts and sequences ────────────────────────────────────── + + +class AudienceFilter(FopostModel): + """Who a broadcast or an enrollment resolves to, over contacts. + + Every clause narrows: a contact has to match all of them. + """ + + #: Contacts with a handle on at least one of these networks. + platforms: list[str] | None = None + label_ids: list[str] | None = None + #: ``inbox``, ``radar`` or ``import``. + source: str | None = None + #: Custom field clauses: ``{"key": ..., "op": ..., "value": ...}``. + fields: list[dict[str, Any]] | None = None + + +class BroadcastCounts(FopostModel): + total: int = 0 + sent: int = 0 + skipped: int = 0 + failed: int = 0 + pending: int = 0 + + +class Broadcast(FopostModel): + """One message, sent into conversations the workspace already has.""" + + id: str + name: str + text: str = "" + account_id: str | None = None + audience: dict[str, Any] = {} + #: ``draft``, ``scheduled``, ``sending``, ``sent`` or ``cancelled``. + status: str = "draft" + scheduled_at: datetime | None = None + sent_at: datetime | None = None + created_at: datetime | None = None + counts: BroadcastCounts | None = None + #: Only on a listing that spans workspaces. + workspace_id: str | None = None + + +class BroadcastRecipient(FopostModel): + """One contact on one broadcast, and what became of their message.""" + + contact_id: str + display_name: str | None = None + #: ``pending``, ``sent``, ``skipped`` or ``failed``. + status: str = "pending" + #: Why nothing was sent: ``window_closed``, ``no_conversation`` or + #: ``unsupported_platform``. ``window_closed`` means the network's + #: messaging window had shut, so nothing was attempted. + skip_reason: str | None = None + sent_at: datetime | None = None + error: str | None = None + + +class SequenceStep(FopostModel): + """One message and how long after the previous step it goes out.""" + + delay_hours: float = 0 + text: str + media_id: str | None = None + + +class SequenceEnrollmentCounts(FopostModel): + total: int = 0 + active: int = 0 + completed: int = 0 + stopped: int = 0 + failed: int = 0 + + +class Sequence(FopostModel): + """A series of messages, each a delay after the one before.""" + + id: str + name: str + account_id: str | None = None + steps: list[SequenceStep] = [] + #: ``active`` or ``paused``. A paused sequence fires nothing. + status: str = "active" + created_at: datetime | None = None + enrollments: SequenceEnrollmentCounts | None = None + #: Only on a listing that spans workspaces. + workspace_id: str | None = None + + +class Enrollment(FopostModel): + """One contact walking one sequence.""" + + id: str + contact_id: str + display_name: str | None = None + #: Steps already sent, so also the index of the next one. + step: int = 0 + next_at: datetime | None = None + #: ``active``, ``completed``, ``stopped`` or ``failed``. + status: str = "active" + last_sent_at: datetime | None = None + #: On a skipped step, the reason it was skipped. + error: str | None = None diff --git a/src/fopost/resources/__init__.py b/src/fopost/resources/__init__.py index f582aef..65d2032 100644 --- a/src/fopost/resources/__init__.py +++ b/src/fopost/resources/__init__.py @@ -2,6 +2,7 @@ from .accounts import AccountsResource from .ads import AdsResource from .ai import AiResource +from .broadcasts import BroadcastsResource, SequencesResource from .contacts import ContactsResource from .inbox import InboxResource from .labels import LabelsResource @@ -15,7 +16,9 @@ "AccountsResource", "AdsResource", "AiResource", + "BroadcastsResource", "ContactsResource", + "SequencesResource", "InboxResource", "LabelsResource", "MediaResource", diff --git a/src/fopost/resources/broadcasts.py b/src/fopost/resources/broadcasts.py new file mode 100644 index 0000000..5af3a0e --- /dev/null +++ b/src/fopost/resources/broadcasts.py @@ -0,0 +1,292 @@ +"""``client.broadcasts`` and ``client.sequences`` — one message into many +conversations, and a series of messages on a delay. + +Neither opens a cold DM: every message lands in a direct-message thread the +contact already started. Both honour each network's messaging window +server-side. Messenger and Instagram take a business-initiated message only +within 24 hours of the contact's last one, so a recipient outside it comes +back ``skipped`` with ``skip_reason="window_closed"`` and nothing is +attempted — which is why the number sent is often lower than the audience. +Telegram, Slack, Bluesky and Reddit have no window. +""" + +from __future__ import annotations + +import builtins +from typing import Any, TypeVar + +from .._http import unwrap +from ..models import ( + AudienceFilter, + Broadcast, + BroadcastRecipient, + Enrollment, + FopostModel, + Page, + PageMeta, + Sequence, + SequenceStep, +) +from ._base import UNSET, Resource, drop_unset, parse_list + +__all__ = ["BroadcastsResource", "SequencesResource"] + + +def _audience(audience: AudienceFilter | dict[str, Any] | None) -> dict[str, Any] | None: + if audience is None: + return None + return ( + audience.model_dump(exclude_none=True) if isinstance(audience, AudienceFilter) else audience + ) + + +def _steps( + steps: builtins.list[SequenceStep] | builtins.list[dict[str, Any]], +) -> builtins.list[dict[str, Any]]: + return [s.model_dump(exclude_none=True) if isinstance(s, SequenceStep) else s for s in steps] + + +T = TypeVar("T", bound=FopostModel) + + +def _page(model: type[T], body: Any, per_page: int) -> Page[T]: + items: builtins.list[T] = parse_list( + model, body.get("data") if isinstance(body, dict) else body + ) + raw = body.get("pagination") if isinstance(body, dict) else None + meta = PageMeta.model_validate(raw) if isinstance(raw, dict) else PageMeta(per_page=per_page) + return Page[model](items=items, meta=meta) # type: ignore[valid-type] + + +class BroadcastsResource(Resource): + def list( + self, + *, + workspace_id: str | None = None, + status: str | None = None, + page: int = 1, + per_page: int = 25, + ) -> Page[Broadcast]: + """One page of broadcasts, newest first. + + Omit ``workspace_id`` to span every workspace the key can reach; each + broadcast then carries ``workspace_id``. + """ + body = self._http.get( + "/broadcasts", + { + "workspace_id": workspace_id, + "status": status, + "page": page, + "per_page": per_page, + }, + ) + return _page(Broadcast, body, per_page) + + def get(self, broadcast_id: str) -> Broadcast: + return Broadcast.model_validate(unwrap(self._http.get(f"/broadcasts/{broadcast_id}"))) + + def create( + self, + *, + workspace_id: str, + account_id: str, + name: str, + text: str, + media_id: str | None = None, + audience: AudienceFilter | dict[str, Any] | None = None, + scheduled_at: str | None = None, + ) -> Broadcast: + """Create it without sending. + + Give ``scheduled_at`` to have it go out on its own at that time; + otherwise call :meth:`send`. ``audience`` omitted means every contact + in the workspace. + """ + payload: dict[str, Any] = { + "workspace_id": workspace_id, + "account_id": account_id, + "name": name, + "text": text, + } + if media_id is not None: + payload["media_id"] = media_id + if audience is not None: + payload["audience"] = _audience(audience) + if scheduled_at is not None: + payload["scheduled_at"] = scheduled_at + return Broadcast.model_validate(unwrap(self._http.post("/broadcasts", payload))) + + def update( + self, + broadcast_id: str, + *, + name: str | None | Any = UNSET, + text: str | None | Any = UNSET, + media_id: str | None | Any = UNSET, + audience: AudienceFilter | dict[str, Any] | Any = UNSET, + scheduled_at: str | None | Any = UNSET, + ) -> Broadcast: + """Only a draft or scheduled broadcast can be edited.""" + payload = drop_unset( + { + "name": name, + "text": text, + "media_id": media_id, + "audience": _audience(audience) if audience is not UNSET else UNSET, + "scheduled_at": scheduled_at, + } + ) + return Broadcast.model_validate( + unwrap(self._http.request("PATCH", f"/broadcasts/{broadcast_id}", json=payload)) + ) + + def send(self, broadcast_id: str) -> dict[str, Any]: + """Freeze the audience into a recipient list and start sending. + + The returned ``recipients`` is how many contacts matched, not how many + will be messaged — the messaging window decides that. Needs the + ``publish`` scope as well as ``inbox``. + """ + result: dict[str, Any] = unwrap(self._http.post(f"/broadcasts/{broadcast_id}/send", {})) + return result + + def cancel(self, broadcast_id: str) -> dict[str, Any]: + """Stop it where it stands. + + Anyone not yet written to stays unsent; messages already delivered are + not recalled. Needs the ``publish`` scope as well as ``inbox``. + """ + result: dict[str, Any] = unwrap(self._http.post(f"/broadcasts/{broadcast_id}/cancel", {})) + return result + + def recipients( + self, + broadcast_id: str, + *, + status: str | None = None, + page: int = 1, + per_page: int = 50, + ) -> Page[BroadcastRecipient]: + """One row per contact, with what became of their message. + + A skipped row carries ``skip_reason``. + """ + body = self._http.get( + f"/broadcasts/{broadcast_id}/recipients", + {"status": status, "page": page, "per_page": per_page}, + ) + return _page(BroadcastRecipient, body, per_page) + + def delete(self, broadcast_id: str) -> None: + """Remove the broadcast and its recipient records. + + Messages already sent stay in the conversations they went to. + """ + self._http.delete(f"/broadcasts/{broadcast_id}") + + +class SequencesResource(Resource): + def list( + self, + *, + workspace_id: str | None = None, + page: int = 1, + per_page: int = 25, + ) -> Page[Sequence]: + body = self._http.get( + "/sequences", + {"workspace_id": workspace_id, "page": page, "per_page": per_page}, + ) + return _page(Sequence, body, per_page) + + def get(self, sequence_id: str) -> Sequence: + return Sequence.model_validate(unwrap(self._http.get(f"/sequences/{sequence_id}"))) + + def create( + self, + *, + workspace_id: str, + account_id: str, + name: str, + steps: builtins.list[SequenceStep] | builtins.list[dict[str, Any]], + status: str | None = None, + ) -> Sequence: + """Creating a sequence enrolls nobody.""" + payload: dict[str, Any] = { + "workspace_id": workspace_id, + "account_id": account_id, + "name": name, + "steps": _steps(steps), + } + if status is not None: + payload["status"] = status + return Sequence.model_validate(unwrap(self._http.post("/sequences", payload))) + + def update( + self, + sequence_id: str, + *, + name: str | None | Any = UNSET, + steps: builtins.list[SequenceStep] | builtins.list[dict[str, Any]] | Any = UNSET, + status: str | None | Any = UNSET, + ) -> Sequence: + """Pausing stops every enrollment from firing without ending any of them.""" + payload = drop_unset( + { + "name": name, + "steps": _steps(steps) if steps is not UNSET and steps is not None else steps, + "status": status, + } + ) + return Sequence.model_validate( + unwrap(self._http.request("PATCH", f"/sequences/{sequence_id}", json=payload)) + ) + + def enroll( + self, + sequence_id: str, + *, + contact_ids: builtins.list[str] | None = None, + audience: AudienceFilter | dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Put contacts on the sequence, by id or by audience. + + Re-enrolling someone restarts their walk from the first step rather + than running two in parallel. Needs the ``publish`` scope as well as + ``inbox``. + """ + payload: dict[str, Any] = {} + if contact_ids is not None: + payload["contact_ids"] = contact_ids + if audience is not None: + payload["audience"] = _audience(audience) + result: dict[str, Any] = unwrap( + self._http.post(f"/sequences/{sequence_id}/enroll", payload) + ) + return result + + def unenroll(self, sequence_id: str, contact_ids: builtins.list[str]) -> dict[str, Any]: + """Nothing further fires for them. Needs the ``publish`` scope.""" + result: dict[str, Any] = unwrap( + self._http.post(f"/sequences/{sequence_id}/unenroll", {"contact_ids": contact_ids}) + ) + return result + + def enrollments( + self, + sequence_id: str, + *, + page: int = 1, + per_page: int = 50, + ) -> Page[Enrollment]: + """Who is on it, what step they are at, and when the next one is due.""" + body = self._http.get( + f"/sequences/{sequence_id}/enrollments", + {"page": page, "per_page": per_page}, + ) + return _page(Enrollment, body, per_page) + + def delete(self, sequence_id: str) -> None: + """Remove the sequence and every enrollment on it.""" + self._http.delete(f"/sequences/{sequence_id}") diff --git a/tests/test_broadcasts.py b/tests/test_broadcasts.py new file mode 100644 index 0000000..0ff79bc --- /dev/null +++ b/tests/test_broadcasts.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import json +from typing import Any + +import httpx +import respx + +from fopost import Fopost +from tests.conftest import BASE_URL + +BROADCAST_FIXTURE: dict[str, Any] = { + "id": "bc_1", + "name": "September check-in", + "text": "New colours just landed.", + "account_id": "acc_1", + "audience": {"platforms": ["instagram"]}, + "status": "sent", + "scheduled_at": None, + "sent_at": "2026-09-19T10:04:00.000Z", + "created_at": "2026-09-19T09:58:00.000Z", + "counts": {"total": 3, "sent": 2, "skipped": 1, "failed": 0, "pending": 0}, +} + +SEQUENCE_FIXTURE: dict[str, Any] = { + "id": "seq_1", + "name": "Welcome", + "account_id": "acc_1", + "steps": [ + {"delay_hours": 0, "text": "Thanks for the follow"}, + {"delay_hours": 48, "text": "Here is what people ask first"}, + ], + "status": "active", + "created_at": "2026-09-12T08:00:00.000Z", + "enrollments": {"total": 4, "active": 1, "completed": 3, "stopped": 0, "failed": 0}, +} + + +@respx.mock +def test_list_carries_the_pagination_block(client: Fopost) -> None: + route = respx.get(f"{BASE_URL}/broadcasts").mock( + return_value=httpx.Response( + 200, + json={ + "data": [BROADCAST_FIXTURE], + "pagination": {"page": 2, "per_page": 10, "total": 11}, + }, + ) + ) + + page = client.broadcasts.list(workspace_id="ws_1", status="sent", page=2, per_page=10) + + assert len(page) == 1 + assert page[0].name == "September check-in" + assert page[0].counts is not None and page[0].counts.skipped == 1 + assert page.meta.total == 11 + assert dict(route.calls.last.request.url.params) == { + "workspace_id": "ws_1", + "status": "sent", + "page": "2", + "per_page": "10", + } + + +@respx.mock +def test_create_sends_the_snake_case_body(client: Fopost) -> None: + route = respx.post(f"{BASE_URL}/broadcasts").mock( + return_value=httpx.Response(201, json={"data": BROADCAST_FIXTURE}) + ) + + client.broadcasts.create( + workspace_id="ws_1", + account_id="acc_1", + name="September check-in", + text="New colours just landed.", + audience={"platforms": ["instagram"]}, + scheduled_at="2026-10-01T09:00:00.000Z", + ) + + body = json.loads(route.calls.last.request.content) + assert body["workspace_id"] == "ws_1" + assert body["account_id"] == "acc_1" + assert body["scheduled_at"] == "2026-10-01T09:00:00.000Z" + + +@respx.mock +def test_a_skipped_recipient_keeps_its_reason(client: Fopost) -> None: + """A closed messaging window has to be readable, or a non-send is a mystery.""" + route = respx.get(f"{BASE_URL}/broadcasts/bc_1/recipients").mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + { + "contact_id": "con_1", + "display_name": "Sam Rivera", + "status": "skipped", + "skip_reason": "window_closed", + "sent_at": None, + "error": None, + } + ], + "pagination": {"page": 1, "per_page": 50, "total": 1}, + }, + ) + ) + + page = client.broadcasts.recipients("bc_1", status="skipped") + + assert page[0].status == "skipped" + assert page[0].skip_reason == "window_closed" + assert dict(route.calls.last.request.url.params)["status"] == "skipped" + + +@respx.mock +def test_send_and_cancel_post_to_their_own_paths(client: Fopost) -> None: + send = respx.post(f"{BASE_URL}/broadcasts/bc_1/send").mock( + return_value=httpx.Response( + 200, json={"data": {"id": "bc_1", "status": "sending", "recipients": 3}} + ) + ) + cancel = respx.post(f"{BASE_URL}/broadcasts/bc_1/cancel").mock( + return_value=httpx.Response(200, json={"data": {"id": "bc_1", "status": "cancelled"}}) + ) + + assert client.broadcasts.send("bc_1")["recipients"] == 3 + assert client.broadcasts.cancel("bc_1")["status"] == "cancelled" + assert send.called and cancel.called + + +@respx.mock +def test_sequence_steps_travel_as_given(client: Fopost) -> None: + route = respx.post(f"{BASE_URL}/sequences").mock( + return_value=httpx.Response(201, json={"data": SEQUENCE_FIXTURE}) + ) + + sequence = client.sequences.create( + workspace_id="ws_1", + account_id="acc_1", + name="Welcome", + steps=[{"delay_hours": 0, "text": "Thanks for the follow"}], + ) + + assert sequence.steps[1].delay_hours == 48 + body = json.loads(route.calls.last.request.content) + assert body["steps"] == [{"delay_hours": 0, "text": "Thanks for the follow"}] + + +@respx.mock +def test_enroll_takes_ids_or_an_audience(client: Fopost) -> None: + route = respx.post(f"{BASE_URL}/sequences/seq_1/enroll").mock( + return_value=httpx.Response(200, json={"data": {"id": "seq_1", "enrolled": 2}}) + ) + + client.sequences.enroll("seq_1", contact_ids=["con_1", "con_2"]) + assert json.loads(route.calls.last.request.content)["contact_ids"] == ["con_1", "con_2"] + + client.sequences.enroll("seq_1", audience={"platforms": ["telegram"]}) + assert json.loads(route.calls.last.request.content)["audience"] == {"platforms": ["telegram"]} + + +@respx.mock +def test_unenroll_names_the_contacts_it_stops(client: Fopost) -> None: + route = respx.post(f"{BASE_URL}/sequences/seq_1/unenroll").mock( + return_value=httpx.Response(200, json={"data": {"id": "seq_1", "stopped": 1}}) + ) + + assert client.sequences.unenroll("seq_1", ["con_1"])["stopped"] == 1 + assert json.loads(route.calls.last.request.content)["contact_ids"] == ["con_1"] From 60e70f56abd4fe2102666e8a4778fa5d3d5c3493 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 15:16:57 +0200 Subject: [PATCH 3/3] style: blank lines the formatter wants around the contacts banner --- src/fopost/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/fopost/models.py b/src/fopost/models.py index 5fa9689..f2b9ab3 100644 --- a/src/fopost/models.py +++ b/src/fopost/models.py @@ -1180,6 +1180,8 @@ class KnowledgeMatch(FopostModel): class KnowledgeSyncResult(FopostModel): id: str status: str + + # ─── Contacts ──────────────────────────────────────────────────────