Главная/Документация/Обратная связь
Контракт обратной связи
Небольшой диалог, который человек может открыть на любой странице вашего сайта или продукта и рассказать, что не работает или чего не хватает. Вставьте один тег скрипта или вызывайте эндпоинт из своего диалога. Присланное попадает в один блок панели рядом с опросом при удалении и Rate Us, а владелец получает уведомление во «Входящие».
1. Вставьте виджет
Один тег с defer, без inline-кода, поэтому работает при строгом CSP:
<script src="https://extops.dev/widget.js"
data-extops-widget="wk_XXXXXXXXXXXXXXXXXXXXXXXXXXXX" defer></script>
Разрешите наш домен в script-src и connect-src. Файл
self-hosted, без зависимостей и весит меньше 7 KB gzip; стили идут через CSSOM внутри
shadow root, поэтому 'unsafe-inline' не нужен, а CSS вашей страницы не
ломает диалог.
Необязательные атрибуты: data-locale, data-api,
data-position="left" и data-button="false", если хотите
открывать диалог сами через window.ExtOpsFeedback.open(). Ещё:
data-button-style="icon" для круглой кнопки-пиктограммы 48px,
data-surface="app", чтобы на телефоне кнопка стояла над нижней навигацией
приложения, и data-color-preset, чтобы цвет кнопки задавала страница.
Светлая и тёмная тема
Каждый цветовой пресет рисуется в светлой и тёмной теме. По умолчанию
(data-theme="auto") виджет следует за вашей страницей: <html data-theme>,
класс dark или light на <html> или его
color-scheme. Страница без своей темы получает тему ОС посетителя. Когда ваш
переключатель темы меняет одно из этих свойств, виджет перекрашивается сразу, без перезагрузки.
Чтобы зафиксировать тему, поставьте data-theme="light" или
data-theme="dark". Если страница хранит тему там, где виджет её не видит, вызовите
window.ExtOpsFeedback.setTheme('dark') из своего переключателя, а
setTheme('auto') вернёт следование за страницей.
2. Или вызывайте эндпоинт сами
Для сайта, который вообще не допускает сторонних скриптов, или продукта, которому нужна форма внутри своей дизайн-системы. Эндпоинт тот же, куда пишет наш виджет, так что выбирать между своим интерфейсом и нашим пайплайном не приходится.
await fetch('https://extops.dev/api/v1/feedback/widget', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
widget_key: 'wk_XXXXXXXXXXXXXXXXXXXXXXXXXXXX',
topic: 'bug',
comment: userText,
page: location.href,
locale: navigator.language,
event_id: crypto.randomUUID(),
dwell_ms: Date.now() - openedAt,
}),
});
Темы, которые настроил владелец, читайте из
GET /api/v1/feedback/widget/config?key=… – тогда ваш диалог предлагает
ровно то же, что и панель. Один event_id используйте на все повторы:
сервер дедуплицирует по нему, поэтому сбой сети не превратит одно сообщение в два.
3. Ключ виджета публичный намеренно
Он напечатан в исходнике страницы, и это нормально: его единственное право – создать
одну строку обратной связи. Он не умеет писать события install/uninstall, не читает
ничего кроме конфигурации диалога и не может назвать проект (поле
project_id в теле не принимается). Именно поэтому у виджета свой ключ, а
не webhook_token источника: тот умеет писать события метрик и в HTML
попадать не должен. Ключ можно перевыпустить в любой момент – старый перестаёт
приниматься сразу.
4. Что мы храним и чего не храним
Храним тему, текст, нормализованную страницу, локаль, необязательный контакт, время, вид фидбека и виджет, которым он собран.
- Не храним query и fragment страницы. Они отбрасываются на приёме: в них регулярно оказываются токены доступа, адреса почты и поисковые запросы, которых мы не просили и которые не хотим держать 180 дней.
- Не храним страну и IP отправителя. Резолвленная страна живёт только в дневном агрегате и никогда на отдельном событии; IP – транзитный ключ лимитера.
- Не храним контакт, если виджет его не спрашивает. Устаревший сниппет, который его всё ещё присылает, не получает ошибку: поле просто отбрасывается, потому что обещание приватности задаёт текущая настройка владельца.
Срок хранения – 180 дней, то же правило, что и для остальных видов обратной связи.
5. Ответы и коды ошибок
Успех выглядит так: {"status":"ok","recorded":true}.
recorded: false – тоже успех: это повтор либо сработавшая
анти-бот эвристика. Показывайте одно и то же «спасибо»: скрипт не должен узнать, какое
правило его поймало, а человек, нажавший «Отправить» после таймаута, не должен увидеть
ошибку по сообщению, которое у нас уже есть.
| HTTP | code | Когда |
|---|---|---|
| 400 | validation_error | нет ключа, нет темы, тема не из списка виджета, пустой комментарий, битое тело |
| 403 | origin_not_allowed | источник страницы не в allowlist виджета |
| 404 | not_found | неизвестный ключ или выключенный виджет: ответ одинаковый, чтобы публичный ключ не служил зондом «существует ли проект» |
| 429 | rate_limited | 60/час на ключ, 10/час на IP; с заголовком Retry-After |
| 401 | source_token_invalid | только серверный путь: неверный токен источника |
| 500 | internal_error | наша ошибка |
6. Темы, виды и статусы
Тема – это id и label. Id – слаг, потому что он хранится на
каждой строке и становится значением фильтра в панели; label – слова самого владельца.
Виджет без настроенных тем предлагает дефолтные: bug, idea,
question, other.
Цвет диалога – один из шести пресетов, общих с hosted-страницами:
amber (по умолчанию), teal, indigo,
violet, rose, slate. Список общий намеренно: виджет и
hosted-страницы одного владельца должны выглядеть одинаково, а вторая палитра разъехалась бы
с первой.
У сохранённой строки есть вид: uninstall, rate_us или
widget. Вид – это ТЕГ, а не отдельный пайплайн: блок панели, уведомления и
срок хранения общие. Статус разметки владельца – new, read или
spam; пометка спамом убирает строку из рабочего списка и из уведомлений, не
удаляя написанное человеком, и её можно вернуть.
7. Защита от абуза живёт на сервере
Раз ключ публичный, проверка на стороне браузера была бы советом, а не правилом. Её несёт
сервер: allowlist источников виджета (пустой список = любой источник, ведь у клиента может
быть десяток поддоменов), два независимых лимита (на ключ и на IP), honeypot-поле и
минимальное время в диалоге, а также дедупликация по (widget_id,
event_id) в базе, а не предварительной проверкой: два одновременных повтора
прошли бы SELECT оба.
Оба лимитера fail-open: если наш Redis недоступен, обратная связь человека всё равно до вас дойдёт. Для ручки, которая пишет один абзац, это меньшее зло. Внешнюю капчу не добавляем – она ломает ваш CSP и приватность ваших посетителей.
8. Машиночитаемый контракт
Формальное описание обоих эндпоинтов приёма, чтения конфигурации, перечислений и кодов ошибок – файл OpenAPI 3.1. В нём та же версия контракта (1.0.0).