[2026-06-17] zulip-router: skip_mention_forward docs
This commit is contained in:
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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=<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.go` → `BotCfg.SkipMentionForward bool`, `main.go` → проверка при @mention от человека.
|
||||
|
||||
**Git:** be53c1d (роутер), 3fba7c8 (Docker конфиг).
|
||||
|
||||
Reference in New Issue
Block a user