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

44 KiB
Raw Blame History

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

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

Проверено (2026-09-26, финал):

  • 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, а не только компиляцией
  • 3 новых теста зелёные (см. §«Тесты»)
  • Закоммичено: 004fda2f feat(mattermost): add Mattermost transport adapter (40 файлов, +3072)

Осталось: ничего по коду. Дальше — Фаза 3 (Балда с MM на Kraken) и Фаза 4 (исполнитель).

🔴 ОБНОВЛЕНО 2026-09-26 (ночь, позже): «все quality gates пройдены» оказалось неполным. При первом живом запуске на Kraken (Фаза 3) Балда ушла в 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.

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

🔧 Инструмент правок: 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

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

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