ADR-006: Bot Integrations — MaxBot и TelegramBot¶
Дата: 2026-04-14
Статус: Accepted
Ветка: dev-0.10
Зависит от: ADR-004 (TlsHelper), ADR-005 (BotManager base class, IntegrationRegistry)
Затрагивает: новые модули integration/maxbot/*, integration/telegram/*, net_manager
Контекст¶
Требуется двусторонняя интеграция с мессенджерами для: - Inbound: приём команд управления (→ CommandRegistry) по аналогии с IMAP - Outbound: отправка уведомлений о событиях (→ через IntegrationRegistry / notifierSend)
Оба мессенджера (MaxBot и TelegramBot) предоставляют REST API с идентичной моделью взаимодействия — polling за обновлениями. Оба реализуются как *Manager : BotManager (ADR-005).
API сравнение¶
| Аспект | MaxBot | TelegramBot |
|---|---|---|
| Base URL | https://platform-api.max.ru |
https://api.telegram.org/bot{TOKEN} |
| Auth | Authorization: <token> header |
токен в URL-пути |
| Get updates | GET /updates?timeout=T&marker=N |
GET /getUpdates?timeout=T&offset=N |
| Cursor field | marker (int64, в response body) |
offset = last update_id + 1 |
| Cursor scope | in-memory (сбрасывается при reboot) | in-memory (аналогично) |
| Send message | POST /messages body {chat_id, text} |
POST /sendMessage body {chat_id, text} |
| Long poll max timeout | 90 сек | не ограничен (обычно 30) |
| Rate limit | 30 rps | 30 msg/sec |
| TLS | TlsHelper::configure() |
TlsHelper::configure() |
Решение¶
BotClient — абстрактный транспорт¶
// include/integration/bot/bot_client.h
#pragma once
#include <Arduino.h>
#include <vector>
namespace integration {
namespace bot {
struct BotMessage {
String chat_id;
String text;
String from; // user name / id (для логирования)
};
struct BotPollResult {
bool ok = false;
long cursor = 0; // следующий marker/offset
std::vector<BotMessage> messages;
};
class BotClient {
public:
virtual ~BotClient() = default;
// Blocking long poll. Возвращает после timeout_s сек или при наличии events.
virtual BotPollResult getUpdates(long cursor, int timeout_s) = 0;
// Отправить сообщение. Возвращает true при успехе.
virtual bool sendMessage(const String& chat_id, const String& text) = 0;
};
} // namespace bot
} // namespace integration
MaxBotClient¶
// include/integration/maxbot/maxbot_client.h
class MaxBotClient : public BotClient {
public:
explicit MaxBotClient(const String& token);
BotPollResult getUpdates(long cursor, int timeout_s) override;
bool sendMessage(const String& chat_id, const String& text) override;
private:
static constexpr const char* BASE_URL = "https://platform-api.max.ru";
String _token;
// HTTP GET с Authorization header
bool httpGet(const String& path, String& response);
bool httpPost(const String& path, const String& body, String& response);
};
getUpdates:
GET https://platform-api.max.ru/updates?timeout=T&marker=N&limit=10
Header: Authorization: <token>
Response:
{
"updates": [
{
"update_type": "message_created",
"message": {
"body": { "text": "DISPENSE" },
"chat_id": 123456789
}
}
],
"marker": 42
}
Cursor: result.cursor = response["marker"]
sendMessage:
POST https://platform-api.max.ru/messages
Header: Authorization: <token>
Body: { "chat_id": 123456789, "text": "OK" }
TelegramClient¶
// include/integration/telegram/telegram_client.h
class TelegramClient : public BotClient {
public:
explicit TelegramClient(const String& token);
BotPollResult getUpdates(long cursor, int timeout_s) override;
bool sendMessage(const String& chat_id, const String& text) override;
private:
String _token;
String baseUrl() const; // "https://api.telegram.org/bot" + _token
bool httpGet(const String& method, const String& params, String& response);
bool httpPost(const String& method, const String& body, String& response);
};
getUpdates:
GET https://api.telegram.org/bot{TOKEN}/getUpdates?timeout=T&offset=N&limit=10
Response:
{
"ok": true,
"result": [
{
"update_id": 12345,
"message": {
"chat": { "id": -1001234567 },
"text": "STATUS",
"from": { "username": "andrey" }
}
}
]
}
Cursor: result.cursor = last update_id + 1
sendMessage:
Производные Manager классы¶
// MaxBotManager : BotManager
class MaxBotManager : public BotManager {
public:
static MaxBotManager& instance();
const char* name() const override { return "maxbot"; }
protected:
BotClient* createClient() override { return new MaxBotClient(_cfg.token); }
const char* nvsNamespace() override { return "int_maxbot"; }
const char* consumerName() override { return NetConsumers::MAXBOT; }
};
// TelegramManager : BotManager
class TelegramManager : public BotManager {
public:
static TelegramManager& instance();
const char* name() const override { return "telegram"; }
protected:
BotClient* createClient() override { return new TelegramClient(_cfg.token); }
const char* nvsNamespace() override { return "int_telegram"; }
const char* consumerName() override { return NetConsumers::TELEGRAM; }
};
Вся логика poll loop, send queue, dispatch, auto-discover chat_id — в BotManager::workerLoop().
chat_id auto-discovery¶
Первое входящее сообщение сохраняет chat_id в NVS если не задан явно:
void BotManager::dispatch(const BotMessage& msg) {
// Auto-discover
if (_cfg.chat_id.isEmpty()) {
_cfg.chat_id = msg.chat_id;
saveToNVS();
Serial.printf("[%s] chat_id auto-discovered: %s\n", name(), msg.chat_id.c_str());
}
// Фильтр: только с нашего chat_id
if (msg.chat_id != _cfg.chat_id) return;
// Dispatch через CommandRegistry
String cmd = msg.text;
cmd.trim();
JsonDocument params;
params["text"] = msg.text;
params["from"] = msg.from;
CommandResult result = CommandRegistry::instance().execute(cmd, params);
// Авто-ответ в тот же чат
// Используем pointer queue (String safety)
String* reply = new String(result.toJson());
if (xQueueSend(_sendQueue, &reply, 0) != pdTRUE) {
delete reply; // queue full — drop
}
}
BotManager::workerLoop()¶
void BotManager::workerLoop() {
while (true) {
// Сон до следующего poll (или досрочный wakeup через triggerPoll())
ulTaskNotifyTake(pdTRUE,
pdMS_TO_TICKS(_cfg.poll_interval_s * 1000));
if (!_configured || !_cfg.enabled) continue;
// Запрос STA
SemaphoreHandle_t sem = xSemaphoreCreateBinary();
NetResult netRes = NetResult::FAILED;
netRequestSTA(consumerName(), [sem, &netRes](NetResult r) {
netRes = r; xSemaphoreGive(sem);
}, 35000);
xSemaphoreTake(sem, portMAX_DELAY);
vSemaphoreDelete(sem);
if (netRes != NetResult::CONNECTED) continue;
// NTP wait (для timestamp в уведомлениях)
waitNtp(5000);
// 1. Drain outbound send queue
drainSendQueue();
// 2. Poll inbound updates
pollAndDispatch();
netReleaseSTA(consumerName());
}
}
NVS layout¶
int_maxbot:
token — bot token (не логировать)
chat_id — target chat (auto-discovered или задан явно)
poll_interval_s — default: 60
poll_timeout_s — default: 30
enabled — bool
int_telegram:
token — bot token (не логировать)
chat_id — target chat (auto-discovered или задан явно)
poll_interval_s — default: 60
poll_timeout_s — default: 30
enabled — bool
REST API¶
// MaxBot
GET /api/integration/maxbot/config
→ { configured, chat_id, token="***", poll_interval_s, poll_timeout_s, enabled }
POST /api/integration/maxbot/config
Body: { token, chat_id?, poll_interval_s, poll_timeout_s, enabled }
POST /api/integration/maxbot/test
→ { success: true, message: "Test message queued" }
POST /api/integration/maxbot/poll
→ { success: true, message: "Poll triggered" }
// TelegramBot — идентичный паттерн
GET /api/integration/telegram/config
POST /api/integration/telegram/config
POST /api/integration/telegram/test
POST /api/integration/telegram/poll
Webhook — почему не используется¶
Webhook требует публично доступного HTTPS endpoint. ESP32: - находится за NAT - не может быть публичным сервером без проброса портов - AP-режим создаёт изолированную сеть
Long polling — единственно применимый механизм для embedded-устройства. Это соответствует рекомендациям обоих API для dev/testing и остаётся корректным для production в данном контексте.
Обновление (ADR-009, dev-0.11): Long-poll заменён на short-poll. BotManager больше не держит STA постоянно — STA поднимается на время одного цикла (poll + drain) раз в
poll_interval_s, затем освобождается. Это соответствует архитектуре IMAP и инварианту STA on-demand (ADR-003). Max.ru API с 11.05.2026 ограничивает long-poll до 2 RPS / 30s timeout. См. ADR-009 для полного обоснования.
Порядок реализации¶
- MaxBot — первым: API проще (Authorization header, marker-cursor), хорошо документирован
- TelegramBot — вторым: более широко распространён, но cursor (offset) требует аккуратности
Реализация MaxBot полностью проверяет BotManager base class — TelegramBot добавляется как второй наследник.
Рассмотренные альтернативы¶
| Альтернатива | Причина отклонения |
|---|---|
| Webhook | Невозможен для ESP32 за NAT без внешней инфраструктуры |
| Один Manager для обоих ботов (runtime switch) | Нарушает single responsibility; сложнее тестировать |
| MQTT вместо ботов | Требует persistent STA — противоречит ADR-003 |
| Отдельный Notifier task | Дублирует FreeRTOS task и queue уже имеющиеся в BotManager |
Последствия¶
- MaxBot и TelegramBot могут работать одновременно — оба зарегистрированы в IntegrationRegistry, у каждого свой NetConsumer
chat_idauto-discovery упрощает первоначальную настройку: пользователь пишет боту, ID сохраняется- Токены хранятся в NVS — не выводить в Serial (аналогично IMAP token)
- pointer queue (BotMessage*) обязателен — та же проблема String shallow copy что и в SmtpManager
Открытые вопросы¶
- Rate limiting при burst уведомлений: добавить
delay(100)между сообщениями вdrainSendQueue() - MAX Bot: нужна регистрация как юрлицо РФ для публичного бота. Для приватного (не публикуемого) — регистрация свободна через dev.max.ru
- TelegramBot:
parse_mode=HTML— добавить при реализации для форматирования ответов - При сбросе конфига:
chat_idдолжен явно очищаться чтобы auto-discover сработал снова