Files
obsidian-vault/personal/projects/archive/balda-mattermost-connector-design-2026-09.md
T

58 KiB
Executable File
Raw Blame History

Balda — Mattermost-коннектор: исторический дизайн

[!WARNING] Историческая спецификация Код Mattermost-коннектора принят в PR #8, merged 2026-09-29; статус перепроверен 2026-10-14. Схема, пути, команды и результаты ниже описывают версию сентября и не служат текущим runbook. Текущий запуск: personal/projects/balda/setup. Текущие PR и код: personal/projects/balda/balda. Broker/ingress recovery и приёмка: personal/projects/balda/execution-broker.

Создано 2026-09-26. Обновлено 2026-09-28 (правки по ревью применены). Статус PR: MERGED, 2026-09-29. Приведённые ниже CI и commit IDs — история приёмки PR #8. 🟢 PR: https://github.com/baldaworks/balda/pull/8 — 46 файлов, +6850/−27, 9 коммитов. Фаза 2 закрыта полностью: код, 103+ новых теста, доки, зелёный CI. Блокер tools/ снят: /tools/ в .gitignore + git rm -r --cached tools/. Детали — personal/plans/balda-mm-pr-plan. 🔧 2026-09-28 — правки по ревью metalagman: голова 05342c5a, CI 8/8 PASS. Закрыты slash-команды (новый HTTP-приёмник command_server.go), /locator (locatorref + guard-тест), групповой DM (сессия по чату), и ScopeResolver-регистрация, которой не было в PR. Разбор — personal/plans/balda-mm-pr8-review-fixes. Часть проекта personal/projects/balda/balda-mm-delegation-plan (Фаза 2). Требование Alex: поддержка всех функций Балды, по новым API, production-качество.

Проверено (2026-09-26, PR):

  • go build ./... → exit 0 · go vet ./... → exit 0
  • go tool golangci-lint run → 0 issues
  • go tool go-arch-lint check --project-path . → OK - No warnings found
  • DI-граф собирается целиком с balda.mattermost.enabled=true — проверено validateBaldaApplication(prepared) через fx, а не только компиляцией
  • 103 новых теста зелёные, включая -race; в CI — 126 PASS, 0 FAIL, 0 SKIP
  • CI: 8/8 джобов PASS на чистом раннере (.github/workflows)

🔴 Исторический урок (Фаза 3): «все quality gates пройдены» оказалось неполным — при первом живом запуске на Kraken Балда ушла в crash-loop: advertised commands have no actor handler: goal. В Advertisement.Names была объявлена несуществующая команда goal (это payload kind, а не имя хендлера; цикл называется goalkeeper). Ни go build, ни go vet, ни golangci-lint, ни go-arch-lint, ни validateBaldaApplication этого не увидели. Исправлено в mattermostfx/module.go и inbound.go дельтой tools/deltas-goal-command-fix.json. Разбор — personal/tech/balda-go-fx-pitfalls §«Дефект 4». 📌 Вывод: «Фаза 2 закрыта» означала «код компилируется и граф собирается», но не «транспорт работает». Живой запуск обязателен. Статус Фазы 3 — personal/projects/balda/balda-kraken-mm-deploy.

✅ PR-ПРОХОД 2026-09-26 — транспорт приведён к PR-качеству

Ветка feat/mattermost-channel, 7 коммитов, PR открыт — baldaworks/balda#8. Подробности и полный список — personal/plans/balda-mm-pr-plan.

9f1e6c9c chore: ignore local project-editing tooling          ← уборка tools/ из PR
58f27120 docs(mattermost): document the transport setup and behavior
80d4f41d test(mattermost): cover the transport, the ingress and the handler
c7f18654 fix(mattermost): send a monotonic seq on every client frame
6dc3077a feat(mattermost): add the inbound handler and wire it into DI
fa1a2855 feat(auth): support the Mattermost channel subject
004fda2f feat(mattermost): add Mattermost transport adapter   ← база прошлой сессии

Исправлено в коде (не в данных):

Дефект Фикс
🔴 Двойной префикс principal — прошлый фикс был в БД и вернулся auth.MattermostSubject() теперь идемпотентен (срезает mattermost: перед повторной квалификацией); из handler.go убран TrimPrefix-костыль
ProgressPolicy.Typing: true в command-пути Приведено к false — у ботов MM нет typing API, SendTyping осознанный no-op

