Metadata-Version: 2.5
Name: fluidtalk
Version: 2.4.0
Summary: Official Python SDK for the FluidTalk Characters API — drive an AI persona across DMs, comments, triggers, and follow-ups.
Project-URL: Homepage, https://talk.fluidvip.com
Project-URL: Documentation, https://api-talk.fluidvip.com/api/v1/characters/openapi.json
Project-URL: Source, https://github.com/Trebuu/FluidTalk-Selfhost
Author: Fluidvip
License-Expression: MIT
Keywords: ai-persona,automation,characters,chatbot,dm,fluidtalk
Requires-Python: >=3.8
Requires-Dist: httpx>=0.24
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == 'dev'
Description-Content-Type: text/markdown

# FluidTalk Characters — Python SDK

Official Python SDK for the [FluidTalk Characters API](https://api-talk.fluidvip.com/api/v1/characters/openapi.json) — drive an AI persona across DMs, comments, triggers, and follow-ups from your own bot or connector.

## Install

```bash
pip install fluidtalk
```

## Quickstart

```python
from fluidtalk import FluidTalk

ft = FluidTalk(token="ftc_live_...")          # base_url defaults to production

# A lead DM'd the character — get the reply and send the bubbles yourself.
reply = ft.chat(platform="instagram", handle="mark", message="hey ava")
for bubble in reply.bubbles:
    send_dm("mark", bubble.text)              # your platform I/O
print("session:", reply.session_id)
```

Your **per-character connector token** (`ftc_live_…`) is sent as `X-Connector-Token`; get it from the character's platform settings in the [dashboard](https://talk.fluidvip.com). The token *is* the character — you name the `platform` in each call.

## API

| Method | Endpoint |
|---|---|
| `ft.chat(platform, handle, message="", image_url=None, session_id=None, own_username=None)` | `POST /chat` |
| `ft.event(platform, handle, external_event_id, event_type="purchase", amount=None, currency=None)` | `POST /events` |
| `ft.trigger(platform, handle, event_id, external_event_id, context=None, own_username=None)` | `POST /triggers` — `event_id="outreach"` fires the built-in Cold Outreach entry |
| `ft.followups.list(platform, own_username=None, limit=100)` | `GET /followups` |
| `ft.followups.ack(followup_id)` | `POST /followups/{id}/ack` |
| `ft.comment(platform, post_ref, caption=None, image_urls=None, author_handle=None)` | `POST /comments` |
| `ft.comment_reply(platform, post_ref, replier_handle, reply_text="", parent_comment_ref=None)` | `POST /comments/reply` |
| `ft.comment_engage(platform, post_ref, thread, post=None, target_comment_ref=None)` | `POST /comments/engage` — join a thread between other people |
| `ft.comment_media(platform, post_ref, data, filename=None, content_type=None)` | `POST /comments/media` |
| `ft.inbound_media(platform, data, filename=None, content_type=None)` | `POST /inbound-media` |

`comment_engage` is the third comment motion: joining a conversation between **other people**,
under a post the character may never have touched. You must send `thread` — we have no rows for
comments we never saw, so it is the character's only context. Omit `target_comment_ref` and the
character picks the comment worth answering, or abstains. **Abstaining is the normal outcome, not
an error** — branch on `reply`, never on the call having succeeded:

```python
res = ft.comment_engage(platform="instagram", post_ref="p1", thread=[
    {"comment_ref": "c1", "author_handle": "dan", "text": "silicone will fill a half-inch gap fine"},
    {"comment_ref": "c2", "author_handle": "mark", "text": "will it though?", "parent_ref": "c1"},
])
if res.reply:                                     # None on every skip
    post_reply(res.target_comment_ref, res.reply)  # your platform I/O
else:
    print("abstained:", res.reason)                # no_target_selected, thread_too_deep, …
```

`comment_media` rehosts a **post's** image so the character can actually see it. We do not fetch
that image — the model *provider* does — so a public URL is not enough; the host has to serve the
provider's fetcher, and plenty of genuinely public ones do not (Wikimedia renders in a browser and
comes back `vision_failed`). Bytes in, a URL for `comment`'s `image_urls` out. Not billed.

```python
up = ft.comment_media(platform="instagram", post_ref="p1", data=raw_bytes, filename="post.jpg")
ft.comment(platform="instagram", post_ref="p1", caption="new deck", image_urls=[up.url])
```

`inbound_media` is for a lead-sent photo you only have the **bytes** of — a Telegram
`file_id` you downloaded, or an Instagram CDN URL that is signed and expires. You pass
raw bytes, it returns a permanent `url` to hand to `chat` as `image_url`. Already have a
publicly-fetchable URL? Skip it and pass that straight to `chat`.

```python
up = ft.inbound_media(platform="instagram", data=raw_bytes, filename="photo.jpg")
ft.chat(platform="instagram", handle="mark", message="what do you think? 😏", image_url=up.url)
```

Responses are returned as attribute-access objects unwrapped from the `{data, request_id}` envelope (`reply.bubbles[0].text`, `res.decision`); a missing field reads as `None`. Use `.to_dict()` for the raw dict.

## Errors

Every non-2xx response raises a typed exception (all subclass `FluidTalkError` / `ApiError`):

```python
from fluidtalk import FluidTalk, PaymentRequiredError, RateLimitError, ApiError

ft = FluidTalk(token="ftc_live_...")
try:
    reply = ft.chat(platform="instagram", handle="mark", message="hi")
except PaymentRequiredError:
    top_up_wallet()        # 402 — the wallet can't cover the turn
except RateLimitError:
    backoff_and_retry()    # 429
except ApiError as e:
    print(e.status, e.code, e.request_id, e.message)
```

`AuthError` (401), `PaymentRequiredError` (402), `PermissionError` (403), `NotFoundError` (404), `ConflictError` (409), `ValidationError` (422), `RateLimitError` (429), and `ApiError` (anything else).

## Configuration

```python
FluidTalk(
    token="ftc_live_...",
    base_url="https://api-talk.fluidvip.com",   # green: https://api-green-talk.fluidvip.com
    timeout=60.0,
)
```

## Development

```bash
pip install -e ".[dev]"
pytest
```
