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

320 lines
15 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-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. Валера не видит разницы.