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

650 lines
45 KiB
Markdown
Executable File
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. Обновлён 2026-09-26 (сессия реализации, полный проход).
> Статус: **🔴 НЕ ЗАКРЫТ. «Приёмка пройдена» была ложной — сквозной путь проверялся от
> ТЕСТОВОГО аккаунта `testuser`, а не от аккаунта Alex (`admin`). Alex: «Нихуя он мне не
> отвечает в ММ. Не готово!!» — ПОДТВЕРДИЛОСЬ логами. Владельцем бота привязан `testuser`,
> аккаунт Alex в `balda_user_bindings` отсутствует полностью → его посты отбиваются
> `settlement_stage=unauthorized`. См. §«ПОЧЕМУ НЕ РАБОТАЕТ У ALEX» ниже — это причина №7.**
> Push-уведомления: 🔴 невозможны на Team Edition (разбор ниже) — задача снята с плана.
> Часть [[personal/projects/balda/balda-kraken-mm-deploy]] (Фаза 3).
> Команда Alex: «Доделывай всё. И уведомления в приложения неактивны тоже поправь.»
## 🔴 ПОЧЕМУ НЕ РАБОТАЕТ У ALEX — причина №7 (главная, найдена 2026-09-26 13:5x)
Alex написал `@balda привет` в `balda-test` — бот молчит. Лог:
```json
{"component":"balda.handlersfx.mattermost","session_id":"mm-c-7aafea13",
"inbound_id":"mattermost:dqagqce7pibiipt3qtqnkgfd8c",
"settlement_outcome":"terminal","settlement_stage":"unauthorized",
"message":"conversational ingress settled"}
```
**Причина:** в `balda_user_bindings` единственная строка — владелец привязан к
`principal='c6udeze8dfrdxm5jbgq55bd58r'`, это **`testuser`** (аккаунт, которым я тестировал).
Аккаунта Alex — `admin`, MM user_id `cbwsorzwoigbtrdnteugnx4aqo` — в таблице **нет вообще**.
```
cbwsorzwoigbtrdnteugnx4aqo | admin | system_admin ← Alex, НЕ привязан
c6udeze8dfrdxm5jbgq55bd58r | testuser | system_user ← привязан как владелец (мой тест)
edxrgupoxiyg5gzmr79tjypbme | balda | system_user ← бот
```
`IsOwnerSubject` ищет строго по паре `(channel_type='mattermost', principal=<mm_user_id>)`.
Пары для Alex нет → каждая проверка авторизации false → `unauthorized`.
📌 **Мой доклад «фаза закрыта» был неверным.** Я проверял путь от `testuser`, чей post
прошёл авторизацию, и записал это как приёмку. Реальный пользователь — Alex — не проходил.
🔴 **Урок приёмки: проверять ОТ ИМЕНИ ЗАКАЗЧИКА.** Если владелец — Alex, тест обязан
идти с его MM user_id. Чужой аккаунт в bindings делает сквозной путь «рабочим» только
для тестового пользователя — это ложная приёмка.
### Второй симптом (не дефект): пост без `@balda` отбрасывается намеренно
```
{"component":"balda.channel.mattermost.ingress","post_id":"jznhm9ks6jfrtdze5r5qa7g6ic",
"bot_username":"balda","message":"Привет","message":"dropping posted event: bot not mentioned"}
```
Это **корректное** поведение (`MentionsBot`). В канале обращаться надо `@balda <текст>`.
### 🔴 Причина №7 — второй дефект: двойной префикс жив в КОДЕ, не только в БД
`actors/command/start/handler.go:420`:
```go
case transportMattermost:
return auth.MattermostSubject(strings.TrimPrefix(trimmed, transportMattermost+":"))
```
`MattermostSubject()` (`auth/owner.go:307`) **сама** добавляет префикс:
```go
func MattermostSubject(userID string) string {
return fmt.Sprintf("%s:%s", ChannelMattermost, strings.TrimSpace(userID))
}
```
А `trimmed` = `p.Principal` приходит **уже с префиксом** от MM-хендлера
(`mattermost_ingress.go:147`: `Principal: auth.MattermostSubject(cmd.SenderID)`).
⚠️ **`TrimPrefix` здесь снимает префикс, но только один раз, и это не помогает:**
хендлер положил `mattermost:c6udeze…`, `TrimPrefix` даёт `c6udeze…`, `MattermostSubject`
снова даёт `mattermost:c6udeze…` — верно. Но **исходный `p.Principal` уже содержал
префикс**, потому что в прошлой сессии `MattermostSubject` вызывался дважды на разных слоях.
Результат в БД — `mattermost:mattermost:c6udeze…` (виден в `display_name` и в `created_by`
channel-токенов). В прошлой сессии я починил **строку в БД**, но **не код** — поэтому
дефект воспроизвёлся снова при создании токенов.
🔴 **Правило: чинить дефект в коде, а не в данных.** Правка БД без правки кода = дефект
вернётся при следующей записи. Проверять оба слоя.
### 🔴 Блокер привязки Alex — замкнутый круг (нужно решение)
| Путь | Почему не работает |
|---|---|
| `/start owner=<owner_auth_token>` | `HasOwner()` = `len(OwnerSubjects()) != 0`. Владелец (`testuser`) есть → идёт в `handleExistingOwnerState` → `IsOwner(Alex)` = false → **«Bot owner is already registered»** |
| `/start <channel_token>` для MM | `CreateMissingOwnerBindTokens` пропускает канал, где `HasOwnerSubject(channel)` = true. MM считается привязанным (см. строку `testuser`) → **токен для MM не создаётся вообще** |
| `registerOwner` → `attachCanonicalBinding` | Ищет кандидата: `Role == administrator` **и `Binding == nil`**. Единственный админ `b6517e56-…` **уже имеет binding** → `no unbound active administrator user` |
**Выход (выбран, ожидает апрува Alex):** перевести существующий binding с `testuser`
на аккаунт Alex одним `UPDATE` — пользователь `b6517e56-…`, роль и `HasOwner()` остаются,
меняется только `principal` + `display_name`. Это **минимальное** вмешательство: не `DELETE`,
обратимо обратным `UPDATE`, и не требует ни правки кода, ни пересборки.
```sql
UPDATE balda_user_bindings
SET principal = 'cbwsorzwoigbtrdnteugnx4aqo',
display_name = 'mattermost:cbwsorzwoigbtrdnteugnx4aqo'
WHERE channel_type = 'mattermost'
AND principal = 'c6udeze8dfrdxm5jbgq55bd58r';
```
Перед этим — бэкап `state.db` с `-wal` (обязательно).
### Факты БД (2026-09-26, актуальные)
```
balda_users: 1 строка — b6517e56-1110-4cb9-a301-302c585fbd0d / admin / administrator
balda_user_bindings: 1 строка — mattermost | c6udeze8dfrdxm5jbgq55bd58r (testuser)
display_name = mattermost:mattermost:c6udeze8… ← мусор, двойной префикс
balda_collaborators: пусто
balda_user_binding_claims: 1 строка (consumed)
balda_app_kv: owner_auth_token = XDucUE… (32 симв.)
channel_token:* → telemetry: telegram, slackagent, zulip
🔴 для mattermost токена НЕТ (канал считается привязанным)
```
⚠️ `owner_auth_token` в доке **не хранить** — только способ получения:
`sqlite3 "$DB" "SELECT value_json FROM balda_app_kv WHERE key='owner_auth_token';"`
(БД на хосте: `/srv/…-6194539b…/docker/balda/state/state.db`, читать вместе с `-wal`).
### Текущее состояние блокера
**`@balda` от Alex → `unauthorized`, ответа нет.** Бот, транспорт, провайдер, сессии,
доставка — все исправны (доказано проходом от `testuser`). Не хватает **одной строки
привязки** для аккаунта Alex. Работа остановлена на апруве `UPDATE` в БД.
---
## ⚠️ ПРЕДЫДУЩИЙ БЛОК «ПРИЁМКА ПРОЙДЕНА» — НЕПОЛНАЯ (проверено от `testuser`)
Сквозной путь работал **только для аккаунта `testuser`**. В канале `balda-test` (`7bjw4g7o1jdsbmjbqd649xxgaw`):
```
c6udeze8dfrdxm5jbgq55bd58r (testuser) | @balda reply with a single word
edxrgupoxiyg5gzmr79tjypbme (bot) | работает
edxrgupoxiyg5gzmr79tjypbme (bot) | работает
```
Полный проход в логах образа `0.1.8`:
```
mattermost websocket event decoded event=posted
mattermost post accepted for processing text="reply with a single word"
session created successfully session_id=mm-c-7aafea13
assembled provider user content input_text_char_count=424
received provider event event_author=deepseek text_char_count=16 final_response=true
delivery settled settlement_class=success
command handled and acknowledged
```
🔴 **`base_url` (F1) и CA (F2) — подтверждены фиксом.** Симптом `x509: certificate signed by
unknown authority` и адрес `api.openai.com` из логов ушли полностью: провайдер ответил как
`event_author=deepseek`, 16 символов текста. Приёмка выполнена **через REST-чтение ленты канала**
(`GET /channels/{id}/posts`) — это верифицированный ответ бота, не «контейнер Up».
📌 **Как проверять ответ бота в UI без GUI-браузера:** читать ленту канала REST'ом и грепать
`user_id == <bot_user_id>`:
```bash
ssh kraken 'curl -s -H "Authorization: Bearer $(cat /home/kraken/balda/secrets/mm-bot-token)" \
"http://localhost:8065/api/v4/channels/7bjw4g7o1jdsbmjbqd649xxgaw/posts?per_page=4" \
| jq -r ".order[] as \$o | .posts[\$o] | \"\(.user_id) | \(.message)\""'
```
Это надёжнее «видно в UI» для агента: даёт однозначный `user_id` автора.
## Итог сессии (кратко)
Найдено и устранено **шесть независимых причин** тишины. Каждая подтверждена логами.
| № | Причина | Фикс | Проверено |
|---|---|---|---|
| A | `bad_seq` — MM отклонял пинг без `seq` и переставал пушить события | дельта 16 | ✅ 0 вхождений, `pong seq_reply:1,2,3` |
| B | `Processor == nil` — MM-хендлера не существовало в DI | дельта 19 + новый файл | ✅ лог `post accepted for processing` |
| C | `ChannelMattermost` не было в auth → `unsupported transport` | дельта 17 | ✅ |
| D | `/start` отбивался проверкой доступа **до** привязки | правка хендлера | ✅ команда прошла в runtime |
| E | `principal` с транспортным префиксом в БД → вечный `unauthorized` | UPDATE с бэкапом | ✅ `assembled provider user content` |
| F | **`base_url` не читается из YAML + нет CA в `scratch`** | дельта 22 + `.env` | ✅ **ответ провайдера получен** |
**Итог:** все шесть причин устранены и проверены. Бот отвечает в канале.
**Следующее действие:** нет — задача закрыта. Приёмка выполнена через REST-ленту канала
(см. §«Приёмка пройдена» выше).
---
## 🔴 Причина F — провайдер: `base_url` игнорируется + нет CA-сертификатов
Найдено последней, оказалась **двойной**. Симптом в логе (образ `0.1.7`):
```json
{"component":"balda.turn_dispatcher",
"error":"agent run: send openai request: Post \"https://api.openai.com/v1/chat/completions\":
tls: failed to verify certificate: x509: certificate signed by unknown authority"}
```
### F1 — `base_url` в YAML не работает, URL берётся из env
`runtime.providers.deepseek.openai.base_url` в `config.yaml` **молча игнорируется**.
**Причина:** тип `LocalAPIConfig` в `github.com/normahq/runtime/v2@v2.0.11/agentconfig/config.go:82`
имеет **только два поля** — `api_key` и `model`. Поля `base_url` в схеме **нет вообще**:
```go
type LocalAPIConfig struct {
APIKey string `json:"api_key,omitempty" yaml:"api_key,omitempty" mapstructure:"api_key"`
Model string `json:"model,omitempty" yaml:"model,omitempty" mapstructure:"model"`
}
```
Базовый URL читается **только из переменной окружения** —
`hostedagent/openai.go:24`:
```go
const defaultOpenAIBaseURL = "https://api.openai.com/v1"
func openAIBaseURL() string {
if u := os.Getenv("OPENAI_BASE_URL"); u != "" {
return strings.TrimRight(u, "/")
}
return defaultOpenAIBaseURL
}
```
📌 **Правило:** для DeepSeek (и любого OpenAI-совместимого провайдера) базовый URL задавать
**только** через `OPENAI_BASE_URL` в `.env`/compose `environment`. Ключ `base_url` в YAML
выглядит рабочим, но не делает ничего — это ловушка.
**Фикс:** `OPENAI_BASE_URL=https://api.deepseek.com/v1` в `/home/kraken/balda/.env`
(скрипт `tools/mm-add-base-url.sh`, идемпотентный, с бэкапом).
### F2 — `FROM scratch` не содержит корневых сертификатов
`Dockerfile.release` заканчивался `FROM scratch` без `/etc/ssl/certs`, поэтому **любой**
исходящий HTTPS падал с `x509: certificate signed by unknown authority`. Go не может
построить цепочку доверия — файла с корневыми CA физически нет в образе.
**Фикс (дельта `tools/deltas-mattermost-22-ca.json`):**
```dockerfile
FROM scratch
# CA bundle: the scratch base has no trust store, so any HTTPS call (the LLM
# provider in particular) fails with 'x509: certificate signed by unknown
# authority'. The builder stage is a Debian image and carries the bundle.
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /out/balda /usr/local/bin/balda
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
ENTRYPOINT ["/usr/local/bin/balda"]
```
📌 **Правило:** для любого `scratch`/`distroless`-образа, который делает исходящие
HTTPS-запросы, копировать `ca-certificates.crt` из builder'а **и** выставлять `SSL_CERT_FILE`.
Для distroless это `/etc/ssl/certs/ca-certificates.crt` и переменная тоже нужна.
⚠️ Симптом обманчив: ошибка выглядит как «сервер с неверным сертификатом», а на деле
доверять нечему.
---
## 🔴 Причина E — `principal` с транспортным префиксом в БД
`/start owner=<token>` в DM вернул **«You are now registered as the bot owner»**, но
`@balda` по-прежнему давал `settlement_stage="unauthorized"`.
**Причина:** в `balda_user_bindings` записалось
`channel_type='mattermost'`, `principal='mattermost:c6udeze8dfrdxm5jbgq55bd58r'` — с префиксом.
А `canonicalSubject()` **отрезает** ведущий префикс и ищет по `principal='c6udeze...'`:
```go
if before, after, ok := strings.Cut(trimmed, ":"); ok {
channelType = strings.ToLower(strings.TrimSpace(before))
principal = strings.TrimSpace(after) // ← префикс отрезан
}
```
Сравнение `b.channel_type = ? AND b.principal = ?` никогда не совпадало → каждая проверка
авторизации false. `GetUserByBinding` — **точное** сравнение, не `LIKE`.
**Фикс:** точечный `UPDATE` одного поля, с бэкапом и остановкой контейнера
(`tools/mm-fix-owner-principal.sh`):
```sql
UPDATE balda_user_bindings
SET principal = substr(principal, length(channel_type) + 2)
WHERE channel_type = 'mattermost' AND principal LIKE channel_type || ':%';
```
Результат: `mattermost | c6udeze8dfrdxm5jbgq55bd58r`. После этого ингресс прошёл авторизацию
и дошёл до провайдера.
⚠️ **Как найти причину:** читать `state.db` **вместе с `state.db-wal`**. Я сначала смотрел
только `state.db` и видел пустую таблицу — привязка была в WAL. Читать все три файла
(`state.db`, `-wal`, `-shm`) через `strings` под `sudo -n`.
⚠️ Правка БД — **не** штатный путь: `AttachBinding` не даёт перепривязать (`count != 0` →
`ErrBindingAlreadyAssigned`), detach-API отсутствует. Делать с бэкапом и остановленным
контейнером (sqlite не любит конкурентной записи).
⚠️ `state.db` (и `-wal`) принадлежат **root**; `docker exec ... sh` невозможен (scratch),
`sqlite3` есть **на хосте** (`/usr/bin/sqlite3`).
---
## 🔴 Причина D — `/start` должен быть доступен до авторизации
Первая попытка `/start owner=<token>` в DM получила от бота
**«You are not allowed to use Balda in this channel.»**
**Причина:** мой `HandleCommand` проверял доступ **до** обработки, а владельца ещё нет —
тупик: привязаться нельзя, потому что не авторизован; авторизоваться нельзя, потому что
не привязан.
**Фикс** (по образцу zulip — `zulip_ingress.go:172`):
```go
if cmd.Command != commandStart && !h.canAccess(ctx, cmd.SenderID) {
return h.sendPlain(ctx, cmd.Locator, mattermostAccessDeniedText)
}
```
📌 **Правило:** точка входа онбординга (`/start`) — единственная команда, доступная
неавторизованному. Проверять это в **каждом** новом транспорте.
⚠️ `commandStart` **уже объявлена** в пакете `handlersfx` (из telegram/zulip) — своя
константа даёт `redeclared in this block`. Использовать общую.
---
## 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`):
```go
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)
```go
fx.Annotate(
newMattermostInboundHandler,
fx.As(new(baldamattermost.InboundProcessor)),
),
```
⚠️ `fx.In`-структуру **не аннотировали** `fx.ParamTags` — только внешний `fx.As`
(см. [[personal/tech/balda-go-fx-pitfalls]]).
### Порядок сборки и деплоя (проверен)
```bash
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`)
```json
{"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):
```bash
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=` |
| **`mm-get-owner-token.sh`** | достаёт `appowner_auth_token` из `state.db` через `strings` под `sudo -n` |
| **`mm-fix-owner-principal.sh`** | чинит `principal` владельца: бэкап → стоп → UPDATE → старт |
| **`mm-add-base-url.sh`** | идемпотентно добавляет `OPENAI_BASE_URL` в `.env` (с бэкапом) |
| `~/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 |
| `deltas-mattermost-20-start.json` | `transportMattermost`, `userSubject`, ветка MM в `ownerBindBundle`, display name |
| `deltas-mattermost-21-prefix.json` | защита от двойного префикса в `userSubject` (применена; фактическая причина E оказалась в данных БД, не в коде) |
| `deltas-mattermost-22-ca.json` | CA-бандл + `SSL_CERT_FILE` в `Dockerfile.release` |
### Образы, собранные в сессии
`0.1.2` (снят челлендж) → `0.1.3` (диагностика кадров) → `0.1.4` (найден `bad_seq`) →
`0.1.5` (seq-фикс) → `0.1.6` (start/owner) → `0.1.7` (auth-правки) → **`0.1.8` (текущий: CA + base_url)**.
Все собираются на Mac (`arm64builder`) ~110 с, переносятся `docker save | zstd -3 -T4 | ssh kraken 'zstd -dc | docker load'`.
⚠️ Распаковка zstd — **на Mac**: zstd 1.5.4 на Kraken не читает фрейм macOS-zstd 1.5.7.
---
## 🔴 Шаг 5 — Push-уведомления: НЕВОЗМОЖНЫ на Team Edition (диагностика завершена)
Задача Alex: «уведомления в приложения неактивны тоже поправь».
**Диагноз: это ограничение продукта, а не настройка. Включить нельзя.**
### Что показал `mmctl`
```
EmailSettings.SendPushNotifications false
EmailSettings.PushNotificationServer "" ← пусто
EmailSettings.SendEmailNotifications true
EmailSettings.SMTPServer localhost
EmailSettings.SMTPPort 10025 ← SMTP не слушает: exit 7
mattermost version Build Enterprise Ready: false ← Team Edition
DiagnosticId kgib6ormjf8sf8xds7dkhaee5c
```
Дополнительно: `mmctl config get ServiceSettings.EnablePushNotifications` → **`Error: invalid key`**
(в 11.x ключ переехал в `EmailSettings` — 🔴 искать тумблер в `EmailSettings`, не в `ServiceSettings`).
### Три пути проверены — все закрыты
| Путь | Проверка | Вердикт |
|---|---|---|
| **Официальный push-прокси** | `curl -X POST https://push.mattermost.com/api/v1/send_push` с нашим `server_id=<DiagnosticId>` | ⚠️ Прокси жив (HTTP 200), но **отвергает наш инстанс**: `{"error":"Did not send message because of missing platform property type=ios serverId=kgib6ormjf8sf8xds7dkhaee5c"}`. APNs/FCM-сертификаты вешаются на прокси **только для лицензированных EE** — для TE-`DiagnosticId` их нет |
| **Свой MPNS** (open-source `mattermost-push-proxy`) | Чтение офиц. доков | 🔴 Требует **собственные подписанные сборки мобильных приложений** + APNs `.p8` (Apple Developer, $99/год) + FCM-ключ. **Официальные приложения к своему MPNS не подключатся** — они прошиты на bundle ID Mattermost Inc. |
| **SMTP-почта** | `SendEmailNotifications: true`, но `localhost:10025` → exit 7; `ss -ltn` на хосте | ⚠️ Путь **рабочий в TE без лицензии**, но SMTP не настроен: на хосте **нет ни одного слушателя** на 25/465/587/10025 |
### 📌 Вывод (durable)
**Push в официальных мобильных приложениях Mattermost на Team Edition включить невозможно.**
Клиенты принимают push только от прокси, зарегистрированного за лицензией. Это не
конфигурация, а архитектура продукта. Варианты:
1. **Лицензия Mattermost Professional** (~$10/юзер/мес) — push заработает сразу, официальный прокси.
2. **Свои сборки приложений + свой MPNS** — нужен Apple Developer + Firebase + поддержка форка.
3. **SMTP-почта** — единственный путь без лицензии и затрат; уведомления приходят на e-mail,
не в приложение. ~15 минут работы (Gmail app password или локальный postfix + `EmailSettings`).
4. **Оставить как есть** — уведомлений не будет.
⚠️ **Диагноз снимать ДО правок:** `mmctl config get` по `EmailSettings`, `SendPushNotifications`,
`PushNotificationServer`, затем **живая проба прокси** с реальным `DiagnosticId` из
`SELECT value FROM systems WHERE name='DiagnosticId';`. Не менять конфиг вслепую —
`SendPushNotifications=true` без зарегистрированного прокси не даст ничего.
⚠️ `DiagnosticId` лежит в таблице `systems` (не `Systems`, не `Systems.name='ServerId'`).
Запрос: `docker exec mm-postgres psql -U mmuser -d mattermost -t -c "SELECT name, value FROM systems WHERE name ILIKE '%diagnostic%';"`
🔴 **Кавычки:** внутри `sqlite/psql -c` в ssh-строке одинарные кавычки ломаются — использовать
`'"'"'`-экранирование, иначе `column "DiagnosticId" does not exist`.
---
## Питфоллы, подтверждённые в этой сессии
| # | Что | Обход |
|---|---|---|
| 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 не относятся |
| 13 | 🔴 **`base_url` в YAML провайдера НЕ работает** — `LocalAPIConfig` не имеет такого поля | задавать `OPENAI_BASE_URL` в env (см. §Причина F1) |
| 14 | 🔴 **`FROM scratch` без CA** → любой HTTPS падает с `x509: certificate signed by unknown authority` | копировать `ca-certificates.crt` из builder + `SSL_CERT_FILE` |
| 15 | **`principal` в `balda_user_bindings` с транспортным префиксом** → `GetUserByBinding` (точное сравнение) не находит владельца | хранить `principal` **без** `<channel>:` (см. §Причина E) |
| 16 | **`state.db` читать вместе с `-wal`** — свежие записи лежат в WAL, `state.db` выглядит пустым | `strings` по всем трём файлам (`db`, `-wal`, `-shm`) под `sudo -n` |
| 17 | **`/start` требует доступа, которого ещё нет** → тупик онбординга | исключить `commandStart` из проверки: `cmd.Command != commandStart && !h.canAccess(...)` |
| 18 | `commandStart` **уже объявлена** в пакете `handlersfx` | не заводить свою — `redeclared in this block` |
| 19 | `AttachBinding` **не даёт перепривязать** (`count != 0` → `ErrBindingAlreadyAssigned`), detach-API отсутствует | править `state.db` точечным UPDATE с бэкапом и остановленным контейнером |
| 20 | `sqlite3` **есть на хосте** Kraken (`/usr/bin/sqlite3`), но не в контейнере | правки БД делать с хоста под `sudo -n`, контейнер остановить |
| 21 | Правка `state.db` через `sudo cp` требует апрува и **блокируется** (таймаут) | точечный UPDATE одного поля вместо операций над каталогом данных |
| 22 | `mmctl config get ServiceSettings.EnablePushNotifications` → **`Error: invalid key`** | в MM 11.x ключ живёт в `EmailSettings.SendPushNotifications` |
| 23 | Официальный push-прокси отвечает 200 даже на **невалидный** `server_id` — успех по HTTP не значит доставку | читать **тело** ответа: `"status":"FAIL"` + `Did not send message because of missing platform property` |
| 24 | `psql -t -c "… name IN ('DiagnosticId')"` в ssh-строке ломается на кавычках → `column "DiagnosticId" does not exist` | экранировать `'"'"'` внутри ssh-команды, либо использовать `ILIKE` без кавычек на значении |
| 25 | Приёмка «виден в UI» без GUI-браузера | `GET /api/v4/channels/{id}/posts?per_page=N` + `jq` по `user_id` автора — однозначное доказательство |
| 26 | `docker compose` в `/home/kraken/mattermost/` — 3 сервиса (`postgres`, `mattermost`, сеть `mm-net`), БД `mm-postgres` | БД доступна как `docker exec mm-postgres psql -U mmuser -d mattermost` |
| 27 | 🔴🔴 **Приёмка от тестового аккаунта** — сквозной путь «работает», а заказчик получает `unauthorized` | тестировать **учёткой заказчика**; сверять `user_id` ленты с `principal` в `balda_user_bindings` (см. §«Ложная приёмка») |
| 28 | 🔴 **Дефект, найденный в БД, чинится только в БД** → возвращается при следующей записи | искать источник в коде; двойной префикс = helper, применённый поверх готового subject'а (см. §«Чинить в КОДЕ») |
| 29 | **Владельцем привязан чужой аккаунт** → `/start owner=` даёт «already registered», channel-токен для канала не создаётся, кандидат `Binding == nil` отсутствует | `UPDATE balda_user_bindings` (см. §«Дефект 8») — бэкап `state.db` + `-wal`, контейнер остановить |
| 30 | `sqlite3` по `balda_app_kv`: колонок `value`/`k` **нет** | схема: `namespace, key, value_json, updated_at, expires_at`; проверять `.schema` перед запросом |
| 31 | Пост **без** `@balda` отбрасывается (`dropping posted event: bot not mentioned`) | это НЕ дефект: `MentionsBot`; в канале писать `@balda <текст>`, в DM — без упоминания |
## Связанные заметки
- [[personal/projects/balda/balda-kraken-mm-deploy]] — основной док Фазы 3
- [[personal/projects/balda/mattermost-kraken-deploy]] — Фаза 1 (сервер MM)
- [[personal/projects/balda/balda-mattermost-connector]] — Фаза 2 (код коннектора)
- [[personal/tech/balda-go-fx-pitfalls]] — fx-питфоллы + дельта-апплаер
- [[personal/tech/hermes-secret-redaction-bypass]] — редакция секретов