botruntime
Интеграции

Модель интеграций и каналы

Как устроены интеграции: канальная логика живёт в интеграции, а не в платформе; per-install конфиг с шифруемыми секретами, входящие события против сообщений, два уровня исходящих вызовов.

Интеграция подключает бота к внешнему сервису: мессенджеру, платёжному провайдеру, CRM. Платформа не знает про Telegram или любой другой канал. Канальная логика живёт в интеграции.

Интеграция — это версионируемый бандл с тем же контрактом обработчика, что и бот. Платформа исполняет и ботов, и интеграции одинаково.

Отсюда простое разделение. Бот описывает поведение: что ответить, что посчитать, что сохранить. Интеграция описывает транспорт: как принять входящее и как доставить исходящее. Платформа гоняет между ними нормализованные сообщения и события. Переезжая с канала на канал, бот не меняет ни строки.

Определение и инсталляция

Интеграция существует в двух формах. Их важно не путать.

  • Определение (definition) — запись в каталоге: имя, версия, схема конфига, набор каналов, действий и событий. Публикуется один раз и переиспользуется всеми ботами воркспейса. В каталоге видны глобальные встроенные определения и ваши приватные.
  • Инсталляция (installation) — установка определения на конкретного бота, со своим конфигом и секретами. У неё есть непрозрачный webhook_id, по умолчанию свой shared-secret и жизненный цикл pending → registered → failed.

Определение отвечает на вопрос «что за интеграция». Инсталляция — «этот бот, этот токен, этот вебхук». Секреты (например botToken) живут только в инсталляции и только запечатанными.

Установите канал

Жизненный цикл интеграции состоит из двух мутаций: install создаёт инсталляцию и запечатывает секреты, register подключает webhook у провайдера. Оба доступны через brt.

Production rollout agent-проекта
# 1. Создать production target и загрузить текущий код
brt deploy --adk --name "Мой бот"

# 2. Установить интеграцию. Конфиг и секреты — только через stdin или файл
printf '{"botToken":"%s"}' "$PROD_TELEGRAM_TOKEN" \
  | brt integrations install botruntime/telegram@1.1.3 --config-stdin
# → installed botruntime/telegram@1.1.3 alias=telegram webhookId=wh_9f2c1a…
#   webhookSecret сохранён в ~/.brt/bots.json (показывается один раз)

# 3. Зарегистрировать webhook у провайдера (это и есть setWebhook)
brt integrations register wh_9f2c1a…
# → registered wh_9f2c1a… -> https://botruntime.ru/hooks/wh_9f2c1a…

# 4. Сверить состояние с Cloud, пересобрать и загрузить production bundle
brt deploy --adk

install валидирует конфиг по схеме определения. Секретные поля изымаются из открытого хранилища и запечатываются. Нет обязательного поля — громкий отказ на установке, а не мёртвая инсталляция. register возвращает публичный webhookUrl вида /hooks/{webhookId} и переводит статус в registered. Публичный webhookId уходит в файл проекта, одноразовый webhookSecret — в профиль CLI (~/.brt/bots.json).

Для agent-проекта install/register обновляют состояние интеграции в Cloud и готовый локальный dependency snapshot. Обновление production snapshot — не generation, build или deploy, поэтому приёмочную проверку выполняют только после второго brt deploy --adk. В dev те же команды с --dev обновляют snapshot, а уже запущенный watcher сам повторяет generation.

Под капотом brt бьёт в admin-API: POST /v1/admin/integrations/install и POST /v1/admin/integrations/{webhookId}/register. Те же операции доступны оператору из веб-консоли (роль owner или admin).

Секреты не идут через argv. Передавайте конфиг только через --config-stdin или --config-file <путь>. Аргументы командной строки видны в списке процессов и попадают в историю shell: printf '<json>' | brt integrations install ….

Как хранятся секреты

Секретные поля запечатываются: платформа шифрует их AEAD-шифром и кладёт в БД блобом. Блоб привязан к своей инсталляции — паре workspace:bot:integration:alias. Переставить запечатанный секрет в другого тенанта или бота нельзя: расшифровка с чужой привязкой падает, а не отдаёт мусор.

Расшифровка происходит только в точке потребления — когда платформа подставляет конфиг обработчику интеграции. Открытые значения секретов больше никуда не попадают: ни в лог, ни в трейс, ни в контекст модели.

Входящее: события и сообщения

Провайдер шлёт входящее на POST /hooks/{webhookId}. Этот путь живёт вне обычной авторизации по ключу. В стандартном режиме гейт двойной: непрозрачный webhookId (128 бит энтропии, не угадать) плюс per-installation shared-secret в заголовке. Тенант выводится из резолвнутой инсталляции, а не из тела запроса. Неизвестный или выключенный webhookId404, несовпадение секрета — 401.

Для встроенного провайдера, который не поддерживает webhook-подпись, platform-owned определение может объявить provider_verified. Тогда kernel по-прежнему резолвит непрозрачный webhook и тенанта, а интеграционный бандл до эмита события повторно получает объект через API провайдера и сверяет его статус и значимые поля. Режим недоступен приватным интеграциям и не является обходом проверки входящего трафика.

webhookAuthMode неизменяем внутри одного name@version. Платформа также не перепривязывает существующую инсталляцию к версии с другим режимом: выполните новую установку, чтобы состояние секрета соответствовало активному бандлу.

