botruntime
Основы платформы

Архитектура платформы

Как устроено облако 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 бэкенда обратно.

Механика на примере воркфлоу. Для сообщений и событий она такая же.

  1. Бэкенд пишет строку и в той же транзакции ставит задачу доставки в outbox. Атомарно: либо записано и запланировано, либо ничего. «Записано, но не доставлено» не бывает.
  2. Dispatch-воркер клеймит задачу и доставляет событие боту: POST на {HostURL}/run/{botId}.
  3. Супервизор роутит вызов в процесс нужного бота. Бандл исполняет шаг: зовёт инструменты, обращается к LLM-шлюзу, пишет результат обратно в REST-API.
  4. Статусы и записи шагов возвращаются в бэкенд обратным вызовом. Таймеры и 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 в чат-режиме отвечает пользователю и вызывает инструменты, но само число приходит из кода — там, где его видно и можно проверить. Как этим управляет агентный цикл — в разделе агентного цикла.

Дальше

On this page