Files
obsidian-vault/personal/projects/zulip-router.md
T

278 lines
14 KiB
Markdown
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.
# 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=<unix>`
на уровне 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 конфиг).