Korat

Сделать чат-бота для ответов клиентам

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

Что такое бот и зачем он нужен

Бот — это ещё один вид «учётной записи» в Korat, за которым никто не сидит: запросы отправляет ваша собственная программа, а сообщение появляется в том диалоге, куда бота пригласили. Реальные применения:

  • Сообщать о новом заказе в чат — ваша торговая система получила заказ ⇒ бот тут же пишет об этом в диалог с клиентом или в комнату команды
  • Предупреждать, что товар заканчивается — остаток опустился ниже заданного ⇒ бот пишет в комнату команды
  • Сообщать о состоянии серверов и систем — ваш скрипт проверки отработал ⇒ бот отчитывается в общую комнату, за которой следят технари

Чего бот не умеет (это факты из базы данных, а не временные ограничения):

  • Бот не читает сообщения в диалоге — он работает только на выход, если только вы сами не настроили вебхук для входящих (см. технический раздел в конце). Но даже с настроенным вебхуком сегодня ничего реально не отправляется: планировщик, который должен разбирать очередь, на продакшене ещё не настроен
  • Бот не может пригласить себя в диалог — приглашает всегда тот, кто уже находится в этом диалоге или управляет им
  • У одного бота не больше 2 действующих токенов одновременно, а на один аккаунт приходится максимум 5 ботов

С чего начать — четыре шага

  1. 1Создайте бота: задайте имя и имя пользователя (оно обязано заканчиваться на bot, например orderbot). Если бот должен принадлежать заведению, а не лично вам, выберите заведение прямо при создании
  2. 2Скопируйте токен: система покажет его ровно один раз после создания — сразу же сохраните его в надёжном месте. Закрыв экран, вернуть настоящее значение будет невозможно, останется только выпустить новый токен
  3. 3Пригласите бота в диалог: выберите личный диалог, комнату сообщества или комнату команды, где бот должен говорить. Приглашать может только тот, кто сам находится в этом диалоге или управляет им
  4. 4Отправьте первое сообщение: возьмите токен из шага 2 и вызовите Bot API — рабочий пример в следующем разделе

Шаги 1–3 делаются в приложении Korat

Меню находится по пути Настройки › Аккаунт › «Мои боты»: там можно создать бота, выпустить, обновить или отозвать токен и пригласить бота в диалог, комнату сообщества или комнату команды — без единой строки кода. Этот раздел появится в следующей версии приложения Korat: в той, которую скачивают сегодня, его ещё нет. Если в настройках «Моих ботов» не видно, дождитесь обновления. А пока шаги 1–3 можно выполнить напрямую через RPC Supabase (см. технический раздел в конце).

🔒 Токен — это пароль бота

Любой, у кого есть этот токен, немедленно может писать от имени бота. Не передавайте токен через переписку, картинки или незашифрованную почту — храните его только на своём сервере или в хранилище секретов. Ни один сотрудник Korat никогда не попросит у вас токен — ни в чате, ни по почте, ни по телефону. Если кто-то представляется командой Korat и просит токен, считайте это мошенничеством и немедленно сообщите через страницу Контакты. Если токен всё же утёк, отзовите его в приложении сразу (действие необратимо, придётся выпустить новый).

Пример — отправить первое сообщение

Точка входа одна: POST https://koratland.com/api/bot/send. Токен передаётся в заголовке, а в теле указывается, что и куда написать:

Через curl (для тех, кому привычен терминал)

curl -X POST "https://koratland.com/api/bot/send" \
  -H "X-Bot-Token: kbot_12_xxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "chat", "chat_id": 9931, "text": "Поступил новый заказ" }'

Замените kbot_12_xxxxxxxxxxxxxxxxxxxx на настоящий токен бота, а 9931 — на номер диалога, куда бот приглашён (номер виден в ссылке на диалог в приложении или в списке диалогов на экране «Мои боты»).

Без программирования (Google Apps Script)

Если своего сервера нет, подойдёт бесплатный Google Apps Script с обычным аккаунтом Google: зайдите на script.google.com → новый проект → вставьте этот код → нажмите «Выполнить». При первом запуске он попросит разрешение на доступ в интернет (его можно дать: это ваш собственный скрипт):

