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

ADR-010: Удалённое управление парком — общая доска в групповом чате MAX

Дата: 2026-05-18 Статус: Accepted (реализация — отдельный task) Ветка: dev-0.12 Затрагивает: integration/maxbot/*, integration/bot_manager.*, commands.cpp, API/integration/maxbot/*, web-UI карточка MaxBot


Контекст

Задача

Парк из 20–30+ моечных боксов, в каждом — контроллер AWDC с 3-канальным дозатором. Боксы стоят в DMZ-сети мойки. Оператор, которому нужно менять параметры дозаторов, находится вне этой сети, в обычном интернете.

Ограничения объекта: - у мойки нет белого IP (серый адрес + CGNAT у провайдера); - VPN не рассматривается (решение заказчика); - своего почтового сервера нет, SMTP/IMAP — публичные сервисы (проблемы доставки, спам-фильтры, блокировки) — отвергнуто; - свой бэкенд / хаб / облачная инфраструктура — нежелательно («строить инфраструктуру»); - у оператора — стандартные средства связи и интернет.

Сетевой инвариант

Устройство за NAT/CGNAT не может принимать входящие соединения. Достучаться до него можно только так: устройство само инициирует исходящее соединение к публично достижимой точке встречи, и оператор общается через эту же точку. Точка встречи обязательна — вопрос лишь в том, насколько она «тяжёлая».


Отвергнутые варианты

Бот MAX «по боту на контроллер»

getUpdates бота — это очередь «один потребитель»: апдейт доставляется ровно одному поллеру (первому), для остальных исчезает. Несколько контроллеров на один токен делят одну очередь и воруют апдейты друг у друга. Подтверждено скриптом test/maxbot_queue_probe.sh:

Тест 1 (persistence): опрос 1 → 4 сообщения, опрос 2 → 0, опрос 3 → 0

Чтение через getUpdates потребляет сообщения. Значит каждому контроллеру нужен свой бот. Но MAX ограничивает: 5 ботов на организацию (и с 08.2025 создание ботов — только для верифицированных юрлиц РФ). Парк >5 так не покрыть.

Канал MAX

Канал годится для чтения (GET /messages идемпотентен), но посты канала анонимны — поле Message.sender для каналов отсутствует (документировано как optional). Подтверждено test/maxbot_messages_probe.sh, Тест 0:

{ "recipient": {"chat_type": "channel"}, "body": {...}, "stat": {"views": 2} }

Без sender не работает whitelist по пользователю и нет аудита «кто поменял». Канал работоспособен, но как запасной вариант.

Шлюз/хаб, своё облако, reverse-туннель

Все требуют либо своей серверной инфраструктуры, либо железа на каждой площадке. Заказчиком отклонено как избыточное. (Анализ этих вариантов сохранён в истории обсуждения; здесь не повторяется.)


Эмпирическая проверка

Перед принятием решения семантика MAX Bot API проверена двумя скриптами (оставлены в test/ как dev-инструменты):

test/maxbot_queue_probe.shGET /updates: - чтение потребляет очередь (single-consumer) — путь «много контроллеров на один токен через updates» закрыт.

test/maxbot_messages_probe.shGET /messages: - GET /messages?chat_id=Xидемпотентен: 3 чтения подряд дают идентичный список (одинаковые mid/seq). Чтение ничего не потребляет → парк контроллеров может читать параллельно. - сообщение несёт body.seq — монотонный 18-значный номер → курсор для дедупа. - from — это пагинация в глубь истории («сообщения старше точки»), не «с этого времени и новее». Для нас не нужен. - групповой чат, бот = админGET /messages возвращает всю историю, и у каждого сообщения есть sender:

"sender": { "user_id": 73651840, "name": "Андрей", "is_bot": false }

(бот как обычный участник группы — GET /messages пуст; права администратора обязательны). - POST /messages в чат от имени бота-админа → HTTP 200, сообщение опубликовано.


Решение

Удалённое управление парком — через общую «доску» в приватном групповом чате MAX. Один бот, один токен, один групповой чат.

Почему групповой чат, а не канал: оба дают идемпотентное чтение GET /messages, но в группе у сообщений есть sender → работает авторизация по user_id и аудит. Канал этого не даёт.

Архитектура

            ┌─────────────── приватный групповой чат MAX ───────────────┐
            │  участники: бот (админ) + операторы                        │
оператор ──▶│  "DISPENSE@box-7 2 45"   ◀── команда (пост оператора)       │
            │  "[box-7] OK: ch2 prop=45" ◀── ответ (пост бота)            │
            └────────────────────────────────────────────────────────────┘
                 ▲ POST /messages              │ GET /messages (идемпотентно)
                 │ (ответы)                    ▼
        ┌────────┴─────────┐         ┌──────────┴──────────┐
        │  контроллер box-7│   ...   │  контроллеры box-1..N│
        │  токен + chat_id │         │  тот же токен+chat_id│
        └──────────────────┘         └─────────────────────┘
  • Один бот (админ группы), один токен. Все контроллеры прошиты одним токеном и одним chat_id группы. Лимит «5 ботов» не задеваем — бот один. В самой группе контроллеры не состоят: они лишь читают/пишут через токен бота; участники группы — бот + операторы.
  • Команда: оператор постит в группу текст с адресацией: <CMD>@<device-id> [аргументы].
  • Контроллеры раз в poll_interval_s делают GET /messages?chat_id= X&count=N, берут сообщения с seq больше сохранённого, отбирают адресованные себе, проверяют отправителя, выполняют.
  • Ответ: контроллер (через бота) POST /messages в ту же группу с префиксом [<имя устройства>].

Почему это снимает оба тупика

Тупик Как снят
Лимит 5 ботов бот один на весь парк
Single-consumer getUpdates используется GET /messagesидемпотентен
Нет отправителя (канал) групповой чат даёт sender.user_id

Модель безопасности

Два рубежа, без зависимости от анонимности: 1. Приватная группа — кто в группе, тот и может постить команды. Управление доступом = управление участниками группы. 2. Whitelist allowed_user_id — контроллер проверяет sender.user_id команды по CSV-списку разрешённых (механизм csvContains уже есть). Не в списке — команда отклоняется, в лог.

Ранее предусматривался третий рубеж — security_token (общий секрет в теле сообщения). С рабочим whitelist по реальному user_id он оказался избыточным и был убран из кода и UI; синтаксис #token в адресной части (<CMD>@<dev>#token) парсится для совместимости, но игнорируется.

Аудит: sender.name + user_id пишутся в лог при каждой команде — видно, кто и что менял.

Сегментация парка — один бот, много групп

Бот может состоять в любом числе групповых чатов (лимит «5 ботов на организацию» считает ботов, не группы). Каждый контроллер привязан конфигом (board_chat_id) к одной группе. Отсюда естественное деление парка:

  • группа на мойку / регион / бригаду;
  • операторы каждой группы видят только свой трафик, контроллеры читают только свою группу, бот — один на весь парк;
  • имена устройств уникальны в пределах группы (в разных группах имена могут повторяться — это разные чаты).

Так board-схема масштабируется на большой парк без единого «шумного» чата: вместо одного чата на 30 боксов — несколько групп по площадкам, и при этом всё ещё один бот-ключ.

Участники группы — бот (админ) + люди-операторы. Контроллеры в группе не «состоят» (они не аккаунты мессенджера) — они привязаны к ней конфигом и читают её через токен бота извне.


Как реализовать

Новый режим интеграции MAX — «board» — в дополнение к текущему DM/getUpdates режиму (его оставляем для одиночной установки).

1. Конфигурация (NVS namespace int_maxbot)

Ключ Назначение
token токен бота (одинаков на всём парке)
mode dm (как сейчас) или board
board_chat_id chat_id группового чата-доски
allowed_uid CSV разрешённых user_id (уже есть)
security_token опциональный секрет (уже есть)
poll_interval_s период опроса (уже есть)
last_seq курсор — последний обработанный body.seq

Адресат команды <device-id> — берётся из уже существующего поля «Имя устройства» (ui/dev_name, ADR не нужен — добавлено ранее). Рекомендация операторам: короткие имена без пробелов (box-7).

2. MaxBotClient — новый метод

getChatMessages(chat_id, count)GET /messages?chat_id=X&count=N, парсит массив messages[] в список структур: { seq, mid, text, sender_user_id, sender_name, is_bot }.

3. Board-цикл опроса (в bot_manager/maxbot)

Раз в poll_interval_s: 1. GET /messages?chat_id=board_chat_id&count=N (N ≈ 50). 2. Отсортировать по seq по возрастанию, обработать seq > last_seq. 3. Для каждого сообщения: - is_bot == true → пропустить (ответы бота и чужих ботов); - распарсить <CMD>@<device-id> [args]; если @<device-id> ≠ наше имя и ≠ @all → пропустить; - sender_user_id нет в allowed_uid → отклонить, записать в лог; - security_token задан и не совпал → отклонить; - распарсить аргументы → CommandRegistry::execute(); - POST /messages ответ: [<имя устройства>] <результат>. 4. last_seq = max(seq), сохранить в NVS.

Первый запуск (last_seq == 0): принять max(seq) за базу без выполнения — свежепрошитый контроллер не должен переигрывать всю историю команд.

4. Парсинг аргументов команд

Сейчас dispatch() передаёт обработчику только text/from, поэтому команды с аргументами (DISPENSE с номером канала) через бот не работают. Board-режим парсит позиционные аргументы в params:

  • STATUS@box-7STATUS, без аргументов;
  • DISPENSE@box-7 2DISPENSE, params.ch = 2;
  • PROP@box-7 2 45params.ch = 2, params.value = 45;
  • GET@box-7 allGET, args=["all"] (специальная форма — вернуть параметры по всем 3 каналам одним сообщением);
  • SET@box-7 2 prop=45 mode=pulseSET, args=["2","prop=45","mode=pulse"] (мульти-ключевая форма).

Управляющие команды (PROP/MODE/DISPENSE/STOP/SET) и GET <ch> на выключенном канале отвечают «канал N выключен или отсутствует на этом контроллере». Исключение — SET с enable=1: она сама включает канал, поэтому проверка пропускается. GET all показывает все 3 канала, выключенные помечает (off).

Грамматика и набор команд — в task-доке реализации.

5. Ответы

POST /messages?chat_id=board_chat_id, тело {"text": "[<имя>] ..."}. Префикс имени устройства уже реализован (botDevicePrefix()).

6. Web-UI

В карточку MaxBot — переключатель режима (dm/board) и поле board_chat_id. Остальное (токен, whitelist, имя устройства) уже есть.

Требование к развёртыванию

Бот обязан быть администратором группового чата — иначе GET /messages по группе возвращает пусто (проверено). Это шаг настройки, как и для канала-варианта.


Что получаем

  • Удалённое управление парком 20–30+ контроллеров за CGNAT — без белого IP, без VPN, без хаба, без своего сервера, без железа на площадке. Точка встречи — сам MAX, которым и так пользуются.
  • Масштаб — ограничен не архитектурой, а rate-limit'ами MAX и размером группы (операторов). 30 контроллеров при poll=120с дают ~0.25 rps на GET /messages — мизер.
  • Авторизация по пользователю + аудит — whitelist по user_id, имя оператора в логе.
  • Store-and-forward — история группы и есть очередь команд: бокс был offline → вернулся → прочитал и выполнил пропущенное. Оператор видит ответ-подтверждение.
  • Двусторонний канал — команды и подтверждения в одном месте.
  • Переиспользование — HTTP/TLS-клиент, CommandRegistry, имя устройства, csvContains, префикс — всё уже в прошивке.
  • Нагрузка на heap — как у текущего бота: один TLS-handshake на опрос, без постоянного соединения. Новой постоянной TLS-арены нет.
  • Оператор работает из обычного приложения MAX — без спец-софта.

Ограничения и последствия

  • Латентность входящих команд = poll_interval_s (не realtime). Для смены параметров дозатора приемлемо.
  • Зависимость от MAX. Умер бот/аккаунт/API — удалённое управление недоступно. Но дозирование полностью локально (параметры в NVS, ESP32 автономен): отказ MAX = «временно рулим на месте по локальному web-UI», а не остановка моек. Точка отказа вынесена и дёшева.
  • Создание бота в MAX требует верифицированного юрлица РФ (бизнес-режим с 08.2025).
  • Ретенция истории группы — команды старше срока хранения пропадают; при регулярном опросе не проблема.
  • Команды — открытый текст в группе: всё, что видят операторы, видят все участники группы. Участники — только доверенные операторы.
  • Идемпотентность выполнения — дедуп по seq; last_seq сохранять в NVS сразу после обработки, чтобы reboot между «выполнил» и «сохранил» не привёл к повторному выполнению.
  • Канал MAX остаётся документированным запасным вариантом (если потребуется анонимность вместо аудита) — но по умолчанию групповой чат.

Проверочные артефакты

  • test/maxbot_queue_probe.sh — доказал single-consumer у getUpdates.
  • test/maxbot_messages_probe.sh — доказал идемпотентность GET /messages, наличие sender в группе (бот=админ), работу POST /messages в чат.

Скрипты оставлены в репозитории как dev-инструменты для повторной проверки при изменениях MAX API.