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

262 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Balda — Mattermost коннектор (архитектура)
> Создано 2026-09-26. Статус: **разведка завершена, код пишется**.
> Часть проекта [[personal/projects/balda/balda-mm-delegation-plan]] (Фаза 2).
> Требование Alex: поддержка **всех** функций Балды, по новым API, production-качество.
---
## Где работаем
| | |
|---|---|
| Репозиторий | `~/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)`.
### `server.go` — ingress
⚠️ **Zulip использует outgoing webhook по HTTP.** У MM для ботов — **WebSocket**
(`wss://<host>/api/v4/websocket`, `Sec-WebSocket-Protocol: bearer <token>`, порт 443/8065).
Это отличается от Zulip-образца принципиально — нужен WS-клиент с reconnect.
Обязательно перенести из Zulip-сервера:
- **семафор на конкурентную обработку** (`processSem chan struct{}`, Zulip — 16) + `WaitGroup`;
при заполнении отвечать «busy»/дропать, а не копить;
- **`defer recover()`** вокруг обработки сообщения → не ронять процесс;
- **таймаут обработки** (`zulipWebhookProcessingTimeout` = 5 мин) через
`context.WithTimeout(context.WithoutCancel(...))`;
- **фильтр эхо-сообщений бота** (`IsBotEcho`) — иначе бесконечный цикл;
- **не превышать лимит тела** (1 MiB) — для MM это актуально для file-attachments;
- graceful `Stop()` с ожиданием активной обработки (`waitForWebhookProcessing`);
- все HTTP-таймауты заданы явно (`ReadHeaderTimeout`/`ReadTimeout`/`WriteTimeout`/`IdleTimeout`);
- `Start`/`Stop` в `appports.TransportLifecycleStage`.
### `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/balda.md` + `README.md` | описание нового транспорта (требование AGENTS.md) |
⚠️ `channel/router.go` **править не нужно** — он map-based по `locator.ChannelType`.
Достаточно зарегистрировать binding в DI (группа `balda_delivery_channel_adapter`).
---
## Объём работ
Оценка из плана: **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
- [[personal/projects/balda/balda-mm-delegation-plan]] — общий план
- [[personal/projects/balda/balda-valera]] — текущая эксплуатация Балды/Валеры
- [[personal/tech/hermes-secret-redaction-bypass]] — как обходить маскировку секретов