[2026-09-26] eagle: personal/plans/balda-mm-pr-plan.md personal/projects/balda/balda-mattermost-connector.md personal/tech/balda-go-fx-pitfalls.md
This commit is contained in:
1 parent
4ab53a2a63
commit
519f7c443c
3 files changed
+301
-47
No files matched your search
@@ -1,6 +1,10 @@
|
||||
# Balda — Mattermost коннектор (архитектура)
|
||||
|
||||
> Создано 2026-09-26. Обновлено 2026-09-26 (вечер). Статус: **✅ ЗАКРЫТА — ВСЕ QUALITY GATES ПРОЙДЕНЫ**.
|
||||
> Создано 2026-09-26. Обновлено 2026-09-26 (сессия PR-подготовки).
|
||||
> Статус: **✅ ЗАКРЫТА — ВСЕ QUALITY GATES ПРОЙДЕНЫ** (код `004fda2f`).
|
||||
> 🟡 **Сессия PR-подготовки:** добавлены тесты транспорта, документация,
|
||||
> починены 2 дефекта в коде. Остаток — линтер + коммит. Детали —
|
||||
> [[personal/plans/balda-mm-pr-plan]].
|
||||
> Часть проекта [[personal/projects/balda/balda-mm-delegation-plan]] (Фаза 2).
|
||||
> Требование Alex: поддержка **всех** функций Балды, по новым API, production-качество.
|
||||
>
|
||||
@@ -358,6 +362,110 @@ fx.Provide(NewIngress),
|
||||
| `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`-несовместимость, ни отсутствующие провайдеры.
|
||||
@@ -537,6 +645,10 @@ balda_mattermost_bot_username
|
||||
| `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-механизма тянуло
|
||||
|
||||
Reference in new issue
Block a user