function sendKoratMessage() {
  var url = "https://koratland.com/api/bot/send";
  var payload = {
    kind: "chat",
    chat_id: 9931,              // подставьте номер своего диалога
    text: "Поступил новый заказ"   // подставьте нужный текст
  };
  var options = {
    method: "post",
    contentType: "application/json",
    headers: { "X-Bot-Token": "kbot_12_xxxxxxxxxxxxxxxxxxxx" },  // подставьте токен своего бота
    payload: JSON.stringify(payload)
  };
  var res = UrlFetchApp.fetch(url, options);
  Logger.log(res.getContentText());  // результат смотрите в журнале выполнения
}

Одного нажатия «Выполнить» достаточно для проверки. Чтобы отправка шла сама — например, при каждой новой строке в Google Sheets с заказами, — воспользуйтесь триггерами Apps Script: вызывать функцию при изменении таблицы (onEdit) или раз в столько-то минут.

Точно так же это работает в любых инструментах автоматизации, где есть действие «HTTP request» или «Webhook» — n8n, Make, Zapier: укажите метод POST на тот же адрес, заголовок X-Bot-Token и такое же JSON-тело, как в примере выше.

Частые проблемы

Что видите (reason)Что это значитКак исправить
not_invitedБота не приглашали в этот диалог (или пригласили, а потом удалили)Пригласите бота заново с экрана «Мои боты» или вызовом bot_invite
invite_staleТот, кто пригласил бота, потерял права в этом диалоге (вышел из сообщества, понижен в роли, лишён управления комнатой команды, комната закрыта) ⇒ бот замолчал сам, хотя его никто не удалялПусть бота пригласит заново тот, у кого права в этом диалоге действительно есть: права всегда считаются по последнему пригласившему
conversation_not_openЭто диалог заведения с клиентом, и клиент ещё ни разу не писал — заведение, включая его ботов, не может написать первымДождитесь сообщения клиента, и только потом пусть бот отвечает
rate_limitedБот шлёт сообщения чаще допустимого (в минуту или в сутки)Подождите столько, сколько указано в retry_after_sec, и повторите. Если нужен более высокий лимит, напишите команде через Контакты
blockedПользователь в этом диалоге заблокировал ботаСо стороны заведения это не исправить — при необходимости используйте другой диалог или другого бота
Бот писал, а потом внезапно замолчалЧаще всего это тот самый invite_stale выше, а не поломка ботаПроверьте, остался ли пригласивший в диалоге и сохранил ли он права

Полный перечень кодов ошибок — в справочной таблице технического раздела ниже.

Технический справочник (для разработчиков)

Дальше — для тех, кто будет обращаться к Bot API из своей программы: все параметры, полная таблица кодов ошибок и вебхук для входящих сообщений.

Шаги 1–3 напрямую через RPC (без приложения)

Если в вашей версии приложения экрана «Мои боты» ещё нет или всё нужно делать автоматически, вызывайте RPC Supabase напрямую через PostgREST, используя access token вошедшего пользователя (не токен бота). Три шага:

1. Создать бота — bot_create

curl -X POST "https://<SUPABASE_PROJECT>.supabase.co/rest/v1/rpc/bot_create" \
  -H "apikey: <SUPABASE_ANON_KEY>" \
  -H "Authorization: Bearer <USER_ACCESS_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{
    "p_name": "Бот уведомлений",
    "p_username": "notifybot",
    "p_business_id": null,
    "p_about": "Сообщает о статусе заказов"
  }'

p_username состоит из a–z 0–9 _, имеет длину 4–31 символ и всегда заканчивается на bot (регулярное выражение ^[a-z][a-z0-9_]{2,29}bot$). Передавайте p_business_id, если бот принадлежит заведению: вызывающий должен быть его сотрудником и обладать правом bot.manage. Успешный ответ содержит tokenнастоящее значение, которое выдаётся один-единственный раз за жизнь токена. База хранит только хеш sha256, поэтому потерянный токен не восстановить: остаётся выпустить новый (bot_rotate_token).

