# Zulip Router _Последнее обновление: 2026-06-17 (skip_mention_forward: устранена двойная доставка при @mention)_ ## Цель Event-роутер между Zulip и balda-ботами (Валера, Клавдий). Решает проблему конкурентного получения событий — сейчас оба бота видят все сообщения одновременно. ## Проблема (root cause) - Каждый balda-бот регистрирует собственную event queue в Zulip Events API - Без роутера оба получают все сообщения, и каждый может ответить на @mention другого - `extractOtherMention()` патч в коде — костыль, не решает проблему полностью ## Как сейчас (до роутера) ``` Zulip ├── Events API ← Валера (valera-bot, poll на :8091) — отключён ├── Events API ← Клавдий (claudio-bot, poll на :8092) — отключён └── Webhook → оба (outgoing webhook через @mention) ``` ## Архитектура (с роутером) ``` Zulip Event Queue ↓ poll (credentials Eagle из ~/.hermes/config.yaml) [zulip-router] │ ├── @mention бота от человека → POST owner-боту + ownership ├── @mention любого бота (включая другого) → ownership, НЕ форвардить ├── трейд с ownership → POST owner-боту ├── новый тред без @mention → default-боту (из конфига) └── reset / system → только владельцу треда ``` **Ключевое:** credentials — Eagle/Орла (не новый бот). Роутер регистрирует свою event queue (отдельную от очереди Eagle). ## Правила маршрутизации (пошагово) Для каждого сообщения из event queue: 1. **Извлечь @mention** — найти все `@**Name**` в тексте (Zulip-формат). Игнорировать `@**all**` и `@**everyone**`. Поиск по Name + Aliases. 2. **Проверить источник @mention:** - Если сообщение от **человека** (sender_id не из списка ботов) и содержит `@**<бот>**`: - Записать этого бота как владельца треда (stream+topic) - Проверить `skip_mention_forward` для этого бота: - **false** (по умолчанию) — FORWARD сообщение этому боту - **true** — НЕ форвардить (Zulip outgoing webhook уже доставил напрямую). Ownership обновляется. - Если сообщение от **человека** и содержит @mention **другого бота** (из конфига, но не того, что стал бы овнером): - Всё равно записать этого бота как владельца треда - НЕ форвардить (Zulip сам отправит webhook целевому боту по @mention) - Если сообщение от **бота** (из конфига) и содержит @mention другого бота: - Обновить ownership, но НЕ форвардить (Zulip сам доставит) 3. **Нет @mention, но тред уже закреплён за ботом:** - FORWARD владельцу треда 4. **Новый тред без @mention:** - FORWARD default-боту (из конфига) 5. **reset / system-команды без @mention:** - FORWARD только владельцу треда ### Важный нюанс: @mention от другого бота Когда один бот пишет `@Валера` (например, Eagle), Zulip отправляет outgoing webhook Валере напрямую — роутер не должен дублировать это сообщение. Поэтому: - Роутер **ставит ownership**, но **не форвардит** если в сообщении есть @mention любого бота из его конфига - Целевой бот получит сообщение через свой собственный outgoing webhook от Zulip ## Конфиг роутера (`~/Docker/zulip-router/config.yaml`) ```yaml zulip: bot_email: "router-bot@zulip.qentra.top" api_key: "aOYAXlBV1bZlv871fnTTgbGTBX7R4DeC" # router-bot, не eagle-bot server_url: "https://zulip.qentra.top" webhook_token: "08cd0f..." # совпадает с secret в конфиге Eagle (route eagle) bots: - name: "Валера" aliases: ["Valera"] webhook: "http://balda-agent-valera-1:8091/zulip/webhook" skip_mention_forward: true - name: "Клавдий" aliases: ["Klavdiy"] webhook: "http://claudio-agent-claudio-1:8092/zulip/webhook" skip_mention_forward: true - name: "Eagle" bot_email: eagle-bot@zulip.qentra.top aliases: ["Орёл", "орёл", "eagle", "Eagle"] webhook: "http://host.docker.internal:8644/webhooks/eagle" skip_mention_forward: true - name: "Кит" bot_email: whale-bot@zulip.qentra.top aliases: ["Whale", "whale", "кит"] webhook: "http://host.docker.internal:8645/webhooks/whale" skip_mention_forward: true # bot_ids — sender_id ботов в Zulip (чтобы отличать сообщения человека от бота) bot_ids: - 9 # Eagle / Орёл (Hermes) - 10 # Клавдий (claudio-bot) - 11 # Валера (balda-bot) - 13 # Кит (whale-bot) - 14 # router-bot default_bot: "Валера" skip_bot_messages: true # не форвардить сообщения от ботов (включая router-bot) ownership: ttl: 24h persist_path: "/data/ownership.json" ``` ## Что меняется в balda После деплоя роутера Events API polling уже отключён у обоих ботов (сделано 2026-06-16): ```yaml zulip: events_polling: enabled: false ``` Сообщения приходят **только через webhook** от роутера (плюс outgoing webhook от Zulip при @mention напрямую). ## Код **Репозиторий:** `~/Developer/zulip-router/` — Go, ~260 LOC (план) **Git:** `git init` 2026-06-16. Первый коммит: `a403562` — чистый оригинал от 14:22. `.bak` файлы — слепки конфигов до правок Кита. **Deploy:** `~/Docker/zulip-router/docker-compose.yaml` | Файл | Назначение | |------|------------| | `main.go` | Poll loop, routing, dispatch | | `config.go` | Config struct + YAML loading + env var expansion | | `zulip.go` | Zulip API client (register_queue, get_events, long-poll) | | `ownership.go` | Topic ownership store (RW mutex + JSON persistence) | | `forwarder.go` | HTTP POST к balda webhook endpoints | ## Deployment **Compose:** `~/Docker/zulip-router/docker-compose.yaml` **Сеть:** `balda_default` (external) — чтобы видеть balda-agent-valera-1 и claudio-agent-claudio-1 по Docker DNS. ### Credentials: Eagle, не новый бот Роутер использует credentials Eagle/Орла из `~/.hermes/config.yaml` → `platforms.zulip`. Не создавать нового бота. Eagle уже имеет права на чтение всех публичных стримов. ### Env vars Создать `~/Docker/zulip-router/.env`: ```env # Credentials Eagle/Орла — из ~/.hermes/config.yaml ZULIP_API_KEY=<скопировать из конфига Eagle> ``` ### Запуск ```bash cd ~/Docker/zulip-router docker-compose up -d docker logs zulip-router --tail 20 # проверить poll loop ``` ## TODO - [x] Написать код роутера (~260 LOC) - [x] Создать compose `~/Docker/zulip-router/docker-compose.yaml` - [x] Набить `.env` с credentials Eagle (из `~/.hermes/config.yaml`) - [x] `docker-compose up -d` - [x] Проверить логи: `docker logs zulip-router --tail 50` - [x] Добавить `all_public_streams=true` в register (роутер не получал события) - [x] Добавить guards: timestamp (старт роутера) + skip_bot_messages - [x] Протестировать @mention от человека в новом треде → правильная маршрутизация - [x] Устранить двойную доставку Орлу (skip_mention_forward: true, Jun 17 16:57) - [ ] Протестировать сообщение без @mention в закреплённом треде → ownership - [ ] Удалить `extractOtherMention()` патч из кода balda-ботов (больше не нужен) ## Опции запуска ``` /app/zulip-router [--config /etc/zulip-router/config.yaml] [--debug] ``` Флаг `--debug` включает `slog.LevelDebug` (structured JSON-text логи). Удобнее: `DEBUG=true` env var (через entrypoint.sh) — подхватывается из docker-compose. По умолчанию `DEBUG=false` — только INFO+. ## Логирование Переведено на `log/slog` (built-in Go 1.21+), формат text handler. Уровни: - **ERROR** — фатальные ошибки, падения, ошибки форварда - **WARN** — skip unparseable event, owner bot not found - **INFO** — queue registered, router ready (start timestamp), routing decision (mention/owner/default), forward success, http start - **DEBUG** — raw event body, poll response, ownership get/set, forward request/response body, routing skip reasons, guard skips Включение: `--debug` флаг или `DEBUG=true` env var. ## Guards (фильтры сообщений) Два guard'а в `processEvent`, выполняются до любой маршрутизации: ### 1. Timestamp guard (всегда включён) Сообщения, созданные **до старта роутера**, скипаются полностью (включая обновление ownership). ```go startTime := time.Now().Unix() // после регистрации очереди if ev.Timestamp < startTime { slog.Debug("skip: event from before router start", ...) return } ``` Лог: `"router ready, events before this timestamp will be skipped" start_timestamp=` на уровне INFO (всегда виден). На уровне DEBUG — `"skip: event from before router start"` для каждого скипнутого события. ### 2. Bot sender guard (конфигурируемый) Если `skip_bot_messages: true` в конфиге, все сообщения от ботов (sender_id из `bot_ids`) скипаются — не форвардятся и не обновляют ownership. Лог (DEBUG): `"skip: bot message (skip_bot_messages=true)"` **Зачем:** router-bot может писать сообщения в стримы для теста/уведомлений. Роутер не должен форвардить их обратно ботам. ## Решённые проблемы ### 1. Event queue умирала, long-poll зависал (FIXED 2026-06-17) **Симптом:** роутер регистрирует очередь, делает `fetchEvents` с `dont_block=false`, и **зависает навсегда**. Логи отсутствуют часами. Сообщения не обрабатываются. **Фикс:** `all_public_streams=true` при регистрации очереди + обработка `BAD_EVENT_QUEUE_ID` с перерегистрацией. ### 2. Роутер не получал события (FIXED 2026-06-17) **Причина:** router-bot не был подписан на стримы. Очередь регистрировалась, но событий не получала. **Фикс:** `all_public_streams=true` в `registerQueue`. ### 3. Сообщения до старта роутера обрабатывались (FIXED 2026-06-17) **Фикс:** timestamp guard — сообщения с timestamp < времени старта скипаются. ### 4. Сообщения от router-bot форвардились ботам (FIXED 2026-06-17) **Фикс:** конфигурируемый `skip_bot_messages: true` — сообщения от ботов скипаются. ### 5. Двойная доставка @mention (FIXED 2026-06-17) **Симптом:** Орёл получал одно сообщение дважды — один раз через Zulip outgoing webhook, второй — через роутер. **Причина:** роутер форвардил @mention-сообщения, хотя Zulip outgoing webhook уже доставил их напрямую боту. **Фикс:** добавлен флаг `skip_mention_forward: true` в конфиг каждого бота, у которого есть outgoing webhook. При @mention от человека: - ownership обновляется (кто владелец треда) - если `skip_mention_forward: true` — forward не делается (логируется `"skip forward: mention delivered via Zulip outgoing webhook"`) - если `skip_mention_forward: false` (по умолчанию) — поведение не меняется **Код:** `config.go` → `BotCfg.SkipMentionForward bool`, `main.go` → проверка при @mention от человека. **Git:** be53c1d (роутер), 3fba7c8 (Docker конфиг).