Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `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`.
Expand Down Expand Up @@ -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 broadcasts.py ads.py
```

Request flow: a resource method builds a snake_case body/params dict, calls
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ 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`, `handover`, `list_approvals`, `approve_reply`, `reject_reply` |
| `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` |
| `knowledge` | `list`, `create`, `update`, `delete`, `sync`, `search` |
| `validate` | `post`, `length`, `media` |
Expand Down
34 changes: 34 additions & 0 deletions src/fopost/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,25 @@
AiCreditBalance,
AiCredits,
Audience,
AudienceFilter,
AudiencesResult,
BoostablePost,
Broadcast,
BroadcastCounts,
BroadcastRecipient,
BulkAdStatusResult,
CaptionResult,
Contact,
ContactChannel,
ContactConversation,
ContactField,
ContactImportResult,
ContactImportSkip,
ContactLabel,
ContentBlock,
ContentSignal,
ConversationAnalytics,
ConversationAnalyticsRow,
Delivery,
DiscordChannel,
DiscordIdentity,
Expand All @@ -62,6 +75,7 @@
DiscordMessageRef,
DiscordRole,
DiscordScheduledEvent,
Enrollment,
ExternalAd,
FeedLead,
InboxAccount,
Expand Down Expand Up @@ -111,6 +125,9 @@
RepurposeResult,
RewriteResult,
RewriteVariant,
Sequence,
SequenceEnrollmentCounts,
SequenceStep,
SlackChannel,
SlackIdentity,
SlackMember,
Expand Down Expand Up @@ -166,6 +183,23 @@
"InboxApproval",
"InboxAttachment",
"InboxConversation",
"AudienceFilter",
"Broadcast",
"BroadcastCounts",
"BroadcastRecipient",
"Contact",
"ContactChannel",
"ContactConversation",
"ContactField",
"ContactImportResult",
"ContactImportSkip",
"ContactLabel",
"ConversationAnalytics",
"ConversationAnalyticsRow",
"Enrollment",
"Sequence",
"SequenceEnrollmentCounts",
"SequenceStep",
"InboxItem",
"KnowledgeMatch",
"KnowledgeSource",
Expand Down
6 changes: 6 additions & 0 deletions src/fopost/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@
AccountsResource,
AdsResource,
AiResource,
BroadcastsResource,
ContactsResource,
InboxResource,
KnowledgeResource,
LabelsResource,
MediaResource,
PostsResource,
SequencesResource,
ValidateResource,
WorkspacesResource,
)
Expand Down Expand Up @@ -72,6 +75,9 @@ def __init__(
self.media = MediaResource(self._http)
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)
self.knowledge = KnowledgeResource(self._http)
Expand Down
206 changes: 206 additions & 0 deletions src/fopost/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1180,3 +1180,209 @@ class KnowledgeMatch(FopostModel):
class KnowledgeSyncResult(FopostModel):
id: str
status: str


# ─── 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


# ─── 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
5 changes: 5 additions & 0 deletions src/fopost/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
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 .knowledge import KnowledgeResource
from .labels import LabelsResource
Expand All @@ -15,6 +17,9 @@
"AccountsResource",
"AdsResource",
"AiResource",
"BroadcastsResource",
"ContactsResource",
"SequencesResource",
"InboxResource",
"KnowledgeResource",
"LabelsResource",
Expand Down
Loading
Loading