{ "ok": true, "bot_id": 12, "user_id": 4821, "username": "notifybot", "token": "kbot_12_…" }

2. Пригласить бота в диалог — bot_invite

Вызывающий должен действительно состоять в этом диалоге (или быть сотрудником заведения, если это диалог заведения) и иметь право управлять этим ботом (can_manage_bot). p_target — это chats.id.

curl -X POST "https://<SUPABASE_PROJECT>.supabase.co/rest/v1/rpc/bot_invite" \
  -H "apikey: <SUPABASE_ANON_KEY>" \
  -H "Authorization: Bearer <USER_ACCESS_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{ "p_bot": 12, "p_kind": "chat", "p_target": 9931 }'

2а. Пригласить бота в комнату сообщества или команды — требования строже

Передайте p_kind: "community_room" или "business_room", а в p_targetcommunity_rooms.id либо business_rooms.id: это уже работает. Но приглашающий обязан пройти проверку прав, соответствующую смыслу комнаты, а не просто состоять в ней:

  • Комната сообщества — приглашающий должен реально иметь право писать в неё по её же правилам (read_role/post_role). В обычной комнате для общения приглашать может участник, а в комнате объявлений или служебной, открытой только администраторам, — лишь владелец или администратор сообщества
  • Комната команды заведения — нужно пройти все проверки самой комнаты (право чтения, право публикации, правила получателей) плюс обязательно иметь право teamroom.manage: привести бота во внутреннюю комнату компании — это «управление комнатой», а не «письмо в комнату»

В обоих случаях приглашающий должен быть ещё и управляющим этого бота (can_manage_bot): общая комната — не место, куда кто угодно втаскивает чужого бота.

🔴 invite_stale — самое важное, что нужно понять до запуска

Права приглашающего проверяются не только в момент приглашения: bot_send заново спрашивает правила комнаты при каждой отправке, от имени того же пригласившего. Как только приглашающий вышел из сообщества, понижен в роли, лишён teamroom.manage или комната закрыта, следующее сообщение бота немедленно получит reason: "invite_stale". Бот замолкает сам, и никто его при этом не удалял. Здесь нет фоновой задачи и нет триггера, который нужно не забыть вызвать: это чистый пересчёт прав.

Отправка сообщения — POST /api/bot/send

Токен передаётся одним из двух способов, на выбор:

# способ 1 — Authorization: Bearer
curl -X POST "https://koratland.com/api/bot/send" \
  -H "Authorization: Bearer kbot_12_xxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-9931-shipped" \
  -d '{ "kind": "chat", "chat_id": 9931, "text": "Посылка отправлена" }'
# способ 2 — X-Bot-Token
curl -X POST "https://koratland.com/api/bot/send" \
  -H "X-Bot-Token: kbot_12_xxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "chat", "chat_id": 9931, "text": "Посылка отправлена" }'

Без токена вернётся 401:

{ "ok": false, "reason": "bad_token", "detail": "передавайте токен только в Authorization: Bearer … или X-Bot-Token" }

Поля тела запроса

ПолеТипОбязательноЗначение
kindstringнет (по умолчанию "chat")Определяет тип адресата. Допустимых значения три: "chat" · "community_room" · "business_room". Любое другое даёт reason: "bad_target" прямо из базы (молчаливого отката к "chat" нет — прежняя ошибка, из-за которой значение было зафиксировано, исправлена)
chat_idinteger > 0да (или target_id)Идентификатор адресата, куда бот приглашён: chats.id при kind: "chat", community_rooms.id при "community_room", business_rooms.id при "business_room". Имя поля в теле всегда остаётся chat_id или target_id, отдельных имён под каждый kind нет
textstringдаТекст сообщения. Пустым быть не может, длина ограничена значением из справочника text_max_message (лимит настраиваемый, а не жёсткое число — смотрите поле max в ошибке too_long)
client_keystringнетКлюч защиты от дублей, замена заголовку Idempotency-Key: длина 8–128 символов, допустимы только A–Z a–z 0–9 _ . : -

Личный диалог и диалог заведения — какое поле решает

