Перейти к содержанию

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:

POST https://api.telegram.org/bot{TOKEN}/sendMessage
Body: { "chat_id": -1001234567, "text": "OK" }

Производные 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 для полного обоснования.


Порядок реализации

  1. MaxBot — первым: API проще (Authorization header, marker-cursor), хорошо документирован
  2. 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_id auto-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 сработал снова