Widget
Для разработчиков

Widget

Чат-виджет — это один скрипт-тег, который можно вставить на любую страницу. Он создаёт плавающую кнопку в углу, открывает чат с оператором, тянет ленту сообщений и историю тикетов. Никакой установки или авторизации клиенту не нужно.

Минимальная установка

index.html
<script>
(function(w, d){
  function loadWidget(token){
    var s = d.createElement('script');
    s.async = 1;
    s.src = 'https://support.forestsnet.com/widget-bundle?ws=WORKSPACE_UUID';
    if (token) s.setAttribute('data-visitor-token', token);
    d.head.appendChild(s);
  }
  // Optional cross-device identity: fetch a signed token from your
  // backend. Drop the fetch + just call loadWidget() if you don't
  // need cross-device. Endpoint format: see /docs/widget/identify.
  fetch('/api/supporthub/token', { credentials: 'include' })
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(data){ loadWidget(data && data.token); })
    .catch(function(){ loadWidget(); });
})(window, document);
</script>
Обязателен только параметр ?ws=. UUID воркспейса возьмите в «Настройки → Виджет» или скопируйте готовый сниппет прямо оттуда (кнопка Embed). Loader-pattern с IIFE добавляет тег <script async> в head на лету — то же что прямой <script async src="..."></script>, но лучше переживает CSP-ограничения и SPA-роутинг. Бандл сам знает, куда ходить за API — URL бэкенда инжектится сервером в preamble. Конфиг виджета тоже инлайнится в ответ на /widget-bundle?ws=…, поэтому виджет стартует за один запрос без отдельного /config.

Атрибуты тега

