temporality
Изменить страницу↗
Инженерные документы

Каналы коммуникации: единая абстракция (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-хранилищем:

Секреты по-прежнему не возвращаются API в открытом виде (SecretHint).

#7. Что не меняется

#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). Контракт:

#10. Безопасность

#11. Фазы

#Фаза 0 — выделение слоя (без изменения поведения)

#Фаза 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. Критерии готовности

  1. Добавление тестового fake-канала (driver + регистрация, ~100 строк) даёт: доставку вопросов, send_file, inbound с дедупом, идентификацию, фолбэк на web — без единой правки вне файла драйвера.
  2. Регресс существующих каналов: матричные вопросы/ответы/файлы, телеграмная доставка, слак-webhook и generic-webhook ведут себя как до рефакторинга (золото: существующие тесты notify/sendfile/matrixinbound без правок ассертов).
  3. Настройки канала, заданные через env и через UI, эквивалентны.
  4. Рестарт кернела не теряет курсоры и не порождает дублей сообщений.

#13. Открытые вопросы