802 lines
57 KiB
Markdown
Executable File
802 lines
57 KiB
Markdown
Executable File
# 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]] — как обходить маскировку секретов
|