АтрибутТипНазначение
?ws= (query)uuidОбязательно. ID воркспейса в URL скрипта — сервер по нему инлайнит конфиг и API URL в бандл.
data-api-baseurlНеобязательно. Override базового URL API (например https://api.staging.example.com/api). По умолчанию бандл уже знает прод-URL — атрибут нужен только для стейджа / self-hosted инсталляций.
data-cookie-domainstringНеобязательно. Домен для visitor-session cookie с лидирующей точкой (.example.com) — чтобы один визитор сохранял одну беседу при переходе между поддоменами. То же самое можно задать через «Конструктор → Поведение → Домен cookie» — атрибут выигрывает у конфига, удобно если стейдж на другом домене.
data-emailstringEmail клиента. Если передан — pre-chat форма пропускается, контакт создаётся/обновляется на стороне бэка через /widget/identify.
data-namestringПолное имя клиента. Отображается в админке и в TG-карточке.
data-phonestringНомер телефона (E.164).
data-telegram-idintTelegram ID для связки виджета и TG-канала бота.
data-visitor-tokenstringHMAC-токен для cross-device идентификации (если посетитель уже залогинен в вашей CRM с подписанным jwt — передайте его сюда, тикеты подцепятся к тому же контакту с других устройств).

Передача данных пользователя

Если ваш сайт уже знает посетителя — передайте поля прямо в тег. Виджет создаст / обновит контакт на бэке через POST /widget/identifyавтоматически при загрузке (даже если посетитель ещё не открывал чат).

Когда нужны data-* атрибуты — используйте прямой <script async> вместо loader-сниппета. IIFE-loader создаёт тег без вашей атрибутики, бандл прочитать её не сможет.

index.html (SSR / Blade / EJS / Twig)
<script
  async
  src="https://support.forestsnet.com/widget-bundle?ws=WORKSPACE_UUID"
  data-email="{{ user.email }}"
  data-name="{{ user.full_name }}"
  data-phone="{{ user.phone }}"
  data-telegram-id="{{ user.telegram_id }}"
></script>

Динамическая идентификация из JS

Если пользователь логинится после загрузки страницы (SPA), вызовите window.SupportHub.identify(). Он экспортируется ещё до завершения init() — вызовы до загрузки бандла буферизуются и отправляются автоматически как только виджет инициализируется.

app.js
// После того как юзер вошёл в аккаунт:
window.SupportHub.identify({
  email: user.email,
  name: user.fullName,
  phone: user.phone,
  telegram_id: user.telegramId,
});

Поля совпадают с data-*-атрибутами. Передавайте только то что знаете — пустые поля не затирают существующие значения. Email при повторном identify обновится (раньше был хард-409 при попытке сменить email — это исправлено в d62e0ef).

Хранение visitor session

ID визитора (vs_<random>) — единственное что привязывает анонимную сессию к контакту в БД. Чтобы он переживал Safari ITP, private mode, перезагрузки страницы и закрытие вкладок, виджет хранит его параллельно в пяти местах:

TierХранилищеЖизненный цикл
1cookie sh_visitor_<ws>1 год, Lax, Secure on https. Единственный tier с поддержкой cross-subdomain (через cookie_domain). ITP в Safari режет до ~7 дней.
2localStorage sh-visitor-<ws>До «очистить данные сайта». Per-origin. Переживает ITP лучше cookie.
3sessionStorageДо закрытия вкладки. Последняя линия обороны для Safari private mode + sandboxed iframe, где tiers 1–2 могут быть недоступны.
4IndexedDB sh-widgetСамые большие квоты, тяжелее всего эвиктится. Async write-back; читается через primeFromIndexedDB при init если все sync tier'ы пусты.
5localStorage sh-visitor-sessionLegacy ключ из эпохи single-workspace. Read-only, для миграции существующих визиторов.

Read precedence: 1 → 2 → 3 → 5. Первое непустое значение побеждает; затем оно зеркалируется во все доступные tier'ы, так что если в одной браузерной сессии cookie был выкинут ITP, при следующей загрузке value поднимается из localStorage и записывается обратно в cookie. IndexedDB пишется fire-and-forget — потерять запись туда не страшно, sync tier'ы уже держат значение.

Cross-tab sync

BroadcastChannel('sh-widget-sync') уведомляет другие вкладки в том же браузере как только session id создан или промотирован — две одновременно открытые вкладки не сгенерируют два параллельных id. Когда BroadcastChannel недоступен (Firefox private mode), используется fallback через storage-event на window (он firing'ится в OTHER вкладках при изменении localStorage).

Эту схему не надо настраивать — она работает из коробки сразу как виджет загружен. Единственная настройка которую вы можете задать —cookie_domain для cross-subdomain (см. ниже). Всё остальное автоматическое.

Один визитор на нескольких поддоменах

По умолчанию tier 1 (cookie) scoped по host: визитор на example.com и app.example.com — два разных контакта, две беседы. Tiers 2–5 тоже scoped per-origin — cross-subdomain работает только через cookie с явно указанным доменом.

Чтобы сохранять одну беседу между корневым доменом и поддоменами, задайте cookie-домен с точкой в начале:

  • Через админку: «Конструктор виджета → Поведение → Домен cookie» → ввести .example.com. Снипет на всех поддоменах остаётся тем же — виджет прочитает значение из конфига.
  • Через атрибут: data-cookie-domain=".example.com"в теге <script>. Атрибут выигрывает у конфига — удобно если стейдж и прод на разных доменах.
Не задавайте слишком широкий домен (например .co.uk) — браузер отклонит cookie, потому что это публичный суффикс. Указывайте именно ваш домен второго уровня. Точка в начале обязательна — без неё cookie ставится только на указанный хост.

Миграция автоматическая: при первом заходе виджет читает старый localStorage-ключ sh-visitor-session и переписывает значение в cookie с указанным доменом. На следующем поддомене браузер найдёт cookie и подхватит ту же сессию.

JS controller API

После загрузки бандла на window.SupportHub доступен controller с публичным API. Методы безопасно вызывать до завершения init() — bundle экспортирует init и identify сразу при загрузке скрипта; вызовы до init буферизуются и выполняются как только controller онлайн.

МетодНазначение
init(opts)Программная инициализация. Опции: workspace_id, config, apiBase, mode. Авто-буут (через ?ws=) уже вызывает init.
open()Открыть панель программно (например, по клику на свою кнопку).
close()Свернуть панель в FAB.
isOpen()Возвращает boolean — открыта ли панель сейчас.
setTab(tab)Переключить активную вкладку: "home" | "chat" | "help" | "news" | "miniapp".
identify(payload)Привязать визитора к контакту. Поля: email, name, phone, telegram_id, visitor_token. Async; возвращает Promise.
applyConfig(cfg)Применить новый конфиг. Используется live-preview iframe в Builder; на проде обычно не нужен.
showState(s, sub?)Демо-mode preview-state переключатель. Только в preview-mode; на проде no-op.
destroy()Размонтировать виджет. Закрывает WS, удаляет DOM. Используйте после logout перед повторным init().

Lifecycle / порядок инициализации

  1. Bundle грузится через <script src=".../widget-bundle?ws=...">. При загрузке IIFE auto-boot:
    • Pin'ит window.__SH_API_BASE из data-api-base или script origin (см. preamble).
    • Скрейпит data-* атрибуты в _identifyState.
    • Если ?ws= в URL — вызывает initInternal().
  2. initInternal():
    • Загружает конфиг (либо из preamble __SH_WIDGET_CONFIG, либо GET /webhooks/widget/{ws}/config).
    • Создаёт shell, state, api client.
    • primeFromIndexedDB — async-восстановление session из IDB если все sync tier'ы пусты.
    • openVisitorWs — opportunistic WS connect (4401 пока нет Contact'а).
    • flushIdentify — POST в /widget/identify если есть pending данные.
    • subscribeSessionSync — cross-tab BroadcastChannel listener.
  3. Controller возвращается из init() и присваивается в window.SupportHub. Дальнейший SupportHub.identify(...) работает через тот же controller.

Глобалы которые виджет ставит на window

  • window.SupportHub — controller (методы выше)
  • window.__SH_API_BASE — base URL API (pinned из script tag origin или preamble)
  • window.__SH_WIDGET_CONFIG — заинлайненный конфиг (только если bundle серверу удалось pre-fetch)
  • window.__SH_WIDGET_V2_MOUNTED — guard от двойного auto-boot

WebSocket protocol

После Contact'a создания виджет открывает wss://api.support.forestsnet.com/api/ws/widget/{visitor_session}?workspace_id={ws} (плюс &signed_token=... для cross-device). Backend пушит JSON-фреймы:

  • { type: "message.created", data: { ticket_id, content, ... } } — новое сообщение от оператора
  • { type: "ticket.assigned", data: { ticket_id, operator_name, operator_avatar_url } } — оператор взял тикет
  • { type: "ticket.transferred", data: { ticket_id, from, to } } — передача между операторами
  • { type: "message.reaction.added"|"removed", data: { message_id, emoji } }
  • { type: "operator_seen", data: { ticket_id, last_seen_message_id } } — оператор открыл тикет
  • { type: "operator_typing", data: { ticket_id, is_typing } }

Visitor-side ping/pong каждые ~30s — иначе backend закрывает connection через 60s неактивности (и помечает offline в ContactChannelStatus).

Расширение через events

Если нужно реагировать на события виджета (открытие чата, отправка сообщения) из своего кода — используйте webhooks API на бекенде (/docs/api-guide/webhooks). Public DOM-events на стороне клиента в текущей версии не эмитятся — это в roadmap (SupportHub.on('open', cb) планируется).

Была ли страница полезной?