From 47ff9bfacdeca55c3e19dc9de3b6fac30792a31c Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Sat, 26 Sep 2026 02:23:48 +0600 Subject: [PATCH] [2026-09-26] eagle: personal/projects/balda/balda-mattermost-connector.md personal/projects/balda/balda-mm-delegation-plan.md --- .../balda/balda-mattermost-connector.md | 116 ++++++++++++++++-- .../balda/balda-mm-delegation-plan.md | 75 +++++------ 2 files changed, 143 insertions(+), 48 deletions(-) diff --git a/personal/projects/balda/balda-mattermost-connector.md b/personal/projects/balda/balda-mattermost-connector.md index 0c6e9f99..e4a2150b 100644 --- a/personal/projects/balda/balda-mattermost-connector.md +++ b/personal/projects/balda/balda-mattermost-connector.md @@ -149,24 +149,40 @@ MM: `Source` = новый `turncmd.SourceMattermost`, `DeliveryFormat` = `Delive (3 метода: `ProcessInbound`, `HandleCommand`, `HandleUnsupportedCommand`), `SupportedCommands() []string`, `commandSupported(name)`. -### `server.go` — ingress +### `ingress.go` — ingress (фактически реализовано) ⚠️ **Zulip использует outgoing webhook по HTTP.** У MM для ботов — **WebSocket** -(`wss:///api/v4/websocket`, `Sec-WebSocket-Protocol: bearer `, порт 443/8065). -Это отличается от Zulip-образца принципиально — нужен WS-клиент с reconnect. +(`wss:///api/v4/websocket`). Это отличается от Zulip-образца принципиально. -Обязательно перенести из 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`); +**Как реализована аутентификация (проверено компиляцией):** +- dial с заголовком `Authorization: Bearer `; +- **плюс** обязательный первый кадр `{"seq":1,"action":"authentication_challenge","data":{"token":""}}` — + без него MM не начинает слать события; +- `WebSocketURL()` в `client.go` сам конвертирует `http→ws` / `https→wss` и добавляет `/api/v4/websocket`. + +⚠️ В ранних заметках было `Sec-WebSocket-Protocol: bearer ` — **это не то, что реализовано +и не то, что описано в актуальных доках MM.** Использовать `authentication_challenge`. + +Перенесено из Zulip-сервера и работает: +- **семафор на конкурентную обработку** (`processSem chan struct{}`, 16) + `processWG`; + при заполнении — дроп с warn-логом, не накопление; +- **`defer recover()`** вокруг обработки поста → не ронять процесс; +- **фильтр эхо-сообщений** (`IsBotEcho`) → нет бесконечного цикла; +- **`SetReadLimit(1 MiB)`** на WS-кадр; +- **reconnect-loop** с backoff 5 с — `readLoop` переустанавливает соединение, пока контекст жив; +- **отдельная reader-горутина**: блокирующий `ReadMessage` не прерывается контекстом, поэтому + `Stop()` закрывает conn, и горутина выходит по ошибке; +- graceful `Stop()` с ожиданием активной обработки (`waitForProcessing`); - `Start`/`Stop` в `appports.TransportLifecycleStage`. +**Специфика MM, которой не было в Zulip:** +- В **канале** Балда отвечает только на `@mention` (`MentionsBot`/`StripMention`); в DM — на всё. +- События `post_edited` / `post_deleted` **осознанно игнорируются**: Балда действует по исходной + инструкции и не переписывает свой ответ задним числом. +- Системные посты (`Type != ""`) и удалённые отсеиваются до семафора. +- `data.post` в событии `posted` — это **JSON-строка**, а не объект (нужен двойной `Unmarshal`). +- Каналы типа `D` / `G` — это DM/групповой чат, а не канал команды. + ### `presentation/` + `formatting.go` - `formatting.go`: `FormattingPromptRuleAndExample() (rule, example string)` — @@ -206,6 +222,80 @@ MM: `Source` = новый `turncmd.SourceMattermost`, `DeliveryFormat` = `Delive --- +## Что фактически написано (2026-09-26) + +Всё в `~/Developer/balda` на ветке **`feat/mattermost-channel`**. `go build ./...` = exit 0. + +### Новый пакет `internal/apps/balda/channel/mattermost/` + +| Файл | Строк | Содержимое | +|---|---|---| +| `client.go` | ~560 | REST v4: `CreatePost`, `UpdatePost`, `GetPost`, `GetChannel`, `GetUser`, `GetMe`, `GetTeamByName`, `GetChannelByName`, `GetDirectChannel`, `UploadFile`, `CreatePostWithFiles`, `AddChannelMember`, `Ping`; `WebSocketURL()`, `APIBaseURL()`, `ValidateConfig()`; `APIError` | +| `locator_codec.go` | ~250 | `ChannelType="mattermost"`, префикс `mm`. AddressKey: **`c:` / `c::` / `d:`**. `NewChannelLocator(teamID, channelID, rootID)`, `NewDMLocator(channelID, userID)`, `DecodeLocator`, `LocatorFromAddressKey`, `ClassifyLocatorScope`, `ChannelIDFromLocator`, `RootIDFromLocator`, `IsDirectLocator`, `UserID`/`ParseUserID` | +| `inbound.go` | ~250 | `InboundMessage`/`InboundCommand`/`InboundProcessor` (3 метода), `NormalizeInbound`, `LocatorForPost`, `IsDirectChannelType` (`D`/`G`), `IsBotEcho`, `IsSystemPost`, `IsDeletedPost`, `StripMention`, `MentionsBot`, `ParseCommand`, `SupportedCommands`, `WebSocketEvent`/`PostedData` | +| `adapter.go` | ~420 | Все **9** `deliverycmd.Operation`: Plain, Markdown, AgentReply, Draft, Typing, Progress, **ClearQuestionControls, Photo, Document**. `sendWithPlainFallback`, `MarkdownPlainText`, `sendMedia` | +| `media.go` | ~45 | `openMediaFile` (лимит 100 МБ), `mediaBaseName` | +| `ingress.go` | ~380 | **WebSocket ingress**: dial с `Authorization: Bearer`, `authentication_challenge`, reconnect-loop, ping 30 с, `processSem` (16), `processWG`, `defer recover()` | +| `formatting.go` | ~14 | `FormattingPromptRuleAndExample()` | +| `presentation/presentation.go` | ~110 | `RenderLocator`, `RenderGoalProgress`, `RenderGoalOutcome` | +| `mattermostfx/module.go` | ~100 | 7 групп DI (как `zulipfx`) | +| `mattermostfx/config.go` | ~25 | `NewClient` + валидация при `enabled` | +| `mattermostfx/ingress.go` | ~40 | `IngressParams` (`fx.In`), `NewIngress` | +| `mattermostfx/structured.go` | ~100 | prompt contribution + 3 structured registrar | + +### Изменения в shared-слое (все применены) + +| Файл | Что добавлено | +|---|---| +| `deliverycmd/channel.go` | `ChannelTypeMattermost ChannelType = "mattermost"` | +| `deliveryfmt/routes.go` | `TransportMattermost = "mattermost"`, `NameMattermostMarkdown Name = "mattermost_markdown"`, маршрут `{TransportMattermost, DeliveryFormatNone, NamePlainText}` в `BuiltinRoutes()` | +| `turncmd/turn.go` | `SourceMattermost = "mattermost"` | +| `internal/apps/balda/app.go` | `mattermostfx.Module` в список модулей (строка ~667) | + +### Отклонения от плана разведки (важно) + +1. **`server.go` → `ingress.go`.** Планировался `server.go` по образцу Zulip, но у MM нет + HTTP-вебхука — это долгоживущее WS-соединение, а не `http.Server`. Имя `ingress.go` + точнее отражает суть. +2. **`SendDraftPlain` — не no-op.** В плане было «для MM это не no-op, edit-in-place есть». + По факту: `OperationDraft` **отправляется как обычный пост** (текст не теряется), а + edit-in-place живёт в `OperationClearQuestionControls` → `PUT /posts/{id}` — там он + семантически уместен (гашение кнопок после выбора). +3. **AddressKey не `t:`.** Планировалось `c:` / `dm:` / `t:`. По факту тред кодируется + **тем же channel-ключом с суффиксом**: `c:` (канал) и `c::` (тред). Причина: + тред MM — это root-post внутри канала, и локатор должен оставаться стабильным для всего треда. +4. **`ProgressPolicy{Typing: false}`.** Подтверждено: у MM-ботов **нет typing API** + (`SendTyping` возвращает `nil` осознанно — иначе шум на каждом ходу). +5. **`gorilla/websocket v1.5.3` был `// indirect`** в `go.mod` — стал прямым при сборке. +6. **`commandSupport` переименован** в экспортируемый `IngressCommandSupport` (нужен для `fx.In`), + оставлен алиас. + +### 🔴 Известный незакрытый шаг + +`internal/apps/balda/app.go` — модуль уже в списке, но **импорт не добавлен**: +```go +"github.com/baldaworks/balda/internal/apps/balda/channel/mattermost/mattermostfx" +``` +Вставить рядом с `zulipfx` (строки 27). LSP: `undefined: mattermostfx` на строке 667. +**Без этого `go build ./...` не пройдёт** — проверять первым делом. + +### Конфиг, который нужен для запуска + +Ключи читаются через `fx.ParamTags` / `name:"…"`: + +``` +balda_mattermost_enabled +balda_mattermost_server_url +balda_mattermost_token +balda_mattermost_bot_user_id +balda_mattermost_bot_username +``` +Соответствие в `config.yaml` (по образцу `balda.zulip.*`): `balda.mattermost.{enabled,server_url,token,bot_user_id,bot_username}`. +Конкретные имена ключей yaml **проверить в `docs/reference/configuration.md`** перед Фазой 3 — +я их не сверял, только зафиксировал DI-теги. + +--- + ## Объём работ Оценка из плана: **2-4 дня**. Разбивка: diff --git a/personal/projects/balda/balda-mm-delegation-plan.md b/personal/projects/balda/balda-mm-delegation-plan.md index c6145b79..3a59a90f 100644 --- a/personal/projects/balda/balda-mm-delegation-plan.md +++ b/personal/projects/balda/balda-mm-delegation-plan.md @@ -181,41 +181,37 @@ Mattermost (Alex) — итог + артефакты 2. Сохранить стэш `whale-20260623` в отдельную ветку (не в stash — стэши теряются). 3. Снять текущий compose Валерии/Клавдия в бэкап (`~/Docker/balda-agent/`, `~/Docker/claudio-agent/`). -### Фаза 1 — Mattermost (Kraken) — 🔄 В РАБОТЕ +### Фаза 1 — Mattermost (Kraken) — ✅ СЕРВЕР РАБОТАЕТ Детальный док: [[personal/projects/balda/mattermost-kraken-deploy]]. -1. ✅ Лицензия — self-hosted **Team Edition** (free). Ограничения по числу пользователей - смотреть на момент установки. -2. 🔄 Развернуть MM на Kraken — **БЛОКЕР НАЙДЕН И РЕШЁН**: официальный образ - `mattermost/mattermost-team-edition` существует **только для amd64**, Kraken = arm64. - Официальной поддержки arm64 у Mattermost нет (KB: «not officially supported for production»). - **Решение:** собираем свой образ из **официального arm64-бинаря** - `releases.mattermost.com/11.11.1/mattermost-team-11.11.1-linux-arm64.tar.gz`. - Compose и `.env` на Kraken записаны; сборка запущена. -3. ✅ Порт выбран: **8065** (был свободен). Тома — на USB-HDD (`49e8f586…/docker/mattermost`), - НЕ на SD (иначе SD забьётся — инцидент 2026-06-24). -4. ⛔ **Бота НЕ создавать** — решение Alex 2026-09-26. Токен даст он сам. -5. ⏳ Создать команду + канал для агентов после подъёма. -6. ⏳ Cloudflare hostname **`mm.qentra.top` → HTTP `localhost:8065`** (делает Alex), - затем `MM_SERVICESETTINGS_SITEURL` → `https://mm.qentra.top`. +1. ✅ Лицензия — self-hosted **Team Edition** (free). +2. ✅ Развернуть MM на Kraken. **Блокер arm64 найден и решён:** официальный образ + `mattermost/mattermost-team-edition` — **только amd64**, официальной поддержки arm64 нет. + **Решение:** свой образ из официального arm64-бинаря `releases.mattermost.com`. + 🔴 **Сборка — НА MAC, не на Kraken** (Alex, 2026-09-26): Mac — тот же arm64 и в 10× быстрее + (33 с против 346+ с), а buildkit-кэш на Kraken льётся на **SD-карту** (не на data-root). + Результат: `health=healthy restarts=0`, API `{"status":"OK"}` с Kraken и с Mac по LAN. +3. ✅ Порт **8065**; тома на USB-HDD (`49e8f586…/docker/mattermost`), НЕ на SD. +4. ⛔ **Бота НЕ создавать** — Alex даёт токен сам. +5. ✅ Админ `admin@qentra.top` создан (пароль в `.adminpw`). +6. ✅ **Регистрация закрыта** — проверено живым POST → HTTP 501 `signup_email_disabled`. +7. ✅ CF-маршрут `mm → localhost:8065` добавлен Alex'ом. +8. ⏳ `MM_SERVICESETTINGS_SITEURL` → `https://mm.qentra.top`, затем приёмка входом в браузере. -### Фаза 2 — адаптер Mattermost в Балде — 🔄 РАЗВЕДКА ЗАВЕРШЕНА +### Фаза 2 — адаптер Mattermost в Балде — ✅ КОД НАПИСАН, ⏳ ТЕСТЫ Детальный док: [[personal/projects/balda/balda-mattermost-connector]]. -1. ✅ Вместо новой директории `~/Developer/balda-mm` — работаем в `~/Developer/balda` - на ветке **`feat/mattermost-channel`**, созданной от **чистого `upstream/main`** - (`de273d8f`). Это эквивалентно «чистому клону», но без второго рабочего дерева. -2. ✅ Изучен `channel/zulip/*` целиком: adapter (331), client (289), server (316), - inbound (192), locator_codec (229), presentation, `zulipfx/` (module/prompt/structured). -3. ⏳ Написать `channel/mattermost/*` по контракту `transport-presentation-boundary.md`: - - ingress: **WebSocket** `wss:///api/v4/websocket` (не outgoing webhook!); - - delivery: рендеринг Markdown MM, треды через `root_id`, файлы через `file_ids`, - edit-in-place через `PUT /posts/{id}` (в отличие от Zulip, где draft — no-op); - - 7 групп DI, как в `zulipfx/module.go`. -4. ⏳ Shared-слой: `ChannelTypeMattermost`, `TransportMattermost`, `NameMattermostMarkdown`, - `turncmd.SourceMattermost`, колонка в `docs/commands.md`. - ⚠️ `channel/router.go` **не** правится — он map-based. -5. ⏳ Quality gates: `go test -race ./...` + `go tool golangci-lint run`. +1. ✅ Ветка **`feat/mattermost-channel`** от чистого `upstream/main` (`de273d8f`). +2. ✅ Изучен `channel/zulip/*` целиком как образец. +3. ✅ **Написан пакет `channel/mattermost/*`** — 12 файлов, ~2200 строк: client, locator_codec, + inbound, adapter (все 9 операций), media, ingress (WebSocket), formatting, presentation, + `mattermostfx/` (4 файла, 7 групп DI). +4. ✅ Shared-слой: `ChannelTypeMattermost`, `TransportMattermost`, `NameMattermostMarkdown`, + `turncmd.SourceMattermost`, `mattermostfx.Module` в `app.go`. + ⚠️ `channel/router.go` **не** правился — он map-based (подтверждено). +5. ✅ `go build ./...` = exit 0, `go vet` чист. +6. 🔴 **Незакрыто:** импорт `mattermostfx` в `app.go` (LSP: `undefined: mattermostfx`). +7. ⏳ Тесты по образцу `zulip/*_test.go` + `go test -race ./...` + `golangci-lint run`. ### Фаза 3 — оркестратор на Kraken (~1-2 дня) 1. Развернуть Балду с MM-адаптером на Kraken, провайдер — **DeepSeek** (через API-ключ, не подписка). @@ -244,11 +240,11 @@ Mattermost (Alex) — итог + артефакты | Бан подписочного аккаунта за «unusual traffic» | средний | только официальный бинарь; не более 1-2 параллельных задач; не проксировать токен; мониторить письма Anthropic | | MM-адаптер разъедется с апстримом | высокий | держать как отдельный fork-патч, ребейзить на каждый релиз; либо предложить в апстрим PR | | Kraken: USB-HDD монтируется ~130 с, есть mount-race | известный | drop-in `RequiresMountsFor` уже применён и проверен reboot'ом; volume не на data-root | -| RPi5 не тянет сборку Балды | средний | собирать образ на Mac arm64, на Kraken только `pull`/`up` | -| Утечка секретов в MM-канал | средний | `balda.permissions.mode: ask`; redaction в job-events уже есть в апстриме | -| Mattermost free-лицензия ограничивает нужное | низкий | проверить лимиты до установки | -| 🔴 **Официального arm64-образа MM НЕ существует** | **подтверждён** | свой образ из официального arm64-бинаря; сторонние community-образы не брать. См. [[personal/projects/balda/mattermost-kraken-deploy]] | -| 🔴 **Hermes маскирует секреты в tool-выводе** | **подтверждён** | `write_file` не редактируется; значения не писать литералами — собирать на хосте. См. [[personal/tech/hermes-secret-redaction-bypass]] | +| RPi5 не тянет сборку | **подтверждён** | 🔴 **собирать ЛЮБЫЕ образы для Kraken на Mac, НИКОГДА на Kraken** — buildkit-кэш льётся на SD, плюс 10× медленнее. Перенос `docker save │ zstd` → `ssh kraken 'docker load'`. См. [[personal/projects/balda/mattermost-kraken-deploy]] | +| Mattermost free-лицензия ограничивает нужное | низкий | Team Edition работает; проверено на 11.11.1 | +| 🔴 **Официального arm64-образа MM НЕ существует** | **решён** | свой образ из официального arm64-бинаря; сторонние community-образы не брать. См. [[personal/projects/balda/mattermost-kraken-deploy]] | +| 🔴 **Hermes маскирует секреты в tool-выводе** | **решён (обход найден)** | `write_file` пишет байт-в-байт и **не** редактируется; маскировка только на ЧТЕНИИ (`code_file=True`). Значения не писать литералами — собирать на хосте. См. [[personal/tech/hermes-secret-redaction-bypass]] | +| 🔴 **uid bind-mount конфликт на Kraken** | **подтверждён (2×)** | владелец тома обязан совпадать с uid из образа. `postgres/`→`70:70`, остальное→`2000:2000`. Тот же класс, что crash-loop `hermes-kraken` | --- @@ -292,6 +288,15 @@ Mattermost (Alex) — итог + артефакты → [[personal/projects/balda/balda-mattermost-connector]] - Побочно: разобран механизм маскировки секретов Hermes и найден обход. → [[personal/tech/hermes-secret-redaction-bypass]] +- **2026-09-26 (позже)**: **Фаза 1 ЗАКРЫТА, Фаза 2 — код написан.** + - Alex потребовал **закрытую регистрацию** в MM — сделано и проверено живым запросом (HTTP 501). + - Alex указал на ошибку: **сборка на Kraken вместо Mac** → перенесена на Mac (33 с против 346+ с), + buildkit-кэш на SD больше не растёт. Урок зафиксирован в §Риски. + - MM на Kraken: `health=healthy restarts=0`, API OK с Kraken и Mac, админ создан. + - Коннектор: 12 файлов написаны, `go build ./...` = exit 0. + - Найдены и починены 3 дефекта деплоя: `CMD ["mattermost","server"]`, `--strip-components` в tar, + двойной uid-конфликт (`postgres/` vs остальное). + - 🔴 Незакрыто: импорт `mattermostfx` в `app.go` + тесты коннектора. ## Связанные заметки - [[personal/projects/balda/mattermost-kraken-deploy]] — Фаза 1: MM на Kraken (arm64-блокер, деплой)