[2026-06-19] zulip-router.md: forward_trigger, sender_email, data, stream_id — полный payload

This commit is contained in:
Alexey Martemyanov
2026-06-19 13:57:18 +06:00
parent 78145b0e9a
commit ab6d1ba5c3
5 changed files with 252 additions and 118 deletions
@@ -97,3 +97,53 @@
3. После включения `display.platforms.webhook.streaming: true` — стриминг пошёл
4. Тест с `rm -rf` подтвердил: текст до аппрува **не появляется** у юзера
5. Логи подтвердили: `got_done` не вызывается → `_send_fallback_final` не срабатывает
---
## Tool progress для webhook
**Статус:** Исправлено ✅
### Проблема
Tool progress (уведомления о tool calls) не работали для вебхука — сообщения о ходе выполнения тулов не доходили до юзера.
### Почему
Два гарда в `gateway/run.py`:
1. **Первый гард** (`run.py:16992`) — условие включения прогресса:
```python
tool_progress_enabled = progress_mode != "off" and (
source.platform != Platform.WEBHOOK
or cfg.display.platforms.get("webhook", {}).get("tool_progress", "off") != "off"
)
```
По умолчанию `display.platforms.webhook.tool_progress` не было в конфиге → `"off"` → прогресс выключен.
2. **Второй гард** (`run.py:17184-17191`) — проверка что у адаптера есть свой `edit_message`:
```python
if type(adapter).edit_message is BasePlatformAdapter.edit_message
and source.platform != Platform.WEBHOOK:
# очистить очередь и выйти
```
У `WebhookAdapter` нет своего `edit_message` (наследует базовый) → прогресс скипается даже если включён.
### Что сделано
1. **Конфиг** — добавлено `display.platforms.webhook.tool_progress: all`
2. **`run.py:16992`** — изменён первый гард: разрешает tool progress для вебхука если в конфиге не `"off"`
3. **`run.py:17215-17217`** — добавлен форс `can_edit=False` для `Platform.WEBHOOK`:
```python
if source.platform == Platform.WEBHOOK:
can_edit = False
```
Прогресс-сообщения для вебхука шлются новыми сообщениями через `adapter.send()`, не `edit_message()`.
### Коммиты
| Дата | Коммит | Описание |
|------|--------|----------|
| 2026-06-18 | `714026a6e` | Исправлен первый гард — разрешён tool progress для webhook |
| 2026-06-18 | (config) | Добавлен `display.platforms.webhook.tool_progress: all` |
| 2026-06-18 | `a92840dcd` | force `can_edit=False` для webhook — прогресс через `send()`, не `edit_message()` |
### Проверка
Tool progress ходит через вебхук — юзер видит уведомления о tool calls (подтверждено).
+24
View File
@@ -0,0 +1,24 @@
Карась — рыба неприхотливая.
Водится в прудах и озёрах.
Хорошо переносит зимовку.
Мясо сладковатое на вкус.
Щука — речной хищник.
Засада и молниеносный бросок.
Держится у зарослей.
Ловится на спиннинг и живца.
Окунь — полосатый разбойник.
Стайная рыба средних размеров.
Клюёт на червя и блесну.
В ухе особенно хорош.
Судак — клыкастый охотник.
Держится глубоких мест.
Предпочитает чистую воду.
Мясо плотное и вкусное.
Лещ — стайник с высоким телом.
Ищет корм на дне.
Хорошо клюёт на мотыля.
Вяленый лещ — деликатес.
@@ -113,12 +113,20 @@ mcp_servers:
7. Идея использовать OpenRouter (`z-ai/glm-5`) как дефолт — отвергнута: используем DeepSeek как у Балды/Whale.
8. Утверждение про "актуализированную доку" 16 июня — на хост-FS дока НЕ менялась (правки шли в контейнерную FS). Реальная актуализация через `mcp_obsidian_write_note` — 2026-06-16.
## Что НЕ делаем без явного подтверждения
- Не пушить изменения в git.
- Не правим config.yaml до согласования итогового формата секции `fast-rlm`.
- Не рестартим Балду без согласования.
## Текущий статус (2026-06-19)
### ✅ Сделано
- Шаги 1–4: образ собран, контейнер `fast-rlm-mcp` Up 2 дня
- `fast-rlm-mcp` в сети `balda_default`, доступен из Валеры по DNS
- MCP HTTP сервер работает, initialize/tools/list отвечают
- Валера (`balda-agent-valera-1`) запущен после простоя — отвечает 401 на webhook, 404 на корень
- **fast-rlm добавлен в `config.yaml` Балды** — и в `runtime.mcp_servers`, и в `balda.mcp_servers`
- Балда рестартнута
### ❓ Не проверено end-to-end
- Нужен реальный вызов `recursive_context_compress` из Валеры (через Zulip @Валера)
- В логах старта Валеры нет явного "MCP connected: fast-rlm" — возможно коннект ленивый
## История
- **2026-06-16 ~14:00**: создан после серии фабрикаций и компакций контекста. План построен с явными точками реальной проверки на каждом шаге.
- **2026-06-16 ~16:00**: пользователь поправил — провайдер LLM = DeepSeek (как у Балды/Whale), не OpenAI/OpenRouter.
- **2026-06-16 ~17:00**: актуализация через `mcp_obsidian_write_note` — добавлены секции "Провайдер LLM" и "Подтверждённый API", compose v1 вместо v2, сеть `balda_default` вместо `balda-agent_default`, файл `.env` в список, Deno в Dockerfile, галлюцинации 5–8.
- **2026-06-16 ~14:00**: создан
- **2026-06-16 ~17:00**: актуализация
- **2026-06-19 ~03:47**: Валера запущен, fast-rlm добавлен в config.yaml и рестартнут
@@ -347,13 +347,23 @@ if result.get("interrupted"):
**Изменяет:** Только сбор и отправку tool outputs после interrupt.
**Не изменяет:** conversation_loop.py, run_agent.py, stream_consumer.py, approval.py.
### Баг 2: Стриминг не работает
### Баг 2: Стриминг — partial не работали
Hermes не стримит ответ через webhook — агент отвечает одним блоком после завершения генерации. Пользователь не видит процесс печати.
**Проблема (оригинал):** WebhookAdapter не имеет `SUPPORTS_MESSAGE_EDITING` — stream_consumer не знал что редактирование не поддерживается. Consumer пытался edit, падал в fallback. Курсор `▉` оставался в финальном ответе.
**Root cause:** Webhook-транспорт не поддерживает стриминг — ответ формируется целиком и шлётся одним POST-запросом в Zulip через роутер.
**Фикс (коммиты `fa271d2d1`, `1ac06d593`, `88cbb77b9`):**
1. Добавлен `adapter_supports_edit: bool = True` в `StreamConsumerConfig`
2. В `run.py` при создании consumer читается `getattr(adapter, "SUPPORTS_MESSAGE_EDITING", True)` — для WebhookAdapter возвращает `True` (нет атрибута), поэтому добавлена ручная настройка через конфиг и проверка `cfg.adapter_supports_edit`
3. Создана `else` ветка для no-edit (webhook) в stream_consumer — без курсора, отправка по `\n`
4. Добавлен rate-limit: partial на `\n` не чаще чем раз в `edit_interval` (~500ms)
5. Убран `buffer_threshold` для no-edit — partial только по `\n` + таймер, чтобы не рвать строки и не создавать дубли
6. Gateway перезапускается только через Eagle Dashboard (`localhost:8880`)
**Фикс:** ? (Пока не чинили)
**Текущее поведение:**
- Partial отправляются только при `\n` в буфере, не чаще раза в 500ms
- При `got_done` — всё накопленное отправляется
- Если LLM генерирует без `\n` — ждёт `got_done` (весь ответ одним куском)
- Всё стабильно, без дублей, без разрыва строк
### Баг 3 (Phase 2): Seed реакций не срабатывал из-за skip_bot_messages
+149 -107
View File
@@ -1,6 +1,6 @@
# Zulip Router
_Последнее обновление: 2026-06-17 (skip_mention_forward: устранена двойная доставка при @mention)_
_Последнее обновление: 2026-06-19 (forward_trigger, sender_email, data, stream_id — payload идентичен Zulip outgoing webhook)_
## Цель
@@ -26,17 +26,17 @@ Zulip
```
Zulip Event Queue
↓ poll (credentials Eagle из ~/.hermes/config.yaml)
↓ poll (credentials router-bot)
[zulip-router]
├── @mention бота от человека → POST owner-боту + ownership
├── @mention любого бота (включая другого) → ownership, НЕ форвардить
├── трейд с ownership → POST owner-боту
├── новый тред без @mention → default-боту (из конфига)
├── тред с ownership → POST owner-боту (forward_trigger из конфига)
├── новый тред без @mention → default-боту (forward_trigger из конфига)
└── reset / system → только владельцу треда
```
**Ключевое:** credentials — Eagle/Орла (не новый бот). Роутер регистрирует свою event queue (отдельную от очереди Eagle).
**Ключевое:** credentials — router-bot@zulip.qentra.top (отдельный бот, не Eagle).
## Правила маршрутизации (пошагово)
@@ -48,7 +48,7 @@ Zulip Event Queue
- Если сообщение от **человека** (sender_id не из списка ботов) и содержит `@**<бот>**`:
- Записать этого бота как владельца треда (stream+topic)
- Проверить `skip_mention_forward` для этого бота:
- **false** (по умолчанию) — FORWARD сообщение этому боту
- **false** (по умолчанию) — FORWARD сообщение этому боту (mention trigger)
- **true** — НЕ форвардить (Zulip outgoing webhook уже доставил напрямую). Ownership обновляется.
- Если сообщение от **человека** и содержит @mention **другого бота** (из конфига, но не того, что стал бы овнером):
- Всё равно записать этого бота как владельца треда
@@ -57,10 +57,10 @@ Zulip Event Queue
- Обновить ownership, но НЕ форвардить (Zulip сам доставит)
3. **Нет @mention, но тред уже закреплён за ботом:**
- FORWARD владельцу треда
- FORWARD владельцу треда (c forward_trigger из конфига, fallback "owner")
4. **Новый тред без @mention:**
- FORWARD default-боту (из конфига)
- FORWARD default-боту (c forward_trigger из конфига, fallback "default")
5. **reset / system-команды без @mention:**
- FORWARD только владельцу треда
@@ -72,51 +72,102 @@ Zulip Event Queue
- Роутер **ставит ownership**, но **не форвардит** если в сообщении есть @mention любого бота из его конфига
- Целевой бот получит сообщение через свой собственный outgoing webhook от Zulip
## Forward payload (что шлёт роутер боту)
Роутер шлёт payload, **идентичный** Zulip outgoing webhook:
```json
{
"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`)
```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)
api_key: "..."
server_url: https://zulip.qentra.top
webhook_token: "..."
bots:
- name: "Валера"
aliases: ["Valera"]
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: "Клавдий"
aliases: ["Klavdiy"]
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"]
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", "кит"]
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
- 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 # не форвардить сообщения от ботов (включая router-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):
@@ -131,62 +182,93 @@ zulip:
## Код
**Репозиторий:** `~/Developer/zulip-router/` — Go, ~260 LOC (план)
**Репозиторий:** `~/Developer/zulip-router/` — Go, ~430 LOC
**Git:** `git init` 2026-06-16. Первый коммит: `a403562` — чистый оригинал от 14:22. `.bak` файлы — слепки конфигов до правок Кита.
**Deploy:** `~/Docker/zulip-router/docker-compose.yaml`
**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) |
| `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):**
```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):**
```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):**
```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.
### Credentials: Eagle, не новый бот
Роутер использует credentials Eagle/Орла из `~/.hermes/config.yaml``platforms.zulip`.
Не создавать нового бота. Eagle уже имеет права на чтение всех публичных стримов.
### Env vars
Создать `~/Docker/zulip-router/.env`:
```env
# Credentials Eagle/Орла — из ~/.hermes/config.yaml
ZULIP_API_KEY=<скопировать из конфига Eagle>
```
### Запуск
```bash
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
- [x] Написать код роутера (~260 LOC)
- [x] Создать compose `~/Docker/zulip-router/docker-compose.yaml`
- [x] Набить `.env` с credentials Eagle (из `~/.hermes/config.yaml`)
- [x] Набить `.env` с credentials (router-bot)
- [x] `docker-compose up -d`
- [x] Проверить логи: `docker logs zulip-router --tail 50`
- [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-ботов (больше не нужен)
- [x] Добавить `all_public_streams=true` в register
- [x] Добавить guards: timestamp + skip_bot_messages
- [x] Протестировать @mention от человека → правильная маршрутизация
- [x] Устранить двойную доставку (skip_mention_forward)
- [x] Добавить forward_trigger, sender_email, data, stream_id (роутер слал неполный payload)
- [ ] Протестировать сообщение без @mention в закреплённом треде
- [ ] Удалить `extractOtherMention()` патч из кода balda-ботов
## Опции запуска
@@ -194,8 +276,8 @@ docker logs zulip-router --tail 20 # проверить poll loop
/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` включает `slog.LevelDebug` (structured JSON-text логи).
Удобнее: `DEBUG=true` env var — подхватывается из docker-compose.
По умолчанию `DEBUG=false` — только INFO+.
@@ -206,72 +288,32 @@ docker logs zulip-router --tail 20 # проверить poll loop
Уровни:
- **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
- **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
Включение: `--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)
### 1–5. Первые проблемы (см. архив)
**Симптом:** роутер регистрирует очередь, делает `fetchEvents` с `dont_block=false`, и **зависает навсегда**. Логи отсутствуют часами. Сообщения не обрабатываются.
### 6. Валера не отвечал на сообщения от роутера (FIXED 2026-06-19)
**Фикс:** `all_public_streams=true` при регистрации очереди + обработка `BAD_EVENT_QUEUE_ID` с перерегистрацией.
**Симптом:** @mention от Zulip outgoing webhook работает, сообщения от роутера — только `Session Started`, без ответа модели. Сообщение `channel ID 0` в системных уведомлениях.
### 2. Роутер не получал события (FIXED 2026-06-17)
**Корень три проблемы в роутере:**
**Причина:** router-bot не был подписан на стримы. Очередь регистрировалась, но событий не получала.
**Фикс:** `all_public_streams=true` в `registerQueue`.
1. **Поле `data` не передавалось.** Роутер не слал `data` (текст сообщения) в payload. Валера на строке 242 читает `payload.Data` — пусто → строка 1030 `if text == ""` → только Welcome.
### 3. Сообщения до старта роутера обрабатывались (FIXED 2026-06-17)
2. **Поле `sender_email` не передавалось.** В `Message` struct не было поля `SenderEmail`. Zulip Events API не отдаёт `sender_email` — роутер не мог его заполнить. Без email `isAllowedOwner()` возвращал false.
**Фикс:** timestamp guard — сообщения с timestamp < времени старта скипаются.
3. **Поле `stream_id` не передавалось.** `Message` struct не имел `StreamID`. Валера на строке 1084 использует `payload.Message.StreamID` → default 0 → `channel ID 0`.
### 4. Сообщения от router-bot форвардились ботам (FIXED 2026-06-17)
4. **trigger=owner вместо trigger=mention.** Валера ожидает `trigger=mention` для авто-создания сессии и регистрации owner'а. Роутер слал `trigger=owner`.
**Фикс:** конфигурируемый `skip_bot_messages: true` — сообщения от ботов скипаются.
**Фиксы в коде (коммит `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` для Валеры и Клавдия
### 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 конфиг).
**Итог:** роутер шлёт payload, **идентичный** Zulip outgoing webhook. Валера не видит разницы.