Архитектура платформы
Как устроено облако botruntime: Go-бэкенд и PostgreSQL, рантайм ботов на Bun и путь сообщения от вебхука до ответа.
Облако botruntime — это Go-бэкенд поверх PostgreSQL плюс рантайм ботов на Bun. Бэкенд хранит и маршрутизирует данные. Логику бота он не исполняет сам: код бота работает отдельным процессом и зовёт бэкенд обратно по REST.
Под капотом — модульный монолит: один Go-бинарь и PostgreSQL. Без микросервисов, Redis, Kafka и Kubernetes.
Ниже — карта системы. Где что живёт, где физически исполняется код бота и как входящее сообщение проходит всю цепочку до ответа.
Из чего собрана платформа
Три исполняемых компонента и одна база данных.
- API-бэкенд (Go + PostgreSQL) — единый сервер платформы. Боты и их деплой, диалоги, сообщения, события, состояние, таблицы, файлы, воркспейсы, интеграции, LLM-шлюз, метеринг, трейсы и аудит. Один процесс, один бинарь.
- Рантайм ботов (Bun) — супервизор, который исполняет задеплоенные бандлы. Один процесс на бота. Тянет бандл из бэкенда, перезапускает процесс при новом деплое. Тот же контракт обработчика используют и боты, и интеграции.
- PostgreSQL — единственное хранилище состояния. Всё durable живёт строками, а не в памяти процесса: диалоги, задачи, таймеры. Бэкенд масштабируется без липких сессий.
Фоновым задачам и таймерам не нужен внешний брокер. Их ведёт polling-воркер
поверх таблицы задач: FOR UPDATE SKIP LOCKED, колонка run_at.
Три плоскости
Ответственности делятся на три плоскости. Держите их раздельно: деплой, отказ и масштабирование влияют на них по-разному.
| Плоскость | Что делает | Где живёт |
|---|---|---|
| Control-plane | боты и деплой, воркспейсы и участники, интеграции, реестр, конфиг и секреты | API-бэкенд (Go) |
| Data-plane | диалоги, сообщения, события, состояние, таблицы, файлы, воркфлоу | API-бэкенд + PostgreSQL |
| Исполнение | агентный цикл, обработчики диалогов, шаги воркфлоу, инструменты | рантайм ботов (Bun) |
Control-plane и data-plane — это Go-бэкенд поверх PostgreSQL. Плоскость исполнения отделена физически: это отдельные Bun-процессы под супервизором.
Где живёт код бота
Главный инвариант: Go-ядро не исполняет логику бота. Оно персистит строки и планирует durable-доставку событий. Весь код — обработчик диалога, шаги воркфлоу, инструменты — исполняется в Bun-бандле. Бандл зовёт REST-API бэкенда обратно.
Механика на примере воркфлоу. Для сообщений и событий она такая же.
- Бэкенд пишет строку и в той же транзакции ставит задачу доставки в outbox. Атомарно: либо записано и запланировано, либо ничего. «Записано, но не доставлено» не бывает.
- Dispatch-воркер клеймит задачу и доставляет событие боту:
POSTна{HostURL}/run/{botId}. - Супервизор роутит вызов в процесс нужного бота. Бандл исполняет шаг: зовёт инструменты, обращается к LLM-шлюзу, пишет результат обратно в REST-API.
- Статусы и записи шагов возвращаются в бэкенд обратным вызовом. Таймеры и
resume ведёт бэкенд: он хранит
timeout_atи продвигает задачи тем же воркером.
Итог: бэкенд — durable-субстрат. Персистентность, доставка, таймеры. Не replay-движок на сервере. Долгие процессы бота переживают рестарты и ретраятся, потому что каждый шаг идемпотентен и кэшируется. Подробнее — в движке воркфлоу.
Путь входящего сообщения
Проследим сообщение от мессенджера до ответа. Точка входа одна — generic-вебхук. Дальше по цепочке ядро уже не знает, что это Telegram.
Telegram ──► POST /hooks/{webhookId} (API-бэкенд, вне authScope)
│ резолв инсталляции по webhook_id (enabled-filtered)
│ сверка shared secret (если задан)
▼
входящее событие ──► durable outbox (та же транзакция, что запись)
│
▼ dispatch-воркер клеймит задачу
POST {HostURL}/run/{botId} (рантайм ботов, Bun)
│
▼ супервизор → процесс бота
обработчик диалога: execute(...) → инструменты, LLM-шлюз
│
▼ эффекты пишутся обратно в REST-API бэкенда
createMessage(outgoing) ──► durable outbox ──► доставка в TelegramЧто важно на этом пути:
/hooks/{webhookId}— вне авторизации. Вебхук гейтится непрозрачнымwebhook_idплюс опциональным shared secret инсталляции. Тенант не дефолтится. Неизвестный или выключенныйwebhook_idдаёт404. Несовпадение секрета даёт401. Открытого ingest нет, только fail-loud.- Тенант выводится из инсталляции. Вебхук анонимен. Воркспейс, владелец и
бот резолвятся из строки инсталляции по
webhook_id. Дальше всё исполняется строго под этим scope. - Доставка durable и at-least-once. Если бэкенд отвечает провайдеру
5xx, Telegram ретраит апдейт. Входящее превращается в задачу outbox, которая переживает рестарт. Её провал виден: задача уходит вfailed, а не в молча потерянную горутину. - Ответ пишет сам агент. В чат-режиме
executeпредсвязан: агент сам формулирует и отправляет текст пользователю. Отдельный вызов отправки для обычного ответа не нужен — см. обработчики диалогов.
Managed Cloud trace-контур не создаёт ещё одну копию секретов и ПДн. Shared
secret вебхука сверяется constant-time и не сохраняется; санитайзер Cloud-трейсов
работает fail-closed; содержимое сообщений, prompt/output, tool I/O и raw errors
не попадают ни в Cloud trace store, ни в Langfuse. Content-флага нет —
сохраняется только закрытый allowlist метаданных. Локальная диагностика brt dev
намеренно может содержать previews, tool I/O и state; обращайтесь с ней как с
чувствительным developer artifact. Точная граница — в
Observability и приватность.
Бот без мессенджера
Бот достижим и без Telegram. Data-plane REST-API (/v1/chat/*) — это тот же
контракт, что зовёт рантайм. Любой клиент может создать пользователя и диалог,
отправить входящее сообщение и вычитать ответы бота.
Чтение идёт keyset-пейджингом. GET /v1/chat/messages возвращает
{ messages, meta: { nextToken } }, где nextToken — курсор на следующую
страницу. Пустой nextToken означает конец. Так web- или embedded-клиент ведёт
диалог напрямую через платформу, а Telegram становится лишь одним из каналов
поверх той же шины. Детали — в Chat API.
Швы платформы
Систему держат вместе три границы. Их соблюдение — условие расширяемости.
Ядро не знает про домен. Пакеты core/* (диалоги, агент, задачи,
транспорт) оперируют generic-механизмами. Про конкретного бота они не знают.
Доменный код импортирует ядро, но не наоборот. Граница закреплена статически:
правило линтера не даёт ядру скомпилироваться со ссылкой на доменный пакет.
Интеграция — тот же контракт, что бот. Задеплоенная интеграция исполняется
тем же рантаймом и тем же shape обработчика, что и бот. Один Bun-супервизор
гоняет и ботов, и интеграции. Отсюда единая точка входа /hooks/{webhookId}:
провайдер шлёт сырой запрос, интеграция превращает его в событие бота.
Провайдеры — за интерфейсами-способностями. Каналы, хранилище файлов, LLM — это capability-интерфейсы над сменными провайдерами. Бот программирует против способности, а не против конкретного вендора. Замена провайдера не трогает код бота. О модели интеграций — в разделе интеграций.
Деньги в коде
Через всю архитектуру проходит структурный инвариант: деньги, сроки и юридически значимые величины считает код. Их считают инструменты и шаги воркфлоу. LLM только формулирует ответ. Расчётов и вердиктов модель не выносит.
Это следствие разделения плоскостей. Детерминированная логика живёт в типизированном коде инструмента или шага. Её можно покрыть табличными тестами, провести через ревью и версионировать. LLM в чат-режиме отвечает пользователю и вызывает инструменты, но само число приходит из кода — там, где его видно и можно проверить. Как этим управляет агентный цикл — в разделе агентного цикла.