npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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",
    });
  }
}

Два места, где легко ошибиться:

  1. Нужен именно raw body. Подпись считается по пришедшим байтам; пересериализованный объект её не пройдёт. NestJS — rawBody: true в NestFactory.create, Express — bodyParser.json({ verify }).
  2. Ключ идемпотентности — 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 любому процессу на машине.