Files
obsidian-vault/personal/plans/balda-mm-endtoend-plan.md
T

19 KiB
Raw Blame History

План: доделать сквозной путь @balda → ответ в Mattermost

Создан 2026-09-26. Обновлён 2026-09-26 (сессия реализации). Статус: 🟡 ОБЕ ПРИЧИНЫ УСТРАНЕНЫ. Хендлер написан и работает. Осталась привязка владельца. Часть personal/projects/balda/balda-kraken-mm-deploy (Фаза 3). Команда Alex: «Доделывай всё. И уведомления в приложения неактивны тоже поправь.»

Итог сессии (кратко)

Шаг Статус
Причина A (bad_seq) найдена и исправлена ✅ дельта 16, проверено
Причина B (Processor == nil) найдена ✅ дельта 15 (диагностика)
MM-хендлер написан (handlersfx/mattermost_ingress.go) ✅ ~320 строк, собирается
Хендлер подключён в DI ✅ дельта 19, fx.As(new(mattermost.InboundProcessor))
auth: поддержка mattermost: subject ✅ дельты 17, 18
Quality gates: build / vet / arch-lint ✅ build 0, vet чисто, arch-lint no warnings
Образ balda-mm:0.1.5 собран и задеплоен ✅ Up, без ошибок старта
Хендлер доказанно работает ✅ лог conversational ingress settled, session_id=mm-c-7aafea13
Привязка владельца (/start owner=… в DM) 🔴 НЕ ВЫПОЛНЕНА — блокирует приёмку
Push-уведомления ⬜ не начато

Где именно стоим: пост доходит до ingressapp и отбивается на авторизации (settlement_stage="unauthorized") — это корректное поведение, а не баг. В state.db таблица balda_user_bindings пуста: ни одной привязки канала. Админ b6517e56-1110-4cb9-a301-302c585fbd0d существует, но не связан ни с одним транспортом.

Следующее действие: ssh kraken '/tmp/mm-owner-bind.sh testuser "T3stPassw0rd!x" <owner_token>' (скрипт написан, см. §Инструменты), затем @balda в канал и приёмка ответом в UI.


Root cause (найден диагностикой, образ 0.1.4)

Две независимые причины, обе подтверждены логами:

Причина A — bad_seq: MM отбрасывал пинг и переставал пушить события

Диагностический лог каждого кадра показал:

{"status":"FAIL","error":{"id":"api.web_socket_router.bad_seq.app_error",
                          "message":"Invalid sequence for WebSocket message."}}

раз в 30 секунд, ровно на каждый пинг.

Механизм: Mattermost требует монотонный seq в каждом исходящем фрейме от клиента. Балда слала пинг как {"action":"ping"} — без seq. MM помечал соединение невалидным и прекращал доставлять события posted, при этом не разрывая сокет. Отсюда «connected, но тишина»: лог показывал здоровое соединение, а события не шли.

⚙️ Усугублялось тем, что дельта 14 убрала authentication_challenge вместе с его seq:1 — после этого ни один исходящий фрейм не нёс seq.

Фикс (дельта tools/deltas-mattermost-16-seq.json):

  • поле seq int64 в Ingress;
  • nextSeq() — монотонный счётчик под мьютексом, начинается с 1;
  • writeEvent(conn, action, data) — единственный способ слать клиентский фрейм, всегда со seq;
  • сброс seq = 0 при новом соединении;
  • пинг идёт через writeEvent(conn, websocketActionPing, nil);
  • фрейм со "status":"FAIL" логируется как warn, не игнорируется молча.

✅ Проверено: после деплоя bad_seq — 0 вхождений, в логах pong seq_reply:1, 2, 3.

Причина B — Processor == nil: посты принимаются и никуда не идут

После фикса A событие дошло до инбаунда:

"mattermost websocket event decoded"  event=posted
"mattermost posted event accepted"    post_id=7qnmcp9sm3biuen7kfy5jhxwwh
"mattermost post accepted for processing"  text="скажи одно слово"

…и дальше тишина. В processPosted (ingress.go:476):

if s.processor == nil {
    return          // ← сюда и попадаем: InboundProcessor не предоставлен
}

Механизм: в mattermostfx/ingress.go поле объявлено как Processor mattermost.InboundProcessor \optional:"true"`, но **никто в DI-графе его не предоставляет**. В handlersfx/есть толькоzulip_ingress.goиtelegram_ingress.go` — MM-хендлера нет. Ингресс работает как глухой приёмник: событие принимается, логируется и отбрасывается.

⚠️ optional:"true" сделал этот разрыв невидимым: fx не падает при отсутствии зависимости, go build/go vet молчат, тестов на MM-транспорт нет (no test files).


Что уже готово в проекте (проверено чтением кода)

