[2026-06-18] taiga-vault: family/how-to/hermes-eagle-mac.md family/how-to/kraken-access.md family/how-to/switch-emulation-rom-infra.md personal/projects/balda/balda-valera.md personal/projects/balda/claudio-klaudiy.md personal/projects/balda/fast-rlm-integration.md personal/projects/balda/setup.md personal/projects/hermes-configs/eagle-config.example.yaml personal/projects/hermes-configs/whale-config.example.yaml personal/projects/personal-os/eagle-dashboard.md

This commit is contained in:
Taiga
2026-06-18 03:03:22 +00:00
parent 4c85079e13
commit 68090c8df7
14 changed files with 1034 additions and 301 deletions
+240 -57
View File
@@ -1,46 +1,141 @@
---
tags: [project, hermes, zulip, go, infra]
status: deployed
created: 2026-06-16
---
# Zulip Router
_Последнее обновление: 2026-06-17 (skip_mention_forward: устранена двойная доставка при @mention)_
## Цель
Event-роутер между Zulip и несколькими Hermes-инстансами.
Решает проблему конкурентного получения событий (Валера, Клавдий и другие боты видели все сообщения одновременно, `reset` срабатывал у всех).
Event-роутер между Zulip и balda-ботами (Валера, Клавдий).
Решает проблему конкурентного получения событий — сейчас оба бота видят все сообщения одновременно.
## Проблема (root cause)
- Каждый Hermes-инстанс в polling-режиме регистрирует собственную event queue в Zulip
- `reset` — системная команда Hermes, обрабатывается до хука `whale-thread-guard`
- Нет механизма ownership топика между инстансами
- Каждый 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 (valera-bot credentials)
↓ poll (credentials Eagle из ~/.hermes/config.yaml)
[zulip-router]
├── @Валера → http://hermes-valera:8765/zulip-webhook
── @Клавдий → http://hermes-klavdiy:8765/zulip-webhook
↕ ownership: stream+topic → bot (TTL 24h, persisted to /data/ownership.json)
── @mention бота от человека → POST owner-боту + ownership
├── @mention любого бота (включая другого) → ownership, НЕ форвардить
├── трейд с ownership → POST owner-боту
├── новый тред без @mention → default-боту (из конфига)
└── reset / system → только владельцу треда
```
Hermes/Орёл — не трогаем, остаётся в polling-режиме.
**Ключевое:** credentials — Eagle/Орла (не новый бот). Роутер регистрирует свою event queue (отдельную от очереди Eagle).
## Правила маршрутизации
## Правила маршрутизации (пошагово)
1. `@Валера` или `@Valera` в тексте → Валера (+ ownership обновляется)
2. `@Клавдий` или `@Klavdiy` в тексте → Клавдий (+ ownership обновляется)
3. Топик уже у кого-то → туда же (TTL refreshed)
4. Новый топик без @mention → Валера (default)
5. `reset` без @mention → уходит только к владельцу топика ✓
Для каждого сообщения из 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 напрямую).
## Код
`~/docker/zulip-router/` — Go, ~260 LOC
**Репозиторий:** `~/Developer/zulip-router/` — Go, ~260 LOC (план)
**Git:** `git init` 2026-06-16. Первый коммит: `a403562` — чистый оригинал от 14:22. `.bak` файлы — слепки конфигов до правок Кита.
**Deploy:** `~/Docker/zulip-router/docker-compose.yaml`
| Файл | Назначение |
|------|------------|
@@ -48,47 +143,135 @@ Hermes/Орёл — не трогаем, остаётся в polling-режим
| `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 к Hermes webhook endpoints |
| `forwarder.go` | HTTP POST к balda webhook endpoints |
## Deployment
**Compose:** `~/docker/hermes/docker-compose.yml` — сервис `zulip-router`
**Compose:** `~/Docker/zulip-router/docker-compose.yaml`
**Env vars** (`~/docker/hermes/.env`):
```
ZULIP_ROUTER_API_KEY=<valera-bot api key из ~/.hermes/profiles/valera/config.yaml>
ZULIP_WEBHOOK_TOKEN=zr-secret-2026
**Сеть:** `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>
```
**Запуск после заполнения .env:**
### Запуск
```bash
cd ~/docker/hermes
docker compose up -d --force-recreate
cd ~/Docker/zulip-router
docker-compose up -d
docker logs zulip-router --tail 20 # проверить poll loop
```
**Перезапуск после изменений в профилях:**
```bash
docker compose restart hermes-valera hermes-klavdiy
```
## Hermes профили
Валера и Клавдий переведены в webhook-режим:
```yaml
platforms:
zulip:
webhook_port: 8765
webhook_path: /zulip-webhook
webhook_token: 'zr-secret-2026'
```
Токен должен совпадать с `ZULIP_WEBHOOK_TOKEN` в `.env`.
## TODO
- [ ] Заполнить ZULIP_ROUTER_API_KEY в ~/docker/hermes/.env
- [ ] `docker compose up -d --force-recreate`
- [ ] Проверить логи: `docker compose logs -f zulip-router`
- [ ] Протестировать: написать @Клавдий и @Валера в отдельных топиках
- [ ] Проверить ownership: обратиться в топик без @mention — должен отвечать последний бот
- [ ] При необходимости: добавить Hermes/Орёл в роутер (отдельная задача)
- [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 конфиг).