SMeetBot API

SMeet Bot API

Рабочие примеры

Шесть ботов на Python и те же шесть на Node.js, небольшая клиентская библиотека для каждого языка, локальный mock-сервер и набор тестов, который запускает их все. Каждый фрагмент кода примеров в этой документации взят из этих файлов.

Скачать#

Примеры можно скачать двумя архивами, по одному на язык:

Каждый архив распаковывается в папку smeet-bot-examples: в ней папка языка (python/ или node/) со своим README, локальный mock-сервер, запуск тестов и .gitignore, который не пускает в git .env и файлы, создаваемые ботами. Файлы с путями внутри этой папки:

ФайлТипЧто показывает
python/echo_bot.py, node/echo_bot.mjsУчебный примерВесь цикл long polling в одном файле: offset, epoch аренды, /start и /help, эхо любого текста.
python/faq_bot.py, node/faq_bot.mjsПример функцииКнопки, callback_query, answerCallbackQuery, editMessageText, меню команд.
python/status_bot.py, node/status_bot.mjsПример функцииСогласие на уведомления кнопкой, подписчики в SQLite, команда notify, после остановки или блокировки ничего не отправляется.
python/file_bot.py, node/file_bot.mjsПример функцииПринимает файл, ждёт file_status_changed, скачивает его, загружает квитанцию и отправляет её через sendDocument.
python/production_poller.py, node/production_poller.mjsProduction-шаблонНадёжный long polling: каждый batch сохраняется до подтверждения, rejectUpdate для событий, которые нельзя обработать.
python/webhook_bot.py, node/webhook_bot.mjsProduction-шаблонТе же гарантии для доставки через webhook, с проверкой HMAC-подписи.
python/smeet.py, node/smeet.mjsБиблиотекаКлиент: повторы с тем же Idempotency-Key, чтение .env, логи без секретов.
python/durable_inbox.py, python/bot_logic.py и их двойники в node/БиблиотекаВходящая очередь в SQLite и пример бизнес-логики для двух production-примеров.
mock-server/smeet_mock.pyТестовый двойникЛокальный Bot API, чтобы пробовать примеры без токена.
run_examples_test.pyТестыЗапускает все примеры с mock-сервером и проверяет то, что видит пользователь.

Учебный пример и production-шаблон отличаются намеренно. echo_bot показывает самый короткий корректный цикл: он обрабатывает событие до подтверждения, и это безопасно только потому, что его ответ передаёт Idempotency-Key. Если бот пишет в базу данных, CRM или платёжную систему, начинайте с production_poller или webhook_bot. Все подробности в README в папке python/ или node/.

Установка#

Python#

Python 3.9 или новее и один пакет, requests, версия закреплена в python/requirements.txt.

Shell
unzip smeet-bot-examples-python.zip
cd smeet-bot-examples/python
python3 -m venv .venv
. .venv/bin/activate              # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env              # затем заполните .env

Node.js#

Node.js 22.13 или новее, без зависимостей npm: примеры используют fetch, node:http, node:crypto и node:sqlite. node:sqlite выводит одно предупреждение об экспериментальном модуле; npm-скрипты его скрывают.

Shell
unzip smeet-bot-examples-node.zip
cd smeet-bot-examples/node
cp .env.example .env              # затем заполните .env
npm run echo                      # а также: faq, status, notify, files, poller, webhook, mock

Настройки#

Каждый пример читает настройки из окружения или из .env в текущей папке; уже заданные переменные окружения важнее .env. Не добавляйте .env в git: .gitignore в папке smeet-bot-examples его исключает.

ПеременнаяЗначение
SMEET_BOT_TOKENТокен бота из «Моих ботов». Обязательна.
SMEET_API_URLАдрес Bot API. По умолчанию https://messenger.scrile.com/bot-api/v1; с mock-сервером http://127.0.0.1:8081/bot-api/v1.
SMEET_WEBHOOK_SECRETСекрет webhook, который один раз показывается в «Моих ботах». Нужна только webhook_bot.
SMEET_CONSUMER_IDУстойчивое имя процесса-получателя. По умолчанию <пример>-<имя хоста>.
SMEET_LOG_LEVELDEBUG добавляет трассировки ошибок. Токен, секрет и текст сообщений в лог не попадают.
WEBHOOK_HOST, WEBHOOK_PORT, WEBHOOK_PATHГде слушает webhook_bot. По умолчанию 127.0.0.1, 8080, /smeet/webhook.
Shell
SMEET_BOT_TOKEN=sbt1_EXAMPLE_REPLACE_WITH_YOUR_TOKEN
SMEET_API_URL=https://messenger.scrile.com/bot-api/v1
SMEET_WEBHOOK_SECRET=EXAMPLE_REPLACE_WITH_YOUR_WEBHOOK_SECRET

