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

15 KiB
Raw Blame History

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:

{
  "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)

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 в системных уведомлениях.

Корень три проблемы в роутере:

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