# Zulip Router _Последнее обновление: 2026-06-19 (forward_trigger, sender_email, data, stream_id — payload идентичен Zulip outgoing webhook)_ ## Цель 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 router-bot) [zulip-router] │ ├── @mention бота от человека → POST owner-боту + ownership ├── @mention любого бота (включая другого) → ownership, НЕ форвардить ├── тред с ownership → POST owner-боту (forward_trigger из конфига) ├── новый тред без @mention → default-боту (forward_trigger из конфига) └── reset / system → только владельцу треда ``` **Ключевое:** credentials — router-bot@zulip.qentra.top (отдельный бот, не Eagle). ## Правила маршрутизации (пошагово) Для каждого сообщения из event queue: 1. **Извлечь @mention** — найти все `@**Name**` в тексте (Zulip-формат). Игнорировать `@**all**` и `@**everyone**`. Поиск по Name + Aliases. 2. **Проверить источник @mention:** - Если сообщение от **человека** (sender_id не из списка ботов) и содержит `@**<бот>**`: - Записать этого бота как владельца треда (stream+topic) - Проверить `skip_mention_forward` для этого бота: - **false** (по умолчанию) — FORWARD сообщение этому боту (mention trigger) - **true** — НЕ форвардить (Zulip outgoing webhook уже доставил напрямую). Ownership обновляется. - Если сообщение от **человека** и содержит @mention **другого бота** (из конфига, но не того, что стал бы овнером): - Всё равно записать этого бота как владельца треда - НЕ форвардить (Zulip сам отправит webhook целевому боту по @mention) - Если сообщение от **бота** (из конфига) и содержит @mention другого бота: - Обновить ownership, но НЕ форвардить (Zulip сам доставит) 3. **Нет @mention, но тред уже закреплён за ботом:** - FORWARD владельцу треда (c forward_trigger из конфига, fallback "owner") 4. **Новый тред без @mention:** - FORWARD default-боту (c forward_trigger из конфига, fallback "default") 5. **reset / system-команды без @mention:** - FORWARD только владельцу треда ### Важный нюанс: @mention от другого бота Когда один бот пишет `@Валера` (например, Eagle), Zulip отправляет outgoing webhook Валере напрямую — роутер не должен дублировать это сообщение. Поэтому: - Роутер **ставит ownership**, но **не форвардит** если в сообщении есть @mention любого бота из его конфига - Целевой бот получит сообщение через свой собственный outgoing webhook от Zulip ## Forward payload (что шлёт роутер боту) Роутер шлёт payload, **идентичный** Zulip outgoing webhook: ```json { "message": { "sender_id": 8, "sender_email": "admin@zulip.local", "stream_id": 10, "subject": "test", "content": "прием" }, "data": "прием", "trigger": "mention", "token": "***", "bot_email": "balda-bot@zulip.qentra.top", "bot_full_name": "Валера" } ``` **Поля, которых не было до 2026-06-19 (баг):** - `data` — без него Валера видел пустой текст и слал только Session Started (никогда не отвечал моделью) - `bot_email` — не обязателен, но для полной идентичности - `sender_email` в message — без него `isAllowedOwner()` возвращал false - `stream_id` в message — без него locator строился с streamID=0 (channel ID 0) **Как это ловилось:** после исправления trigger=mention, Валера заходил в `handleAutoClaimMention`, но `payload.Data` был пустым → строка 1030 `if text == ""` → только Welcome, `handleMessage` не вызывался. ## Конфиг роутера (`~/Docker/zulip-router/config.yaml`) ```yaml zulip: bot_email: "router-bot@zulip.qentra.top" api_key: "..." server_url: https://zulip.qentra.top webhook_token: "..." bots: - name: "Валера" bot_email: balda-bot@zulip.qentra.top aliases: [Valera, valera] webhook: "http://balda-agent-valera-1:8091/zulip/webhook" webhook_token: "..." skip_mention_forward: true forward_trigger: mention - name: "Клавдий" bot_email: claudio-bot@zulip.qentra.top aliases: [Klavdiy, klavdiy, Claudio, claudio] webhook: "http://claudio-agent-claudio-1:8092/zulip/webhook" skip_mention_forward: true forward_trigger: mention - 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: - 9 # zulip-router-bot (legacy) - 11 # Валера (balda-bot) - 13 # Клавдий (claudio-bot) - 14 # Орёл (eagle-bot) - 15 # router-bot (current poller) - 16 # Кит (whale-bot) default_bot: "Валера" skip_bot_messages: true ownership: ttl: 24h persist_path: "/data/ownership.json" http: listen: :8090 ``` ### Поля конфига бота | Поле | Описание | |------|----------| | `name` | Отображаемое имя бота | | `bot_email` | Email бота в Zulip (для `bot_email` в forward payload) | | `aliases` | Алиасы для @mention (все регистры) | | `webhook` | URL POST-эндпоинта бота | | `webhook_token` | Токен для X-Hub-Signature-256 | | `skip_mention_forward` | Не дублировать @mention (Zulip уже доставил) | | `forward_trigger` | Значение поля `trigger` в forward payload (fallback: owner/default) | `forward_trigger` — добавлен 2026-06-19. Для ботов, ожидающих `trigger=mention` чтобы создать сессию. Без него роутер слал `trigger=owner`, Валера не создавал сессию и молчал. ## Что меняется в balda После деплоя роутера Events API polling уже отключён у обоих ботов (сделано 2026-06-16): ```yaml zulip: events_polling: enabled: false ``` Сообщения приходят **только через webhook** от роутера (плюс outgoing webhook от Zulip при @mention напрямую). ## Код **Репозиторий:** `~/Developer/zulip-router/` — Go, ~430 LOC **Git:** `git init` 2026-06-16. Последний коммит: `487f481` (2026-06-19). | Файл | Назначение | |------|------------| | `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, get_user_email, add_reaction) | | `ownership.go` | Topic ownership store (RW mutex + JSON persistence) | | `forwarder.go` | HTTP POST к balda webhook endpoints | ### Структуры **Message (config.go):** ```go type Message struct { ID int64 `json:"id"` Content string `json:"content"` Timestamp int64 `json:"timestamp"` SenderID int64 `json:"sender_id"` SenderEmail string `json:"sender_email,omitempty"` SenderFullName string `json:"sender_full_name,omitempty"` DisplayRecipient string `json:"display_recipient"` StreamID int64 `json:"stream_id,omitempty"` Subject string `json:"subject"` Stream string `json:"stream,omitempty"` Topic string `json:"topic,omitempty"` } ``` **BotCfg (config.go):** ```go type BotCfg struct { Name string `yaml:"name"` BotEmail string `yaml:"bot_email"` Aliases []string `yaml:"aliases"` Webhook string `yaml:"webhook"` WebhookToken string `yaml:"webhook_token"` SkipMentionForward bool `yaml:"skip_mention_forward"` ForwardTrigger string `yaml:"forward_trigger"` } ``` **forwardPayload (forwarder.go):** ```go type forwardPayload struct { Message Message `json:"message"` Data string `json:"data"` Trigger string `json:"trigger"` Token string `json:"token,omitempty"` BotEmail string `json:"bot_email,omitempty"` Bot string `json:"bot_full_name,omitempty"` } ``` ## Deployment **Compose:** `~/Docker/zulip-router/docker-compose.yaml` **Сеть:** `balda_default` (external) — чтобы видеть balda-agent-valera-1 и claudio-agent-claudio-1 по Docker DNS. ### Запуск ```bash cd ~/Docker/zulip-router docker-compose up -d docker logs zulip-router --tail 20 # проверить poll loop # После изменений в коде: cd ~/Docker/zulip-router && docker-compose build --no-cache && docker-compose up -d ``` ## TODO - [x] Написать код роутера (~260 LOC) - [x] Создать compose `~/Docker/zulip-router/docker-compose.yaml` - [x] Набить `.env` с credentials (router-bot) - [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) - [x] Добавить forward_trigger, sender_email, data, stream_id (роутер слал неполный payload) - [ ] Протестировать сообщение без @mention в закреплённом треде - [ ] Удалить `extractOtherMention()` патч из кода balda-ботов ## Опции запуска ``` /app/zulip-router [--config /etc/zulip-router/config.yaml] [--debug] ``` Флаг `--debug` включает `slog.LevelDebug` (structured JSON-text логи). Удобнее: `DEBUG=true` env var — подхватывается из 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, forward success, http start - **DEBUG** — raw event body, poll response, ownership get/set, forward request/response body, routing skip reasons, guard skips ## Решённые проблемы ### 1–5. Первые проблемы (см. архив) ### 6. Валера не отвечал на сообщения от роутера (FIXED 2026-06-19) **Симптом:** @mention от Zulip outgoing webhook работает, сообщения от роутера — только `Session Started`, без ответа модели. Сообщение `channel ID 0` в системных уведомлениях. **Корень три проблемы в роутере:** 1. **Поле `data` не передавалось.** Роутер не слал `data` (текст сообщения) в payload. Валера на строке 242 читает `payload.Data` — пусто → строка 1030 `if text == ""` → только Welcome. 2. **Поле `sender_email` не передавалось.** В `Message` struct не было поля `SenderEmail`. Zulip Events API не отдаёт `sender_email` — роутер не мог его заполнить. Без email `isAllowedOwner()` возвращал false. 3. **Поле `stream_id` не передавалось.** `Message` struct не имел `StreamID`. Валера на строке 1084 использует `payload.Message.StreamID` → default 0 → `channel ID 0`. 4. **trigger=owner вместо trigger=mention.** Валера ожидает `trigger=mention` для авто-создания сессии и регистрации owner'а. Роутер слал `trigger=owner`. **Фиксы в коде (коммит `487f481`):** - `config.go`: добавлены поля `SenderEmail`, `StreamID` в Message, `ForwardTrigger` в BotCfg - `zulip.go`: добавлен метод `GetUserEmail(userID)` — дёргает Zulip API `/api/v1/users/{id}` - `main.go`: после guards, вызов `GetUserEmail` для не-ботов; при owner-routing использует `bot.ForwardTrigger` с fallback "owner"; default-routing аналогично - `forwarder.go`: добавлены поля `Data`, `BotEmail` в forwardPayload; заполняются при Forward() - `config.yaml`: `forward_trigger: mention` для Валеры и Клавдия **Итог:** роутер шлёт payload, **идентичный** Zulip outgoing webhook. Валера не видит разницы.