Зачем ticket
API-ключ нельзя класть в query string (логи прокси, история браузера). POST /v1/ws/ticket (право ws:connect) выдаёт одноразовый билет ~ 60 секунд (expires_in_secs). Обёртка SDK запрашивает новый на каждый reconnect.
| Что | Значение |
|---|---|
| Кто | Интегратор (API-ключ) или виджет (visitor JWT через bootstrap / embed-session) |
| TTL билета | Интегратор ~60 с, виджет ~120 с. Один раз. |
| URL | wss://ws.sapportly.pro/ws?ticket=… |
| Сессия после redeem | Интеграторский JWT ~3600 с, затем close 4401 |
| Направление | Только сервер → клиент. Текстовые кадры от вас игнорируются |
import { SapportlyClient } from "@sapportly/sdk";
import { SapportlyInbox } from "@sapportly/sdk/realtime";
const client = new SapportlyClient({
apiKey: process.env.SAPPORTLY_API_KEY ?? process.env.SUPPORTLY_API_KEY!,
});
const inbox = new SapportlyInbox(client, { channels: ["custom:shop"] });
inbox.onVisitor((m) => console.log("in", m.body));
inbox.onAgent((m) => {
if (m.echo) return;
console.log("out", m.body);
});
inbox.onAi((d) => {
if (d.partial) return;
console.log("ai", d.draft_body);
});
await inbox.connect();Кадр wire v2
Это то, что приходит в браузер / Node. Внутренний NATS-конверт с metadata / kind / delivery на этот сокет не кладётся.
{
"v": 2,
"type": "message.delivered",
"event_id": "uuid",
"payload": {
"type": "message.delivered",
"message_id": "uuid",
"role": "visitor",
"channel": "custom:shop",
"body": "текст",
"content_encoding": "plain"
}
}type | Смысл |
|---|---|
message.delivered | Сообщение в ленте. role: visitor — входящее; agent или assistant — исходящее; system — не посетитель и не агент |
ai.draft | Черновик ИИ. Стрим: partial: true, затем финал. Не строка истории. message_id = id входящего, на которое отвечает модель |
Если у канала включено шифрование тенанта (enc_v1_standard), в кадре payload.body будет ciphertext, не открытый текст. Публичный SDK ключ тенанта не отдаёт — для ботов обычно оставляют канал в plaintext.
Keepalive и коды закрытия
| Код | Когда | Что делать |
|---|---|---|
| 4401 | Истёк JWT сессии | Новый ticket, reconnect. SDK делает сам |
| 4403 | Revoke или переполнен исходящий буфер | Новый ticket, reconnect; не долбить без backoff |
| 1008 | Слишком много клиентских ping (~60/мин) | Не слать прикладной ping. Сервер пингует сам каждые 25 с |
Стабильное подключение
Подписи «Онлайн», «Восстановление подключения» и «Переподключение» описывают состояние транспорта, а не состояние диалога. Один клиент должен держать один сокет, переподключаться с экспоненциальной задержкой и получать новый ticket на каждую попытку. Не создавайте новый сокет при каждом рендере интерфейса и не запускайте несколько циклов reconnect параллельно.
После успешного reconnect считайте REST источником истины: дочитайте сообщения с последнего подтверждённого message_id, объедините их по id и только затем продолжайте realtime-поток. Это защищает от дублей, пропусков и гонок между вебхуком, сокетом и обновлением страницы. Если сеть недоступна долго, показывайте оператору понятный статус, но не теряйте введённый текст и не отправляйте его повторно без idempotency key.
Пропущенные события
Интеграторский сокет не реплеит историю. Очередь pending_ws — для офлайн посетителей виджета. После reconnect: client.conversations.iterateMessages(channel). Один message_id может прийти и по вебхуку — SapportlyInbox дедупит по умолчанию, но не трогает ai.draft.
Низкоуровневый сокет
import { SapportlyRealtime } from "@sapportly/sdk/realtime";
const stream = new SapportlyRealtime({
tickets: client.realtime,
onEvent: (event) => console.log(event.type, event.payload),
});
await stream.connect();Node 18/20: нет глобального WebSocket. Node 22+, Bun, браузер — есть. Иначе npm i ws и WebSocket: (url) => new WebSocket(url).
URL
Канон: Caddy wss://ws.sapportly.pro/ws?ticket=… → realtime-плоскость. Health: https://ws.sapportly.pro/health.