Mock-сервер#

mock-server/smeet_mock.py хранит всё в памяти и работает как тестовый двойник Bot API только на стандартной библиотеке. Он обслуживает одного бота с токеном sbt1_mock_localtestonly по адресу http://127.0.0.1:8081/bot-api/v1, а управляющий API под /_mock/ играет роли пользователя, владельца и оператора:

ЗапросЧто делает
POST /_mock/start {"start_param": "A-1001"}Пользователь нажимает «Начать», можно как по ссылке с ?start=.
POST /_mock/message {"chat_id", "text"}Пользователь пишет текст; /stop останавливает бота.
POST /_mock/file {"chat_id", "kind", "file_name", "content_text"}Пользователь отправляет файл; scan_delay, scan_result и scan_reason управляют проверкой.
POST /_mock/press {"chat_id", "message_id", "callback_data"}Пользователь нажимает кнопку.
POST /_mock/access {"chat_id", "status": "stopped"}Остановка, блокировка (blocked) или выход из пространства (removed).
POST /_mock/maintenance {"enabled": true, "retry_after": 5}Техническая пауза: методы данных отвечают 503 BOTS_MAINTENANCE.
POST /_mock/webhook, POST /_mock/pollingСмена режима доставки с pending_policy keep или drop.
POST /_mock/replay {"update_id"}Владелец повторяет событие из FAILED.
POST /_mock/webhook/resume, POST /_mock/lease/resetВладелец нажимает Возобновить доставку или Сбросить подключение.
POST /_mock/faults {"method": "sendMessage", "code": "RATE_LIMITED"}Следующий вызов завершится ошибкой, чтобы посмотреть на повторы.
GET /_mock/state, GET /_mock/requestsОчередь, курсор, аренда и все вызовы Bot API.

Полный список адресов в описании в начале smeet_mock.py. Mock умеет меньше настоящей платформы (квоты, ограничения частоты, надёжность, несколько ботов и пространств) и не служит образцом поведения SMeet: образцом служит контракт.

Эхо-бот#

Shell
python echo_bot.py                # или: node echo_bot.mjs

На /start и /help бот отвечает коротким текстом, любой другой текст возвращает без изменений. Позиция (offset) и epoch аренды хранятся в echo_bot_state.json, поэтому после перезапуска бот продолжает с того же места. Логика ответа в версии на Node.js:

JavaScriptdocs/bots/examples/node/echo_bot.mjs
async function handle(client, update) {
  // Only new messages matter here. Button presses, edits (an edit is a correction, not a new
  // command), access changes and update types added in later API versions are ignored.
  if (update.type !== 'message') return;
  const message = update.message;
  await client.sendMessage(
    { chat_id: message.chat.id, text: replyFor(message) },
    // Same event, same key: a repeated update never produces a second reply.
    { idempotencyKey: smeet.actionKey(update.event_id, 'reply') },
  );
  log.info(`Replied to update ${update.update_id} in chat ${message.chat.id}`);
}

FAQ-бот с кнопками#

Shell
python faq_bot.py                 # или: node faq_bot.mjs

На любое сообщение бот присылает меню с кнопками вопросов и кнопкой-ссылкой. Нажатие сразу подтверждается через answerCallbackQuery, затем то же сообщение редактируется: появляются ответ и кнопка Back to questions, поэтому новые сообщения не копятся. При запуске бот задаёт меню команд через setMyCommands. Версия на Node.js:

JavaScriptdocs/bots/examples/node/faq_bot.mjs
async function handle(client, update) {
  const eventId = update.event_id;
  if (update.type === 'message') {
    const chatId = update.message.chat.id;
    await client.sendMessage({ chat_id: chatId, text: MENU_TEXT, reply_markup: menuMarkup() },
      { idempotencyKey: smeet.actionKey(eventId, 'menu') });
    log.info(`Menu sent to chat ${chatId}`);
    return;
  }
  if (update.type !== 'callback_query') return; // access changes, edits and unknown types need no answer here

  const query = update.callback_query;
  const chatId = query.message.chat.id;
  const data = query.data;
  let text;
  let markup;
  if (data === 'menu') {
    [text, markup] = [MENU_TEXT, menuMarkup()];
  } else if (data.startsWith('faq:') && Object.hasOwn(FAQ, data.slice('faq:'.length))) {
    const [title, answer] = FAQ[data.slice('faq:'.length)];
    [text, markup] = [`${title}\n\n${answer}`, BACK_MARKUP];
  } else {
    // A button from an older version of this bot: say so instead of leaving the user waiting.
    await client.answerCallbackQuery({ callback_query_id: query.id, text: 'This button is out of date. Send /start.' },
      { idempotencyKey: smeet.actionKey(eventId, 'answer') });
    return;
  }

  // Answer the press first: it stops the waiting indicator in the app. Then edit the message.
  await client.answerCallbackQuery({ callback_query_id: query.id }, { idempotencyKey: smeet.actionKey(eventId, 'answer') });
  await client.editMessageText({ chat_id: chatId, message_id: query.message.message_id, text, reply_markup: markup },
    { idempotencyKey: smeet.actionKey(eventId, 'edit') });
  log.info(`Button ${data} handled in chat ${chatId}`);
}

