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

15 KiB
Raw Blame History

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

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)

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, а не спрашивать.
  • Команд, требующих подтверждения, не использовать — писать скрипты.
  • Не останавливаться до исполнения задачи.

Связанные заметки