Widget
Чат-виджет — это один скрипт-тег, который можно вставить на любую страницу. Он создаёт плавающую кнопку в углу, открывает чат с оператором, тянет ленту сообщений и историю тикетов. Никакой установки или авторизации клиенту не нужно.
Минимальная установка
<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-base | url | Необязательно. Override базового URL API (например https://api.staging.example.com/api). По умолчанию бандл уже знает прод-URL — атрибут нужен только для стейджа / self-hosted инсталляций. |
data-cookie-domain | string | Необязательно. Домен для visitor-session cookie с лидирующей точкой (.example.com) — чтобы один визитор сохранял одну беседу при переходе между поддоменами. То же самое можно задать через «Конструктор → Поведение → Домен cookie» — атрибут выигрывает у конфига, удобно если стейдж на другом домене. |
data-email | string | Email клиента. Если передан — pre-chat форма пропускается, контакт создаётся/обновляется на стороне бэка через /widget/identify. |
data-name | string | Полное имя клиента. Отображается в админке и в TG-карточке. |
data-phone | string | Номер телефона (E.164). |
data-telegram-id | int | Telegram ID для связки виджета и TG-канала бота. |
data-visitor-token | string | HMAC-токен для cross-device идентификации (если посетитель уже залогинен в вашей CRM с подписанным jwt — передайте его сюда, тикеты подцепятся к тому же контакту с других устройств). |
Передача данных пользователя
Если ваш сайт уже знает посетителя — передайте поля прямо в тег. Виджет создаст / обновит контакт на бэке через POST /widget/identifyавтоматически при загрузке (даже если посетитель ещё не открывал чат).
Когда нужны data-* атрибуты — используйте прямой <script async> вместо loader-сниппета. IIFE-loader создаёт тег без вашей атрибутики, бандл прочитать её не сможет.
<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() — вызовы до загрузки бандла буферизуются и отправляются автоматически как только виджет инициализируется.
// После того как юзер вошёл в аккаунт:
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 | Хранилище | Жизненный цикл |
|---|---|---|
| 1 | cookie sh_visitor_<ws> | 1 год, Lax, Secure on https. Единственный tier с поддержкой cross-subdomain (через cookie_domain). ITP в Safari режет до ~7 дней. |
| 2 | localStorage sh-visitor-<ws> | До «очистить данные сайта». Per-origin. Переживает ITP лучше cookie. |
| 3 | sessionStorage | До закрытия вкладки. Последняя линия обороны для Safari private mode + sandboxed iframe, где tiers 1–2 могут быть недоступны. |
| 4 | IndexedDB sh-widget | Самые большие квоты, тяжелее всего эвиктится. Async write-back; читается через primeFromIndexedDB при init если все sync tier'ы пусты. |
| 5 | localStorage sh-visitor-session | Legacy ключ из эпохи 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 / порядок инициализации
- 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().
- Pin'ит
initInternal():- Загружает конфиг (либо из preamble
__SH_WIDGET_CONFIG, либоGET /webhooks/widget/{ws}/config). - Создаёт
shell,state,apiclient. primeFromIndexedDB— async-восстановление session из IDB если все sync tier'ы пусты.openVisitorWs— opportunistic WS connect (4401 пока нет Contact'а).flushIdentify— POST в/widget/identifyесли есть pending данные.subscribeSessionSync— cross-tab BroadcastChannel listener.
- Загружает конфиг (либо из preamble
- 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) планируется).