18 KiB
Executable File
Balda — PR #8 review fixes: slash commands, /locator, group DM session
Создан 2026-09-28. Статус: ✅ РЕАЛИЗОВАНО, ЗАПУШЕНО, CI 8/8 PASS. Голова PR:
05342c5a(9 коммитов). Основная правка —cd4ce4bc, тесты —05342c5a. PR baldaworks/balda#8: 46 файлов, +6850/−27, MERGEABLE. CI 8/8 PASS: architecture-lint · ingress-e2e · jetstream-integration · lint · postgres-integration · projection-replay · sqlite-integration · test. Источник: ревьюmetalagman(опубликовано в PR 2026-09-28T06:02:48Z, state COMMENTED, inline-комментариев 0 — всё в теле ревью) + четвёртый дефект, найденный мной (§5). Часть personal/plans/balda-mm-pr-plan (PR #8).
0a. Сверка с текстом ревью — построчно
Ревью metalagman опубликовано в PR (не «оставлено локально»). Inline-комментариев нет:
gh api repos/baldaworks/balda/pulls/8/comments → length: 0. Три пункта в теле ревью:
| # | Дословное требование ревью | Статус |
|---|---|---|
| 1 | «Please add a supported command ingress (and its setup), or change the user-facing invocation to one that arrives as a normal post» | ✅ HTTP-приёмник + раздел «Slash command setup» в docs/mattermost.md |
| 1b | «needs a real Mattermost integration check because the current command test injects a synthetic posted frame» |
✅ тесты идут через httptest.NewRequest (реальный HTTP), processPosted/PostedData — 0 вхождений |
| 2 | «Please add transport-neutral Mattermost parsing and a round-trip test» | ✅ locatorref + TestMattermostLocatorSatisfiesCommandGuard — воспроизводит точный guard handler'а: Parse(Format(l)) и Format(parsed) == ref |
| 3 | «give group DMs a stable channel-based session identity and cover posts from two senders in one group channel» | ✅ TestGroupChannelPostsFromTwoSendersShareOneSession + 2 теста изоляции сессий |
📌 Важно: требование 3 изначально закрыто не было. Существовавший
TestLocatorForPostUsesDirectLocatorForDMAndGroupChannels брал одного user-1 для D и
G и не проверял двух отправителей — то есть именно тот случай, о котором писал ревьюер.
Добавлены 3 теста в inbound_test.go + 1 в locatorref/mattermost_test.go.
📌 Требование 2: тест был, но проверял Parse → Format, а handler требует
Parse(Format(l)) + Format(parsed) == ref — другой порядок. Добавлен точный.
0. Итог
Ревью Alex подтверждено в коде — все три пункта. Плюс найден четвёртый дефект, которого в ревью не было (§5). Все четыре закрыты одним коммитом.
| # | Дефект | Статус |
|---|---|---|
| Б1 | Slash-команды не доходят (только WS posted) |
✅ HTTP-приёмник command_server.go |
| Б2 | /locator падает: locatorref не знает mattermost |
✅ ветка + декодер + тесты |
| В3 | Групповой DM расщепляется по авторам | ✅ свой тип/префикс/сессия + scope |
| Б4 | Регистрация в ScopeResolver отсутствовала В PR |
✅ app.go + preflight.go |
1. Гейты — все зелёные (локально, на закоммиченном дереве)
gofmt (мои файлы) → чисто
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 (без runtime-local)
go test -race (7 затронутых пакетов) → все ok
go test ./cmd/balda/ (fx-граф) → ok
Новые тесты: 19 (command_server_test.go 15 + locatorref/mattermost_test.go 4).
⚠️ go-arch-lint: 28 замечаний «not attached» — все из runtime-local/
(наша локальная папка с форком runtime, в PR её нет). Проверено mv runtime-local →
OK - No warnings found. Это не наши правки.
⚠️ gofmt: в репо 80 файлов не отформатированы — унаследовано, проверено на HEAD
(git show HEAD:<file> | gofmt -l /dev/stdin). Мои файлы чисты. Свои gofmt-правки в
adapter.go/ingress_test.go откатил, чтобы не раздувать diff PR-ом чужих файлов.
2. Б1 — slash-команды: HTTP-приёмник (вариант A)
📌 Факт из доки MM: «if you type in a slash command it will execute without posting
a message» — WS-события posted для slash не будет. Ревью право на 100%.
Что уже было и что добавлено:
| Компонент | Статус |
|---|---|
mattermostInboundHandler.HandleCommand — публикация в commandcmd |
✅ уже был, корректен |
ParseCommand — разбор /name args |
✅ уже был |
вызов из processPosted (mention-путь: @balda /locator) |
✅ работает |
| HTTP-путь для slash | ❌ добавлен channel/mattermost/command_server.go |
🔴 Правка Alex (важный урок): «в твоей ММ реализации взят существующий slack транспорт
за основу? Они должны быть практически идентичны». Первая версия разошлась со Slack в
пяти местах без причины — переписал по образцу slackagent/server.go:
| Что | Slack | Было у меня | Стало |
|---|---|---|---|
invocationID |
хеш от тела | из post_id |
commandInvocationID(body) |
| таймаут команды | 2500ms |
30s |
2500ms |
| семафор | processSem 16 |
отсутствовал | 16 |
webhookMaxBodyBytes |
1 << 20 |
64 << 10 |
1 << 20 |
ReadTimeout/Idle |
10s / 30s | 15s / 60s | 10s / 30s |
Осталось ровно два отличия, и оба диктует провайдер (не наш выбор):
- Верификация: у MM
tokenв теле запроса (сравнение constant-time), у Slack — HMAC-подписьX-Slack-Signature. КопироватьverifySignatureнельзя. - Форма ответа: MM
{"response_type":"ephemeral","text":"..."}, Slack{"text":...}.
Поведение:
- принимает обе конфигурации команды:
/locator(аргументы вtext) и/balda locator(первое словоtext= подкоманда) — как у Slack с/balda; - неизвестная команда → 200 + usage (не 400, не тишина) — тоже как у Slack;
- тип канала (
D/G/O/P) резолвится черезGetChannel, т.к. slash его не несёт; при ошибке резолва — трактуем как не-DM (строгая авторизация); - отвечает сразу, результат уходит постом (MM показывает timeout автору за ~3 с).
Новые ключи конфига (balda.mattermost.*): commands_enabled,
commands_listen_addr (:8093), commands_path (/mattermost/commands),
commands_token. Все 4 + провайдеры name:-тегов в app.go.
3. Б2 — /locator: mattermost в locatorref
Механически, по образцу slackagentLocatorFromAddressKey.
🔴 Питфолл архитектуры ЗАКРЫТ правильно: AGENTS.md требует независимости locatorref
от транспортов. Не импортировал channel/mattermost — использовал
string(deliverycmd.ChannelTypeMattermost), ровно как для Slack
(channelTypeSlackAgent = string(deliverycmd.ChannelTypeSlackAgent)).
deliverycmd уже в allow-list locatorref. go-arch-lint зелёный.
🔴 Ошибка, которую поймал тестами: первая версия декодера использовала типы
"channel"/"direct", а транспорт определяет "channel"/"dm"/"group". Несовпадение
строк = локатор декодируется, но транспорт его отвергает. Сверить имена типов с
locator_codec.go, не выдумывать.
Хвост Б2 (закрыт тем же изменением):
internalmcp/manager.go:409—locatorref.Parseтеперь работает для MM;envelopetarget/resolve.go:62—balda.scheduler.jobstarget: locatorдля MM теперь работает. Это вторая половина фразы ревью «исключает Mattermost из config targets».
Тесты: locatorref/mattermost_test.go — 4 теста (канал/тред, DM/группа,
Format-round-trip, отказ на кривом ключе).
4. В3 — групповой DM
Было (дефект):
dmSessionID: if a.UserID != "" { return "mm-dm-<hash(UserID)>" } // АВТОР, не чат
G-канал общий, авторов много → у каждого своя сессия.
Стало:
| Тип MM | AddressKey | SessionID | Scope |
|---|---|---|---|
D (1:1) |
d:<channel_id> |
mm-dm-<hash(channel_id)> |
personal |
G (групповой) |
g:<channel_id> |
mm-gdm-<hash(channel_id)> |
personal |
🔴 Отдельный префикс ключа g: — не косметика. Общий d: не различим при
LocatorFromAddressKey: round-trip d: восстанавливал любой групповой DM как 1:1.
Свой префикс сохраняет тип.
Закрыто в 8 местах (§«Тип канала» — самый коварный):
addressTypeGroupDM · NewGroupDMLocator · dmAddressKey · dmSessionID ·
IsDirectLocator · validateLocatorAddress · ClassifyLocatorScope ·
LocatorForPost + IsGroupChannelType.
⚠️ validateLocatorAddress и ClassifyLocatorScope — я их пропустил в первой версии.
DecodeLocator возвращает ok=false для неизвестного типа, и тогда все下游-хелперы
fail-closed: IsDirectLocator, ChannelIDFromLocator, ClassifyLocatorScope.
Тест TestLocatorForPostUsesDirectLocatorForDMAndGroupChannels/G это и поймал.
⚠️ Обратная совместимость: старая форма D-сессии была mm-dm-<hash(UserID)>, стала
mm-dm-<hash(channel_id)> → существующие 1:1 DM-сессии на Kraken осиротеют (новые
создадутся, старые останутся мёртвыми). Тестовая инсталляция — принято.
5. 🔴 Б4 — четвёртый дефект, которого НЕ было в ревью
Регистрация MM в ScopeResolver отсутствовала в PR #8.
Диагностика: grep ClassifyLocatorScope internal/apps/balda/app.go — строка есть в
рабочем дереве, но git show HEAD:...app.go | grep ClassifyLocatorScope — строка
отсутствует. Значит она была WIP-правкой на диске и в коммиты не уехала.
В preflight.go её не было вообще (обе ветки).
Почему это критично (питфолл #11 навыка): sessionmemoryapp.ScopeResolver строит карту
классификаторов по channelType; резолвер fail-closed (capture.go:
classifier == nil → CodeUnsupportedScope). Симптом при session_memory.enabled: true:
warn … "no session-memory classifier is registered for \"mattermost\""
→ в чате: "Could not reset this session."
- MCP-инструменты не подключаются (
function_call_part_count: 0). Компилируется,go build/vet/линтеры зелёные, отказ только в рантайме.
Зарегистрировано в обоих файлах (app.go и preflight.go) — карт две,
правка одной даёт расхождение «preflight прошёл, рантайм упал».
📌 Урок метода: «зелёные гейты» ≠ «рабочий транспорт». Эта строка проходила все
статическиe проверки, отсутствуя в PR. Сверять git show HEAD:<file> с рабочим деревом
для критичных регистраций, а не доверять git status.
6. Что понадобилось дополнительно (для будущих транспортов)
- 🔴 Каждый
name:-тег вmattermostfx= обязательный провайдер вapp.go. Добавлены 4:balda_mattermost_commands_{enabled,listen_addr,path,token}. Иначе fx:missing types: string[name="balda_mattermost_commands_enabled"]. - ⚠️ Ключ обязан быть в
cmd/balda/balda.yaml— env-override (BALDA_MATTERMOST_COMMANDS_*) применяется только к leaf-ключам, уже присутствующим в YAML (appconfig/loader.go). Молчаливое игнорирование иначе. - ✅ DI-граф проверен реально —
TestValidateCommandRunsValidationWithMattermostTransportпрогоняетloadBaldaCommandConfig(true)+validateBaldaApplication(prepared)с включённым MM. В логе видно оба провайдера:NewCommandServer+NewIngress.go buildэтого не проверяет — толькоfx.ValidateApp.
Файлы (новые)
| Файл | Строк |
|---|---|
channel/mattermost/command_server.go |
~409 |
channel/mattermost/command_server_test.go |
~361 |
locatorref/mattermost_test.go |
~94 |
Дельты (все применены, tools/)
deltas-pr8-b2-locatorref.json · -b2-locatorref-types.json · -p3-groupdm.json ·
-p3-groupdm-keys.json · -b1-wiring.json · -b1-config-keys.json · -lint-fixes.json ·
-docs-commands.json · -docs-mattermost.json
7. Доки
docs/commands.md— MM-колонка приведена к реальности:/start,/topic,/goalkeeper,/auto,/usage,/close,/cancel,/user,/locator= yes (былоno— дока врала). Абзац про HTTP-природу slash + обе конфигурации команды.docs/mattermost.md— новый раздел Slash command setup (Request URL, token, шаги), 4 ключа в таблице, env-образец, slash-специфичный troubleshooting.
8. Дальше
| # | Шаг | Статус |
|---|---|---|
| 1 | CI на 05342c5a (8 джобов) |
✅ 8/8 PASS |
| 2 | Ре-ревью PR #8 — reviewDecision пустой, ревью было COMMENTED ≠ APPROVED |
⛔ внешний блокер |
| 3 | Живая приёмка slash на Kraken (публичный HTTPS для Request URL!) | ⏳ следующий шаг |
| 4 | Фаза 4 — исполнитель (делегирование) | 🎯 основной фронт |
⚠️ Б1 требует публичного HTTPS для Request URL slash-команды. Стенд Kraken — проверить достижимость эндпоинта до приёмки; иначе нужен туннель. ⚠️ Команды PR (воспроизводимо):
gh pr checks 8 --repo baldaworks/balda
gh api repos/baldaworks/balda/pulls/8/reviews | jq -r '.[] | "\(.user.login) \(.state)"'
gh api repos/baldaworks/balda/pulls/8/comments | jq 'length' # inline-комменты; 0 = всё в теле ревью
9. Конфиг — точные значения (для Фазы 3/приёмки)
cmd/balda/balda.yaml, блок balda.mattermost (все 4 ключа обязаны быть в файле,
иначе env молча игнорируется):
balda:
mattermost:
enabled: true
server_url: "http://mattermost:8065"
token: "${BALDA_MATTERMOST_TOKEN}"
bot_user_id: "${BALDA_MATTERMOST_BOT_USER_ID}"
bot_username: "balda"
commands_enabled: true # ← HTTP-приёмник slash, НЕЗАВИСИМ от enabled
commands_listen_addr: ":8093"
commands_path: "/mattermost/commands"
commands_token: "${BALDA_MATTERMOST_COMMANDS_TOKEN}"
Env-имена (проверено, appconfig/loader.go SetEnvPrefix("BALDA") + "." → "_"):
BALDA_MATTERMOST_COMMANDS_ENABLED / _LISTEN_ADDR / _PATH / _TOKEN
DI-провайдеры в internal/apps/balda/app.go (name:-теги, иначе fx missing types):
balda_mattermost_commands_{enabled,listen_addr,path,token}.
⚠️ commands_enabled и enabled — разные переключатели: enabled = websocket-поток
(разговоры), commands_enabled = HTTP-приёмник (slash). Без второго команды не работают.
Связанные заметки
- personal/plans/balda-mm-pr-plan — PR #8, состояние, деплой, приёмка
- personal/projects/balda/balda-mattermost-connector — Фаза 2 (код коннектора)
- personal/tech/balda-go-fx-pitfalls — fx-питфоллы, дельта-апплаер, arch-lint
- personal/projects/balda/balda-mm-delegation-plan — общий план