15 KiB
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:
-
Извлечь @mention — найти все
@**Name**в тексте (Zulip-формат). Игнорировать@**all**и@**everyone**. Поиск по Name + Aliases. -
Проверить источник @mention:
- Если сообщение от человека (sender_id не из списка ботов) и содержит
@**<бот>**:- Записать этого бота как владельца треда (stream+topic)
- Проверить
skip_mention_forwardдля этого бота:- false (по умолчанию) — FORWARD сообщение этому боту (mention trigger)
- true — НЕ форвардить (Zulip outgoing webhook уже доставил напрямую). Ownership обновляется.
- Если сообщение от человека и содержит @mention другого бота (из конфига, но не того, что стал бы овнером):
- Всё равно записать этого бота как владельца треда
- НЕ форвардить (Zulip сам отправит webhook целевому боту по @mention)
- Если сообщение от бота (из конфига) и содержит @mention другого бота:
- Обновить ownership, но НЕ форвардить (Zulip сам доставит)
- Если сообщение от человека (sender_id не из списка ботов) и содержит
-
Нет @mention, но тред уже закреплён за ботом:
- FORWARD владельцу треда (c forward_trigger из конфига, fallback "owner")
-
Новый тред без @mention:
- FORWARD default-боту (c forward_trigger из конфига, fallback "default")
-
reset / system-команды без @mention:
- FORWARD только владельцу треда
Важный нюанс: @mention от другого бота
Когда один бот пишет @Валера (например, Eagle), Zulip отправляет outgoing webhook Валере напрямую — роутер не должен дублировать это сообщение. Поэтому:
- Роутер ставит ownership, но не форвардит если в сообщении есть @mention любого бота из его конфига
- Целевой бот получит сообщение через свой собственный outgoing webhook от Zulip
Forward payload (что шлёт роутер боту)
Роутер шлёт payload, идентичный Zulip outgoing webhook:
{
"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()возвращал falsestream_idв message — без него locator строился с streamID=0 (channel ID 0)
Как это ловилось: после исправления trigger=mention, Валера заходил в handleAutoClaimMention, но payload.Data был пустым → строка 1030 if text == "" → только Welcome, handleMessage не вызывался.
Конфиг роутера (~/Docker/zulip-router/config.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):
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):
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):
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):
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.
Запуск
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
- Написать код роутера (~260 LOC)
- Создать compose
~/Docker/zulip-router/docker-compose.yaml - Набить
.envс credentials (router-bot) docker-compose up -d- Проверить логи:
docker logs zulip-router --tail 50 - Добавить
all_public_streams=trueв register - Добавить guards: timestamp + skip_bot_messages
- Протестировать @mention от человека → правильная маршрутизация
- Устранить двойную доставку (skip_mention_forward)
- Добавить 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 в системных уведомлениях.
Корень три проблемы в роутере:
-
Поле
dataне передавалось. Роутер не слалdata(текст сообщения) в payload. Валера на строке 242 читаетpayload.Data— пусто → строка 1030if text == ""→ только Welcome. -
Поле
sender_emailне передавалось. ВMessagestruct не было поляSenderEmail. Zulip Events API не отдаётsender_email— роутер не мог его заполнить. Без emailisAllowedOwner()возвращал false. -
Поле
stream_idне передавалось.Messagestruct не имелStreamID. Валера на строке 1084 используетpayload.Message.StreamID→ default 0 →channel ID 0. -
trigger=owner вместо trigger=mention. Валера ожидает
trigger=mentionдля авто-создания сессии и регистрации owner'а. Роутер слалtrigger=owner.
Фиксы в коде (коммит 487f481):
config.go: добавлены поляSenderEmail,StreamIDв Message,ForwardTriggerв BotCfgzulip.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. Валера не видит разницы.