Тесты — 6 новых файлов, 103 теста: locator_codec_test.go (15), inbound_test.go (18), adapter_test.go (17), client_test.go (13), ingress_test.go (23) + handlersfx/mattermost_ingress_test.go (17). Ключевые регрессионные guard'ы: writeEvent всегда с seq и уникальный seq под 16 горутинами (bad_seq), plain-fallback только на content-rejection (не на 5xx), идемпотентность subject'а, тред = одна сессия ≠ канальной, /start доступен до авторизации. Пакеты mattermost + handlersfx в verbose-прогоне: 126 PASS, 0 FAIL, 0 SKIP.

Доки: README + docs/balda.md + новый docs/mattermost.md (создание бота, минтинг токена, membership, 5 ключей, поведение, troubleshooting).

Все гейты зелёные на закоммиченном дереве:

go build ./...                              → 0
go vet ./...                                → 0
go tool golangci-lint run                   → 0 issues
go tool go-arch-lint check --project-path . → OK - No warnings found
go test -race (затронутые пакеты)           → все ok

⚠️ 5 пакетов падают локально (cmd/balda, apps/balda, mcpruntime, session, state) — это апстримная база, проверено git stash (те же 4 теста падают без правок). 🔴 В CI эти же тесты ПРОШЛИ — локальное падение было особенностью машины, не дефектом.

tools/ вычищен из ветки: /tools/ в .gitignore + git rm -r --cached tools/ (коммит 9f1e6c9c). Файлы на диске целы (39 шт.), в PR-diff их нет. ⚠️ gitignore не отвязывает уже отслеживаемые файлы — нужны оба шага.


Статус Фазы 2: код закрыт (исторический раздел ниже)

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

🔧 Инструмент правок: tools/apply-project-deltas.py + tools/run-project-deltas.sh + tools/deltas-mattermost*.json — правки проекта идут текстовыми дельтами с якорями, не sed/in-place. См. §«Метод правок: дельта-апплаер».


Где работаем

Репозиторий ~/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/reference/configuration.md 5 ключей balda.mattermost.* ✅
README.md / docs/balda.md ⚠️ не обновлялись — README всё ещё перечисляет 3 chat surface ⏳

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

📌 Остаётся один незакрытый пункт требований AGENTS.md (не блокирующий): README и docs/balda.md не упоминают Mattermost. Закрыть при подготовке PR в апстрим либо в Фазе 3.


Что фактически написано (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)

Wiring-дельты (2026-09-26, вечер — закрыт блокер сборки)

Это то, что оставалось «незакрытым» в первой половине дня. Применено дельта-апплаером.

Файл Что добавлено Зачем
internal/apps/balda/app.go импорты channel/mattermost и channel/mattermost/mattermostfx корневой пакет нужен для mattermost.ValidateConfig; mattermostfx — для Module
internal/apps/balda/app.go 5 fx-провайдеров зарегистрированных строк: balda_mattermost_enabled, _server_url, _token, _bot_user_id, _bot_username модуль запрашивает их через name:-теги — без провайдеров fx падает «missing types»
internal/apps/balda/app.go validateMattermostConfig(cfg MattermostConfig) error + вызов рядом с validateSlackConfig fail-fast при enabled=true без URL/токена/bot_user_id
internal/apps/balda/app.go функция вставлена перед validateIdentifierValue порядок как у соседей
internal/apps/balda/config.go поле Mattermost MattermostConfig в BaldaConfig + тип MattermostConfig{Enabled, ServerURL, Token, BotUserID, BotUsername} конфиг-поверхность
cmd/balda/command.go !cfg.Balda.Mattermost.Enabled в validateBaldaChannelConfiguration + подсказка про BALDA_MATTERMOST_ENABLED в тексте ошибки иначе MM-only деплой отбивается «at least one channel is required»
cmd/balda/balda.yaml дефолтный блок balda.mattermost с 5 ключами и комментариями шаблон генератора
docs/reference/configuration.md 5 строк описания balda.mattermost.* (перед balda.slack.bot_token) документация ключей
docs/commands.md абзац про поведение MM (mention в каналах, /slash-команды, нет typing) + колонка Mattermost в таблице + строка /plugin ... таблица доступности команд

