15 KiB
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.
Два пути вывода:
- Model-authored text → prompt-format путь. Транспорт может инжектить свои правила форматирования.
- 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, а не спрашивать.
- Команд, требующих подтверждения, не использовать — писать скрипты.
- Не останавливаться до исполнения задачи.
Связанные заметки
- 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 — как обходить маскировку секретов