58 KiB
Executable File
Balda — Mattermost-коннектор: исторический дизайн
[!WARNING] Историческая спецификация Код Mattermost-коннектора принят в PR #8, merged 2026-09-29; статус перепроверен 2026-10-14. Схема, пути, команды и результаты ниже описывают версию сентября и не служат текущим runbook. Текущий запуск: personal/projects/balda/setup. Текущие PR и код: personal/projects/balda/balda. Broker/ingress recovery и приёмка: personal/projects/balda/execution-broker.
Создано 2026-09-26. Обновлено 2026-09-28 (правки по ревью применены). Статус PR: MERGED, 2026-09-29. Приведённые ниже CI и commit IDs — история приёмки PR #8. 🟢 PR: https://github.com/baldaworks/balda/pull/8 — 46 файлов, +6850/−27, 9 коммитов. Фаза 2 закрыта полностью: код, 103+ новых теста, доки, зелёный CI. Блокер
tools/снят:/tools/в.gitignore+git rm -r --cached tools/. Детали — personal/plans/balda-mm-pr-plan. 🔧 2026-09-28 — правки по ревьюmetalagman: голова05342c5a, CI 8/8 PASS. Закрыты slash-команды (новый HTTP-приёмникcommand_server.go),/locator(locatorref+ guard-тест), групповой DM (сессия по чату), иScopeResolver-регистрация, которой не было в PR. Разбор — personal/plans/balda-mm-pr8-review-fixes. Часть проекта personal/projects/balda/balda-mm-delegation-plan (Фаза 2). Требование Alex: поддержка всех функций Балды, по новым API, production-качество.Проверено (2026-09-26, PR):
go build ./...→ exit 0 ·go vet ./...→ exit 0go tool golangci-lint run→ 0 issuesgo 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.
Два пути вывода:
- Model-authored text → prompt-format путь. Транспорт может инжектить свои правила форматирования.
- System-authored service messages → типизированные дескрипторы + детерминированные рендереры, регистрируемые каждым транспортом отдельно. Не в shared-пакетах.
Образец: internal/apps/balda/channel/zulip/
Размеры (эталон объёма для MM):
| Файл | Строк |
|---|---|
adapter.go |
331 |
adapter_test.go |
~235 |
client.go |
289 |
server.go |
316 |
server_test.go |
593 |
locator_codec.go |
229 |
inbound.go |
192 |
formatting.go |
14 |
presentation/locator.go |
18 |
presentation/goal.go |
76 |
zulipfx/{module,prompt,structured}.go |
66 + 36 + 54 |
zulipfx/module.go — что регистрируется через DI
var Module = fx.Module("balda_channel_zulip_fx",
fx.Provide(
// 1. Advertisement — список поддерживаемых команд для транспорта
fx.Annotate(func(enabled bool) commandcmd.Advertisement {
return commandcmd.Advertisement{Transport: zulip.ChannelType, Enabled: enabled,
Names: []string{"locator","reset","usage","auto","cancel","goalkeeper","topic","close","start","user","skill"}}
}, fx.ParamTags(`name:"balda_zulip_webhook_enabled"`),
fx.ResultTags(`group:"balda_command_advertisements"`)),
zulip.NewServer, // 2. ingress carrier
func(client *zulip.Client, logger zerolog.Logger) *zulip.Adapter {
adapter := zulip.NewAdapter(client, logger)
adapter.SetTypingThrottleInterval(4 * time.Second)
return adapter
}, // 3. delivery adapter
fx.Annotate(func(a *zulip.Adapter) deliveryfx.ChannelAdapterBinding {
return deliveryfx.ChannelAdapterBinding{ChannelType: zulip.ChannelType, Adapter: a}
}, fx.ResultTags(`group:"balda_delivery_channel_adapter"`)), // 4. binding в роутер
fx.Annotate(NewPromptRegistryContribution,
fx.ResultTags(`group:"balda_delivery_prompt_contribution"`)), // 5. prompt-правила
fx.Annotate(NewLocatorStructuredRegistrar, // 6. structured renderers
fx.ResultTags(`group:"balda_delivery_structured_registrar"`)),
fx.Annotate(NewGoalProgressStructuredRegistrar, …),
fx.Annotate(NewGoalOutcomeStructuredRegistrar, …),
fx.Annotate(func(h *zulip.Server) appports.TransportLifecycleStage {
return appports.TransportLifecycleStage{Name: "zulip ingress", Start: h.Start, Stop: h.Stop}
}, fx.ResultTags(`group:"balda_transport_lifecycle_stage"`)), // 7. lifecycle
),
)
7 групп DI — столько же нужно для MM. Имена групп фиксированы.
locator_codec.go — модель локатора
ChannelType = "zulip"(MM:"mattermost"),sessionIDPrefix = "zu"(MM:"mm").- Address payload — JSON:
{type, stream_id, topic, user_id}. MM:{type, channel_id, root_id, user_id}. - AddressKey:
s:<id>:<escaped-topic>/dm:<id>. MM:c:<channel_id>/dm:<user_id>/t:<root_id>. - SessionID:
zu-s-<streamID>-<8 hex от sha256(topic)>/zu-dm-<uid>. - Три обязательные функции:
NewXLMLocator,DecodeLocator(возвращает(addr, ok, err)),LocatorFromAddressKey,ClassifyLocatorScope→LocatorScopePersonal/LocatorScopeGroup. - Дополнительно:
UserID(int) string↔ParseUserID(string) (int, error)— префиксzu-/mm-.
adapter.go — deliverycmd.Adapter
Реализует var _ deliverycmd.Adapter = (*Adapter)(nil). Константы операций
(deliverycmd.Operation*), которые надо покрыть: Plain, Markdown, AgentReply,
Draft, Typing, Progress. Результат — deliverycmd.Result{ProviderMessageID string}.
Обязательные публичные методы (по образцу Zulip — вызываются роутером и gateway):
SendPlain, SendMarkdown, SendMarkdownWithFormat, SendAgentReply,
SendAgentReplyWithProviderMessageID, SendAgentReplyWithProviderMessageIDAndFormat,
SendDraftPlain, SendTyping, SendProgress, Deliver.
Питфоллы, заложенные в Zulip-адаптере (повторить):
sendWithPlainFallback— если провайдер отбил markdown (isContentRejectedError), отправить plain-вариант. Ошибки объединять черезerrors.Join, не терять первую.- Fallback только на content-rejection (400/BAD_REQUEST), не на 5xx — иначе дубли сообщений при временных сбоях.
- Typing throttle по
locator.SessionID(Zulip — 4 с), с инъектируемымnow func() time.Time(нужно для тестов). ПолеtypingLastSentAt map[string]time.Timeпод mutex. validateReady()— явная проверкаclient != nil, возвращает человекочитаемую ошибку.SendDraftPlainдля Zulip — no-op (return nil), т.к. нет edit-in-place. Для MM это не no-op: у MM естьPUT /posts/{id}(edit) — реализовать по-настоящему.SendProgress:ProgressThinking→ no-op;ProgressPlanUpdate→ отправить текст.
inbound.go — нормализация входа
Возвращает turncmd.NormalizedInbound с полями: ID (turncmd.InboundID("zulip:"+msgID)),
Text, Locator, ProviderMessageID, UserID, MessageID, ReceivedAt (RFC3339 UTC),
DeliveryFormat, ProgressPolicy{Typing, PlanUpdates}, Direct, Source.
MM: Source = новый turncmd.SourceMattermost, DeliveryFormat = DeliveryFormatMarkdown,
ProgressPolicy{Typing: false, PlanUpdates: true} — в MM нет typing-индикатора в каналах
(есть только в DM через WebSocket user_typing; для каналов не работает).
Также в inbound.go: InboundMessage, InboundCommand, интерфейс InboundProcessor
(3 метода: ProcessInbound, HandleCommand, HandleUnsupportedCommand),
SupportedCommands() []string, commandSupported(name).
ingress.go — ingress (фактически реализовано)
⚠️ Zulip использует outgoing webhook по HTTP. У MM для ботов — WebSocket
(wss://<host>/api/v4/websocket). Это отличается от Zulip-образца принципиально.
Как реализована аутентификация (проверено компиляцией):
- dial с заголовком
Authorization: Bearer <token>; - плюс обязательный первый кадр
{"seq":1,"action":"authentication_challenge","data":{"token":"<token>"}}— без него MM не начинает слать события; WebSocketURL()вclient.goсам конвертируетhttp→ws/https→wssи добавляет/api/v4/websocket.
⚠️ В ранних заметках было Sec-WebSocket-Protocol: bearer <token> — это не то, что реализовано
и не то, что описано в актуальных доках MM. Использовать authentication_challenge.
Перенесено из Zulip-сервера и работает:
- семафор на конкурентную обработку (
processSem chan struct{}, 16) +processWG; при заполнении — дроп с warn-логом, не накопление; defer recover()вокруг обработки поста → не ронять процесс;- фильтр эхо-сообщений (
IsBotEcho) → нет бесконечного цикла; SetReadLimit(1 MiB)на WS-кадр;- reconnect-loop с backoff 5 с —
readLoopпереустанавливает соединение, пока контекст жив; - отдельная reader-горутина: блокирующий
ReadMessageне прерывается контекстом, поэтомуStop()закрывает conn, и горутина выходит по ошибке; - graceful
Stop()с ожиданием активной обработки (waitForProcessing); Start/Stopвappports.TransportLifecycleStage.
Специфика MM, которой не было в Zulip:
- В канале Балда отвечает только на
@mention(MentionsBot/StripMention); в DM — на всё. - События
post_edited/post_deletedосознанно игнорируются: Балда действует по исходной инструкции и не переписывает свой ответ задним числом. - Системные посты (
Type != "") и удалённые отсеиваются до семафора. data.postв событииposted— это JSON-строка, а не объект (нужен двойнойUnmarshal).- Каналы типа
D/G— это DM/групповой чат, а не канал команды.
presentation/ + formatting.go
formatting.go:FormattingPromptRuleAndExample() (rule, example string)— правило для модели (MM-совместимый Markdown), иMarkdownPlainText(text).presentation/locator.go:RenderLocator(body locatorfmt.Response) string— локатор + YAML-блокtarget: locator/key: <locator>для scheduler/webhook.presentation/goal.go:RenderGoalProgress(goalkeepercmd.GoalProgress)иRenderGoalOutcome(goalkeepercmd.GoalOutcome)— используютShouldRenderValidation(),ShouldRenderVerified(),ShouldRenderNotVerified(),ShouldRenderNextAction().
zulipfx/{prompt,structured}.go
prompt.go→deliveryfx.PromptRegistryContributionс тремя полями:Formats(правило+пример),Formatters(реализацияdeliveryfmt.Formatter:Name() Name+Format(text) (Message, error)),Routes({Transport, DeliveryFormat, RegisteredName}).structured.go→ по одномуdeliveryfx.StructuredRegistryRegistrarна каждый message kind черезdeliveryfx.NewStructuredRegistrar(deliveryfmt.TransportX, <descriptor>, renderer). Renderer реализуетRenderStructured(ctx, deliveryfmt.StructuredEnvelope[T]) (StructuredPresentation, error). Три штуки:locatorfmt.ResponseDescriptor,goalkeepercmd.ProgressDescriptor,goalkeepercmd.OutcomeDescriptor.
Что нужно добавить в shared-слой — ✅ ВСЁ ПРИМЕНЕНО
| Файл | Добавить | Статус |
|---|---|---|
deliverycmd/channel.go |
ChannelTypeMattermost ChannelType = "mattermost" |
✅ |
deliveryfmt/routes.go |
TransportMattermost = "mattermost", NameMattermostMarkdown Name = "mattermost_markdown" |
✅ |
turncmd (source) |
SourceMattermost |
✅ |
docs/commands.md |
колонка Mattermost в таблице доступности команд | ✅ |
docs/reference/configuration.md |
5 ключей balda.mattermost.* |
✅ |
README.md / docs/balda.md |
⚠️ не обновлялись — README всё ещё перечисляет 3 chat surface | ⏳ |
⚠️ channel/router.go править не нужно — он map-based по locator.ChannelType.
Достаточно зарегистрировать binding в DI (группа balda_delivery_channel_adapter).
📌 Остаётся один незакрытый пункт требований AGENTS.md (не блокирующий): README и
docs/balda.md не упоминают Mattermost. Закрыть при подготовке PR в апстрим либо в Фазе 3.
Что фактически написано (2026-09-26)
Всё в ~/Developer/balda на ветке feat/mattermost-channel. go build ./... = exit 0.
Новый пакет internal/apps/balda/channel/mattermost/
| Файл | Строк | Содержимое |
|---|---|---|
client.go |
~560 | REST v4: CreatePost, UpdatePost, GetPost, GetChannel, GetUser, GetMe, GetTeamByName, GetChannelByName, GetDirectChannel, UploadFile, CreatePostWithFiles, AddChannelMember, Ping; WebSocketURL(), APIBaseURL(), ValidateConfig(); APIError |
locator_codec.go |
~250 | ChannelType="mattermost", префикс mm. AddressKey: c:<ch> / c:<ch>:<root> / d:<ch>. NewChannelLocator(teamID, channelID, rootID), NewDMLocator(channelID, userID), DecodeLocator, LocatorFromAddressKey, ClassifyLocatorScope, ChannelIDFromLocator, RootIDFromLocator, IsDirectLocator, UserID/ParseUserID |
inbound.go |
~250 | InboundMessage/InboundCommand/InboundProcessor (3 метода), NormalizeInbound, LocatorForPost, IsDirectChannelType (D/G), IsBotEcho, IsSystemPost, IsDeletedPost, StripMention, MentionsBot, ParseCommand, SupportedCommands, WebSocketEvent/PostedData |
adapter.go |
~420 | Все 9 deliverycmd.Operation: Plain, Markdown, AgentReply, Draft, Typing, Progress, ClearQuestionControls, Photo, Document. sendWithPlainFallback, MarkdownPlainText, sendMedia |
media.go |
~45 | openMediaFile (лимит 100 МБ), mediaBaseName |
ingress.go |
~380 | WebSocket ingress: dial с Authorization: Bearer, authentication_challenge, reconnect-loop, ping 30 с, processSem (16), processWG, defer recover() |
formatting.go |
~14 | FormattingPromptRuleAndExample() |
presentation/presentation.go |
~110 | RenderLocator, RenderGoalProgress, RenderGoalOutcome |
mattermostfx/module.go |
~100 | 7 групп DI (как zulipfx) |
mattermostfx/config.go |
~25 | NewClient + валидация при enabled |
mattermostfx/ingress.go |
~40 | IngressParams (fx.In), NewIngress |
mattermostfx/structured.go |
~100 | prompt contribution + 3 structured registrar |
Изменения в shared-слое (все применены)
| Файл | Что добавлено |
|---|---|
deliverycmd/channel.go |
ChannelTypeMattermost ChannelType = "mattermost" |
deliveryfmt/routes.go |
TransportMattermost = "mattermost", NameMattermostMarkdown Name = "mattermost_markdown", маршрут {TransportMattermost, DeliveryFormatNone, NamePlainText} в BuiltinRoutes() |
turncmd/turn.go |
SourceMattermost = "mattermost" |
internal/apps/balda/app.go |
mattermostfx.Module в список модулей (строка ~667) |
Wiring-дельты (2026-09-26, вечер — закрыт блокер сборки)
Это то, что оставалось «незакрытым» в первой половине дня. Применено дельта-апплаером.
| Файл | Что добавлено | Зачем |
|---|---|---|
internal/apps/balda/app.go |
импорты channel/mattermost и channel/mattermost/mattermostfx |
корневой пакет нужен для mattermost.ValidateConfig; mattermostfx — для Module |
internal/apps/balda/app.go |
5 fx-провайдеров зарегистрированных строк: balda_mattermost_enabled, _server_url, _token, _bot_user_id, _bot_username |
модуль запрашивает их через name:-теги — без провайдеров fx падает «missing types» |
internal/apps/balda/app.go |
validateMattermostConfig(cfg MattermostConfig) error + вызов рядом с validateSlackConfig |
fail-fast при enabled=true без URL/токена/bot_user_id |
internal/apps/balda/app.go |
функция вставлена перед validateIdentifierValue |
порядок как у соседей |
internal/apps/balda/config.go |
поле Mattermost MattermostConfig в BaldaConfig + тип MattermostConfig{Enabled, ServerURL, Token, BotUserID, BotUsername} |
конфиг-поверхность |
cmd/balda/command.go |
!cfg.Balda.Mattermost.Enabled в validateBaldaChannelConfiguration + подсказка про BALDA_MATTERMOST_ENABLED в тексте ошибки |
иначе MM-only деплой отбивается «at least one channel is required» |
cmd/balda/balda.yaml |
дефолтный блок balda.mattermost с 5 ключами и комментариями |
шаблон генератора |
docs/reference/configuration.md |
5 строк описания balda.mattermost.* (перед balda.slack.bot_token) |
документация ключей |
docs/commands.md |
абзац про поведение MM (mention в каналах, /slash-команды, нет typing) + колонка Mattermost в таблице + строка /plugin ... |
таблица доступности команд |
⚠️ app.go правился в 4 захода (импорт → провайдер enabled → валидация → остальные 4 строки).
Каждый заход — отдельная дельта-группа; всё в tools/deltas-mattermost*.json.
🔴 Три реальных дефекта, найденных тестами (не компиляцией)
Компиляция проходила, но fx-граф был сломан. Поймано только запуском
validateBaldaApplication с включённым Mattermost.
1. fx.In нельзя аннотировать внешним fx.ParamTags — дефект в mattermostfx/module.go.
Симптом:
Failed: invalid annotation function func(mattermostfx.IngressParams) (*mattermost.Ingress, error):
fx.In structs cannot be annotated with fx.ParamTags or fx.From
Причина: в module.go NewIngress был обёрнут в fx.Annotate(NewIngress, fx.ParamTags(...)).
Но IngressParams уже содержит fx.In — его теги обязаны жить внутри структуры.
Правильно (как в zulipfx/zulip.ServerParams):
// ingress.go — теги на полях структуры
type IngressParams struct {
fx.In
Processor mattermost.InboundProcessor `optional:"true"`
Client *mattermost.Client
Commands *commandcmd.Registry
Enabled bool `name:"balda_mattermost_enabled"`
BotUserID string `name:"balda_mattermost_bot_user_id"`
BotUsername string `name:"balda_mattermost_bot_username"`
Logger zerolog.Logger
}
// module.go — голое предоставление, БЕЗ fx.Annotate/fx.ParamTags
fx.Provide(NewIngress),
⚠️ Commands был *commandcmd.Registry optional:"true"`` — заменён на обязательный
*commandcmd.Registry (как у Zulip). commandcmd.Registry в графе есть всегда.
2. Отсутствовали провайдеры зарегистрированных строк.
Симптом: missing types: string[name="balda_mattermost_server_url"] (did you mean to Provide it?)
×3 (server_url, token, bot_user_id).
Причина: модуль читает значения через fx.ParamTags/name:, а провайдеров этих имён в
app.go не было. У Zulip они есть (balda_zulip_*) — для MM забыли.
Правильно: 5 fx.Provide(fx.Annotate(func() ..., fx.ResultTags(name:"balda_mattermost_*"))).
Для несуществующих ключей YAML strings.TrimSpace даёт "" — безопасно, как у Zulip.
Каждый новый name:-тег в модуле = обязательный провайдер в app.go.
3. Отсутствовал импорт корневого пакета mattermost.
app.go:965: undefined: mattermost — validateMattermostConfig вызывает
mattermost.ValidateConfig, а импортирован был только mattermostfx.
Тесты (написаны 2026-09-26)
| Тест | Файл | Что проверяет |
|---|---|---|
TestLoadConfigDocument_AppliesMattermostEnvOverrides |
cmd/balda/command_test.go |
5 env-override BALDA_MATTERMOST_* доезжают в doc.Balda.Mattermost |
TestValidateCommandRunsValidationWithMattermostTransport |
cmd/balda/command_test.go |
реальный loadBaldaCommandConfig(true) + validateBaldaApplication(prepared) — весь fx-граф с включённым MM |
| — (встроено в существующие) | — | регрессионная проверка deliveryfmt |
🔴 Тесты транспорта и хендлера (2026-09-26, сессия PR-подготовки)
Шесть новых файлов, ~90 тестов, все зелёные включая -race.
| Файл | Кол-во | Что покрывает |
|---|---|---|
channel/mattermost/locator_codec_test.go |
~16 | локаторы channel/thread/DM; изоляция thread↔channel сессий; LocatorFromAddressKey; ClassifyLocatorScope; UserID/ParseUserID; отказ чужим транспортам |
channel/mattermost/inbound_test.go |
~17 | NormalizeInbound → shared-контракт; Typing:false; LocatorForPost (канал/тред/DM); фильтры; ParseCommand; whitelist (goalkeeper есть, goal нет); SupportedCommands возвращает копию |
channel/mattermost/adapter_test.go |
~16 | все Deliver-операции; plain-fallback только на 400; draft-as-post; typing = no-op без сети; thread root_id; 5xx не ретраится |
channel/mattermost/client_test.go |
~13 | ws/wss derivation; Authorization: Bearer; APIError; isContentRejectedError; контекст; 2xx без id = ошибка |
channel/mattermost/ingress_test.go |
~20 | 🔴 регрессия bad_seq; монотонность/уникальность seq под 16 горутинами; все фильтры; recover(); семафор; Start отказ без bot_user_id |
handlersfx/mattermost_ingress_test.go |
~18 | публикация всех команд; 🔴 одиночный префикс principal; /start до авторизации; отказ чужим без публикации; nil-safety |
Метод (для будущих транспортов):
- REST —
httptest.NewServer+ fake-handler наPOST /api/v4/posts, запоминающий запросы и с инъецируемымrespond(attempt, req) (status, body). Так проверяются fallback-сценарии (первый запрос → 400, второй → 201). - WebSocket — реальный
websocket.Upgraderвhttptest+websocket.DefaultDialer. Единственный способ проверить, чтоwriteEventвсегда шлётseq. - Хендлер — тестируется структура напрямую (
&mattermostInboundHandler{...}), без fx: так проверяются авторизация, префикс principal и nil-safety в изоляции. fake...OwnerKVStore+recording...CommandIngress— по образцуzulip_ingress_test.go.
🔴 Дефект P1.1 — двойной префикс principal: починен в КОДЕ
Симптом (прод): /start owner=<token> в DM отвечает успехом, но @balda даёт
settlement_stage="unauthorized" навсегда.
Корень: auth.MattermostSubject(userID) добавляет префикс
(fmt.Sprintf("%s:%s", ChannelMattermost, ...)), а actors/command/start/handler.go:420
вызывал её поверх p.Principal, который уже приходит префиксованным от
mattermost_ingress.go:147 (Principal: auth.MattermostSubject(cmd.SenderID)).
Результат в БД — mattermost:mattermost:<id>, а GetUserByBinding сравнивает точно →
совпадений нет → unauthorized. Плюс мусор в display_name и created_by channel-токенов.
Прошлый «фикс» был в данных (прямой UPDATE в state.db) — и дефект вернулся при
следующей записи. 🔴 Правило: дефект, найденный в данных, чинить в коде.
Фикс (дельта tools/deltas-mattermost-23-prefix-idempotent.json): хелпер сделан
идемпотентным — срезает уже существующий префикс:
// MattermostSubject returns the channel-qualified subject for a Mattermost user.
// It is idempotent: a value that is already channel-qualified ("mattermost:<id>")
// yields the same subject, so callers may pass either a raw user ID or a subject
// without risking a doubled prefix ("mattermost:mattermost:<id>").
func MattermostSubject(userID string) string {
trimmed := strings.TrimSpace(userID)
trimmed = strings.TrimPrefix(trimmed, ChannelMattermost+":")
return fmt.Sprintf("%s:%s", ChannelMattermost, trimmed)
}
Из userSubject убран локальный TrimPrefix-костыль:
case transportMattermost:
return auth.MattermostSubject(trimmed)
📌 Почему фикс в хелпере, а не в вызывающем коде: MattermostSubject вызывается из
трёх мест (userSubject, mattermost_ingress.go:147, authorizeInbound). Идемпотентность
закрывает все пути сразу и защищает от будущих вызовов поверх готового subject'а.
Остальные вызовы были корректны — дефект ровно один, но источник общий.
Регресс-тесты: TestMattermostSubjectIsIdempotent (3 случая: raw, subject, padded) и
TestMattermostHandlerSendsChannelQualifiedPrincipalExactlyOnce (через публикуемую команду).
🔴 Дефект P1.6 — ProgressPolicy.Typing расходился между путями
handlersfx/mattermost_ingress.go в HandleCommand ставил
ProgressPolicy{Typing: true, PlanUpdates: true}, тогда как inbound-путь
(inbound.go:135) и Adapter.SendTyping (осознанный no-op — у ботов MM нет typing API)
говорили false. Мёртвый код: SendProgress при Typing:true вызывал SendTyping,
который всегда возвращал nil, и на каждом ходу писался бы warn только при ошибке.
Фикс (дельта tools/deltas-mattermost-24-typing.json): Typing: false — command-путь
приведён к inbound-пути. Оба пути теперь согласованы, и тест это фиксирует.
📌 Урок: при копировании Zulip-паттерна проверять каждую фичу на применимость к
провайдеру. Pan-путь и turn-путь обязаны давать одинаковую ProgressPolicy для одного
транспорта — расхождение невидимо компилятору.
🔴 Второй урок: не доверять «проверил — зелёно» без проверки в изоляции
Три моих первых теста падали, и все три — моя ошибка, не дефект кода:
| Тест | Что я предположил | Реальность |
|---|---|---|
TestLocatorForPostScopesReplyToThreadRoot |
тред-сессия == сессия root-поста | Тред отдельная сессия от канала (mm-t-... vs mm-c-...) — это правильный дизайн, тест переписан на проверку «два ответа в треде → одна сессия» |
TestNewIngressFallsBackToClientUserID |
NewIngress подставляет bot_user_id из клиента |
Не подставляет. Гард живёт в onStart («refuses to start without bot user id») — тест переписан на реальный контракт |
TestMattermostHandlerRejectsUnauthorizedCommand |
отказ возвращает nil |
Возвращает runtime is unavailable (нет dispatcher для доставки отказа). Главное — команда НЕ публикуется, это и проверяется |
📌 Дыра в дизайне, найденная тестом: root-пост треда (RootID="") → сессия канала
mm-c-<hash(channel)>, а ответ в этом треде (RootID=post-1) → mm-t-<hash(channel|root)>.
То есть тред — отдельная беседа от канала. Это соответствует комментарию в
locator_codec.go («the locator key must stay stable for a whole thread»), но означает:
бот, отвечающий в тред, ведёт отдельную от канала сессию. Зафиксировано тестом
TestLocatorForPostScopesReplyToThreadRoot (два ответа в треде → одна сессия).
⚠️ Метод: когда тест падает — сначала перечитать код и понять, чей это контракт. Из трёх падений ноль оказались дефектами кода; все — неверные предположения теста.
🔴 Ключевой урок: go build ./... = exit 0 не значит, что транспорт рабочий.
fx-граф проверяется только validateBaldaApplication (или fx.ValidateApp). Компиляция
не видит ни fx.In-несовместимость, ни отсутствующие провайдеры.
Как воспроизвести проверку графа (эталон):
prepared, err := loadBaldaCommandConfig(true) // нужен os.Chdir в tempdir с .config/balda/config.yaml
err = validateBaldaApplication(prepared) // ← вот это и есть проверка графа
Конфиг для теста — mattermost.enabled: true + server_url/token/bot_user_id/bot_username,
провайдер balda_agent: opencode_acp. Якорь func TestValidateCommandRunsValidation(t *testing.T) {
использован как точка вставки.
✅ Текущее состояние — ЗАКРЫТО (2026-09-26, ночь)
Оба ранее открытых пункта выполнены:
- ✅
TestBuiltinRoutes— в mapwantдобавлен маршрут Mattermost (internal/apps/balda/deliveryfmt/registry_test.go). Пакетdeliveryfmtзелёный. - ✅
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_wiringingress_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-задачи — это дефекты апстрима, отдельная работа.
Отклонения от плана разведки (важно)
server.go→ingress.go. Планировалсяserver.goпо образцу Zulip, но у MM нет HTTP-вебхука — это долгоживущее WS-соединение, а неhttp.Server. Имяingress.goточнее отражает суть.SendDraftPlain— не no-op. В плане было «для MM это не no-op, edit-in-place есть». По факту:OperationDraftотправляется как обычный пост (текст не теряется), а edit-in-place живёт вOperationClearQuestionControls→PUT /posts/{id}— там он семантически уместен (гашение кнопок после выбора).- AddressKey не
t:<root_id>. Планировалосьc:/dm:/t:. По факту тред кодируется тем же channel-ключом с суффиксом:c:<ch>(канал) иc:<ch>:<root>(тред). Причина: тред MM — это root-post внутри канала, и локатор должен оставаться стабильным для всего треда. ProgressPolicy{Typing: false}. Подтверждено: у MM-ботов нет typing API (SendTypingвозвращаетnilосознанно — иначе шум на каждом ходу).gorilla/websocket v1.5.3был// indirectвgo.mod— стал прямым при сборке.commandSupportпереименован в экспортируемыйIngressCommandSupport(нужен дляfx.In), оставлен алиас.
✅ Импорт mattermostfx — ЗАКРЫТО
internal/apps/balda/app.go — импорты добавлены (строки 27-28, рядом с zulipfx):
"github.com/baldaworks/balda/internal/apps/balda/channel/mattermost"
"github.com/baldaworks/balda/internal/apps/balda/channel/mattermost/mattermostfx"
Корневой mattermost нужен для mattermost.ValidateConfig в validateMattermostConfig.
mattermostfx.Module в списке модулей (строка ~667). go build ./... = exit 0.
🔴 Что понадобилось дополнительно (не было в исходном коде)
Первоначально модуль компилировался, но DI-граф не собирался. Три дефекта:
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).- Не хватало провайдеров registered strings. Модуль запрашивает
balda_mattermost_{server_url,token,bot_user_id,bot_username}— их никто не предоставлял. Фикс: 4fx.Provideвapp.go(блок// Mattermost transport), значения берутся изcfg.Balda.Mattermost.*черезstrings.TrimSpace. Для несуществующих ключейGetStringвозвращает""— тот же паттерн, что уbalda_zulip_*. - Валидация стояла не там. Функцию
validateMattermostConfigдобавили вapp.go(рядом сvalidateSlackConfig), вызов — послеvalidateSlackConfigвModule().
Метод, который это поймал: go build/go vet этих ошибок не видят — только реальный
fx.ValidateApp через validateBaldaApplication. Для любого нового транспорта писать такой
тест сразу: он ловит неполный wiring, а не только синтаксис.
Конфиг, который нужен для запуска
Ключи читаются через fx.ParamTags / name:"…":
balda_mattermost_enabled
balda_mattermost_server_url
balda_mattermost_token
balda_mattermost_bot_user_id
balda_mattermost_bot_username
Соответствие в config.yaml (по образцу balda.zulip.*): balda.mattermost.{enabled,server_url,token,bot_user_id,bot_username}.
Конкретные имена ключей yaml проверить в docs/reference/configuration.md перед Фазой 3 —
я их не сверял, только зафиксировал DI-теги.
✅ Проверено 2026-09-26: env-имена — BALDA_MATTERMOST_ENABLED, BALDA_MATTERMOST_SERVER_URL,
BALDA_MATTERMOST_TOKEN, BALDA_MATTERMOST_BOT_USER_ID, BALDA_MATTERMOST_BOT_USERNAME.
Механизм: appconfig/loader.go:367 делает SetEnvPrefix("BALDA") +
SetEnvKeyReplacer(".", "_") на leaf-ключах. Env применяется только к ключам, которые уже есть
в YAML — поэтому блок balda.mattermost обязан присутствовать в cmd/balda/balda.yaml,
иначе env-переменные молча игнорируются. Это же верно для любого нового транспорта.
Метод правок: дельта-апплаер (инструмент проекта)
Правки проекта идут не sed/не in-place, а текстовыми дельтами. Причина: правило Alex «никогда не использовать in-place скрипты и sed; писать скрипт, потом исполнять» + нужна идемпотентность при повторных заходах.
| Файл | Роль |
|---|---|
tools/apply-project-deltas.py |
движок: применяет JSON-дельты, ведёт состояние |
tools/run-project-deltas.sh |
обёртка: check / apply / list |
tools/deltas-mattermost.json |
основная группа (12 дельт: wiring + доки) |
tools/deltas-mattermost-2.json |
импорт корневого mattermost |
tools/deltas-mattermost-3.json |
тест env-override |
tools/deltas-mattermost-4.json |
тест fx-графа |
tools/deltas-mattermost-5.json |
фикс fx.In + module.go |
tools/deltas-mattermost-6.json |
4 провайдера строк |
tools/deltas-mattermost-7.json |
маршрут Mattermost в TestBuiltinRoutes |
tools/deltas-mattermost-8.json |
.go-arch-lint.yml: mattermost_transport/mattermost_wiring |
tools/deltas-mattermost-9.json |
inamedparam + удаление shouldSendTyping/полей/websocketReceiveTimeout |
tools/deltas-mattermost-10.json |
удаление SetTypingThrottleInterval + typingMu + вызов/импорт time в module.go |
tools/deltas-mattermost-11.json |
удаление неиспользуемого импорта sync |
tools/deltas-mattermost-12.json |
unlambda: голое mattermost.NewAdapter |
tools/deltas-mattermost-13.json |
удаление неиспользуемого импорта zerolog из module.go |
tools/deltas-mattermost-23-prefix-idempotent.json |
🔴 P1.1: MattermostSubject идемпотентна (срезает существующий префикс) + убран TrimPrefix-костыль из userSubject |
tools/deltas-mattermost-24-typing.json |
🔴 P1.6: ProgressPolicy.Typing → false в command-пути (handlersfx/mattermost_ingress.go) |
tools/deltas-mattermost-25-docs-balda.json |
docs/balda.md: Mattermost в runtime stack, typing-нюанс, ссылка на гайд |
tools/deltas-mattermost-26-docs-readme.json |
README.md: Mattermost в 4 списках + balda.mattermost.* + ссылка на гайд |
tools/.deltas-state.json |
состояние (sha256 применённых якорей) |
📌 Дельты 9-13 — это один каскад линтера. Удаление throttle-механизма тянуло
SetTypingThrottleInterval → typingMu → sync → zerolog. Пришлось делать 5 заходов,
каждый раз пересобирая. Урок: удалять мёртвую фичу одной группой дельт, а не по одному
симптому — тогда каскад импортов закрывается за раз.
Формат дельты:
{ "file": "путь/относительно/корня", "note": "что делаем",
"search": "уникальный фрагмент текущего текста",
"replace": "чем заменить" }
Три состояния, которые различает апплаер:
replaceуже есть в файле →skipped(идемпотентно, повторный прогон безопасен)searchесть ровно один раз →applied- ни
search, ниreplace→error: ЯКОРЬ НЕ НАЙДЕН(файл изменился не так, как ожидалось)
Команды:
cd ~/Developer/balda
bash tools/run-project-deltas.sh check tools/deltas-mattermost.json tools/.deltas-state.json
bash tools/run-project-deltas.sh apply tools/deltas-mattermost.json tools/.deltas-state.json
bash tools/run-project-deltas.sh list tools/deltas-mattermost.json tools/.deltas-state.json
📌 check обязателен перед apply — он показывает TODO/MISSING/OK по каждому якорю.
Именно на этой проверке я поймал, что дельта валидации указывала в несуществующий
validate() в config.go (валидация живёт в app.go) и переписал её до записи.
⚠️ Не писать search с литеральными секретами — значения собираются на хосте.
⚠️ Дельты с длинным replace писать в JSON-файл через write_file, не в heredoc:
кавычки и \n ломаются.
Объём работ
Оценка из плана: 2-4 дня. Разбивка:
| Шаг | Объём |
|---|---|
client.go (REST v4: posts, threads, files, reactions, websocket) |
1 день |
locator_codec.go + inbound.go |
0.5 дня |
adapter.go (все 6 операций + MM-специфика) |
1 день |
server.go (WS ingress, reconnect, семафор, recover) |
1 день |
mattermostfx/ + presentation + prompt + structured |
0.5 дня |
тесты (go test -race ./...) + golangci-lint |
0.5-1 день |
Quality gates (обязательны, из AGENTS.md)
go test -race ./...
go tool golangci-lint run
Логирование (правило проекта)
Разрешено: zerolog, log/slog. Запрещено: logrus, zap, stdlib log.
Инициализация через internal/logging.Init(). Предпочитать структурированные поля.
Компонент логировать как logger.With().Str("component", "balda.channel.mattermost").Logger().
Коммиты
Conventional Commits. Синхронизация общих веток — merge (git pull --no-rebase), не rebase.
Что понадобится из Mattermost API
| Задача | Endpoint |
|---|---|
| Отправка поста | POST /api/v4/posts ({channel_id, message, root_id?, file_ids?}) |
| Редактирование (draft→update) | PUT /api/v4/posts/{post_id} |
| Треды | root_id в create — ответы в тред |
| Файлы | POST /api/v4/files + file_ids в посте |
| Реакции (для question-контролов) | POST /api/v4/reactions |
| WebSocket события | wss://<host>/api/v4/websocket (posted, post_edited) |
| Ping / health | GET /api/v4/system/ping |
Авторизация: Bot account + Personal Access Token (Authorization: Bearer <token>).
⚠️ Alex сказал: бота создавать не надо — токен предоставит он сам.
Открытые вопросы / решения Alex
- Бота не создавать (2026-09-26, явно). Токен для MM-адаптера даст Alex.
- Вопросы не задавать — решения принимать самому, искать в Obsidian, а не спрашивать.
- Команд, требующих подтверждения, не использовать — писать скрипты.
- Не останавливаться до исполнения задачи.
Связанные заметки
- 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 — как обходить маскировку секретов