⚠️ app.go правился в 4 захода (импорт → провайдер enabled → валидация → остальные 4 строки). Каждый заход — отдельная дельта-группа; всё в tools/deltas-mattermost*.json.

🔴 Три реальных дефекта, найденных тестами (не компиляцией)

Компиляция проходила, но fx-граф был сломан. Поймано только запуском validateBaldaApplication с включённым Mattermost.

1. fx.In нельзя аннотировать внешним fx.ParamTags — дефект в mattermostfx/module.go.

Симптом:

Failed: invalid annotation function func(mattermostfx.IngressParams) (*mattermost.Ingress, error):
        fx.In structs cannot be annotated with fx.ParamTags or fx.From

Причина: в module.go NewIngress был обёрнут в fx.Annotate(NewIngress, fx.ParamTags(...)). Но IngressParams уже содержит fx.In — его теги обязаны жить внутри структуры.

Правильно (как в zulipfx/zulip.ServerParams):

// ingress.go — теги на полях структуры
type IngressParams struct {
    fx.In
    Processor   mattermost.InboundProcessor `optional:"true"`
    Client      *mattermost.Client
    Commands    *commandcmd.Registry
    Enabled     bool   `name:"balda_mattermost_enabled"`
    BotUserID   string `name:"balda_mattermost_bot_user_id"`
    BotUsername string `name:"balda_mattermost_bot_username"`
    Logger      zerolog.Logger
}

// module.go — голое предоставление, БЕЗ fx.Annotate/fx.ParamTags
fx.Provide(NewIngress),

⚠️ Commands был *commandcmd.Registry optional:"true"`` — заменён на обязательный *commandcmd.Registry (как у Zulip). commandcmd.Registry в графе есть всегда.

2. Отсутствовали провайдеры зарегистрированных строк.

Симптом: missing types: string[name="balda_mattermost_server_url"] (did you mean to Provide it?) ×3 (server_url, token, bot_user_id).

Причина: модуль читает значения через fx.ParamTags/name:, а провайдеров этих имён в app.go не было. У Zulip они есть (balda_zulip_*) — для MM забыли.

Правильно: 5 fx.Provide(fx.Annotate(func() ..., fx.ResultTags(name:"balda_mattermost_*"))). Для несуществующих ключей YAML strings.TrimSpace даёт "" — безопасно, как у Zulip. Каждый новый name:-тег в модуле = обязательный провайдер в app.go.

3. Отсутствовал импорт корневого пакета mattermost. app.go:965: undefined: mattermost — validateMattermostConfig вызывает mattermost.ValidateConfig, а импортирован был только mattermostfx.

Тесты (написаны 2026-09-26)

Тест Файл Что проверяет
TestLoadConfigDocument_AppliesMattermostEnvOverrides cmd/balda/command_test.go 5 env-override BALDA_MATTERMOST_* доезжают в doc.Balda.Mattermost
TestValidateCommandRunsValidationWithMattermostTransport cmd/balda/command_test.go реальный loadBaldaCommandConfig(true) + validateBaldaApplication(prepared) — весь fx-граф с включённым MM
— (встроено в существующие) — регрессионная проверка deliveryfmt

🔴 Тесты транспорта и хендлера (2026-09-26, сессия PR-подготовки)

Шесть новых файлов, ~90 тестов, все зелёные включая -race.

Файл Кол-во Что покрывает
channel/mattermost/locator_codec_test.go ~16 локаторы channel/thread/DM; изоляция thread↔channel сессий; LocatorFromAddressKey; ClassifyLocatorScope; UserID/ParseUserID; отказ чужим транспортам
channel/mattermost/inbound_test.go ~17 NormalizeInbound → shared-контракт; Typing:false; LocatorForPost (канал/тред/DM); фильтры; ParseCommand; whitelist (goalkeeper есть, goal нет); SupportedCommands возвращает копию
channel/mattermost/adapter_test.go ~16 все Deliver-операции; plain-fallback только на 400; draft-as-post; typing = no-op без сети; thread root_id; 5xx не ретраится
channel/mattermost/client_test.go ~13 ws/wss derivation; Authorization: Bearer; APIError; isContentRejectedError; контекст; 2xx без id = ошибка
channel/mattermost/ingress_test.go ~20 🔴 регрессия bad_seq; монотонность/уникальность seq под 16 горутинами; все фильтры; recover(); семафор; Start отказ без bot_user_id
handlersfx/mattermost_ingress_test.go ~18 публикация всех команд; 🔴 одиночный префикс principal; /start до авторизации; отказ чужим без публикации; nil-safety

