Модель интеграций и каналы
Как устроены интеграции: канальная логика живёт в интеграции, а не в платформе; per-install конфиг с шифруемыми секретами, входящие события против сообщений, два уровня исходящих вызовов.
Интеграция подключает бота к внешнему сервису: мессенджеру, платёжному провайдеру, CRM. Платформа не знает про Telegram или любой другой канал. Канальная логика живёт в интеграции.
Интеграция — это версионируемый бандл с тем же контрактом обработчика, что и бот. Платформа исполняет и ботов, и интеграции одинаково.
Отсюда простое разделение. Бот описывает поведение: что ответить, что посчитать, что сохранить. Интеграция описывает транспорт: как принять входящее и как доставить исходящее. Платформа гоняет между ними нормализованные сообщения и события. Переезжая с канала на канал, бот не меняет ни строки.
Определение и инсталляция
Интеграция существует в двух формах. Их важно не путать.
- Определение (definition) — запись в каталоге: имя, версия, схема конфига, набор каналов, действий и событий. Публикуется один раз и переиспользуется всеми ботами воркспейса. В каталоге видны глобальные встроенные определения и ваши приватные.
- Инсталляция (installation) — установка определения на конкретного бота, со своим конфигом и секретами. У неё есть непрозрачный
webhook_id, по умолчанию свой shared-secret и жизненный циклpending → registered → failed.
Определение отвечает на вопрос «что за интеграция». Инсталляция — «этот бот, этот токен, этот вебхук». Секреты (например botToken) живут только в инсталляции и только запечатанными.
Установите канал
Жизненный цикл интеграции состоит из двух мутаций: install создаёт инсталляцию и
запечатывает секреты, register подключает webhook у провайдера. Оба доступны
через brt.
# 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 --adkinstall валидирует конфиг по схеме определения. Секретные поля изымаются из открытого хранилища и запечатываются. Нет обязательного поля — громкий отказ на установке, а не мёртвая инсталляция. 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 в заголовке. Тенант выводится из резолвнутой инсталляции, а не из тела запроса. Неизвестный или выключенный webhookId — 404, несовпадение секрета — 401.
Для встроенного провайдера, который не поддерживает webhook-подпись,
platform-owned определение может объявить provider_verified. Тогда kernel
по-прежнему резолвит непрозрачный webhook и тенанта, а интеграционный бандл до
эмита события повторно получает объект через API провайдера и сверяет его
статус и значимые поля. Режим недоступен приватным интеграциям и не является
обходом проверки входящего трафика.
webhookAuthMode неизменяем внутри одного name@version. Платформа также не
перепривязывает существующую инсталляцию к версии с другим режимом: выполните
новую установку, чтобы состояние секрета соответствовало активному бандлу.
Дальше интеграция нормализует сырой апдейт провайдера. Здесь проходит важная граница:
- Сообщения пользователя нормализуются в channel-neutral модель (
text,image,file,contact, …). Бот видит их одинаково на любом канале. - Прочие факты — платёж, событие CRM, реакция — становятся событием, а не сообщением. Их обрабатывают триггеры и воркфлоу. В диалоге они не выглядят как реплика пользователя.
Обработчик различает их по полю type в props:
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 внутри инструмента:
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.
Полученный диалог — обычный: тот же обработчик, те же инструменты, те же таблицы и воркфлоу. Смена канала не трогает код бота. Канал живёт в интеграции, а не в платформе.