kind определяет тип таблицы адресата (диалог, комната сообщества, комната команды). А вот личный диалог и диалог заведения по kind не различаются: и то и другое — строки в таблице chats, а отличает их наличие значения в chats.business_id (значение есть — это диалог заведения). Проверка прав на приглашение, проверка блокировок и правило «заведение не пишет клиенту первым» (conversation_not_open) читают именно эту колонку. Переданный chat_id должен указывать на правильный диалог: отдельного параметра «личный или деловой» в API нет.

Idempotency-Key и лимиты частоты

Передавайте либо стандартный заголовок Idempotency-Key, либо поле client_key в теле — что-то одно. Если пришло и то и другое, значения обязаны совпадать, иначе будет 422 idempotency_key_conflict. Повтор того же запроса с тем же ключом (та же комната и тот же ключ) возвращает прежний ответ 200 с полем "duplicate": true и второго сообщения не создаёт.

Лимитов два уровня: на IP (120 запросов в минуту, на самом Worker) и на бота (по умолчанию 20 в минуту и 1000 в сутки на одного бота, администратор меняет индивидуально). Оба отвечают кодом 429 и заголовком Retry-After в секундах (на IP — всегда 60 · на бота в минуту — 60 · на бота в сутки — 3600).

Приём входящих — вебхук

Когда кто-то пишет в диалог, куда приглашён бот (и отправитель — не сам бот: так исключается эхо собственных сообщений), Korat отправляет POST на заданный адрес. Настраивается это через RPC от имени вошедшего пользователя, который управляет этим ботом (can_manage_bot), — как и получение токена:

curl -X POST "https://<SUPABASE_PROJECT>.supabase.co/rest/v1/rpc/bot_set_webhook" \
  -H "apikey: <SUPABASE_ANON_KEY>" \
  -H "Authorization: Bearer <USER_ACCESS_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{ "p_bot": 12, "p_url": "https://your-server.example.com/korat-webhook" }'
{ "ok": true, "secret": "a1b2c3…", "note": "этот secret показывается один раз — сохраните его для проверки подписи" }

Адрес обязан начинаться с https:// и не может указывать на внутренний, локальный или link-local хост (localhost, 127.0.0.1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, включая адреса метаданных всех облачных провайдеров). Такой адрес отклоняется уже при настройке и проверяется повторно, с настоящим разрешением DNS, перед каждой отправкой — на случай, если DNS позже перенаправят. secret виден только один раз, при настройке или обновлении: bot_rotate_webhook_secret(p_bot) обновляет его в любой момент, bot_disable_webhook(p_bot) и bot_enable_webhook(p_bot) выключают и включают вебхук, а bot_webhook_status(p_bot) показывает состояние (secret он не возвращает).

Сегодня наружу ничего не уходит

Очередь отправки (bot_webhook_deliveries) работает правильно и проверена в базе, секрет маршрута отправки (BOT_WEBHOOK_DISPATCH_SECRET) владельцем задан, но то, что разбирает очередь и отправляет (pg_cron, который должен вызывать Worker каждую минуту), на продакшене ещё не настроено. Вебхук настроить можно, сообщения встанут в очередь, но ваш адрес ничего не получит, пока не будет заведён планировщик pg_cron.

Как выглядит запрос, который придёт вам

POST /korat-webhook HTTP/1.1
Content-Type: application/json
X-Korat-Signature: sha256=<hex>
X-Korat-Timestamp: 1750000000000

{
  "event": "message.created",
  "chat_id": 9931,
  "message_id": 88213,
  "text": "А красный есть в наличии?",
  "time": "14:02",
  "sender": { "id": 4821, "username": "ivan_p", "name": "Иван П." }
}

И это всё — ни телефона, ни почты, ни чьих-либо токенов в теле нет. Бот видит только диалог, текст и минимум сведений об отправителе, необходимый для ответа.

Проверьте подпись, прежде чем доверять запросу

X-Korat-Signature — это HMAC-SHA256(secret, "<timestamp>." + rawBody) в шестнадцатеричном виде. Пример на Node.js:

