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

14 KiB
Raw Blame History

Zulip Router

Последнее обновление: 2026-06-17 (skip_mention_forward: устранена двойная доставка при @mention)

Цель

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 Eagle из ~/.hermes/config.yaml)
[zulip-router]
    │
    ├── @mention бота от человека → POST owner-боту + ownership
    ├── @mention любого бота (включая другого) → ownership, НЕ форвардить
    ├── трейд с ownership → POST owner-боту
    ├── новый тред без @mention → default-боту (из конфига)
    └── reset / system → только владельцу треда

Ключевое: credentials — Eagle/Орла (не новый бот). Роутер регистрирует свою event queue (отдельную от очереди Eagle).

Правила маршрутизации (пошагово)

Для каждого сообщения из 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)

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):

zulip:
  events_polling:
    enabled: false

Сообщения приходят только через webhook от роутера (плюс outgoing webhook от Zulip при @mention напрямую).

Код

Репозиторий: ~/Developer/zulip-router/ — Go, ~260 LOC (план)

Git: git init 2026-06-16. Первый коммит: a403562 — чистый оригинал от 14:22. .bak файлы — слепки конфигов до правок Кита.

Deploy: ~/Docker/zulip-router/docker-compose.yaml

Файл Назначение
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)
ownership.go Topic ownership store (RW mutex + JSON persistence)
forwarder.go HTTP POST к balda webhook endpoints

Deployment

Compose: ~/Docker/zulip-router/docker-compose.yaml

Сеть: balda_default (external) — чтобы видеть balda-agent-valera-1 и claudio-agent-claudio-1 по Docker DNS.

Credentials: Eagle, не новый бот

Роутер использует credentials Eagle/Орла из ~/.hermes/config.yamlplatforms.zulip.

Не создавать нового бота. Eagle уже имеет права на чтение всех публичных стримов.

Env vars

Создать ~/Docker/zulip-router/.env:

# Credentials Eagle/Орла — из ~/.hermes/config.yaml
ZULIP_API_KEY=<скопировать из конфига Eagle>

Запуск

cd ~/Docker/zulip-router
docker-compose up -d
docker logs zulip-router --tail 20   # проверить poll loop

TODO

  • Написать код роутера (~260 LOC)
  • Создать compose ~/Docker/zulip-router/docker-compose.yaml
  • Набить .env с credentials Eagle (из ~/.hermes/config.yaml)
  • docker-compose up -d
  • Проверить логи: docker logs zulip-router --tail 50
  • Добавить all_public_streams=true в register (роутер не получал события)
  • Добавить guards: timestamp (старт роутера) + skip_bot_messages
  • Протестировать @mention от человека в новом треде → правильная маршрутизация
  • Устранить двойную доставку Орлу (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).

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.goBotCfg.SkipMentionForward bool, main.go → проверка при @mention от человека.

Git: be53c1d (роутер), 3fba7c8 (Docker конфиг).