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

802 lines
57 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Balda — Mattermost коннектор (архитектура)
> Создано 2026-09-26. Обновлено 2026-09-26 (PR открыт).
> Статус: **✅ PR ОТКРЫТ — baldaworks/balda#8, 8/8 CI PASS, MERGEABLE.**
> 🟢 **PR:** https://github.com/baldaworks/balda/pull/8 — 41 файл, +5622/−18, 7 коммитов.
> Фаза 2 закрыта полностью: код, 103 новых теста, доки, зелёный CI.
> Блокер `tools/` снят: `/tools/` в `.gitignore` + `git rm -r --cached tools/`.
> Детали — [[personal/plans/balda-mm-pr-plan]].
> Часть проекта [[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
```go
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`):**
```go
// 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`):** хелпер сделан
**идемпотентным** — срезает уже существующий префикс:
```go
// 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`-костыль:
```go
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`-несовместимость, ни отсутствующие провайдеры.
**Как воспроизвести проверку графа (эталон):**
```go
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`):
```go
"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 заходов,
каждый раз пересобирая. **Урок: удалять мёртвую фичу одной группой дельт**, а не по одному
симптому — тогда каскад импортов закрывается за раз.
**Формат дельты:**
```json
{ "file": "путь/относительно/корня", "note": "что делаем",
"search": "уникальный фрагмент текущего текста",
"replace": "чем заменить" }
```
**Три состояния, которые различает апплаер:**
- `replace` уже есть в файле → `skipped` (идемпотентно, повторный прогон безопасен)
- `search` есть ровно один раз → `applied`
- ни `search`, ни `replace` → `error: ЯКОРЬ НЕ НАЙДЕН` (файл изменился не так, как ожидалось)
**Команды:**
```bash
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`)
```bash
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, а не спрашивать.
- **Команд, требующих подтверждения, не использовать** — писать скрипты.
- **Не останавливаться** до исполнения задачи.
## Связанные заметки
- [[personal/projects/balda/mattermost-kraken-deploy]] — сервер MM на Kraken (Фаза 1, ✅ закрыта)
- [[personal/projects/balda/balda-mm-delegation-plan]] — общий план
- [[personal/tech/balda-go-fx-pitfalls]] — 🔴 fx-питфоллы (fx.In/ParamTags, провайдеры) + метод дельт
- [[personal/projects/balda/balda-valera]] — текущая эксплуатация Балды/Валеры
- [[personal/tech/hermes-secret-redaction-bypass]] — как обходить маскировку секретов