const crypto = require("crypto");
function verify(rawBody, signatureHeader, timestampHeader, secret) {
  const age = Date.now() - Number(timestampHeader);
  if (!(age >= 0 && age < 5 * 60_000)) return false; // защита от повторов: не старше 5 минут
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(`${timestampHeader}.${rawBody}`)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}

Ответ 2xx в течение 8 секунд считается успешной доставкой. Любой другой ответ (включая перенаправления 3xx, за которыми Korat не следует) или отсутствие ответа считается неудачей, и доставка ставится в очередь на повтор.

Повторы, когда адресат недоступен

При неудаче идёт экспоненциальная задержка (1, 2, 4, 8, 16, 32, 64, 128 минут), максимум 8 попыток на сообщение, после чего это сообщение больше не отправляется. Если адресат подряд отказал 15 раз (по разным сообщениям), вебхук выключается автоматически, а владелец бота получает уведомление в приложении — бесконечно долбиться в мёртвый адрес система не будет. После устранения проблемы включите его снова вызовом bot_enable_webhook.

Полная таблица кодов ошибок

В любом неуспешном ответе всегда есть {"ok": false, "reason": "…"}: решение нужно принимать по reason, а не по одному лишь HTTP-статусу.

HTTPreasonИсточникЗначение и что делать
401bad_tokenWorker / БДТокен не передан, неверен, отозван либо бот отключён — ответ намеренно один и тот же, чтобы нельзя было подобрать перебором
403not_invitedБДБота не приглашали в этот диалог или удалили из него — сначала вызовите bot_invite
403blockedБДПользователь в диалоге заблокировал бота (только в диалогах, не относящихся к заведению)
403account_suspendedБДОдна из сторон диалога заблокирована — это вопрос прав, а не ошибки запроса
404no_such_chatБДТакого chat_id не существует
404no_such_endpointWorkerНеверный путь: существует только POST /api/bot/send
405method_not_allowedWorkerИспользован метод, отличный от POST
409conversation_not_openБДЭто диалог заведения, и клиент ещё не писал — заведение, включая бота, не может начать первым
409chat_closedБДДиалог в знакомствах больше не принимает сообщения
422empty_textБДtext пуст после удаления пробелов
422too_longБДТекст длиннее лимита — смотрите приложенное значение max
422bad_targetБДkind не равен ни одному из "chat"/"community_room"/"business_room"
404no_such_roomБДКомнаты сообщества или команды с таким chat_id не существует (она удалена)
409room_archivedБДКомната закрыта — писать не могут ни люди, ни боты
403invite_staleБДПрава пригласившего пересчитаны и больше не проходят: он вышел из сообщества, понижен в роли, лишён teamroom.manage или комната закрыта. Уточнение в detail (not_a_member/room_role/not_teamroom_manager). Лечится тем, что бота заново приглашает тот, у кого права есть (bot_invite), а не ожиданием
422bad_jsonWorkerТело запроса не является корректным JSON
422bad_chat_idWorkerchat_id или target_id должен быть целым положительным числом
422bad_textWorkertext должен быть строкой
422idempotency_key_conflictWorkerIdempotency-Key и client_key пришли вместе, но не совпадают — достаточно передать что-то одно
422bad_client_keyWorkerКлюч защиты от дублей не соответствует формату (8–128 символов, A–Z a–z 0–9 _ . : -)
429rate_limitedWorker / БДЛимит превышен — смотрите заголовок Retry-After и поля window/retry_after_sec
502database_errorWorkerБаза ответила, но не кодом 2xx — в pg_code и detail приходит настоящее сообщение Postgres
503service_key_not_configuredWorkerНа сервере не задан service key — это проблема на стороне Korat, а не вызывающего
504database_unreachableWorkerОбращение к базе не удалось или истекло время — смотрите safe_to_retry: повторять безопасно только если вы передавали client_key либо Idempotency-Key

Ответ при успехе

{ "ok": true, "duplicate": false, "message_id": 88213 }

duplicate: true означает, что этот запрос уже отправлялся раньше (защита по client_key или Idempotency-Key): нового сообщения не создано, но это по-прежнему успех (200), потому что нужный вызывающему результат уже достигнут.