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