Уведомления о статусе с согласием#

Shell
python status_bot.py run                           # держите запущенным
python status_bot.py notify A-1001 approved        # из вашей системы при каждой смене статуса

Человек открывает ссылку с окончанием ?start=A-1001 или пишет /track A-1001, и только кнопка Notify me сохраняет подписку (SQLite, status_bot.sqlite3). notify пишет только подписчикам. После остановки, блокировки или выхода из пространства бот получает chat_access_changed и удаляет подписки этого чата; уведомление, которое встретилось с остановкой, о которой бот ещё не знает, получает 403 BOT_STOPPED_OR_BLOCKED, и бот поступает так же. Повторный запуск того же notify ничего нового не отправит, потому что ключ строится из заявки, статуса и чата; передайте --change-id, если один и тот же статус может законно повториться.

Получение и отправка файла#

Shell
python file_bot.py                # или: node file_bot.mjs

Отправьте боту фото или документ. Обычно сообщение приходит, пока SMeet ещё проверяет файл, и бот ждёт file_status_changed. Для файла в статусе ready он скачивает байты, загружает текстовую квитанцию с их SHA-256, дожидается проверки квитанции через getFile и retry_after и отправляет её ответом:

Pythondocs/bots/examples/python/file_bot.py
def send_receipt(client: smeet.Client, chat_id: str, message_id: str, file_id: str) -> None:
    """Download a checked file and answer with a receipt file."""
    info = client.get_file(file_id)  # a ready file comes with download_path
    if info["status"] != "ready":
        log.info("File %s is %s, no receipt", file_id, info["status"])
        return
    content = client.download_file(info["download_path"], max_bytes=MAX_DOWNLOAD)
    receipt = "\n".join([
        "Receipt from the SMeet file bot example",
        f"File name: {info['file_name']}",
        f"Kind: {info['kind']}",
        f"Type: {info.get('mime_type', 'unknown')}",
        f"Size: {len(content)} bytes",
        f"SHA-256: {hashlib.sha256(content).hexdigest()}",
        "",
    ]).encode("utf-8")

    uploaded = client.upload_file(receipt, receipt_name(info["file_name"]), kind="document", mime_type="text/plain",
                                  idempotency_key=smeet.action_key(file_id, "receipt"))
    try:
        ready = smeet.wait_for_file(client, uploaded)  # our own upload is checked as well
    except TimeoutError:
        log.warning("The check of the receipt for file %s did not finish in time", file_id)
        return
    if ready["status"] != "ready":
        log.warning("The receipt for file %s was not accepted: %s", file_id, ready.get("reason") or ready["status"])
        return
    client.send_document(chat_id, ready["file_id"], caption=f"Receipt for {info['file_name']}",
                         reply_to_message_id=message_id,
                         idempotency_key=smeet.action_key(file_id, "receipt", "send"))
    log.info("Receipt for file %s sent to chat %s", file_id, chat_id)

Надёжный long polling#

Shell
python production_poller.py       # или: node production_poller.mjs
  1. Получение: getUpdates возвращает batch; событие, которое нельзя обработать никогда, уходит в FAILED через rejectUpdate.
  2. Сохранение: остальные события batch и next_offset записываются в SQLite одной транзакцией, во входящую очередь с UNIQUE(event_id).
  3. Подтверждение: только после commit следующий getUpdates передаёт offset=next_offset.
  4. Обработка: рабочий поток выполняет бизнес-логику из очереди, с Idempotency-Key на основе event_id. Пока SMeet проверяет загруженный файл, поток откладывает это событие и обрабатывает другие чаты.

Для отказоустойчивости запустите вторую копию с другим SMEET_CONSUMER_ID на другой машине: она ждёт на CONSUMER_CONFLICT и подхватывает работу, когда первая копия остановится. Шаг 1 в версии на Node.js:

