@sergiyiva/support-bot-sdk
v2.1.0
Published
Support Chat SDK: protocol, platform-neutral client, React Native UI and the app-backend helpers
Downloads
660
Readme
@sergiyiva/support-bot-sdk
Встроенный чат поддержки для мобильных приложений. Один пакет на обе стороны интеграции: клиент и хелперы для бэкенда приложения.
Пользователь пишет в поддержку не уходя в мессенджер. Клиент подключается напрямую к support-боту (REST + WebSocket), а приложение отвечает ровно за две вещи: выдать токен и доставить push. Переписку, историю, realtime, ретраи и оффлайн-очередь держит SDK и support-бот.
mobile: @sergiyiva/support-bot-sdk ──getToken()──► ваш бэкенд: issueSupportToken()
│ │ JWT HS256, ttl 15 мин
└───── HTTPS + WSS ──► support-bot ──вебхук──► ваш бэкенд: verifySupportWebhook()
└─► ваш push-провайдерУстановка
npm i @sergiyiva/support-bot-sdk
bun add @sergiyiva/support-bot-sdkГотовому экрану нужны две библиотеки — иконки и отступы под системные панели:
npx expo install react-native-svg react-native-safe-area-contextОбе резолвятся Metro статически, поэтому «поставить по желанию» не выйдет: без
них не соберётся бандл. Если вы рисуете свой UI на useSupportChat() и не
импортируете SupportChat/MessageBubble/Composer/ConnectionBanner — они
не нужны.
Ещё нужен expo-secure-store — в нём лежит оффлайн-очередь (см. «Хранилище»).
Все peer-зависимости помечены необязательными, поэтому бэкенд ставит пакет без
них: @sergiyiva/support-bot-sdk/server не тянет ни React, ни нативные модули.
Точки входа
| Импорт | Для чего | Где используется |
|---|---|---|
| @sergiyiva/support-bot-sdk | Провайдер, хуки, готовый экран | React Native |
| @sergiyiva/support-bot-sdk/server | Выдача токена, проверка вебхука | Бэкенд приложения |
| @sergiyiva/support-bot-sdk/core | Платформенно-нейтральное ядро | Веб-виджет, консоль оператора |
| @sergiyiva/support-bot-sdk/protocol | zod-схемы и типы протокола | Обе стороны |
Мобильное приложение
1. Провайдер
Ставится внутрь того, что даёт getToken доступ к сети (в приложениях на
Apollo — внутрь ApolloWrapper), и выше экранов, чтобы бейдж непрочитанных
работал при закрытом чате.
import { SupportChatProvider } from "@sergiyiva/support-bot-sdk";
<SupportChatProvider
config={{
baseUrl: process.env.EXPO_PUBLIC_SUPPORT_API!,
appId: "your-app",
getToken: async () => (await client.query({ query: GET_SUPPORT_TOKEN })).data.supportToken,
locale: i18n.language,
}}
theme={supportTheme} // Partial<SupportTheme>, необязательно
strings={supportStrings} // Partial<SupportStrings>, необязательно
context={{ // Record<string, unknown>, необязательно
name: user.displayName,
colorScheme: scheme,
}}
>
{children}
</SupportChatProvider>appId — это "your-app" только в примерах: подставьте id, под которым
приложение заведено на стороне поддержки (раздел «Приложения» в операторской
консоли). Тот же id уходит в iss токена, и по нему сервер выбирает секрет для
проверки подписи — с чужим или выдуманным значением любой запрос вернёт 401.
Во всех трёх фрагментах ниже он должен совпадать.
baseUrl — единственный адрес, который задаёт приложение: WebSocket SDK строит
из него сам (https://… → wss://…/v1/ws), а не берёт wsUrl из ответа сервера.
За прокси или в туннеле API_URL бэкенда часто указывает на внутренний хост —
REST бы работал, а сокет уходил в никуда.
Динамические данные о пользователе
context — то, что известно про пользователя и приложение и должно ехать с
каждым сообщением: имя, текущая цветовая схема, что угодно ещё, без чего
непонятна жалоба. Читается в момент отправки, а не при монтировании: переименуйте
пользователя или переключите тему — следующее сообщение уедет с новым значением.
Сообщение, которое лежит в оффлайн-очереди, сохраняет тот контекст, с которым его
написали, — это честная запись момента.
Экран может добавить своё, не повторяя фон; при совпадении ключей выигрывает экран, потому что он знает то, чего провайдер знать не может:
// провайдер: { name: "Аня", colorScheme: "dark" }
<SupportChat context={{ generationId }} />
// уедет: { name: "Аня", colorScheme: "dark", generationId: "…" }⚠️ context приходит с клиента и не подписан. Он описывает, а не
удостоверяет: пользователь технически может отправить любое имя. Всё, на чём
оператор основывает решения — кто это и что человек оплатил, — берётся из токена
и продуктовой БД (карточка пользователя в консоли), а не отсюда.
Тема при этом остаётся про внешний вид: передайте в theme новый объект —
чат перерисуется, диалог и сокет не пересоздаются (провайдер пересоздаёт чат
только на смену baseUrl/appId).
2. Экран
import { SupportChat } from "@sergiyiva/support-bot-sdk";
export default function SupportScreen() {
return <SupportChat onClose={() => router.back()} />;
}| Проп | Зачем |
|---|---|
| onClose | Показывает крестик в шапке. Без него крестика нет — экран, открытый через навигацию, закрывается своими средствами |
| showHeader | false, если у экрана уже есть заголовок от навигации |
| subtitle | Заменяет вычисленную вторую строку в шапке — например, «обращение №4821» |
| context | Данные экрана, с которого пришли; уезжают с первым сообщением |
| bottomInset | Отступ под композером, если хост считает системные зоны сам. 0, если хост уже отступил снизу |
| topInset | Отступ над шапкой (статус-бар, вырез). 0, если хост уже отступил сверху |
Системные панели и клавиатура. Начиная с RN 0.81 андроид рисует edge-to-edge:
окно не сжимается ни под статус-бар, ни под панель навигации (с тремя кнопками
она высокая), ни под клавиатуру. Экран разбирается с этим сам — отступает шапкой
от статус-бара и поднимает композер над клавиатурой, а когда она убрана, над
нижним safe-area inset. Insets читаются из SafeAreaInsetsContext, поэтому:
- есть
<SafeAreaProvider>— отступы появятся сами; - нет — отступов не будет, но и падения тоже (в отличие от
useSafeAreaInsets()); - хост отступает сам (например, обернул чат в
<SafeAreaView>) — нуженbottomInset={0}и/илиtopInset={0}.<SafeAreaView>добавляет padding нативно, а контекст оставляет как есть, так что чат по-прежнему видит полный inset и без этого отступ применится дважды.
Сверху отступает именно шапка, так что статус-бар лежит на её фоне
(colors.surface). При showHeader={false} topInset по умолчанию 0: экран с
навигационным заголовком уже опущен под статус-бар самой навигацией. Если чат
открыт без шапки и на весь экран — передайте topInset явно.
Высота клавиатуры на андроиде приходит из RN уже без панели навигации
(ReactRootView), поэтому под клавиатурой чат отступает на сумму «клавиатура +
нижний inset»; на iOS кадр клавиатуры и так достаёт до низа экрана. Отсюда же
берётся эффект трёх кнопок: без слагаемого композер уезжал под клавиатуру ровно
на их высоту.
3. Бейдж непрочитанных
const { unreadCount, isConnected } = useSupportUnread();Отдельный хук, а не поле в useSupportChat(): он не подписывает компонент на
список сообщений, поэтому таб-бар не перерисовывается на каждое сообщение.
4. Свой UI
Готовый экран не обязателен — useSupportChat() отдаёт то же состояние и те же
действия:
const {
messages, mode, status, connection, capabilities,
unreadCount, agentTyping, hasMore, isLoadingMore,
send, retry, loadMore, markRead, notifyTyping, vote,
} = useSupportChat();messages — уже отсортированный и дедуплицированный список. Транспорт доставляет
как минимум один раз; ровно один раз на экране обеспечивает клиент. У
сообщений есть status: "sending" | "sent" | "failed" и optimistic.
Отдельные части UI экспортируются: MessageBubble, Composer, ConnectionBanner.
Темизация и строки
Через простые объекты, не через className — у приложений разные палитры и версии tailwind, и SDK не должен про них знать:
const supportTheme = { colors: { accent: Palette.primary, /* … */ } };
const supportStrings = { title: t("support.title") };Полный список полей — SupportTheme и SupportStrings; незаданные берутся из
defaultTheme / defaultStrings.
Цвета из colors, которыми рисуется текст (agentText, userText,
systemText, muted, danger, accent), должны давать против своего фона
(background, surface, userBubble) не меньше 4.5:1 — SDK контраст не
подкручивает и не проверяет.
Миграция на 2.0
Ломается только SupportStrings. Если вы передаёте частичный объект
(strings={{ title: … }}) — менять нечего: лишние ключи TypeScript отсеет, а
остальное придёт из defaultStrings. Если у вас есть объект, типизированный как
SupportStrings целиком (таблица локали), правки такие:
| Было | Стало |
|---|---|
| online, lastSeen | удалены — шапка больше не выдаёт «Анна · онлайн» |
| typingBy | удалён — индикатор печати снова безымянный, agentTyping |
| operatorJoined, helpful, notHelpful | удалены — соответствующего UI нет |
| — | answeredByOperator — шапка в режиме human |
| — | sla — шапка до первого сообщения |
| — | close, today, yesterday, pending, delivered |
Имени и присутствия оператора в протоколе нет: typing сообщает только
assistant/operator, а connection === "online" — это наш сокет, а не
человек за консолью. Пока протокол их не отдаёт, шапка говорит, что отвечает
(ассистент или оператор), и не называет кого. Имя автора конкретного сообщения
как и раньше приходит в operatorName и рисуется над его бабблом.
Хранилище оффлайн-очереди
По умолчанию — expo-secure-store (Keychain / Keystore). В очереди лежит текст
обращений, а он регулярно содержит персональные данные, номера заказов и
обстоятельства платежей, поэтому plaintext дефолтом не является.
Если expo-secure-store в приложении нет:
import { asyncStorageAdapter } from "@sergiyiva/support-bot-sdk";
config={{ /* … */ storage: asyncStorageAdapter() }}Риск явный: AsyncStorage не шифруется — содержимое читается на рутованном или джейлбрейкнутом устройстве и попадает в бэкапы устройства.
Что SDK делает сам
- переоткрывает токен за минуту до истечения и по
401(ровно один раз, без цикла); - реконнектит WS с джиттер-бэкоффом 1→30 с, ping/pong каждые 25 с;
- после разрыва догоняет пропущенное через
GET /v1/messages?since=; - держит оффлайн-очередь между запусками и досылает её при появлении сети, сохраняя порядок;
- дедуплицирует доставку по
id, свои сообщения матчит поclientMessageId.
Бэкенд приложения
Токен
import { issueSupportToken } from "@sergiyiva/support-bot-sdk/server";
@UseGuards(AuthGuard)
@Query(() => String)
supportToken(@CurrentUserId() userId: string) {
return issueSupportToken({
appId: "your-app",
userId,
secret: this.config.get("SUPPORT_APP_SECRET"),
ctx: { appVersion, platform, locale }, // необязательно, видно оператору
});
}⚠️
userIdобязан приходить из сессии. Это единственный пункт подключения, который нужно ревьюить глазами: любая возможность передать его аргументом отдаёт клиенту чужую переписку целиком.
HS256, фиксированный aud, TTL 15 минут, уникальный jti — один токен можно
отозвать, не трогая остальные.
Вебхук
@Post("support/webhook")
async webhook(@Req() req: RawBodyRequest<Request>) {
const event = verifySupportWebhook(req.rawBody, req.headers, secret); // бросает при неверной подписи
// Сначала фиксируем eventId, потом побочные эффекты.
if (!(await this.consumed.tryInsert(event.eventId))) return;
if (event.event === "message.created" && !event.userOnline) {
await this.push.sendToUser(event.userId, "Поддержка ответила", event.message.preview, {
route: "support",
});
}
}Два места, где легко ошибиться:
- Нужен именно raw body. Подпись считается по пришедшим байтам;
пересериализованный объект её не пройдёт. NestJS —
rawBody: trueвNestFactory.create, Express —bodyParser.json({ verify }). - Ключ идемпотентности —
eventId, неmessage.id. Уconversation.updatedсообщения нет вовсе, а одинmessage.idпорождает несколько событий. Запись о потреблённом событии делается до побочных эффектов: иначе самый частый случай — таймаут после успешной обработки и ретрай — отправит push дважды.
verifySupportWebhook проверяет подпись (HMAC-SHA256 по "<timestamp>.<body>",
сравнение константное по времени), окно timestamp'а 5 минут и схему payload'а.
Проактивные сообщения и удаление аккаунта
const token = await issueS2sToken({ appId: "your-app", secret });
await fetch(`${SUPPORT_API}/v1/s2s/messages`, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({ userId, text: "Возврат одобрен" }),
});
// при удалении аккаунта — чтобы переписка его не пережила
await fetch(`${SUPPORT_API}/v1/s2s/users/${userId}`, {
method: "DELETE",
headers: { authorization: `Bearer ${token}` },
});S2S-токен не несёт sub: он говорит от имени приложения, а не пользователя.
support-бот отвергает s2s-токен с sub и не пускает его на пользовательские пути
(и наоборот — 403, а не 401, чтобы SDK не пытался обновлять токен вечно).
Ограничения v1
- только текст, вложений нет (
capabilities.supportsInboundAttachments === false); - без Markdown;
- лимит длины приходит с сервера в
capabilities.maxMessageLength— считайте по нему, значение совпадает с тем, что сервер enforce'ит.
Протокол зарезервирован так, что вложения добавятся без релиза приложения: кнопка рисуется по серверному флагу.
Разработка
bun run build # dist/ в CommonJS + .d.ts, в git не хранится
bun run typecheck
bun testСборка в CommonJS не случайна: бэкенды приложений собираются nest build
(tsc, module: commonjs, moduleResolution: node) — сырой TypeScript из
node_modules они не компилируют, а ESM не подключат через require. Metro и bun
с CJS работают без оговорок, поэтому одной сборки хватает всем. По той же причине
в зависимостях нет jose (она ESM-only): подпись HS256 сделана на node:crypto.
prepack собирает dist автоматически, так что npm publish достаточно.
Стенд
UI по тестам не проверить — для этого есть packages/support-bot-playground:
тот же <SupportChat /> в браузере и на устройстве, против локального
support-бота и настоящего issueSupportToken.
cd api && bun run dev # support-бот
cd api && bun run dev:app-backend # бэкенд приложения: токен + вебхук
cd packages/support-bot-playground && bun run web # стендdist для этого собирать не нужно: метро резолвит пакет в src/, поэтому
правка в src/react-native/ui/ видна сразу. Оговорки — в README стенда.
Релиз
С чистым рабочим деревом и поднятым version:
NPM_TOKEN=npm_... bun run releaseНужен npm automation token — он не требует 2FA-подтверждения и потому работает в
CI. Скрипт проверит, что версии ещё нет в реестре (перезаписать опубликованную
нельзя, и переиспользовать номер после unpublish — тоже), соберёт пакет и
опубликует. Токен уходит во временный userconfig, а не в ~/.npmrc и не в
argv — argv виден в ps любому процессу на машине.
