# Chat: message history, typing indicators, read receipts

Text chat rides on the existing room WebSocket (`GET /v1/ws?token=…`). The server stores the last
**500 messages per room** (persisted to `<DATA_DIR>/chat.json`), relays typing and read receipts, and
exposes history and system-message injection over REST. Peers with a `subscriber` token can read but
not send. Code: `internal/chat`, routes in `internal/api/ext_chat.go`, demo at `/demo/chat.html`.

## WebSocket messages

All frames use the signaling envelope `{ "type", "from", "to", "payload", "error" }`.

### `chat` — peer → server

```json
{ "type": "chat", "payload": { "text": "hello", "client_id": "c-123", "to": "astro-1" } }
```

| Field | Notes |
|-------|-------|
| `text` | required, 1–4000 characters (whitespace-only is rejected) |
| `client_id` | optional idempotency key (≤128 chars). Re-sending the same `from`+`client_id` returns the already stored message instead of storing a duplicate. |
| `to` | optional peer id for a **private message** (also accepted as the top-level `to`). Private messages are stored and delivered only to the target; the sender sees them in history, other peers do not. |

The server assigns `id`, `seq` (per room, starts at 1) and `ts`, stores the message and delivers it:

- to every *other* connected peer in the room (or only to `to`) as

  ```json
  { "type": "chat", "from": "user-42", "to": "", "payload": { "id": "msg_…", "seq": 7, "from": "user-42", "to": "", "text": "hello", "ts": "2026-10-27T10:00:00Z" } }
  ```

- to the sender as an ack

  ```json
  { "type": "chat-ack", "to": "user-42", "payload": { "id": "msg_…", "seq": 7, "from": "user-42", "text": "hello", "ts": "…", "client_id": "c-123" } }
  ```

  On rejection the ack carries `error` and the payload only echoes `client_id` (and `to`):
  `{ "type": "chat-ack", "error": "text exceeds 4000 characters", "payload": { "client_id": "c-123" } }`.
  Errors: `text is required`, `text exceeds 4000 characters`, `subscriber tokens cannot send chat`.

A private message to a peer that is not connected is still stored and acked; the target reads it from
history when it joins.

### `typing` — peer → room (not stored)

```json
{ "type": "typing", "payload": { "typing": true } }
```
Relayed to every other peer as `{ "type": "typing", "from": "user-42", "payload": { "from": "user-42", "typing": true } }`.
Set `"to"` to send it to one peer only. Dropped for subscriber tokens. Clients should turn it off after
a few seconds (the SDK does this automatically after 3 s).

### `read` — peer → room

```json
{ "type": "read", "payload": { "up_to_seq": 7 } }
```
Stores the peer's last read seq (monotonic, clamped to the room's latest seq) and relays
`{ "type": "read", "from": "astro-1", "payload": { "from": "astro-1", "up_to_seq": 7 } }` to the others.
A value that does not advance the receipt is ignored and not relayed. Subscribers may send it.

## REST

| Endpoint | Auth | Purpose |
|----------|------|---------|
| `GET /v1/rooms/{id}/messages?since_seq=0&limit=500` | API key **or peer token** (own room only) | History, oldest first |
| `POST /v1/rooms/{id}/messages` | API key | Inject a message, by default from `system` |
| `GET /v1/rooms/{id}/messages/receipts` | API key | Last read seq per peer |

The room must belong to the authenticated app; otherwise `404 room not found`. A peer token may be sent
as `Authorization: Bearer <token>` or `?token=`.

### History

`GET /v1/rooms/{id}/messages?since_seq=3&limit=100` →

```json
{ "room_id": "room_…", "latest_seq": 9, "has_more": false,
  "messages": [ { "id": "msg_…", "seq": 4, "from": "user-42", "text": "…", "ts": "…" }, … ] }
```

Returns messages with `seq > since_seq`, ascending, at most `limit` (default and maximum 500). Page
forward by passing the last `seq` you have as `since_seq`; `has_more` tells you to continue. With a
**peer token** private messages are filtered to those the peer sent or received; with an API key
everything is returned. History survives the room becoming empty and is deleted when the room is
deleted or expires (checked on a timer and on every snapshot save).

### System messages

`POST /v1/rooms/{id}/messages` with `{ "text": "Consultant will join shortly" }` (optional `"from"`,
default `"system"`; optional `"to"` for one peer) → `201 { "message": {…} }`. The message is stored
with the next seq and broadcast to everyone connected (the sender exclusion does not apply).

### Receipts

`GET /v1/rooms/{id}/messages/receipts` → `{ "room_id": "room_…", "latest_seq": 9, "receipts": { "user-42": 9, "astro-1": 7 } }`

## Events

Every stored message emits `chat.message` to analytics and the app webhook with
`app_id, room_id, call_id (if a call is live), peer_id = from` and `data: { "seq": 7, "length": 5 }`.
The text itself is never included in events.

## SDK (`sdk/js/calling-client.js`)

```js
const client = new CallingClient({ baseUrl, token });
const { messages, latest_seq } = await client.fetchMessages({ sinceSeq: 0 }); // REST with the peer token, works before connect()
client.on('chat',   m => render(m));                       // {id, seq, from, text, ts, to}; not fired for your own messages
client.on('typing', ({ from, typing }) => …);
client.on('read',   ({ from, up_to_seq }) => …);
await client.connect();
const sent = await client.sendChat('hello', { to: 'astro-1' }); // resolves with the ack payload, rejects on error / 10 s timeout
client.setTyping(true);    // sends typing:true once, auto-off after 3 s (call again while typing to extend)
client.markRead(sent.seq); // or markRead() for the latest seq seen (client.lastSeq)
```

`sendChat` generates a `client_id` per call (pass `{ clientId }` to retry the same message safely) and turns
the typing indicator off. Your own messages arrive only through the `sendChat` promise, so render them from
its result. `client.lastSeq` tracks the highest seq seen from history or live messages.

## Limits and storage

- 4000 characters per message, 500 messages kept per room (older ones are dropped), `limit` ≤ 500 per request.
- `from`, `to` and `client_id` are limited to 128 bytes.
- Snapshot: `<DATA_DIR>/chat.json`, written at most every 500 ms after a change; rooms missing from the store
  are pruned on save and once a minute. Single-node only, like the rest of the in-memory store.
- Chat frames count toward the normal WebSocket limits (64 KiB per frame, slow readers are disconnected).

## Try it locally

1. `make create-app NAME="Chat"` and copy the key.
2. Open <http://localhost:8080/demo/chat.html>, paste the key, click **Create / join room with both peers**.
3. Type on either side: the other side shows "… is typing", messages show `✓ sent` → `✓✓ read by …`.
4. **Inject system message** posts via REST; **Leave**, reload, keep the room id and join again to see history.

```bash
curl "$API/v1/rooms/$ROOM/messages?since_seq=0" -H "Authorization: Bearer ck_live_…"
curl -X POST "$API/v1/rooms/$ROOM/messages" -H "Authorization: Bearer ck_live_…" -d '{"text":"Hello from the backend"}'
curl "$API/v1/rooms/$ROOM/messages/receipts" -H "Authorization: Bearer ck_live_…"
```
