Files
obsidian-vault/personal/projects/balda/balda-mattermost-connector.md
T

23 KiB
Raw Blame History

Balda — Mattermost коннектор (архитектура)

Создано 2026-09-26. Обновлено 2026-09-26. Статус: КОД НАПИСАН, КОМПИЛИРУЕТСЯ. Часть проекта personal/projects/balda/balda-mm-delegation-plan (Фаза 2). Требование Alex: поддержка всех функций Балды, по новым API, production-качество.

Проверено: go build ./... → exit 0, go vet чист. Тесты ещё не написаны. Осталось: добавить импорт mattermostfx в app.go (LSP подсветил, не успел), затем тесты + go test -race ./... + golangci-lint.

Разделы ниже — сначала контракт/разведка, затем фактически написанные файлы. Ищи §«Что фактически написано» для реестра файлов.


Где работаем

Репозиторий ~/Developer/balda (форк mallexxx/balda)
Ветка feat/mattermost-channel — создана от чистого upstream/main
База de273d8f feat(balda): embed backoffice in startup lifecycle
Апстрим baldaworks/balda (normahq/balda редиректит туда — SHA идентичны)
Наши коммиты не тащим (отставание 426 коммитов; решение в плане)

Проверка: git rev-list --left-right --count main...upstream/main → 2 426.

⚠️ В форке остаётся 5ad65955 — replace normahq/norma => ./norma-local в go.mod. На ветке от upstream его нет (ветка чистая), но при любом мерже с нашей main — удалять.


Контракт: docs/architecture/transport-presentation-boundary.md

Ключевое правило (generic invariant для транспорта X):

  • X может зависеть от shared-контрактов; shared-контракты не могут зависеть от X;
  • внешний wiring импортирует X (root) и Xfx, но не внутренние presentation/delivery;
  • всё UX-специфичное живёт внутри X.

Два пути вывода:

  1. Model-authored text → prompt-format путь. Транспорт может инжектить свои правила форматирования.
  2. System-authored service messages → типизированные дескрипторы + детерминированные рендереры, регистрируемые каждым транспортом отдельно. Не в shared-пакетах.

Образец: internal/apps/balda/channel/zulip/

Размеры (эталон объёма для MM):

Файл Строк
adapter.go 331
adapter_test.go ~235
client.go 289
server.go 316
server_test.go 593
locator_codec.go 229
inbound.go 192
formatting.go 14
presentation/locator.go 18
presentation/goal.go 76
zulipfx/{module,prompt,structured}.go 66 + 36 + 54

zulipfx/module.go — что регистрируется через DI

var Module = fx.Module("balda_channel_zulip_fx",
    fx.Provide(
        // 1. Advertisement — список поддерживаемых команд для транспорта
        fx.Annotate(func(enabled bool) commandcmd.Advertisement {
            return commandcmd.Advertisement{Transport: zulip.ChannelType, Enabled: enabled,
                Names: []string{"locator","reset","usage","auto","cancel","goalkeeper","topic","close","start","user","skill"}}
        }, fx.ParamTags(`name:"balda_zulip_webhook_enabled"`),
           fx.ResultTags(`group:"balda_command_advertisements"`)),

        zulip.NewServer,                          // 2. ingress carrier
        func(client *zulip.Client, logger zerolog.Logger) *zulip.Adapter {
            adapter := zulip.NewAdapter(client, logger)
            adapter.SetTypingThrottleInterval(4 * time.Second)
            return adapter
        },                                        // 3. delivery adapter

        fx.Annotate(func(a *zulip.Adapter) deliveryfx.ChannelAdapterBinding {
            return deliveryfx.ChannelAdapterBinding{ChannelType: zulip.ChannelType, Adapter: a}
        }, fx.ResultTags(`group:"balda_delivery_channel_adapter"`)),   // 4. binding в роутер

        fx.Annotate(NewPromptRegistryContribution,
            fx.ResultTags(`group:"balda_delivery_prompt_contribution"`)),   // 5. prompt-правила

        fx.Annotate(NewLocatorStructuredRegistrar,     // 6. structured renderers
            fx.ResultTags(`group:"balda_delivery_structured_registrar"`)),
        fx.Annotate(NewGoalProgressStructuredRegistrar, …),
        fx.Annotate(NewGoalOutcomeStructuredRegistrar, …),

        fx.Annotate(func(h *zulip.Server) appports.TransportLifecycleStage {
            return appports.TransportLifecycleStage{Name: "zulip ingress", Start: h.Start, Stop: h.Stop}
        }, fx.ResultTags(`group:"balda_transport_lifecycle_stage"`)),  // 7. lifecycle
    ),
)

7 групп DI — столько же нужно для MM. Имена групп фиксированы.