Дальше интеграция нормализует сырой апдейт провайдера. Здесь проходит важная граница:

  • Сообщения пользователя нормализуются в channel-neutral модель (text, image, file, contact, …). Бот видит их одинаково на любом канале.
  • Прочие факты — платёж, событие CRM, реакция — становятся событием, а не сообщением. Их обрабатывают триггеры и воркфлоу. В диалоге они не выглядят как реплика пользователя.

Обработчик различает их по полю type в props:

src/conversations/index.ts
import { Conversation } from '@holocronlab/botruntime-runtime'

export default new Conversation({
  channel: '*',
  handler: async (props) => {
    if (props.type === 'message') {
      // Нормализованное сообщение: текст, фото, файл, контакт — одинаково для всех каналов.
      const text = props.message.payload.text
      await props.execute({ instructions: `Ответь на: ${text ?? '(вложение)'}` })
      return
    }
    if (props.type === 'event') {
      // Провайдерский факт (платёж, CRM-событие) — не реплика в чат.
      // Считает и решает код, не модель.
    }
  },
})

Значимые величины считает код. Суммы платежей, статусы, вердикты — это обработчик, а не модель. Модель формулирует ответ. Оплачено обращение или нет — решает код.

Идентичность и привязка

Входящее создаёт новый диалог. Интеграция не держит собственную таблицу пользователей — она сообщает платформе внешние идентификаторы (chat id, user id провайдера). Платформа делает get-or-create: находит или заводит внутренний диалог и пользователя, а провайдерские id кладёт в теги (conversation.tags, user.tags).

Привязка работает и обратно. Инвоцируя обработчик, интеграция называет своё стабильное имя (например telegram). Платформа резолвит по нему ровно одну включённую инсталляцию в scope бота и биндит диалог к ней (installation_id). Поэтому:

  • Исходящее уходит тем же каналом, которым пришло входящее. Outbound берёт инсталляцию из привязки диалога, а не угадывает.
  • Проактивные сообщения работают: зная диалог, бот пишет в него, даже если пользователь давно молчит.
  • Мультиканальность обходится без ветвлений в коде бота. Один обработчик обслуживает диалоги из разных каналов.

Резолюция fail-loud. Ноль включённых инсталляций (отвечать некуда) или больше одной без явной привязки (непонятно, от кого отвечать) — это ошибка, а не тихий выбор первой попавшейся.

Исходящее: два уровня

Наружу бот ходит на двух уровнях. Выбор осознанный.

Типизированный клиент — для сервиса, с которым вы работаете постоянно. Это обычный модуль в src/: конфиг и секреты из окружения, ретраи, идемпотентность, трейсы. Всё под типами и под вашим контролем. Так устроен, например, клиент к внешнему API внутри инструмента:

src/clients/rates.ts
export class RatesClient {
  private readonly baseUrl: string
  constructor(cfg: { baseUrl?: string } = {}) {
    // Конфиг из окружения; никаких секретов в аргументах модели.
    this.baseUrl = cfg.baseUrl ?? 'https://api.example.com'
  }

  async rateOn(date: string): Promise<string> {
    const resp = await fetch(`${this.baseUrl}/rate?date=${date}`)
    if (!resp.ok) {
      // Ошибка сервиса — громкая, а не «ноль по-тихому».
      throw new Error(`rates: unexpected status ${resp.status}`)
    }
    const { value } = await resp.json()
    return value
  }
}

Универсальный http_request — для разовых или редких вызовов, которые незачем оформлять отдельным клиентом. У него allowlist хостов: модель обращается только к заранее одобренным адресам, а не к произвольному URL. Секреты в такой запрос подставляет код из конфига бота — заголовки авторизации, токены. В контекст модели они не попадают: агент видит, что запрос ушёл, но не видит ключа.

Секрет никогда не проходит через модель. И типизированный клиент, и http_request вставляют авторизационные данные кодом в момент вызова. В промпт, в аргументы инструмента и в трейс они не попадают.

Telegram как первый канал

Соберём разобранное на живом примере. Telegram — первый канал платформы. Он проходит ровно тот же путь, что любая интеграция.

Первый deploy. Для agent production target сначала выполните brt deploy --adk --name "Мой бот". Инсталляция принадлежит существующему боту, поэтому этот шаг создаёт target до настройки канала.

Установка. brt integrations install telegram@<версия> --config-stdin с {"botToken":"…"}. Токен изымается из открытого конфига и запечатывается; в ответе — webhookId и одноразовый webhookSecret.

Зарегистрируйте. brt integrations register <webhookId> дёргает у провайдера setWebhook и передаёт ему публичный /hooks/{webhookId} и shared-secret. Статус инсталляции переходит в registered.

Второй deploy. Выполните brt deploy --adk: CLI сверяет обновлённое состояние target с Cloud до генерации и сборки, затем загружает bundle с зарегистрированной интеграцией. Только после этого production target готов к приёмочной проверке.

Входящее. Апдейты Telegram приходят на /hooks/{webhookId}, гейтятся секретом и нормализуются: текст — в сообщение text, фото — в image, документ — в file. Обработчик channel: '*' получает их как обычные сообщения.

Отдайте медиа. Файлы и фото интеграция скачивает сама и передаёт боту ссылкой или байтами. Токен бота не утекает в отдаваемые наружу URL.

Текущий production CLI поддерживает через brt integrations install одну installation на бота. Повторная установка того же alias обновляет metadata; второй alias завершается fail-loud, даже если backend-модель шире. Не планируйте два Telegram alias на одном боте до отдельного CLI lifecycle change.

Полученный диалог — обычный: тот же обработчик, те же инструменты, те же таблицы и воркфлоу. Смена канала не трогает код бота. Канал живёт в интеграции, а не в платформе.

Дальше

On this page