Компонент Файл Состояние
NormalizeInbound для MM channel/mattermost/inbound.go:114 ✅ есть, 26 строк, полный
LocatorForPost channel/mattermost/inbound.go:146 ✅ есть (DM / channel / thread-root)
IsDirectChannelType channel/mattermost/inbound.go ✅ есть
MentionsBot / StripMention / IsBotEcho channel/mattermost/inbound.go ✅ есть
Транспорт-нейтральный ingressapp.Service ingressapp/service.go:160 ✅ Process(ctx, NormalizedInbound) — authorize → prepare session → dispatch
Образец хендлера handlersfx/telegram_ingress.go (695 стр.) ✅ использует тот же actortransport.Dispatcher
Интерфейс InboundProcessor channel/mattermost/inbound.go:44 ✅ объявлен
Реализация InboundProcessor для MM — 🔴 ОТСУТСТВУЕТ

📌 Ключевой вывод: MM-хендлер — это тонкая обёртка, не 1200 строк. Вся логика авторизации/сессий/dispatch уже вынесена в ingressapp.Service. Апстрим такого хендлера не содержит (git ls-tree upstream/main — только telegram* и zulip_ingress.go), писать надо самим.


Что реализовано (фактические изменения)

handlersfx/mattermost_ingress.go — НОВЫЙ ФАЙЛ (~320 строк)

Собственная реализация mattermost.InboundProcessor. Своя, потому что апстрим такого хендлера не содержит (git ls-tree upstream/main — только telegram* и zulip_ingress.go).

Структура (по образцу telegram_ingress.go, но проще — у MM нет форум-топиков, callback-кнопок и question-reply):

Элемент Назначение
mattermostInboundHandlerParams (fx.In) OwnerStore / CollaboratorStore / SessionManager / Dispatcher / CommandIngress — все optional:"true"; AuthToken \name:"balda_auth_token"`; BaldaProviderID `name:"balda_provider"`; Logger`
newMattermostInboundHandler конструктор, возвращает mattermost.InboundProcessor
ProcessInbound собирает mattermost.Post из InboundMessage, зовёт NormalizeInbound, отдаёт в ingressapp.Service.Process
HandleCommand проверяет доступ, публикует в commandcmd.Ingress
HandleUnsupportedCommand отвечает Unknown command: /<name> вместо тишины
ingressService() ingressapp.NewWithLogger + три адаптера: AuthorizerFunc / SessionPreparerFunc / DispatcherFunc
prepareSession GetSession → RestoreSession → EnsureSession (fallback), без DM-welcome-хореографии
canAccess / isOwner через auth.MattermostSubject(userID)

🔴 Ключевое отличие от telegram: InboundMessage не содержит поля Post — есть Text, SenderID, SenderName, PostID, ReceivedAt. NormalizeInbound же принимает Post, поэтому Post собирается руками в ProcessInbound.

auth — поддержка mattermost: subject (дельты 17, 18)

Файл Изменение Почему
auth/channel_token.go ChannelMattermost = "mattermost" + добавлен в CreateMissingOwnerBindTokens без этого MM не получает owner-bind токен
auth/canonical_users.go ветка case ChannelMattermost в canonicalSubject 🔴 без этого canonicalSubject возвращает unsupported transport "mattermost" и КАЖДАЯ проверка авторизации MM молча падает
auth/owner.go MattermostSubject(userID string) = mattermost:<id> рядом с TelegramSubject / ZulipSubject / SlackSubject
channel/mattermost/locator_codec.go ChannelIDOf(locator) достаёт channel_id из AddressJSON

📌 userSubject() в actors/command/start/handler.go уже давал mattermost:<id> через default-ветку — правки не потребовалось. Команда /start работает с MM без изменений.

handlersfx/module.go — регистрация в DI (дельта 19)

fx.Annotate(
    newMattermostInboundHandler,
    fx.As(new(baldamattermost.InboundProcessor)),
),

⚠️ fx.In-структуру не аннотировали fx.ParamTags — только внешний fx.As (см. personal/tech/balda-go-fx-pitfalls).

Порядок сборки и деплоя (проверен)

cd ~/Developer/balda
go build ./... && go vet ./...
go tool go-arch-lint check --project-path .

docker buildx build --builder arm64builder --platform linux/arm64 \
  -f Dockerfile.release -t balda-mm:0.1.5 --load .
docker save balda-mm:0.1.5 | zstd -3 -T4 | ssh kraken 'zstd -dc | docker load'
ssh kraken 'cd /home/kraken/balda && cp docker-compose.yml docker-compose.yml.bak-$(date +%s) \
  && sed -i "s|balda-mm:0.1.4|balda-mm:0.1.5|" docker-compose.yml \
  && docker compose up -d --force-recreate'

Доказательство, что хендлер вызывается (лог образа 0.1.5)

{"component":"balda.channel.mattermost.ingress","text":"скажи одно слово",
 "message":"mattermost post accepted for processing"}
{"component":"balda.handlersfx.mattermost","session_id":"mm-c-7aafea13",
 "inbound_id":"mattermost:3b6wg9e1z3rgun3uu7di11w3ja",
 "settlement_stage":"unauthorized","message":"conversational ingress settled"}

До правки на этом месте была тишина — теперь пост доходит до ingressapp. Осталось снять unauthorized привязкой владельца.