JavaScriptdocs/bots/examples/node/production_poller.mjs
async function accept(client, update, consumerId, epoch) {
  const problem = checkUpdate(update);
  if (problem === null) return true;
  const updateId = update.update_id;
  if (typeof updateId !== 'string') {
    log.error(`An update without update_id was skipped: ${problem}`);
    return false;
  }
  try {
    // One key per delivery position: a replay of the same event gets a new update_id, and
    // rejecting it again must not collide with the first rejection.
    const key = smeet.actionKey(String(update.event_id || 'no-event-id'), 'reject', updateId);
    await client.rejectUpdate(
      { update_id: updateId, reason: `Cannot process this update: ${problem}`, consumer_id: consumerId, epoch },
      { idempotencyKey: key },
    );
    log.warn(`Update ${updateId} moved to FAILED: ${problem}`);
  } catch (error) {
    if (!(error instanceof smeet.ApiError) || error.code !== 'UPDATE_NOT_FOUND') throw error;
    log.info(`Update ${updateId} is no longer pending (${error.code}), nothing to reject`);
  }
  return false;
}

Webhook с проверкой подписи#

Shell
python webhook_bot.py             # или: node webhook_bot.mjs; слушает 127.0.0.1:8080, путь /smeet/webhook
  1. В «Моих ботах» выберите Webhook, укажите свой HTTPS-адрес, например https://bots.example.com/smeet/webhook, и скопируйте секрет (он показывается один раз) в SMEET_WEBHOOK_SECRET.
  2. Поставьте перед программой свой TLS-терминатор, например nginx:
nginx
location = /smeet/webhook {
    proxy_pass http://127.0.0.1:8080;
}
  1. Программа проверяет подпись (Проверка подписи), сохраняет событие с UNIQUE(event_id), отвечает 200 и обрабатывает его в фоне. GET /healthz отвечает ok для балансировщика.

Пример выбирает ответы осознанно: 400 для тела, которое не является JSON, 413 для тела больше 1 МиБ, 422 для события, которое он никогда не сможет обработать (SMeet переводит это одно событие в FAILED и идёт дальше), 503, когда недоступна его база данных (SMeet повторяет в пределах бюджета события), и 401 для отсутствующей или неверной подписи или устаревшего timestamp. 401 приостанавливает доставку, а не переводит события в FAILED, поэтому неверный секрет ничего не стоит: впишите правильный секрет в SMEET_WEBHOOK_SECRET, перезапустите программу и нажмите Возобновить доставку в «Моих ботах», и ожидающие события уйдут по порядку (Endpoint на паузе).

Чтобы попробовать с mock-сервером, направьте webhook на локальную программу; обычный HTTP разрешает только mock:

Shell
SMEET_WEBHOOK_SECRET=local-secret python webhook_bot.py
curl -s -X POST localhost:8081/_mock/webhook \
  -d '{"url": "http://127.0.0.1:8080/smeet/webhook", "secret": "local-secret", "pending_policy": "keep"}'

Как примеры реагируют на ошибки#

ОтветЧто делают примеры
429 RATE_LIMITED, 503 TEMPORARILY_UNAVAILABLE, 503 BOTS_MAINTENANCEЖдут retry_after и повторяют тот же запрос с тем же Idempotency-Key и тем же offset.
409 CONSUMER_CONFLICTЖдут retry_after и спрашивают снова с теми же consumer_id, epoch и offset.
409 CURSOR_BEHINDПродолжают с confirmed_offset из ошибки.
400 OFFSET_NOT_ISSUEDСпрашивают снова без offset.
403 BOT_STOPPED_OR_BLOCKEDБольше не пишут в этот чат; status_bot удаляет его подписки.
409 FILE_NOT_READYПовторяют после retry_after с тем же ключом; рабочий поток production-примеров откладывает событие, а не спит.
422 FILE_REJECTEDНе повторяют; объясняют человеку error.reason.
401 INVALID_TOKEN, 409 DELIVERY_MODE_CONFLICT, 403 BOT_SUSPENDED, 403 SPACE_BOTS_DISABLEDОстанавливаются с одной строкой о том, что исправить, код выхода 2. Production poller при DELIVERY_MODE_CONFLICT вместо этого ждёт, чтобы смена режима его не останавливала.
Сетевая ошибка или тайм-аутПовторяют с растущей паузой, от 1 до 30 секунд, с тем же ключом.

Автоматические тесты#

run_examples_test.py запускает mock, каждый пример отдельным процессом и проверяет то, что видит пользователь: ответы, редактирование по кнопкам, согласие на уведомления, тишину после остановки, круговой путь файлов, объяснение отказа по коду причины, rejectUpdate, проверку подписи, отсутствие второго ответа на повтор или replay, пропуски с известными и незнакомыми причинами и сохранение offset во время технической паузы. Несколько тестов сверяют с контрактом сам mock: offset, аренды и epoch, идемпотентность, файлы и таблицу ответов webhook.

Shell
cd smeet-bot-examples
python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements-dev.txt
pytest -v run_examples_test.py    # тесты другого языка пропускаются