Метод (для будущих транспортов):

  • REST — httptest.NewServer + fake-handler на POST /api/v4/posts, запоминающий запросы и с инъецируемым respond(attempt, req) (status, body). Так проверяются fallback-сценарии (первый запрос → 400, второй → 201).
  • WebSocket — реальный websocket.Upgrader в httptest + websocket.DefaultDialer. Единственный способ проверить, что writeEvent всегда шлёт seq.
  • Хендлер — тестируется структура напрямую (&mattermostInboundHandler{...}), без fx: так проверяются авторизация, префикс principal и nil-safety в изоляции.
  • fake...OwnerKVStore + recording...CommandIngress — по образцу zulip_ingress_test.go.

🔴 Дефект P1.1 — двойной префикс principal: починен в КОДЕ

Симптом (прод): /start owner=<token> в DM отвечает успехом, но @balda даёт settlement_stage="unauthorized" навсегда.

Корень: auth.MattermostSubject(userID) добавляет префикс (fmt.Sprintf("%s:%s", ChannelMattermost, ...)), а actors/command/start/handler.go:420 вызывал её поверх p.Principal, который уже приходит префиксованным от mattermost_ingress.go:147 (Principal: auth.MattermostSubject(cmd.SenderID)). Результат в БД — mattermost:mattermost:<id>, а GetUserByBinding сравнивает точно → совпадений нет → unauthorized. Плюс мусор в display_name и created_by channel-токенов.

Прошлый «фикс» был в данных (прямой UPDATE в state.db) — и дефект вернулся при следующей записи. 🔴 Правило: дефект, найденный в данных, чинить в коде.

Фикс (дельта tools/deltas-mattermost-23-prefix-idempotent.json): хелпер сделан идемпотентным — срезает уже существующий префикс:

// MattermostSubject returns the channel-qualified subject for a Mattermost user.
// It is idempotent: a value that is already channel-qualified ("mattermost:<id>")
// yields the same subject, so callers may pass either a raw user ID or a subject
// without risking a doubled prefix ("mattermost:mattermost:<id>").
func MattermostSubject(userID string) string {
	trimmed := strings.TrimSpace(userID)
	trimmed = strings.TrimPrefix(trimmed, ChannelMattermost+":")
	return fmt.Sprintf("%s:%s", ChannelMattermost, trimmed)
}

Из userSubject убран локальный TrimPrefix-костыль:

case transportMattermost:
	return auth.MattermostSubject(trimmed)

📌 Почему фикс в хелпере, а не в вызывающем коде: MattermostSubject вызывается из трёх мест (userSubject, mattermost_ingress.go:147, authorizeInbound). Идемпотентность закрывает все пути сразу и защищает от будущих вызовов поверх готового subject'а. Остальные вызовы были корректны — дефект ровно один, но источник общий.

Регресс-тесты: TestMattermostSubjectIsIdempotent (3 случая: raw, subject, padded) и TestMattermostHandlerSendsChannelQualifiedPrincipalExactlyOnce (через публикуемую команду).

🔴 Дефект P1.6 — ProgressPolicy.Typing расходился между путями

handlersfx/mattermost_ingress.go в HandleCommand ставил ProgressPolicy{Typing: true, PlanUpdates: true}, тогда как inbound-путь (inbound.go:135) и Adapter.SendTyping (осознанный no-op — у ботов MM нет typing API) говорили false. Мёртвый код: SendProgress при Typing:true вызывал SendTyping, который всегда возвращал nil, и на каждом ходу писался бы warn только при ошибке.

Фикс (дельта tools/deltas-mattermost-24-typing.json): Typing: false — command-путь приведён к inbound-пути. Оба пути теперь согласованы, и тест это фиксирует.

📌 Урок: при копировании Zulip-паттерна проверять каждую фичу на применимость к провайдеру. Pan-путь и turn-путь обязаны давать одинаковую ProgressPolicy для одного транспорта — расхождение невидимо компилятору.

🔴 Второй урок: не доверять «проверил — зелёно» без проверки в изоляции