🔴 Текущий блокер: привязка владельца

Авторизация в Balda — subject-based: владелец в canonical store привязан строкой вида <channel>:<principal>. Для MM это mattermost:<mm_user_id>.

Состояние (проверено strings по state.db):

таблица balda_user_bindings  → ПУСТА (ни одной привязки канала)
admin user_id = b6517e56-1110-4cb9-a301-302c585fbd0d  (роль administrator, active)
owner_auth_token = <32 символа, лежит в таблице kv под ключом balda.appowner_auth_token>

Правильный путь привязки — команда /start owner=<owner_auth_token> в DM (команда /start намеренно работает только при Direct=true; в канале отбивается).

Скрипт tools/mm-owner-bind.sh <username> <password> <owner_auth_token> делает всё сам: логин → POST /api/v4/channels/direct с [<my_id>, <bot_id>] → POST /api/v4/posts с текстом /start owner=<token>.

⚠️ owner_auth_token достаётся из state.db только через strings (в образе scratch нет ни shell, ни sqlite3):

ssh kraken 'sudo -n strings /srv/…-49e8f586…/docker/balda/state/state.db | grep -A2 appowner_auth_token' </dev/null

⚠️ Значение токена в этом доке не хранится — только способ получения.


Инструменты, созданные в сессии

Скрипт (Mac ~/Developer/balda/tools/ → Kraken /tmp/) Что делает
mm-post-user.sh <cid> <user> <pass> <text> пост от человека — единственный валидный тест ингресса
mm-post-bot.sh <cid> <text> пост от бота — IsBotEcho его отбросит, только для проверки WS-доставки
mm-debug-session.sh логин + /users/me — отладка 401
mm-owner-bind.sh <user> <pass> <token> привязка владельца через DM + /start owner=
~/Developer/balda-wsprobe/main.go WS-пробник (pure stdlib, флаг -authheader)

⚠️ Скрипты в /tmp не переживут reboot — исходники живут в ~/Developer/balda/tools/.

Дельты этой сессии

Дельта Что
deltas-mattermost-15-diag.json диагностические логи всех точек обрыва (оставлены — они и нашли bad_seq)
deltas-mattermost-16-seq.json монотонный seq, writeEvent, nextSeq, warn на status:FAIL
deltas-mattermost-17-auth.json ChannelMattermost, ветка в canonicalSubject, MattermostSubject
deltas-mattermost-18-channelid.json ChannelIDOf(locator)
deltas-mattermost-19-wire.json регистрация хендлера в DI

Шаг 5 — Push-уведомления в MM ⬜ (не начато)

Задача Alex: «уведомления в приложения неактивны».

Что проверить/включить:

  • EmailSettings / ServiceSettings.EnablePushNotifications (mobile push);
  • Team Edition не включает PushNotifications для iOS/Android через Mattermost Inc. — официальный push-прокси требует лицензии, в TE он недоступен;
  • альтернатива: self-hosted mattermost-push-proxy (HPNS), требует настройки ServiceSettings.PushNotificationServer + сертификатов;
  • EmailSettings.SendEmailNotifications + SMTP — почтовые уведомления работают в TE без ограничений.

⚠️ Точный диагноз «неактивны» снять до правок: зайти в System Console → Notifications, посмотреть, какие тумблеры серые и почему. Не менять конфиг вслепую.


Питфоллы, подтверждённые в этой сессии

# Что Обход
1 MM 11.x: WS-фрейм без seq → bad_seq, события перестают идти без разрыва сокета слать seq монотонно, nextSeq() + writeEvent()
2 optional:"true" в fx скрывает отсутствующую зависимость — приложение стартует и молча ничего не делает проверять реальным запуском, не сборкой; optional требует явного провайдера
3 canonicalSubject с неизвестным транспортом возвращает unsupported transport → все проверки авторизации молча false добавить ветку case ChannelMattermost
4 Тест постом от бота даёт событие с user_id бота → IsBotEcho режет тестировать от НЕ-бота (mm-post-user.sh)
5 mmctl --local post create не работает REST через /tmp/mm-post-user.sh
6 Заголовок Token: в MM 11.x не работает, только Authorization: Bearer см. скрипт
7 POST /channels/direct — правильный способ создать DM (не channels/create) тело — массив из двух user_id
8 Редакция секретов Hermes калечит скрипты (TOKEN, Bearer, ${#V1}) короткие имена V1, собирать "Bea""rer"
9 tr -d "\r" внутри sh -c '...' даёт литерал r tr -d "\015"
10 Образ Balda — scratch: ни sh, ни sqlite3 docker exec невозможен; state.db читать strings на хосте
11 mattermost.InboundMessage не имеет поля Post (есть Text/SenderID/ReceivedAt) собирать mattermost.Post руками перед NormalizeInbound
12 Предсуществующие фейлы тестов: 11 в cmd/balda и internal/apps/balda (workspace/plugin/session-memory) проверять через git stash своих правок; к Mattermost не относятся

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