locator_codec.go — модель локатора

  • ChannelType = "zulip" (MM: "mattermost"), sessionIDPrefix = "zu" (MM: "mm").
  • Address payload — JSON: {type, stream_id, topic, user_id}. MM: {type, channel_id, root_id, user_id}.
  • AddressKey: s:<id>:<escaped-topic> / dm:<id>. MM: c:<channel_id> / dm:<user_id> / t:<root_id>.
  • SessionID: zu-s-<streamID>-<8 hex от sha256(topic)> / zu-dm-<uid>.
  • Три обязательные функции: NewXLMLocator, DecodeLocator (возвращает (addr, ok, err)), LocatorFromAddressKey, ClassifyLocatorScope → LocatorScopePersonal / LocatorScopeGroup.
  • Дополнительно: UserID(int) string ↔ ParseUserID(string) (int, error) — префикс zu-/mm-.

adapter.go — deliverycmd.Adapter

Реализует var _ deliverycmd.Adapter = (*Adapter)(nil). Константы операций (deliverycmd.Operation*), которые надо покрыть: Plain, Markdown, AgentReply, Draft, Typing, Progress. Результат — deliverycmd.Result{ProviderMessageID string}.

Обязательные публичные методы (по образцу Zulip — вызываются роутером и gateway): SendPlain, SendMarkdown, SendMarkdownWithFormat, SendAgentReply, SendAgentReplyWithProviderMessageID, SendAgentReplyWithProviderMessageIDAndFormat, SendDraftPlain, SendTyping, SendProgress, Deliver.

Питфоллы, заложенные в Zulip-адаптере (повторить):

  • sendWithPlainFallback — если провайдер отбил markdown (isContentRejectedError), отправить plain-вариант. Ошибки объединять через errors.Join, не терять первую.
  • Fallback только на content-rejection (400/BAD_REQUEST), не на 5xx — иначе дубли сообщений при временных сбоях.
  • Typing throttle по locator.SessionID (Zulip — 4 с), с инъектируемым now func() time.Time (нужно для тестов). Поле typingLastSentAt map[string]time.Time под mutex.
  • validateReady() — явная проверка client != nil, возвращает человекочитаемую ошибку.
  • SendDraftPlain для Zulip — no-op (return nil), т.к. нет edit-in-place. Для MM это не no-op: у MM есть PUT /posts/{id} (edit) — реализовать по-настоящему.
  • SendProgress: ProgressThinking → no-op; ProgressPlanUpdate → отправить текст.

inbound.go — нормализация входа

Возвращает turncmd.NormalizedInbound с полями: ID (turncmd.InboundID("zulip:"+msgID)), Text, Locator, ProviderMessageID, UserID, MessageID, ReceivedAt (RFC3339 UTC), DeliveryFormat, ProgressPolicy{Typing, PlanUpdates}, Direct, Source.

MM: Source = новый turncmd.SourceMattermost, DeliveryFormat = DeliveryFormatMarkdown, ProgressPolicy{Typing: false, PlanUpdates: true} — в MM нет typing-индикатора в каналах (есть только в DM через WebSocket user_typing; для каналов не работает).

Также в inbound.go: InboundMessage, InboundCommand, интерфейс InboundProcessor (3 метода: ProcessInbound, HandleCommand, HandleUnsupportedCommand), SupportedCommands() []string, commandSupported(name).

ingress.go — ingress (фактически реализовано)

