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
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ src/fopost/
_http.py HttpClient: headers, retry loop, decode, unwrap()
errors.py FopostError + subclasses + error_from_response()
models.py pydantic models, PLATFORMS, POST_STATUSES, Page/PageMeta
chat_adapter.py ChatAdapter — the inbox as a send/receive interface, imported on its own
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 broadcasts.py ads.py
Expand All @@ -55,6 +56,21 @@ Request flow: a resource method builds a snake_case body/params dict, calls
`HttpClient._decode()` (raises or returns the parsed body) → back in the resource,
`unwrap(body)` peels `{"data": ...}` and `Model.model_validate(...)` types it.

**`chat_adapter` is a consumer of the client, not a resource.** `ChatAdapter` holds a
`Fopost` instance and calls `client.inbox.*`; it never touches `HttpClient` and never adds
an endpoint of its own. An endpoint it needs goes into `InboxResource` first. It is not
re-exported from `fopost/__init__.py`: `from fopost.chat_adapter import ChatAdapter` is the
import, matching `@fopost/sdk/chat-adapter` in the TypeScript SDK, and the two surfaces
move together.

**`inbox.message_received` carries ids only.** The payload is `itemId`, `type`, `platform`,
`accountId`, `receivedAt`, with no text and no author. There is no `GET /v1/inbox/:id` and
no `id` filter on the list, so `receive_one` scans `lookback_pages` of that account's items
and answers `None` when it does not find one. If the API grows a single-item read, switch
to it. Webhook verification prefers `X-FoPost-Signature-256` (HMAC over
`{timestamp}.{body}`, refused past a 300s tolerance) and falls back to the body-only
`X-FoPost-Signature`; both compare with `hmac.compare_digest`.

**The envelope unwrap lives in the resources, not the transport.** `_http.unwrap()` is a
free function the resources call; `HttpClient` hands back the whole decoded body so the
escape hatch and paginated readers can see `meta`.
Expand Down
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,73 @@ authenticated call and hands back the decoded body:
client.request("GET", "/analytics/summary", params={"workspace_id": workspace.id})
```

## Chat adapter

`fopost.chat_adapter` wraps the inbox conversation and reply endpoints in a send/receive
interface, so a chatbot framework can treat FoPost as one channel across every network
that carries direct messages.

```python
from fopost import Fopost
from fopost.chat_adapter import ChatAdapter

chat = ChatAdapter(
Fopost(api_key=os.environ["FOPOST_API_KEY"]),
workspace_id=workspace_id,
webhook_secret=os.environ["FOPOST_WEBHOOK_SECRET"],
)
```

**Inbound** is the `inbox.message_received` webhook. Subscribe an endpoint to it in FoPost,
then hand the raw body and the request headers to `parse_webhook`. It verifies the
signature, refuses a replay, and returns the event; the event carries ids only, so
`receive_one` reads the text back:

```python
@app.post("/webhooks/fopost")
async def inbound(request: Request) -> Response:
event = chat.parse_webhook(await request.body(), request.headers)
message = chat.receive_one(event)
if message is None:
return Response(status_code=204)

chat.typing(message.conversation_id, message.account_id)
chat.send(reply_to=message.id, text=your_bot(message.text))
chat.mark_read(message)
return Response(status_code=204)
```

No webhook? `receive()` polls the same thing:

```python
for message in chat.receive():
chat.send(reply_to=message.id, text=your_bot(message.text))
chat.mark_read(message)
```

**Outbound** takes one of three shapes. Reply to a message, reply into a thread, or open
one by handle:

```python
chat.send(reply_to=message.id, text="On it.")
chat.send(conversation_id="conv_...", text="Still here.")
chat.send(account_id="acc_...", handle="samrivera", text="Following up.")
```

| Method | What it does |
| ------ | ------------ |
| `parse_webhook(body, headers)` | Verifies a delivery and returns the `ChatEvent` |
| `verify_webhook(body, headers)` | Signature check on its own; raises on a forged or stale delivery |
| `receive(...)` | Inbound DMs, unread by default |
| `receive_one(event_or_id)` | The full message behind an event id, or `None` |
| `send(...)` | Reply, reply into a thread, or open one |
| `typing(conversation_id, account_id, on=True)` | Typing indicator |
| `mark_read(message)` | Marks the message read |

Sending needs the `publish` scope on top of `inbox`. Every failure is a `ChatAdapterError`
with a `code` (`invalid_signature`, `stale_delivery`, `unexpected_event`,
`unsupported_target`, and the rest) or the usual `FopostError` from the API.

## Example

[`examples/create_post.py`](examples/create_post.py) creates a post against a
Expand Down
Loading
Loading