У посетителя вопрос в одиннадцать вечера, а форма обратной связи для него — письмо в бутылке. Чат поддержки решает это: кнопка чата на каждой странице сайта, а переписка падает в CRM, где ваша команда отвечает в реальном времени. Если widget.js у вас уже стоит ради живого редактирования, до чата ровно ноль строк кода.
Как устроен чат поддержки
- Посетитель нажимает кнопку чата в углу сайта и пишет сообщение — а если слов не хватает, прикладывает скриншот.
- Сообщение тут же появляется в CRM → Поддержка → Чаты. Всё идёт через WebSocket, жать F5 никому не нужно.
- Оператор отвечает из CRM, посетитель сразу видит ответ — вместе с «печатает…» и отметками о прочтении.
Чтобы чат не потерялся, пока все пьют кофе, у операторов есть счётчик непрочитанных в CRM, уведомления браузера и, по желанию, Telegram-бот с тихими часами. Всё это настраивается в CRM, а не в вашем коде.
Чат и режим согласия
Если на сайте включён режим согласия, чат не ждёт, пока посетитель разберётся с баннером: кнопка на месте, написать можно сразу. Разница только под капотом:
- до согласия виджет не создаёт и не хранит
crm_vid, поэтому чат получает одноразовый идентификатор, который живёт только в памяти вкладки и никуда не записывается; - разговор, начатый раньше, по-прежнему восстанавливается по своему
crm_chat_token; - без идентификаторов посетителя и сессии чат не привязывается к рекламному каналу и UTM-меткам — атрибуции у такого разговора нет.
Как добавить онлайн-чат на сайт
Отдельного кода для чата нет. Хватает того же единственного тега, на котором держится всё остальное:
<script src="https://widget.sitecog.com/widget.js" defer></script>Когда чат для сайта доступен, widget.js сам подгружает модуль чата (widget.support.js), и в углу появляется плавающая кнопка. Когда недоступен — посетители не скачивают ничего лишнего. Если тег случайно попал на страницу дважды, ничего страшного: вторая копия игнорируется.
Убедитесь, что чат входит в ваш тариф
Чат работает на тарифах, где он есть. Нет чата в тарифе — нет и кнопки, что бы ни было написано в коде.Проверьте, что домен добавлен как сайт в CRM
Чат узнаёт сайт по домену, на котором открыта страница (Originбраузера;www.не учитывается). Этот домен должен быть сайтом в CRM.Поставьте widget.js на страницу
Тег выше или варианты для Next.js и Vite из раздела Виджет и разметка → Шаг 1.Оформите чат в CRM
Иконка, цвета, положение и тексты — в CRM → Поддержка → Настройки, подробности ниже.
Внешний вид и тексты в CRM
Всё, что касается внешнего вида, настраивается в CRM → Поддержка → Настройки. Никаких переопределений CSS и выкладок: поменяли там — виджет подхватил.
| Настройка | Допустимые значения | Заметки |
|---|---|---|
| Иконка | bubble, headset, question, envelope, spark или своя картинка | Свою иконку выбирают из файлового хранилища сайта. |
| Положение | bottom-right по умолчанию, bottom-left, top-right, top-left | Выбирайте угол, где не живёт баннер про cookie. |
| Цветовая схема | indigo по умолчанию, emerald, midnight, graphite | Готовые палитры для кнопки и окна чата. |
| Заголовок | до 80 символов | Шапка окна чата. |
| Подзаголовок | до 160 символов | Строка под заголовком — хорошее место для «обычно отвечаем за 10 минут». |
| Подсказка в поле ввода | до 80 символов | Серый текст в пустом поле сообщения. |
| Приветствие | до 200 символов | Показывается в пустом чате, пока посетитель ничего не написал. |
Тексты на разных языках и запасной вариант
Каждый текст можно заполнить для каждого языка сайта. Виджет выбирает текст под язык посетителя так:
- точное совпадение языка;
- иначе — совпадение по первым двум буквам;
- иначе — первый заполненный вариант.
Если текст пустой, виджет берёт свой встроенный. Так что можно вообще ничего не заполнять и получить вполне приличный чат — настройки нужны, когда хочется, чтобы он говорил вашим голосом.
Автоответ
Включите автоответ и напишите текст для каждого языка (до 1000 символов). Он отвечает на первое сообщение нового разговора — посетитель видит, что его услышали, даже если вся команда на планёрке. Новым разговор считается после N минут тишины: N вы задаёте сами, от 1 до 180, по умолчанию 5.
Когда никого нет в сети
Живой чат, где никто не отвечает, — ловушка: посетитель задаёт вопрос, ждёт, закрывает вкладку, и вы так и не узнаёте, кто это был. Офлайн-режим в такие минуты превращает чат в форму «оставьте почту» — и вопрос не исчезает вместе с посетителем.
Включается он в CRM → Поддержка → Настройки → «Когда никого нет в сети». Чат уходит в офлайн-режим, когда:
- ни у одного оператора не открыта CRM этого сайта. Считается любая страница CRM, не только чаты: оператор «в сети», пока не закрыта последняя вкладка CRM;
- сейчас нерабочее время, если вы его задали: рабочие дни, время «с» и «до» и часовой пояс. Расписание необязательно — без него действует только первое правило. Вне рабочего времени чат в офлайне, даже если у кого-то случайно открыта CRM.
Что меняется в офлайн-режиме:
- Виджет пишет «Сейчас мы не в сети — оставьте почту, и мы ответим» и просит почту до сообщения. Без почты сообщение не уйдёт: иначе ответу просто некуда было бы прийти.
- В CRM чат помечается «Офлайн-заявка», и почта посетителя видна тут же.
- Уведомление в Telegram (если бот подключён) уходит сразу — такой чат ждёт ответа по определению.
Пока операторы в сети, чат работает как обычно, а почта необязательна: посетитель всё равно может нажать «Получать ответы на почту» — пригодится тому, кто вот-вот закроет вкладку.
Ответы на почту
Если посетитель оставил почту и не прочитал ответ оператора в виджете примерно за 2 минуты, ответ уходит ему письмом. Несколько ответов подряд приходят одним письмом, а не очередью уведомлений. В письме только ответы оператора — не вся переписка — и ссылка на ту страницу вашего сайта, с которой писал посетитель: продолжить разговор можно прямо там.
Язык виджета
Виджет говорит на языке вашей страницы: читает <html lang> и берёт первые две буквы, так что en-GB и en для него одно и то же.
- Встроенные тексты интерфейса есть для
ru,en,uk,es,deиzh. Для остальных языков — английский. - Ваши тексты из CRM выбираются по правилам выше.
- Одностраничное приложение с переключателем языка? Просто поменяйте
document.documentElement.lang— виджет заметит и перерисует тексты на лету, без перезагрузки.
function setLanguage(lang) {
// ...переключаем свои переводы...
document.documentElement.lang = lang; // чат подстроится сам
}Как передать в чат вошедшего пользователя
По умолчанию оператор видит «Посетитель». Если на сайте есть личные кабинеты, можно лучше: положите имя пользователя и ваш внутренний id в localStorage, и оператор будет знать, с кем говорит.
support_client_namestring, ≤ 80 символовнеобязательноsupport_client_idstring, ≤ 64 символовнеобязательноuser_8421). Виден в карточке посетителя, чтобы оператор нашёл аккаунт в вашей админке.Виджет эти ключи только читает. Он отправляет их при подключении и ещё раз перед каждым сообщением, если они изменились, — поэтому неважно, запишете вы их до загрузки страницы или сразу после входа. При выходе удаляйте оба ключа: иначе следующий человек за тем же компьютером унаследует чужое имя.
// support-identity.js
function setSupportIdentity(user) {
try {
if (user) {
localStorage.setItem('support_client_name', user.name.slice(0, 80));
localStorage.setItem('support_client_id', String(user.id).slice(0, 64));
} else {
localStorage.removeItem('support_client_name');
localStorage.removeItem('support_client_id');
}
} catch {
// Хранилище заблокировано (строгие настройки приватности) — чат работает, просто анонимно
}
}
// После успешного входа
setSupportIdentity({ id: 'user_8421', name: 'Анна Шмидт' });
// При выходе
setSupportIdentity(null);import { useEffect } from 'react';
type User = { id: string; name: string };
// user: undefined — ещё грузится, null — не вошёл, объект — вошёл
export function useSupportIdentity(user: User | null | undefined) {
useEffect(() => {
if (user === undefined) return; // пока авторизация грузится, ключи не трогаем
try {
if (user) {
localStorage.setItem('support_client_name', user.name.slice(0, 80));
localStorage.setItem('support_client_id', user.id.slice(0, 64));
} else {
localStorage.removeItem('support_client_name');
localStorage.removeItem('support_client_id');
}
} catch {
// Хранилище заблокировано — чат останется анонимным, ничего не сломается
}
}, [user]);
}
// В оболочке приложения:
// const { user } = useAuth();
// useSupportIdentity(user);// app/support-identity.tsx
'use client';
import { useEffect } from 'react';
export function SupportIdentity({ id, name }: { id?: string; name?: string }) {
useEffect(() => {
try {
if (id && name) {
localStorage.setItem('support_client_name', name.slice(0, 80));
localStorage.setItem('support_client_id', id.slice(0, 64));
} else {
localStorage.removeItem('support_client_name');
localStorage.removeItem('support_client_id');
}
} catch {}
}, [id, name]);
return null; // ничего не рисует, только синхронизирует ключи
}
// app/layout.tsx — серверный компонент точно знает, кто вошёл
// const user = await getCurrentUser(); // ваша авторизация
// ...
// <body>
// {children}
// <SupportIdentity id={user?.id} name={user?.name} />
// <Script src="https://widget.sitecog.com/widget.js" strategy="afterInteractive" />
// </body>Вложения и ограничения
Посетители могут прикладывать файлы — скриншот ошибки стоит тысячи слов.
| Что | Ограничение |
|---|---|
| Картинки | jpeg, png, gif, webp, avif |
| Документы | pdf, doc, docx, xls, xlsx, txt, csv |
| Размер файла | до 10 МБ |
| Сообщения | 20 в минуту на один чат |
| Подключения | до 12 одновременно с одного IP |
| Новые чаты | 10 в час с одного IP |
Людям этих лимитов хватает с запасом, ботам — мешают, в этом и смысл. Упереться в лимит подключений можно разве что из офиса, где вся команда сидит за одним IP и держит сайт открытым в десятке вкладок.
Открыть чат своей кнопкой
Круглая кнопка в углу подходит большинству сайтов, но иногда чату место в вашем собственном дизайне: ссылка «Написать нам» в шапке, кнопка на странице тарифов, значок помощи в приложении. Есть два способа — и один из них без единой строчки JavaScript.
Без JavaScript: data-crm-chat
Поставьте data-crm-chat на любой элемент — и клик по нему откроет чат:
<a href="/contacts" data-crm-chat="open">Написать нам</a>
<button type="button" data-crm-chat="toggle">💬 Поддержка</button>open(он же — для пустого значения),closeилиtoggle.- Элементы, добавленные позже — модальные окна, страницы одностраничного приложения, — тоже работают: виджет слушает весь документ.
- Встроенный запасной вариант. Если чат на сайте недоступен (или
widget.jsзаблокирован), клик не перехватывается, и ссылка просто ведёт по своемуhref. Направьте её на страницу контактов — и никто не упрётся в мёртвую кнопку.
Из JavaScript: window.crmChat
crmChat.open()functionнеобязательноcrmChat.close()functionнеобязательноcrmChat.toggle()functionнеобязательноcrmChat.isOpen()() => booleanнеобязательноcrmChat.availableboolean | nullнеобязательноtrue — чат на сайте работает; false — нет (тариф, настройки); null — ещё неизвестно, виджет как раз спрашивает.crmChat.unreadnumberнеобязательноwidget.js создаёт window.crmChat сразу, ещё до загрузки самого чата. Вызовы, сделанные в эту первую секунду, запоминаются и выполняются, как только чат готов, — клик по вашей кнопке не потеряется. В режиме Live в CRM чат не загружается вовсе, а crmChat тихо ничего не делает — так что и там ваш код не упадёт.
document.querySelector('#help').addEventListener('click', () => {
// ?. — на случай, если блокировщик рекламы не дал загрузиться widget.js
window.crmChat?.open();
});export function HelpButton() {
return (
<button type="button" onClick={() => window.crmChat?.open()}>
Нужна помощь?
</button>
);
}Скрыть встроенную кнопку
Есть своя кнопка? Спрячьте круглую кнопку виджета атрибутом data-chat-button="hidden" на том же теге скрипта. Панель открывается и закрывается как обычно — у неё свой крестик в шапке.
<script src="https://widget.sitecog.com/widget.js" data-chat-button="hidden" defer></script>import Script from 'next/script';
<Script src="https://widget.sitecog.com/widget.js" data-chat-button="hidden" strategy="afterInteractive" />Показать непрочитанные на своей кнопке
Без круглой кнопки посетителю нужен другой способ заметить, что оператор ответил. Виджет при каждом изменении присылает событие crm:chat на window с { available, open, unread } в detail. Сделайте по нему значок — и спрячьте свою кнопку, если чат недоступен.
import { useEffect, useState } from 'react';
type ChatState = { available: boolean | null; open: boolean; unread: number };
export function useCrmChat(): ChatState {
const [state, setState] = useState<ChatState>({ available: null, open: false, unread: 0 });
useEffect(() => {
const api = window.crmChat;
if (api) setState({ available: api.available, open: api.isOpen(), unread: api.unread });
const onChange = (e: Event) => setState((e as CustomEvent<ChatState>).detail);
window.addEventListener('crm:chat', onChange);
return () => window.removeEventListener('crm:chat', onChange);
}, []);
return state;
}
// Как пользоваться
export function ChatButton() {
const { available, unread } = useCrmChat();
if (available === false) return null;
return (
<button type="button" onClick={() => window.crmChat?.open()}>
Поддержка {unread > 0 && <span className="badge">{unread}</span>}
</button>
);
}Content Security Policy для чата
Если CSP на сайте нет — смело пропускайте раздел. Если есть, чату нужны такие источники:
script-src https://widget.sitecog.com
connect-src https://back.sitecog.com wss://back.sitecog.com
style-src 'unsafe-inline'
img-src <хост, с которого отдаются файлы из CRM>script-src— чтобы загрузилисьwidget.jsи модуль чата.connect-src— для запроса настроек и WebSocket. Обратите внимание наwss://: одногоhttps://для WebSocket недостаточно.style-src— окно чата живёт в shadow DOM со встроенным<style>, поэтому строгой политике может понадобиться'unsafe-inline'(например,style-src 'self' 'unsafe-inline').img-src— вложения и своя иконка отдаются из файлового хранилища сайта. Откройте любую картинку из CRM и разрешите её хост.
Для продвинутых: свой интерфейс чата
Свой клиент — это три шага и ещё один, когда никого нет в сети:
Прочитать настройки
Включён ли чат для сайта, что написано в текстах и есть ли кто-то в сети?Начать чат
Получить по HTTP токен чата и сохранить его.Оставить почту — в офлайн-режиме
Ответить сейчас некому? Сохраните почту посетителя до его первого сообщения.Разговаривать через WebSocket
Подключиться с токеном, отправлять и получать кадры.
Все адреса поддержки публичные, ключ API не нужен. Сайт узнаётся по заголовку Origin браузера (запасной вариант — Referer), поэтому вызывайте их со страниц на настоящем домене. Домен, который не добавлен сайтом в CRM, получит 400 с {"message":"unknown_domain"}, запрос совсем без источника — {"message":"unknown_origin"}.
1. Прочитать настройки чата
GET https://back.sitecog.com/support/config?lang=enlangquerystringнеобязательноen. Работают те же правила выбора.{
"enabled": true,
"look": {
"icon": "bubble",
"iconUrl": null,
"position": "bottom-right",
"skin": "indigo",
"texts": {
"title": null,
"subtitle": null,
"placeholder": null,
"greeting": null
}
},
"offline": {
"form": true,
"away": true,
"reason": "no_agents"
}
}{ "enabled": false }enabledbooleanfalse — ничего не показывайте и дальше не идите.lookobjectlook →
iconstringbubble, headset, question, envelope или spark.iconUrlstring | nullpositionstringbottom-right, bottom-left, top-right или top-left.skinstringindigo, emerald, midnight или graphite. В своём интерфейсе сопоставьте со своими цветами — или проигнорируйте.textsobjectnull — «не заполнено, берите свой текст по умолчанию».texts →
titlestring | nullsubtitlestring | nullplaceholderstring | nullgreetingstring | nullofflineobjectenabled: true. Присутствие меняется поминутно, так что переспрашивайте при открытии окна чата, а не держите ответ в кэше подолгу.offline →
formbooleanfalse — почту не просите никогда, просто чат.awaybooleantrue — ответить сейчас некому: покажите форму и попросите почту до первого сообщения (шаг 3). При form: false всегда false.reasonstring | nullafter_hours — нерабочее время, no_agents — ни у одного оператора не открыта CRM. null, когда away равно false.2. Начать или продолжить чат
POST https://back.sitecog.com/support/chat/start
Content-Type: application/json
{
"visitorId": "5f1c2a9e-3b7d-4c1e-9a0f-8d2b6e4c7a10",
"lang": "en",
"token": "<токен, полученный в прошлый раз, если он есть>"
}visitorIdstringобязательноA–Z a–z 0–9 _ . : -. Если на странице работает widget.js, возьмите его crm_vid из localStorage; если нет — сгенерируйте свой один раз (UUID подойдёт) и храните. Включён режим согласия, а согласия ещё нет? Тогда crm_vid не будет — сгенерируйте временный id и держите его в памяти, ничего не сохраняя.langstringнеобязательноen.tokenstringнеобязательно{
"enabled": true,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
"chatId": 123,
"lastSeq": 5,
"readSeq": 5,
"status": "open",
"messages": [
{ "seq": 1, "author": "visitor", "text": "Здравствуйте! А в Австрию доставляете?", "at": "2026-10-01T09:12:03.000Z" },
{ "seq": 2, "author": "agent", "text": "Здравствуйте, Анна! Да, 2–4 дня.", "at": "2026-10-01T09:13:40.000Z" }
],
"email": null,
"offline": { "form": true, "away": false, "reason": null }
}enabledbooleanfalse — чат для сайта выключен, больше в ответе ничего нет.tokenstring (JWT)localStorage) и всегда перезаписывайте последним полученным. Им открывается WebSocket и продолжается этот же чат в следующий раз.chatIdnumberlastSeqnumberseq — ваш курсор во всём, что ниже.readSeqnumberstatusstringopen или closed — как выставили операторы в CRM.messagesMessage[]emailstring | nullofflineobjectoffline в настройках: { form, away, reason }, на момент запроса.errorstring | nullinvalid_visitor (неверный visitorId) или rate_limit_exceeded (слишком много новых чатов с этого IP).3. Оставить почту
Когда offline.away равно true, сохраните почту посетителя до отправки его первого сообщения — так делает встроенный виджет, и только так ответ дойдёт до человека, который уже закрыл вкладку. Пока операторы в сети, тот же вызов работает для необязательной кнопки «Получать ответы на почту».
POST https://back.sitecog.com/support/chat/contact
Content-Type: application/json
{
"token": "<токен чата из chat/start>",
"email": "anna@example.com",
"page": "https://shop.example/delivery?utm_source=newsletter"
}tokenstringобязательноchat/start. Почту можно привязать только к своему чату.emailstringобязательноpagestringнеобязательно#фрагмент отбрасываются — адрес из примера сохранится как https://shop.example/delivery.{ "ok": true, "email": "anna@example.com" }{ "ok": false, "error": "invalid_email" }error | Что случилось |
|---|---|
invalid_email | Это не похоже на адрес почты. Попросите посетителя проверить. |
invalid_token | Токена нет, он истёк или выдан другому сайту. Вызовите chat/start заново. |
rate_limit_exceeded | Лимит общий с сообщениями чата — 20 в минуту. Подождите немного и повторите. |
disabled | Чат для сайта выключили. |
chat_not_found | Чат удалён. Забудьте токен и начните новый. |
// chat — ответ chat/start (или более свежий кадр ready)
async function sendFirstMessage(text, email) {
if (chat.offline.away && !chat.email) {
const res = await fetch('https://back.sitecog.com/support/chat/contact', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token: chatToken, email, page: location.href }),
}).then((r) => r.json());
if (!res.ok) return showEmailError(res.error); // сообщение не теряем, пусть поправит адрес
chat.email = res.email;
}
send(text); // кадр send по WebSocket из шага 4
}4. Подключиться к WebSocket
wss://back.sitecog.com/support/wsТокен передаётся в списке подпротоколов, а не в адресе: адреса оседают в логах, подпротоколы — нет. Подпротоколов два: версия протокола и token. + ваш токен чата. Origin браузер отправит сам, и это должен быть ваш сайт.
const socket = new WebSocket('wss://back.sitecog.com/support/ws', [
'crm.support.v1',
'token.' + chatToken,
]);
socket.onopen = () => {
// Здороваемся и сообщаем серверу последнее сообщение, которое у нас уже есть
socket.send(JSON.stringify({ t: 'hello', since: lastSeq }));
};На hello сервер отвечает кадром ready со всем, что вы пропустили после since. Каждый кадр — JSON-объект с типом в поле t, не больше 8 КБ. Сервер шлёт ping каждые 25 секунд; браузер отвечает на него сам, делать ничего не нужно.
Кадры, которые отправляете вы
hello{ t, since }since — последний seq, который у вас есть (0, если ничего). Ответ — ready.send{ t, id, text, file? }id — ваш собственный id сообщения, [A-Za-z0-9_-], 1–64 символа; повтор с тем же id никогда не создаёт дубль, так что повторяйте смело. text — до 4000 символов. file — квитанция загрузки (см. вложения).read{ t, seq }seq». Отсюда берутся отметки о прочтении, которые видит оператор.typing{ t }history{ t, before }seq = before (0 — начиная с самых новых). Ответ — history.profile{ t, name?, contact?, clientId? }name ≤ 80, contact (телефон или почта) ≤ 120, clientId ≤ 64. Хотя бы одно поле должно быть непустым.{ "t": "hello", "since": 5 }
{ "t": "send", "id": "m-1727775123-1", "text": "А в Австрию доставляете?" }
{ "t": "read", "seq": 7 }
{ "t": "typing" }
{ "t": "history", "before": 51 }
{ "t": "profile", "name": "Анна Шмидт", "contact": "anna@example.com", "clientId": "user_8421" }Кадры, которые приходят вам
readyframehello: состояние чата и всё пропущенное.ready →
chatIdnumbervisitorOnlinebooleanlastSeqnumberstatusstringopen или closed.read{ agent, visitor }unreadnumbermessagesMessage[]since.gapbooleantrue, если пропущено больше, чем помещается в одну догрузку, — остальное подтяните через history.emailstring | nullchat/start.offline{ form, away, reason }messageframemessage →
chatIdnumbermMessagem →
seqnumberauthorstringvisitor, agent или system.textstringtextContent), никогда как HTML.atstring (ISO 8601)file{ url, name, mime, size, kind } | nullkind — image или doc.ack{ id, seq, at }send с этим id сохранён как сообщение seq. Самое время сменить «отправляется…» на «отправлено».read{ chatId, by, seq }by) дочитал до seq — например, оператор прочитал ваши сообщения.typing{ chatId, by }history{ chatId, before, messages, done }history. done: true — старше ничего нет.presenceframechat_goneframechat/start — начнётся новый чат.error{ code, id? }id заполнен, если речь о вашем сообщении. Коды — ниже.{
"t": "message",
"chatId": 123,
"m": { "seq": 6, "author": "agent", "text": "Посылка сегодня уехала со склада 🚚", "at": "2026-10-01T09:20:11.000Z" }
}Минимальный клиент целиком
Старт → подключение → отправка → приём, плюс переподключения. Около 90 строк без зависимостей — допишите свои render и markSent, и чат готов.
const SUPPORT = 'https://back.sitecog.com/support';
const WS_URL = 'wss://back.sitecog.com/support/ws';
const store = {
get: (k) => { try { return localStorage.getItem(k); } catch { return null; } },
set: (k, v) => { try { localStorage.setItem(k, v); } catch {} },
del: (k) => { try { localStorage.removeItem(k); } catch {} },
};
// Берём id посетителя от виджета, если он есть, иначе храним свой
function visitorId() {
let id = store.get('crm_vid') || store.get('my_chat_vid');
if (!id) {
id = crypto.randomUUID();
store.set('my_chat_vid', id);
}
return id;
}
let socket = null;
let lastSeq = 0;
let failures = 0;
const seen = new Set();
function show(m) {
if (seen.has(m.seq)) return; // одно и то же сообщение может прийти из start, ready и message
seen.add(m.seq);
lastSeq = Math.max(lastSeq, m.seq);
render(m); // ваш интерфейс: m.author, m.text (как текст!), m.at, m.file
}
async function boot() {
const res = await fetch(SUPPORT + '/chat/start', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
visitorId: visitorId(),
lang: (document.documentElement.lang || 'en').slice(0, 2),
token: store.get('my_chat_token') || undefined,
}),
});
const data = await res.json();
if (!data.enabled) return; // чат для сайта выключен
if (data.error) throw new Error(data.error);
store.set('my_chat_token', data.token); // действует 180 дней
data.messages.forEach(show);
lastSeq = Math.max(lastSeq, data.lastSeq);
connect(data.token);
}
function connect(token) {
socket = new WebSocket(WS_URL, ['crm.support.v1', 'token.' + token]);
socket.onopen = () => {
failures = 0;
socket.send(JSON.stringify({ t: 'hello', since: lastSeq }));
};
socket.onmessage = (event) => {
const frame = JSON.parse(event.data);
if (frame.t === 'ready') frame.messages.forEach(show);
else if (frame.t === 'message') show(frame.m);
else if (frame.t === 'ack') markSent(frame.id, frame.seq);
else if (frame.t === 'chat_gone') {
store.del('my_chat_token');
socket.onclose = null;
socket.close();
boot(); // совершенно новый чат
} else if (frame.t === 'error') console.warn('[chat]', frame.code, frame.id);
};
socket.onclose = (event) => {
if (event.code === 4402 || event.code === 4403) return; // чат выключен / доступ запрещён: стоп
failures += 1;
const delay = Math.min(30000, 1000 * 2 ** failures) + Math.random() * 1000;
// Несколько неудач подряд — возможно, токен больше не действует: начинаем заново
setTimeout(() => (failures > 3 ? boot() : connect(token)), delay);
};
}
function send(text) {
const id = crypto.randomUUID(); // повторять безопасно: тот же id — то же сообщение
socket.send(JSON.stringify({ t: 'send', id, text }));
return id; // показываем «отправляется…» до ack с этим id
}
boot();Переподключение и пропущенные сообщения
- Храните наибольший показанный
seq. После переподключения отправьте{ "t": "hello", "since": lastSeq }— кадрreadyпринесёт ровно то, что вы пропустили. - Если
ready.gapравенtrue, пропущено много — заполните дыру запросамиhistory. - Одно сообщение может прийти дважды (например, из
chat/startи изready). Убирайте дубли поseq. - Делайте паузы между попытками (1 с, 2 с, 4 с… с небольшой случайной добавкой) — перезапуск сервера не должен превращаться в лавину переподключений.
- Не уверены, дошло ли сообщение? Отправьте его ещё раз с тем же
id— сохранится один раз.
Вложения в своём интерфейсе
Файл проходит три коротких шага: получить разрешение, загрузить файл, отправить квитанцию.
// 1. Разрешение на загрузку — действует 5 минут
const { permit } = await fetch('https://back.sitecog.com/support/chat/upload-permit', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token: chatToken }),
}).then((r) => r.json());
// 2. Загружаем файл (картинки или документы, до 10 МБ)
const form = new FormData();
form.append('file', fileInput.files[0]);
const upload = await fetch('https://back.sitecog.com/storage/support/upload', {
method: 'POST',
headers: { 'x-support-permit': permit },
body: form,
}).then((r) => r.json());
// upload = { receipt, url, name, mime, size, kind }
// 3. Отправляем сообщение с квитанцией (текст может быть пустым)
socket.send(JSON.stringify({ t: 'send', id: crypto.randomUUID(), text: '', file: upload.receipt }));Ошибки и коды закрытия
Ошибки на живом соединении приходят как { "t": "error", "code": "…", "id": "…" }:
| Код | Что случилось |
|---|---|
rate_limit_exceeded | Больше 20 сообщений в минуту в этом чате. Подождите и повторите с тем же id. |
invalid_text | Текста нет или он длиннее 4000 символов. |
invalid_file | Квитанцию загрузки не удалось проверить — истекла или от другого чата. Загрузите файл заново. |
invalid_message_id | id — не 1–64 символа из A–Z a–z 0–9 _ -. |
frame_too_large | Кадр больше 8 КБ. |
invalid_json | Кадр — не корректный JSON. |
unknown_frame | Неизвестный t. Опечатка или кадр из более новой версии протокола. |
empty_profile | Кадр profile, в котором все поля пустые. |
chat_not_found | Чата больше нет. Начните новый. |
disabled | Чат для сайта выключен. |
Само соединение тоже может быть отклонено или закрыто:
| Когда | Код | Значение |
|---|---|---|
| Рукопожатие | 401 unauthorized | Токена нет, он неверный или истёк. Снова вызовите chat/start. |
| Рукопожатие | 429 too_many_connections | Больше 12 подключений с этого IP. Закройте лишние. |
| Закрытие | 4402 | Чат для сайта выключили. Не переподключайтесь. |
| Закрытие | 4403 | Доступ запрещён. Не переподключайтесь. |
Если что-то не работает
Кнопка чата не появляется
- Чата нет в тарифе или он выключен. Адрес настроек отвечает
{ "enabled": false }. - Неизвестный домен. Страница открыта на домене, который не добавлен сайтом в CRM: тестовый хост,
localhost, новый домен, который ещё не завели. (www.роли не играет — он отбрасывается.) - Мешает CSP. Поищите в консоли браузера «Refused to load» или «Refused to connect» и сверьтесь с разделом про CSP.
- Вы смотрите на сайт внутри CRM. В режиме Live виджет загружает только редактор — ни чата, ни аналитики. Откройте сайт в обычной вкладке. (А внутри чужого iframe, куда кто-то встроил ваш сайт, редактор не загружается вовсе — он работает только во фрейме Live самой CRM.)
- На этой странице нет
widget.js— легко упустить, когда у сайта несколько шаблонов. - Чат только что включили, а браузер ещё помнит старые настройки — до ~10 минут (см. ниже).
Можно спросить у сервера напрямую, что он думает о вашем домене:
curl "https://back.sitecog.com/support/config?lang=en" -H "Origin: https://your-site.com"Изменения из CRM не видны
- Настройки кэшируются в браузере примерно на 10 минут. Либо подождите, либо удалите ключ
crm_chat_cfgв DevTools → Application → Local Storage и обновите страницу. - Вы правили тексты для одного языка, а у страницы другой
<html lang>. Сверьтесь с правилами выбора. - Поле текста пустое, поэтому виджет показывает встроенный текст — так и задумано.