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