SMeet Bot API
Рабочие примеры
Шесть ботов на Python и те же шесть на Node.js, небольшая клиентская библиотека для каждого языка, локальный mock-сервер и набор тестов, который запускает их все. Каждый фрагмент кода примеров в этой документации взят из этих файлов.
Скачать#
Примеры можно скачать двумя архивами, по одному на язык:
- smeet-bot-examples-python.zip: Python 3.9 или новее и один пакет,
requests. - smeet-bot-examples-node.zip: Node.js 22.13 или новее, без зависимостей npm.
Каждый архив распаковывается в папку 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.mjs | Production-шаблон | Надёжный long polling: каждый batch сохраняется до подтверждения, rejectUpdate для событий, которые нельзя обработать. |
python/webhook_bot.py, node/webhook_bot.mjs | Production-шаблон | Те же гарантии для доставки через 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.
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 # затем заполните .envNode.js#
Node.js 22.13 или новее, без зависимостей npm: примеры используют fetch, node:http, node:crypto и node:sqlite. node:sqlite выводит одно предупреждение об экспериментальном модуле; npm-скрипты его скрывают.
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_LEVEL | DEBUG добавляет трассировки ошибок. Токен, секрет и текст сообщений в лог не попадают. |
WEBHOOK_HOST, WEBHOOK_PORT, WEBHOOK_PATH | Где слушает webhook_bot. По умолчанию 127.0.0.1, 8080, /smeet/webhook. |
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_SECRETMock-сервер#
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: образцом служит контракт.
Эхо-бот#
python echo_bot.py # или: node echo_bot.mjsНа /start и /help бот отвечает коротким текстом, любой другой текст возвращает без изменений. Позиция (offset) и epoch аренды хранятся в echo_bot_state.json, поэтому после перезапуска бот продолжает с того же места. Логика ответа в версии на Node.js:
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-бот с кнопками#
python faq_bot.py # или: node faq_bot.mjsНа любое сообщение бот присылает меню с кнопками вопросов и кнопкой-ссылкой. Нажатие сразу подтверждается через answerCallbackQuery, затем то же сообщение редактируется: появляются ответ и кнопка Back to questions, поэтому новые сообщения не копятся. При запуске бот задаёт меню команд через setMyCommands. Версия на Node.js:
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}`);
}Уведомления о статусе с согласием#
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, если один и тот же статус может законно повториться.
Получение и отправка файла#
python file_bot.py # или: node file_bot.mjsОтправьте боту фото или документ. Обычно сообщение приходит, пока SMeet ещё проверяет файл, и бот ждёт file_status_changed. Для файла в статусе ready он скачивает байты, загружает текстовую квитанцию с их SHA-256, дожидается проверки квитанции через getFile и retry_after и отправляет её ответом:
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#
python production_poller.py # или: node production_poller.mjs- Получение: getUpdates возвращает batch; событие, которое нельзя обработать никогда, уходит в FAILED через rejectUpdate.
- Сохранение: остальные события batch и
next_offsetзаписываются в SQLite одной транзакцией, во входящую очередь сUNIQUE(event_id). - Подтверждение: только после commit следующий getUpdates передаёт
offset=next_offset. - Обработка: рабочий поток выполняет бизнес-логику из очереди, с Idempotency-Key на основе
event_id. Пока SMeet проверяет загруженный файл, поток откладывает это событие и обрабатывает другие чаты.
Для отказоустойчивости запустите вторую копию с другим SMEET_CONSUMER_ID на другой машине: она ждёт на CONSUMER_CONFLICT и подхватывает работу, когда первая копия остановится. Шаг 1 в версии на Node.js:
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 с проверкой подписи#
python webhook_bot.py # или: node webhook_bot.mjs; слушает 127.0.0.1:8080, путь /smeet/webhook- В «Моих ботах» выберите Webhook, укажите свой HTTPS-адрес, например
https://bots.example.com/smeet/webhook, и скопируйте секрет (он показывается один раз) вSMEET_WEBHOOK_SECRET. - Поставьте перед программой свой TLS-терминатор, например nginx:
location = /smeet/webhook {
proxy_pass http://127.0.0.1:8080;
}- Программа проверяет подпись (Проверка подписи), сохраняет событие с
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:
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.
cd smeet-bot-examples
python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements-dev.txt
pytest -v run_examples_test.py # тесты другого языка пропускаются