Каналы коммуникации: единая абстракция (Channel Drivers)
#1. Цель
Одна точка расширения для каналов. Matrix, Telegram, Slack, Mattermost, Teams, вебхуки — любые новые каналы добавляются реализацией драйвера, без правок в ядре доставки, inbound-конвейере, настройках, UI и модели данных.
Принцип: ядро не знает каналов, драйверы не знают продукта. Всё, что канало-специфично (протокол, payload, идемпотентность, причуды API) — внутри драйвера. Всё общее (маршрутизация, получатели, идентификация, события, сущности, UI) — в ядре, написано один раз.
#2. Проблема, которую решал слой драйверов (история)
До появления слоя драйверов добавление канала требовало правок восьми
несвязанных мест (собственный inbound-луп под протокол — его роль играл
отдельный файл типа matrixinbound.go, — свой sender в notify.go, ветка в
доставке файлов, матчинг ответов под матричные комнаты, формы UI, env
compose), а модель доставки была зашита: long-poll /sync для matrix,
getUpdates для telegram и никакого push. Слой драйверов (Фаза 0 ниже)
закрыл это: общий inbound-конвейер с дедупом, драйвер владеет только
протоколом.
#3. Контракт драйвера
// ChannelDriver — единственная точка расширения. Реализация инкапсулирует
// протокол; ядро зовёт только этот контракт.
type ChannelDriver interface {
// Метаданные
Type() string // "matrix", "telegram", "slack", "mattermost", "webhook"
Spec() ChannelSpec // label, address hint, capabilities, схема настроек
// Настройки
// Generic map (JSONB из workspace + env fallback) приходит аргументами в
// вызовы; драйвер сам валидирует свои поля, ядро не знает имён полей.
IsConfigured(settings map[string]string) bool
// Исходящая доставка (настройки и адрес — аргументы вызова)
SendText(ctx context.Context, settings map[string]string, to ChannelAddress, msg OutboundText) error
SendFile(ctx context.Context, settings map[string]string, to ChannelAddress, file OutboundFile) error // ErrUnsupported по capabilities
// Входящие — драйвер реализует ровно одну модель:
Poller() ChannelPoller // pull: matrix /sync, telegram getUpdates, mattermost websocket
Webhook() ChannelWebhook // push: slack events, webhook
}
// ChannelSpec описывает канал для ядра и UI.
type ChannelSpec struct {
Type string
Label string
AddressHint string
Capabilities Capabilities
Settings []SettingField // name, label, secret — UI рендерит generic
}
type Capabilities struct {
Text bool // все
TwoWay bool // ответ в канале маршрутизируется обратно (подсказка «reply here»)
FilesOut bool // отправка файлов агентом
FilesIn bool // приём вложений от пользователя (скачание — метод поллера)
Threads bool // ответ должен попасть в тред вопроса (slack/mattermost)
}
Интерактивные кнопки (options как кнопки) и лимиты длины текста — будущее
расширение контракта (см. docs/interactive-HITL.md); настройки канала
приходят аргументами, скачивание вложений живёт на стороне поллера.
// Pull-модель: курсорное долгое ожидание. type ChannelPoller interface { // Poll блокируется до появления событий или таймаута; cursor — opaque // строка, ядро персистит её между циклами. Poll(ctx context.Context, cursor string) (events []InboundMessage, next string, err error) // VerifySelf опциональная самопроверка (matrix whoami — не матчить себя). VerifySelf(ctx context.Context) error }
// Push-модель: канал сам стучится HTTP. type ChannelWebhook interface { Route() string // "/v1/channels/slack/events" VerifyRequest(r *http.Request, body []byte) error // подписи/токены канала Parse(r *http.Request, body []byte) []InboundMessage }
## 4. Нормализованная модель сообщений
Всё канало-специфичное остаётся в `refs`, ядро работает с общими полями:
```go
type InboundMessage struct {
ID string // канальный идентификатор (matrix event_id, tg update_id) — дедуп
Channel string // тип драйвера
ChatID string // комната/чат — сопоставляется с адресом канала пользователя
Sender string // канальная идентичность автора (mxid, member id, tg user id)
Text string
Files []InboundFile // {Ref, Name}; Ref — opaque для ядра (mxc://, file_id, URL)
Timestamp time.Time
ReplyTo string // если канал несёт ссылку на сообщение-вопрос (треды, reply_to)
}
// Ядро: «дай мне байты вложения» — скачивание инкапсулировано в драйвере
// (matrix — авторизованный download mxc://, telegram — getFile, slack — URL).
FetchFile(ctx context.Context, file InboundFile) ([]byte, error)
#5. Общий inbound-конвейер
Сегодняшний matrixInboundLoop — частный случай. Общий конвейер (один на
инсталляцию, по потоку на driver):
[Poller | Webhook] → InboundMessage
→ дедуп по (channel, ID) -- workspace_inbound_message, UNIQUE
→ идентификация отправителя -- закрытый мир: Sender == SenderIdentity(user)
→ матчинг ответов -- открытые human requests с адресом этого чата
→ иначе intake -- docs/inbound-messages.md
Матчинг ответов (MatchMatrixReplies) обобщается без канальной логики:
«сообщение в чате X отвечает старейшему открытому вопросу, доставленному в
чат X» — правило про адреса и время, не про матрицу. Ответ на вопрос, чей
воркфлоу уже завершился («зомби»-строка human_request после рестарта или
ребилда), не «съедает» сообщение: строка закрывается силами роутера
(аналог terminal-run hygiene sweep), отправителю уходит «вопрос уже
неактуален», а сообщение уходит в intake как новое — иначе каждое следующее
сообщение чата тихо умирало в мёртвом диалоге, и бот выглядел мёртвым для
конкретного пользователя, пока кто-то не открыл бы инбокс.
Отдельно живёт
workspace_channel_cursor (channel, cursor, updated_at) — телеграмный
offset обязан переживать рестарт, матричный since — желательно.
Webhook-драйверы регистрируют свой route в общем mux; после Verify+Parse сообщение попадает в тот же конвейер.
#6. Настройки: схема вместо типизированной структуры
TransportSettings с именованными полями заменяется generic-хранилищем:
- workspace:
channel_transport→ payload JSONB{"matrix": {"homeserver": …, "access_token": …}, "telegram": {"bot_token": …}}(миграция колонок в JSONB один раз, дальше новые каналы без миграций); - env fallback:
KERNEL_CHANNEL__<TYPE>__<FIELD>; легасиKERNEL_MATRIX_*/KERNEL_TELEGRAM_*читаются как алиасы; Configuredи «чего не хватает» (NotConfiguredHint) вычисляет драйвер по своей схеме — UI рендерит форму настроек изSettings, а не из свича.
Секреты по-прежнему не возвращаются API в открытом виде (SecretHint).
#7. Что не меняется
- Разрешение получателя (
ResolveRecipient), выбор канала (pickChannel), правила доставки — канало-нейтральны уже сейчас. human_requestкак сущность, маршрутизация ответов через Approval-signal, таймауты с политиками.- Отправка из активностей Temporal (25-секундный таймаут, доставка не роняет ран) — драйверы обязаны в него укладываться.
- «Web» как безусловный фолбэк: канал не сконфигурирован/упал → web inbox.
#8. Чеклист нового канала
После рефакторинга добавление Mattermost = один файл-драйвер + одна строка регистрации. Бесплатно получаете: доставку вопросов, send_file, inbound с дедупом и идентификацией, матчинг ответов, intake, настройки в UI, фолбэк на web, события, тестовую обвязку.
Обязанности драйвера: протокол и auth, рендер текста под лимиты/разметку канала, идемпотентность отправки (matrix txnID — уже так), FetchFile, VerifyRequest (для push), курсор (для pull).
#9. Особенности известных каналов
| Канал | Модель | Identity | Специфика |
|---|---|---|---|
| matrix | pull /sync |
mxid | whoami для пропуска себя; mxc:// download; txn idempotency; E2EE (m.room.encrypted) не поддерживается — такие события фиксируются и отклоняются с уведомлением (intake.rejected, reason: encrypted_unsupported), а не теряются молча |
| telegram | pull getUpdates |
chat id (личка) = адрес | offset-курсор персистить; лимит 4096 → tgformat рендерит и режет по частям; parse_mode=HTML с фолбэком на plain; Options → inline keyboard (callback a:<id>:<idx>, cancel a:<id>:x), answerCallbackQuery + editMessageText «решён»; schema-ask → HTML-виджет (Bot API 10.4 sendWidget, состояние в workspace_channel_widget, ops ≤64Б, ack ≤15с, throttle 5/5с); rich-ответы через sendRichMessage с фолбэком; черновики генерации sendMessageDraft в личке |
| slack (webhook) | только outbound | — | текущее поведение, остаётся отдельным типом |
| slack (bot) | push Events API | member id | треды (ReplyTo=ts), подпись запроса, blocks (future) |
| mattermost | pull WebSocket (posted) |
user id | self-hosted: URL сервера в настройках; тот же base_url+token стримит входящие через WS, включая DM, куда outgoing webhooks не достают; REST v4 для отправки. Ранее — outgoing webhooks: их стоит удалить из сервера, роут /v1/channels/mattermost/hooks больше не регистрируется |
| teams | push Bot Framework | aad id | верификация Microsoft, самый тяжёлый onboard |
| webhook (generic) | только outbound | — | escape hatch: envelope + HMAC-подпись, ответы в UI |
#9.1. Треды (Capabilities.Threads)
Канал объявляет Threads, если умеет держать беседу в треде вопроса
(slack ts, mattermost root post). Контракт:
OutboundText.Thread— указатель треда (""= верхний уровень); ядро заполняет его изthread_refпредыдущего вопроса того же рана тому же адресату — продолжение беседы не плодит новые треды.- Драйвер может реализовать
ThreadedTextPoster: возвращает id отправленного сообщения, ядро закрепляет его какthread_ref(fill-when-empty: якорь беседы не переназначается). Драйверы без расширения остаются на базовомSendText. - Ответ с
ReplyToматчится сначала по точному совпадению сthread_refвопроса, и только затем — общий fallback «чат + самый старый открытый вопрос». Первый тредный ответ на вопрос безthread_refзаполняет его (backfill): остальная часть треда матчится точно. - Team fan-out хранит один
thread_refна запрос (зеркало первого доставленного участника) — колонка на уровне запроса, не доставки.
#10. Безопасность
- Push-драйверы обязаны верифицировать запрос (подпись/секрет) —
VerifyRequestне опционален. - Закрытый мир идентичности (inbound-messages.md §5): инициативное сообщение
принимается только при совпадении
Senderс зарегистрированной идентичностью владельца канала. Полеsender_idпоявляется в канале пользователя, заполняется по подсказке драйвера. - Исходящие секреты (токены ботов) — только в настройках/секрет-сторе, в события и логи не попадают.
#11. Фазы
#Фаза 0 — выделение слоя (без изменения поведения)
- Интерфейс + реестр драйверов; matrix/telegram/slack-webhook/generic-webhook
переезжают в драйверы;
sendToChannelи sendfile-switch → dispatch по реестру. - Настройки: JSONB payload + env-алиасы, generic админ-форма из схемы.
matrixInboundLoop→ общий poll-луп + matrix-драйвер; общий конвейер с дедупом; матчинг ответов канализируется.- Курсоры в БД.
#Фаза 1 — intake поверх слоя
docs/inbound-messages.md фаза 1 строится на общем конвейере (matrix —
первый драйвер с TwoWay).
#Фаза 2 — новые inbound-каналы
Telegram inbound (pull, персистентный offset), Mattermost (push), Slack-bot (push, треды) — по мере надобности, каждый = драйвер.
#Фаза 3 — интерактивность
Options как кнопки там, где capabilities.Interactive (telegram inline keyboard, slack blocks); везде остаётся текстовый фолбэк.
#12. Критерии готовности
- Добавление тестового fake-канала (driver + регистрация, ~100 строк) даёт: доставку вопросов, send_file, inbound с дедупом, идентификацию, фолбэк на web — без единой правки вне файла драйвера.
- Регресс существующих каналов: матричные вопросы/ответы/файлы, телеграмная доставка, слак-webhook и generic-webhook ведут себя как до рефакторинга (золото: существующие тесты notify/sendfile/matrixinbound без правок ассертов).
- Настройки канала, заданные через env и через UI, эквивалентны.
- Рестарт кернела не теряет курсоры и не порождает дублей сообщений.
#13. Открытые вопросы
Разметка исходящих: markdown у нас, у каналов HTML — нужен renderer per driverрешено (tgformat.go): общий goldmark-конвейер с матрицей, маппинг на подмножество Telegram-HTML (b/i/s/code/pre/a/blockquote), таблицы схлопываются в текст, длинные тела режутся по абзацам (лимит 4096); parse_mode=HTML с автоматическим фолбэком на plain при «can't parse entities». Матрица продолжает org.matrix.custom.html.- Несколько ботов одного типа (два матрикс-аккаунта для разных юнитов) — настройки станут списком инстансов драйвера; пока один на тип.
- Клиентский мост HTML-виджетов (Bot API 10.4): серверная часть задокументирована
в Bot API, клиентская поверхность виджета (как JS внутри виджета постит
widget_query) — одна точка интеграции в widgetrender.go (
TelegramWidget.send); уточнить по официальной документации виджетов при появлении. - Черновики (sendMessageDraft) стримят накопленный текст из журнала рана (model.text_delta через answer-watcher); потоковый канал токенов реального времени (SSE-эндпоинт рана) — следующее уточнение, когда понадобится посимвольный стриминг.
- Rate limits и retry-политики отправки по каналам (у телеграма жёсткие) — общий бюджет попыток в ядре, паузы в драйвере.
- Voice/stickers: игнорируем или транскрибируем — вне скоупа, фиксируем в capabilities как отсутствие.