Три моих первых теста падали, и все три — моя ошибка, не дефект кода:

Тест Что я предположил Реальность
TestLocatorForPostScopesReplyToThreadRoot тред-сессия == сессия root-поста Тред отдельная сессия от канала (mm-t-... vs mm-c-...) — это правильный дизайн, тест переписан на проверку «два ответа в треде → одна сессия»
TestNewIngressFallsBackToClientUserID NewIngress подставляет bot_user_id из клиента Не подставляет. Гард живёт в onStart («refuses to start without bot user id») — тест переписан на реальный контракт
TestMattermostHandlerRejectsUnauthorizedCommand отказ возвращает nil Возвращает runtime is unavailable (нет dispatcher для доставки отказа). Главное — команда НЕ публикуется, это и проверяется

📌 Дыра в дизайне, найденная тестом: root-пост треда (RootID="") → сессия канала mm-c-<hash(channel)>, а ответ в этом треде (RootID=post-1) → mm-t-<hash(channel|root)>. То есть тред — отдельная беседа от канала. Это соответствует комментарию в locator_codec.go («the locator key must stay stable for a whole thread»), но означает: бот, отвечающий в тред, ведёт отдельную от канала сессию. Зафиксировано тестом TestLocatorForPostScopesReplyToThreadRoot (два ответа в треде → одна сессия).

⚠️ Метод: когда тест падает — сначала перечитать код и понять, чей это контракт. Из трёх падений ноль оказались дефектами кода; все — неверные предположения теста.

🔴 Ключевой урок: go build ./... = exit 0 не значит, что транспорт рабочий. fx-граф проверяется только validateBaldaApplication (или fx.ValidateApp). Компиляция не видит ни fx.In-несовместимость, ни отсутствующие провайдеры.

Как воспроизвести проверку графа (эталон):

prepared, err := loadBaldaCommandConfig(true)   // нужен os.Chdir в tempdir с .config/balda/config.yaml
err = validateBaldaApplication(prepared)        // ← вот это и есть проверка графа

