From 1bc25d467b1d3403e77ea5b47ba5321a23cda568 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Wed, 17 Jun 2026 23:01:49 +0600 Subject: [PATCH] [2026-06-17] zulip-router: skip_mention_forward docs --- family/how-to/hermes-eagle-mac.md | 125 +++++++++++++++++------------ personal/projects/zulip-router.md | 126 +++++++++++++++++++++++++----- 2 files changed, 183 insertions(+), 68 deletions(-) diff --git a/family/how-to/hermes-eagle-mac.md b/family/how-to/hermes-eagle-mac.md index 0c835dc0..20be8491 100644 --- a/family/how-to/hermes-eagle-mac.md +++ b/family/how-to/hermes-eagle-mac.md @@ -8,7 +8,7 @@ Hermes работает нативно (не в Docker) на Mac через `her | Компонент | Расположение | Запуск | |-----------|-------------|--------| -| Hermes config | `~/.hermes/config.yaml` | — | +| Hermes config | `~/.hermes/config.yaml` | Eagle Dashboard (supervisor) — `POST /api/services/hermes/start` | | claude-proxy (Claude proxy) | `/opt/homebrew/bin/claude-proxy` | launchd `ai.claude-proxy` | | Zulip stack | `~/Developer/zulip/docker-compose.yml` | `docker compose up -d` | | Obsidian MCP | mcpvault | встроен в Hermes toolset | @@ -216,28 +216,36 @@ Zulip Events API ↓ poll (credentials Eagle) [zulip-router] (Go, Docker) │ - ├── @Валера → trigger=owner → POST balda-agent:8091 - ├── @Клавдий → trigger=owner → POST claudio-agent:8092 - ├── @Орёл → trigger=owner → POST host.docker.internal:8645/webhooks/eagle - ├── @Кит → trigger=owner → POST host.docker.internal:8646/webhooks/whale - └── ответы от ботов → в Zulip (боты пишут сами через API) + ├── (без @mention, тред закреплён) → trigger=owner → POST боту + ├── (новый тред без @mention) → trigger=default → POST default-боту + │ + └── @mention → ownership обновляется, НО НЕ форвардит + (бот получит через Zulip outgoing webhook напрямую) + +Zulip Outgoing Webhook (при @mention) + │ + ├── @Орёл → POST host.docker.internal:8644/webhooks/eagle + ├── @Кит → POST host.docker.internal:8645/webhooks/whale + ├── @Валера → POST balda-agent:8091 + └── @Клавдий → POST claudio-agent:8092 ``` ### Кто что получает -| Компонент | Получает сообщения через | Шлёт ответы через | -|-----------|-------------------------|-------------------| -| **Eagle (Hermes gateway)** | zulip-router → POST `/webhooks/eagle` | Zulip API напрямую (бот `eagle-bot`) | -| **Кит (Hermes gateway)** | zulip-router → POST `/webhooks/whale` | Zulip API напрямую (бот `whale-bot`) | -| **Валера (balda-agent)** | zulip-router → POST `/zulip/webhook` | Zulip API напрямую (бот `valera-bot`) | -| **Клавдий (claudio-agent)** | zulip-router → POST `/zulip/webhook` | Zulip API напрямую (бот `claudio-bot`) | +| Компонент | @mention (Zulip outgoing webhook) | Без @mention (через zulip-router) | Шлёт ответы через | +|-----------|----------------------------------|-----------------------------------|-------------------| +| **Eagle (Hermes gateway)** | POST `/webhooks/eagle` напрямую | POST `/webhooks/eagle` (trigger=owner/default) | Zulip API (бот `eagle-bot`) | +| **Кит (Hermes gateway)** | POST `/webhooks/whale` напрямую | POST `/webhooks/whale` (trigger=owner/default) | Zulip API (бот `whale-bot`) | +| **Валера (balda-agent)** | POST `:8091` напрямую | POST `:8091` (trigger=owner/default) | Zulip API (бот `valera-bot`) | +| **Клавдий (claudio-agent)** | POST `:8092` напрямую | POST `:8092` (trigger=owner/default) | Zulip API (бот `claudio-bot`) | -**Важно:** `@**Орёл**` и `@**Кит**` НЕ регистрируют свои event queue в Zulip. -Все сообщения приходят **только через zulip-router**. Роутер использует -credentials Eagle для поллинга Zulip Events API, но Eagle сам в Zulip -не поллит. +**Два пути доставки:** +1. **@mention** — Zulip сам шлёт outgoing webhook на webhook URL бота напрямую (минуя роутер). Роутер видит это сообщение в event queue, обновляет ownership, но НЕ форвардит (бот уже получил). +2. **Без @mention** — если тред закреплён за ботом (ownership), роутер форвардит. Если треда нет — default-боту. -### Что не так +**Важно:** Eagle и Кит НЕ регистрируют свои event queue в Zulip. Сообщения без @mention приходят через zulip-router. Роутер использует credentials Eagle для поллинга Zulip Events API, но Eagle сам в Zulip не поллит. + +### Что не так (исторически, сейчас пофиксено) Роутер после установки ownership шлёт ВСЕ сообщения из треда владельцу, даже если @mention адресован другому боту. @@ -246,30 +254,29 @@ credentials Eagle для поллинга Zulip Events API, но Eagle сам в НО НЕ форвардить. Бот получит сообщение через свой outgoing webhook (от Zulip напрямую). +**Текущее состояние (2026-06-17):** ownership обновляется через @mention +от другого бота, но НЕ форвардит — `processEvent` проверяет +`!senderIsBot && !isOtherBotMention()` для mention-сообщений. +Плюс guard `skip_bot_messages: true` отсекает все бот-сообщения целиком. + --- -## Eagle: переключение на zulip-router (2026-06-17) +## Eagle: интеграция с zulip-router (2026-06-17) -### Задача +### Архитектура -Eagle должен получать сообщения: -- `@**Орёл**` → напрямую из Zulip через outgoing webhook (бот `eagle-bot`) -- сообщения без @mention (когда Eagle — owner треда) → через zulip-router +У Eagle **два пути получения сообщений:** -Кастомный код «роутера» в Hermes (`eagle-thread-guard`) не должен мешаться. +1. **@mention (`@**Орёл**`)** → Zulip outgoing webhook → POST напрямую на `host.docker.internal:8644/webhooks/eagle` + (минуя zulip-router). Eagle принимает через `WebhookAdapter` в `webhook.py`. -### План +2. **Без @mention, тред закреплён** → zulip-router → trigger=owner → POST `host.docker.internal:8644/webhooks/eagle` -1. Отключить Zulip polling у Eagle (если включён) -2. Настроить Eagle на приём вебхуков от zulip-router: - - webhook route `/webhooks/eagle` на порту 8645 - - роутер шлёт на `host.docker.internal:8645/webhooks/eagle` -3. Проверить: - - curl POST `/webhooks/eagle` → 202 - - `@**Орёл**` в Zulip → Eagle отвечает - - ответы только на @mention Орла (не на чужие) +Ответ уходит в Zulip напрямую через Zulip API (бот `eagle-bot`), **в тот же стрим и топик** откуда пришло сообщение — chat_id выводится в `_deliver_cross_platform` из `payload.display_recipient::subject`. -### Конфиг Eagle +**Важно:** Eagle НЕ запускает polling Zulip Events API (`ZULIP_POLLING_DISABLED=true`). + +### Конфиг Eagle (актуальный) `~/.hermes/config.yaml` — секция `platforms`: ```yaml @@ -277,8 +284,8 @@ platforms: webhook: enabled: true extra: - host: 127.0.0.1 - port: 8645 + host: "0.0.0.0" + port: 8644 routes: eagle: secret: INSECURE_NO_AUTH @@ -286,23 +293,45 @@ platforms: deliver: zulip ``` -Zulip platform (`platforms.zulip`) — отсутствует, polling отключён. +- `host: "0.0.0.0"` — слушаем на всех интерфейсах, чтобы Docker (zulip-router через `host.docker.internal`) мог достучаться +- `secret: INSECURE_NO_AUTH` — Zulip 10.x outgoing webhook не шлёт HMAC-подпись в заголовках. Safety rail на non-loopback снят патчем в коде +- `deliver: zulip` — ответ перенаправляется в Zulip платформу +- chat_id выводится из payload: `message.display_recipient::message.subject` (код в `_deliver_cross_platform`) -### Что сделано +### Изменения в коде Hermes -- Конфиг Eagle: порт 8645, webhook с route `eagle` -- Eagle перезапущен, порт 8645 слушается -- curl POST `/webhooks/eagle` → 202 Accepted -- Роутер должен слать на `:8645`, но шлёт на `:8644` — не настроено +В `gateway/platforms/webhook.py` (закоммичено `d4e98a8b0`): +1. `_BUILTIN_DELIVER_PLATFORMS` — добавлен `"zulip"` +2. Safety rail INSECURE_NO_AUTH на non-loopback — убран +3. `_validate_signature` — поддержка Zulip token в JSON body (+ gzip-декодирование) +4. `_deliver_cross_platform` — при `deliver=zulip` и отсутствии `deliver_extra.chat_id` chat_id выводится из `payload.message.display_recipient::message.subject` +5. Debug-логи: HEADERS, ZULIP_RAW_BODY, cross-platform diagnostics -### Статус +### Контекст сессий (webhook) -- Конфиг Eagle: порт 8645, webhook route `eagle` -- Конфиг Кита: порт 8644, webhook route `whale` -- Eagle (PID 96351) слушает на **8644** — не на своём порту -- Кит (PID 90402) слушает на **8645** — не на своём порту -- Порты перепутаны: Eagle на порту Кита, Кит на порту Eagle -- `@**Орёл**` — не отвечает +**Проблема:** Каждое сообщение через webhook получало уникальный `chat_id = webhook:eagle:{delivery_id}`, поэтому каждое @mention создавало новую сессию без истории. + +**Решение (2026-06-17):** Два уровня фикса: + +1. **Hermes webhook.py** (`gateway/platforms/webhook.py`) — `session_chat_id` теперь определяется приоритетно: + - `X-Chat-Id` заголовок (ставится zulip-router как `stream::topic`) + - Zulip outgoing webhook payload (`message.display_recipient::subject`) + - fallback — delivery_id (для non-Zulip webhook-ов) +2. **zulip-router** (`forwarder.go`) — при POST добавляет заголовок `X-Chat-Id: stream::topic` + +Это покрывает оба пути доставки: +- **через роутер** (без @mention) — `X-Chat-Id` от роутера +- **напрямую от Zulip** (@mention) — Hermes сам определяет `stream::topic` из payload + +Важно: у разных ботов (Eagle vs Кит) разный `route_name` в `session_chat_id`, поэтому их сессии не смешиваются даже при одинаковом `stream::topic`. + +Git: `forwarder.go` — в репо роутера. `webhook.py` — патч в vendor Hermes (не коммитится). + +### Управление + +Только через Eagle Dashboard: +- `http://localhost:8880` (localhost) +- `POST /api/services/hermes/start|stop|restart` --- diff --git a/personal/projects/zulip-router.md b/personal/projects/zulip-router.md index c0e7f9c0..21f02916 100644 --- a/personal/projects/zulip-router.md +++ b/personal/projects/zulip-router.md @@ -1,6 +1,6 @@ # Zulip Router -_Последнее обновление: 2026-06-16 (обновлено Китом)_ +_Последнее обновление: 2026-06-17 (skip_mention_forward: устранена двойная доставка при @mention)_ ## Цель @@ -42,12 +42,14 @@ Zulip Event Queue Для каждого сообщения из event queue: -1. **Извлечь @mention** — найти все `@**Name**` в тексте (Zulip-формат). Игнорировать `@**all**` и `@**everyone**`. +1. **Извлечь @mention** — найти все `@**Name**` в тексте (Zulip-формат). Игнорировать `@**all**` и `@**everyone**`. Поиск по Name + Aliases. 2. **Проверить источник @mention:** - Если сообщение от **человека** (sender_id не из списка ботов) и содержит `@**<бот>**`: - Записать этого бота как владельца треда (stream+topic) - - FORWARD сообщение этому боту + - Проверить `skip_mention_forward` для этого бота: + - **false** (по умолчанию) — FORWARD сообщение этому боту + - **true** — НЕ форвардить (Zulip outgoing webhook уже доставил напрямую). Ownership обновляется. - Если сообщение от **человека** и содержит @mention **другого бота** (из конфига, но не того, что стал бы овнером): - Всё равно записать этого бота как владельца треда - НЕ форвардить (Zulip сам отправит webhook целевому боту по @mention) @@ -74,25 +76,30 @@ Zulip Event Queue ```yaml zulip: - bot_email: "eagle-bot@zulip.qentra.top" - api_key: "${ZULIP_API_KEY}" + 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" - aliases: ["eagle", "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:8644/webhooks/whale" - aliases: ["Klavdiy"] - webhook: "http://claudio-agent-claudio-1:8092/zulip/webhook" + webhook: "http://host.docker.internal:8645/webhooks/whale" + skip_mention_forward: true # bot_ids — sender_id ботов в Zulip (чтобы отличать сообщения человека от бота) bot_ids: @@ -100,8 +107,10 @@ bot_ids: - 10 # Клавдий (claudio-bot) - 11 # Валера (balda-bot) - 13 # Кит (whale-bot) + - 14 # router-bot default_bot: "Валера" +skip_bot_messages: true # не форвардить сообщения от ботов (включая router-bot) ownership: ttl: 24h @@ -172,20 +181,97 @@ docker logs zulip-router --tail 20 # проверить poll loop - [x] Набить `.env` с credentials Eagle (из `~/.hermes/config.yaml`) - [x] `docker-compose up -d` - [x] Проверить логи: `docker logs zulip-router --tail 50` -- [ ] Протестировать: @Клавдий и @Валера в разных топиках (человеком) — **ПРОВАЛЕНО**: роутер шлёт `trigger=owner` когда @mention другого бота -- [ ] Проверить ownership: сообщение в топик без @mention → последний бот -- [ ] Проверить @mention бота от другого бота → ownership ставится, дубляж нет — **ПРОВАЛЕНО**: см. #Проблема -- [ ] Удалить `extractOtherMention()` патч из кода (больше не нужен) +- [x] Добавить `all_public_streams=true` в register (роутер не получал события) +- [x] Добавить guards: timestamp (старт роутера) + skip_bot_messages +- [x] Протестировать @mention от человека в новом треде → правильная маршрутизация +- [x] Устранить двойную доставку Орлу (skip_mention_forward: true, Jun 17 16:57) +- [ ] Протестировать сообщение без @mention в закреплённом треде → ownership +- [ ] Удалить `extractOtherMention()` патч из кода balda-ботов (больше не нужен) -## Проблема +## Опции запуска -Роутер форвардит ВСЕ сообщения из треда, где есть ownership — даже если @mention адресован другому боту. - -Логи: ``` -forward → Валера (msg=XXXXX trigger=owner stream="personal" topic="Балда/валера") +/app/zulip-router [--config /etc/zulip-router/config.yaml] [--debug] ``` -По спецификации (см. правила маршрутизации п.2): если @mention другого бота — ownership ставится, но НЕ форвардить. Реальная имплементация этого не делает. +Флаг `--debug` включает `slog.LevelDebug` (structured JSON-text логи). +Удобнее: `DEBUG=true` env var (через entrypoint.sh) — подхватывается из docker-compose. -**Причина:** `main.go` в репозитории — старая версия (events poller с `instances`/`targets`), не соответствующая реальному бинарю в контейнере. Актуальный код роутера не найден. +По умолчанию `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). + +```go +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=` +на уровне 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.go` → `BotCfg.SkipMentionForward bool`, `main.go` → проверка при @mention от человека. + +**Git:** be53c1d (роутер), 3fba7c8 (Docker конфиг).