⚠️ Zulip использует outgoing webhook по HTTP. У MM для ботов — WebSocket (wss://<host>/api/v4/websocket). Это отличается от Zulip-образца принципиально.

Как реализована аутентификация (проверено компиляцией):

  • dial с заголовком Authorization: Bearer <token>;
  • плюс обязательный первый кадр {"seq":1,"action":"authentication_challenge","data":{"token":"<token>"}} — без него MM не начинает слать события;
  • WebSocketURL() в client.go сам конвертирует http→ws / https→wss и добавляет /api/v4/websocket.

⚠️ В ранних заметках было Sec-WebSocket-Protocol: bearer <token> — это не то, что реализовано и не то, что описано в актуальных доках MM. Использовать authentication_challenge.

Перенесено из Zulip-сервера и работает:

  • семафор на конкурентную обработку (processSem chan struct{}, 16) + processWG; при заполнении — дроп с warn-логом, не накопление;
  • defer recover() вокруг обработки поста → не ронять процесс;
  • фильтр эхо-сообщений (IsBotEcho) → нет бесконечного цикла;
  • SetReadLimit(1 MiB) на WS-кадр;
  • reconnect-loop с backoff 5 с — readLoop переустанавливает соединение, пока контекст жив;
  • отдельная reader-горутина: блокирующий ReadMessage не прерывается контекстом, поэтому Stop() закрывает conn, и горутина выходит по ошибке;
  • graceful Stop() с ожиданием активной обработки (waitForProcessing);
  • Start/Stop в appports.TransportLifecycleStage.

Специфика MM, которой не было в Zulip:

  • В канале Балда отвечает только на @mention (MentionsBot/StripMention); в DM — на всё.
  • События post_edited / post_deleted осознанно игнорируются: Балда действует по исходной инструкции и не переписывает свой ответ задним числом.
  • Системные посты (Type != "") и удалённые отсеиваются до семафора.
  • data.post в событии posted — это JSON-строка, а не объект (нужен двойной Unmarshal).
  • Каналы типа D / G — это DM/групповой чат, а не канал команды.

presentation/ + formatting.go

  • formatting.go: FormattingPromptRuleAndExample() (rule, example string) — правило для модели (MM-совместимый Markdown), и MarkdownPlainText(text).
  • presentation/locator.go: RenderLocator(body locatorfmt.Response) string — локатор + YAML-блок target: locator / key: <locator> для scheduler/webhook.
  • presentation/goal.go: RenderGoalProgress(goalkeepercmd.GoalProgress) и RenderGoalOutcome(goalkeepercmd.GoalOutcome) — используют ShouldRenderValidation(), ShouldRenderVerified(), ShouldRenderNotVerified(), ShouldRenderNextAction().

zulipfx/{prompt,structured}.go

  • prompt.go → deliveryfx.PromptRegistryContribution с тремя полями: Formats (правило+пример), Formatters (реализация deliveryfmt.Formatter: Name() Name + Format(text) (Message, error)), Routes ({Transport, DeliveryFormat, RegisteredName}).
  • structured.go → по одному deliveryfx.StructuredRegistryRegistrar на каждый message kind через deliveryfx.NewStructuredRegistrar(deliveryfmt.TransportX, <descriptor>, renderer). Renderer реализует RenderStructured(ctx, deliveryfmt.StructuredEnvelope[T]) (StructuredPresentation, error). Три штуки: locatorfmt.ResponseDescriptor, goalkeepercmd.ProgressDescriptor, goalkeepercmd.OutcomeDescriptor.

Что нужно добавить в shared-слой

Файл Добавить
deliverycmd/channel.go ChannelTypeMattermost ChannelType = "mattermost"
deliveryfmt/routes.go TransportMattermost = "mattermost", NameMattermostMarkdown Name = "mattermost_markdown"
turncmd (source) SourceMattermost
docs/commands.md колонка Mattermost в таблице доступности команд
docs/balda.md + README.md описание нового транспорта (требование AGENTS.md)

⚠️ channel/router.go править не нужно — он map-based по locator.ChannelType. Достаточно зарегистрировать binding в DI (группа balda_delivery_channel_adapter).


Что фактически написано (2026-09-26)

Всё в ~/Developer/balda на ветке feat/mattermost-channel. go build ./... = exit 0.

Новый пакет internal/apps/balda/channel/mattermost/

Файл Строк Содержимое
client.go ~560 REST v4: CreatePost, UpdatePost, GetPost, GetChannel, GetUser, GetMe, GetTeamByName, GetChannelByName, GetDirectChannel, UploadFile, CreatePostWithFiles, AddChannelMember, Ping; WebSocketURL(), APIBaseURL(), ValidateConfig(); APIError
locator_codec.go ~250 ChannelType="mattermost", префикс mm. AddressKey: c:<ch> / c:<ch>:<root> / d:<ch>. NewChannelLocator(teamID, channelID, rootID), NewDMLocator(channelID, userID), DecodeLocator, LocatorFromAddressKey, ClassifyLocatorScope, ChannelIDFromLocator, RootIDFromLocator, IsDirectLocator, UserID/ParseUserID
inbound.go ~250 InboundMessage/InboundCommand/InboundProcessor (3 метода), NormalizeInbound, LocatorForPost, IsDirectChannelType (D/G), IsBotEcho, IsSystemPost, IsDeletedPost, StripMention, MentionsBot, ParseCommand, SupportedCommands, WebSocketEvent/PostedData
adapter.go ~420 Все 9 deliverycmd.Operation: Plain, Markdown, AgentReply, Draft, Typing, Progress, ClearQuestionControls, Photo, Document. sendWithPlainFallback, MarkdownPlainText, sendMedia
media.go ~45 openMediaFile (лимит 100 МБ), mediaBaseName
ingress.go ~380 WebSocket ingress: dial с Authorization: Bearer, authentication_challenge, reconnect-loop, ping 30 с, processSem (16), processWG, defer recover()
formatting.go ~14 FormattingPromptRuleAndExample()
presentation/presentation.go ~110 RenderLocator, RenderGoalProgress, RenderGoalOutcome
mattermostfx/module.go ~100 7 групп DI (как zulipfx)
mattermostfx/config.go ~25 NewClient + валидация при enabled
mattermostfx/ingress.go ~40 IngressParams (fx.In), NewIngress
mattermostfx/structured.go ~100 prompt contribution + 3 structured registrar

Изменения в shared-слое (все применены)

Файл Что добавлено
deliverycmd/channel.go ChannelTypeMattermost ChannelType = "mattermost"
deliveryfmt/routes.go TransportMattermost = "mattermost", NameMattermostMarkdown Name = "mattermost_markdown", маршрут {TransportMattermost, DeliveryFormatNone, NamePlainText} в BuiltinRoutes()
turncmd/turn.go SourceMattermost = "mattermost"
internal/apps/balda/app.go mattermostfx.Module в список модулей (строка ~667)

Отклонения от плана разведки (важно)

  1. server.go → ingress.go. Планировался server.go по образцу Zulip, но у MM нет HTTP-вебхука — это долгоживущее WS-соединение, а не http.Server. Имя ingress.go точнее отражает суть.
  2. SendDraftPlain — не no-op. В плане было «для MM это не no-op, edit-in-place есть». По факту: OperationDraft отправляется как обычный пост (текст не теряется), а edit-in-place живёт в OperationClearQuestionControls → PUT /posts/{id} — там он семантически уместен (гашение кнопок после выбора).
  3. AddressKey не t:<root_id>. Планировалось c: / dm: / t:. По факту тред кодируется тем же channel-ключом с суффиксом: c:<ch> (канал) и c:<ch>:<root> (тред). Причина: тред MM — это root-post внутри канала, и локатор должен оставаться стабильным для всего треда.
  4. ProgressPolicy{Typing: false}. Подтверждено: у MM-ботов нет typing API (SendTyping возвращает nil осознанно — иначе шум на каждом ходу).
  5. gorilla/websocket v1.5.3 был // indirect в go.mod — стал прямым при сборке.
  6. commandSupport переименован в экспортируемый IngressCommandSupport (нужен для fx.In), оставлен алиас.

🔴 Известный незакрытый шаг

internal/apps/balda/app.go — модуль уже в списке, но импорт не добавлен:

"github.com/baldaworks/balda/internal/apps/balda/channel/mattermost/mattermostfx"

Вставить рядом с zulipfx (строки 27). LSP: undefined: mattermostfx на строке 667. Без этого go build ./... не пройдёт — проверять первым делом.

Конфиг, который нужен для запуска

Ключи читаются через fx.ParamTags / name:"…":

balda_mattermost_enabled
balda_mattermost_server_url
balda_mattermost_token
balda_mattermost_bot_user_id
balda_mattermost_bot_username

Соответствие в config.yaml (по образцу balda.zulip.*): balda.mattermost.{enabled,server_url,token,bot_user_id,bot_username}. Конкретные имена ключей yaml проверить в docs/reference/configuration.md перед Фазой 3 — я их не сверял, только зафиксировал DI-теги.


Объём работ

Оценка из плана: 2-4 дня. Разбивка:

Шаг Объём
client.go (REST v4: posts, threads, files, reactions, websocket) 1 день
locator_codec.go + inbound.go 0.5 дня
adapter.go (все 6 операций + MM-специфика) 1 день
server.go (WS ingress, reconnect, семафор, recover) 1 день
mattermostfx/ + presentation + prompt + structured 0.5 дня
тесты (go test -race ./...) + golangci-lint 0.5-1 день

Quality gates (обязательны, из AGENTS.md)

go test -race ./...
go tool golangci-lint run

Логирование (правило проекта)

Разрешено: zerolog, log/slog. Запрещено: logrus, zap, stdlib log. Инициализация через internal/logging.Init(). Предпочитать структурированные поля. Компонент логировать как logger.With().Str("component", "balda.channel.mattermost").Logger().

Коммиты

Conventional Commits. Синхронизация общих веток — merge (git pull --no-rebase), не rebase.


Что понадобится из Mattermost API

Задача Endpoint
Отправка поста POST /api/v4/posts ({channel_id, message, root_id?, file_ids?})
Редактирование (draft→update) PUT /api/v4/posts/{post_id}
Треды root_id в create — ответы в тред
Файлы POST /api/v4/files + file_ids в посте
Реакции (для question-контролов) POST /api/v4/reactions
WebSocket события wss://<host>/api/v4/websocket (posted, post_edited)
Ping / health GET /api/v4/system/ping

Авторизация: Bot account + Personal Access Token (Authorization: Bearer <token>). ⚠️ Alex сказал: бота создавать не надо — токен предоставит он сам.

Открытые вопросы / решения Alex

  • Бота не создавать (2026-09-26, явно). Токен для MM-адаптера даст Alex.
  • Вопросы не задавать — решения принимать самому, искать в Obsidian, а не спрашивать.
  • Команд, требующих подтверждения, не использовать — писать скрипты.
  • Не останавливаться до исполнения задачи.

Связанные заметки