Конфиг для теста — mattermost.enabled: true + server_url/token/bot_user_id/bot_username, провайдер balda_agent: opencode_acp. Якорь func TestValidateCommandRunsValidation(t *testing.T) { использован как точка вставки.

✅ Текущее состояние — ЗАКРЫТО (2026-09-26, ночь)

Оба ранее открытых пункта выполнены:

  1. ✅ TestBuiltinRoutes — в map want добавлен маршрут Mattermost (internal/apps/balda/deliveryfmt/registry_test.go). Пакет deliveryfmt зелёный.
  2. ✅ golangci-lint — запущен, 4 замечания → исправлены (см. §«Что дал golangci-lint» ниже).

Дополнительно в этом же заходе:

  • ✅ .go-arch-lint.yml обновлён — компоненты mattermost_transport / mattermost_wiring, права зависимости у composition_root и ingress_wiring. Требование AGENTS.md.
  • ✅ Удалён мёртвый typing-throttle целиком (не заглушен).
  • ✅ Закоммичено: 004fda2f feat(mattermost): add Mattermost transport adapter (40 файлов, +3072).

Ничего не осталось по коду. Дальше — Фаза 3 (развернуть Балду с MM на Kraken, провайдер DeepSeek) → personal/projects/balda/balda-mm-delegation-plan.

Что дал golangci-lint (4 замечания, все закрыты)

Замечание Файл Фикс
inamedparam ×2 ingress.go:452-453 (interface assertion) Start(ctx context.Context) error / Stop(ctx context.Context) error
unused adapter.go:149 shouldSendTyping удалён вместе со всем throttle-механизмом
unused ingress.go:21 websocketReceiveTimeout константа удалена
unlambda mattermostfx/module.go:51 обёртка func(client, logger) *Adapter заменена на голое mattermost.NewAdapter

Корневая причина: typing-throttle (shouldSendTyping, typingThrottleInterval, typingLastSentAt, typingMu, SetTypingThrottleInterval) был скопирован из Zulip по аналогии, но у MM-ботов нет typing API — SendTyping всегда return nil, значит весь механизм недостижим. Удалён целиком группой (каскад: поле → setter → sync → zerolog в module.go), а не заглушен nolint.

📌 Урок для будущих транспортов: при копировании Zulip-адаптера проверять каждую фичу на применимость к провайдеру. Перенос «по аналогии» создаёт мёртвый код, который линтер находит только на финальном прогоне. Детали — personal/tech/balda-go-fx-pitfalls.

.go-arch-lint.yml — что добавлено

Требование AGENTS.md: архитектурное изменение обязано обновлять arch-lint в том же коммите. Без этого go tool go-arch-lint check падает двумя классами ошибок (composition_root shouldn't depend on …/mattermost, not attached to any component in archfile).

4 правки по образцу zulip:

  • components: → mattermost_transport (channel/mattermost + presentation/) и mattermost_wiring (mattermostfx/)
  • mayDependOn для обоих новых компонентов
  • composition_root → + mattermost_transport, mattermost_wiring
  • ingress_wiring → + mattermost_transport

Полные YAML-фрагменты — personal/tech/balda-go-fx-pitfalls §«go-arch-lint».

✅ Финальные quality gates — все зелёные

go build ./...                               → exit 0
go vet ./...                                 → exit 0
go tool golangci-lint run                    → 0 issues
go tool go-arch-lint check --project-path .  → OK - No warnings found
тесты Mattermost                             → 3 PASS

✅ Предсуществующие падения тестов — НЕ наши

13 пакетов падают и на чистом upstream/main. Проверено git stash (правки убраны → тесты перезапущены → правки возвращены). Падающие: TestInitCommand_NonInteractiveAutoSelectsRootAndGeneratesDetectedAgents (cmd/balda), TestMigrateSessionMemoryLayout_* ×4 (state), TestWorkspaceMCPImport/Export_* (session), TestPluginResolverResolvesContainedArgvFilteredEnvAndLaunchSecrets (mcpruntime), TestResolveWorkspaceBaseBranch_ConfigPreferredWhenValid, TestCanonicalSessionMemory* (apps/balda). ⚠️ Не пытаться «чинить» их как часть MM-задачи — это дефекты апстрима, отдельная работа.

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

  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), оставлен алиас.

✅ Импорт mattermostfx — ЗАКРЫТО

internal/apps/balda/app.go — импорты добавлены (строки 27-28, рядом с zulipfx):

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

Корневой mattermost нужен для mattermost.ValidateConfig в validateMattermostConfig. mattermostfx.Module в списке модулей (строка ~667). go build ./... = exit 0.

🔴 Что понадобилось дополнительно (не было в исходном коде)

Первоначально модуль компилировался, но DI-граф не собирался. Три дефекта:

  1. fx.In нельзя аннотировать внешним fx.ParamTags. IngressParams содержит fx.In, а в module.go стоял fx.Annotate(NewIngress, fx.ParamTags(...)) → fx падает с fx.In structs cannot be annotated with fx.ParamTags or fx.From. Фикс: name:-теги перенесены в поля IngressParams, NewIngress без fx.Annotate. Так же сделано в zulipfx (zulip.NewServer идёт голой ссылкой, теги в ServerParams).
  2. Не хватало провайдеров registered strings. Модуль запрашивает balda_mattermost_{server_url,token,bot_user_id,bot_username} — их никто не предоставлял. Фикс: 4 fx.Provide в app.go (блок // Mattermost transport), значения берутся из cfg.Balda.Mattermost.* через strings.TrimSpace. Для несуществующих ключей GetString возвращает "" — тот же паттерн, что у balda_zulip_*.
  3. Валидация стояла не там. Функцию validateMattermostConfig добавили в app.go (рядом с validateSlackConfig), вызов — после validateSlackConfig в Module().

Метод, который это поймал: go build/go vet этих ошибок не видят — только реальный fx.ValidateApp через validateBaldaApplication. Для любого нового транспорта писать такой тест сразу: он ловит неполный wiring, а не только синтаксис.

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

Ключи читаются через 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-теги.

✅ Проверено 2026-09-26: env-имена — BALDA_MATTERMOST_ENABLED, BALDA_MATTERMOST_SERVER_URL, BALDA_MATTERMOST_TOKEN, BALDA_MATTERMOST_BOT_USER_ID, BALDA_MATTERMOST_BOT_USERNAME. Механизм: appconfig/loader.go:367 делает SetEnvPrefix("BALDA") + SetEnvKeyReplacer(".", "_") на leaf-ключах. Env применяется только к ключам, которые уже есть в YAML — поэтому блок balda.mattermost обязан присутствовать в cmd/balda/balda.yaml, иначе env-переменные молча игнорируются. Это же верно для любого нового транспорта.

Метод правок: дельта-апплаер (инструмент проекта)

Правки проекта идут не sed/не in-place, а текстовыми дельтами. Причина: правило Alex «никогда не использовать in-place скрипты и sed; писать скрипт, потом исполнять» + нужна идемпотентность при повторных заходах.

Файл Роль
tools/apply-project-deltas.py движок: применяет JSON-дельты, ведёт состояние
tools/run-project-deltas.sh обёртка: check / apply / list
tools/deltas-mattermost.json основная группа (12 дельт: wiring + доки)
tools/deltas-mattermost-2.json импорт корневого mattermost
tools/deltas-mattermost-3.json тест env-override
tools/deltas-mattermost-4.json тест fx-графа
tools/deltas-mattermost-5.json фикс fx.In + module.go
tools/deltas-mattermost-6.json 4 провайдера строк
tools/deltas-mattermost-7.json маршрут Mattermost в TestBuiltinRoutes
tools/deltas-mattermost-8.json .go-arch-lint.yml: mattermost_transport/mattermost_wiring
tools/deltas-mattermost-9.json inamedparam + удаление shouldSendTyping/полей/websocketReceiveTimeout
tools/deltas-mattermost-10.json удаление SetTypingThrottleInterval + typingMu + вызов/импорт time в module.go
tools/deltas-mattermost-11.json удаление неиспользуемого импорта sync
tools/deltas-mattermost-12.json unlambda: голое mattermost.NewAdapter
tools/deltas-mattermost-13.json удаление неиспользуемого импорта zerolog из module.go
tools/deltas-mattermost-23-prefix-idempotent.json 🔴 P1.1: MattermostSubject идемпотентна (срезает существующий префикс) + убран TrimPrefix-костыль из userSubject
tools/deltas-mattermost-24-typing.json 🔴 P1.6: ProgressPolicy.Typing → false в command-пути (handlersfx/mattermost_ingress.go)
tools/deltas-mattermost-25-docs-balda.json docs/balda.md: Mattermost в runtime stack, typing-нюанс, ссылка на гайд
tools/deltas-mattermost-26-docs-readme.json README.md: Mattermost в 4 списках + balda.mattermost.* + ссылка на гайд
tools/.deltas-state.json состояние (sha256 применённых якорей)

📌 Дельты 9-13 — это один каскад линтера. Удаление throttle-механизма тянуло SetTypingThrottleInterval → typingMu → sync → zerolog. Пришлось делать 5 заходов, каждый раз пересобирая. Урок: удалять мёртвую фичу одной группой дельт, а не по одному симптому — тогда каскад импортов закрывается за раз.

Формат дельты:

{ "file": "путь/относительно/корня", "note": "что делаем",
  "search": "уникальный фрагмент текущего текста",
  "replace": "чем заменить" }

Три состояния, которые различает апплаер:

  • replace уже есть в файле → skipped (идемпотентно, повторный прогон безопасен)
  • search есть ровно один раз → applied
  • ни search, ни replace → error: ЯКОРЬ НЕ НАЙДЕН (файл изменился не так, как ожидалось)

Команды:

cd ~/Developer/balda
bash tools/run-project-deltas.sh check tools/deltas-mattermost.json tools/.deltas-state.json
bash tools/run-project-deltas.sh apply tools/deltas-mattermost.json tools/.deltas-state.json
bash tools/run-project-deltas.sh list  tools/deltas-mattermost.json tools/.deltas-state.json

📌 check обязателен перед apply — он показывает TODO/MISSING/OK по каждому якорю. Именно на этой проверке я поймал, что дельта валидации указывала в несуществующий validate() в config.go (валидация живёт в app.go) и переписал её до записи.

⚠️ Не писать search с литеральными секретами — значения собираются на хосте. ⚠️ Дельты с длинным replace писать в JSON-файл через write_file, не в heredoc: кавычки и \n ломаются.


Объём работ

Оценка из плана: 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, а не спрашивать.
  • Команд, требующих подтверждения, не использовать — писать скрипты.
  • Не останавливаться до исполнения задачи.

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