[2026-06-18] eagle: personal/projects/zulip-router-approval-reactions.md

This commit is contained in:
Alexey Martemyanov
2026-06-18 09:00:04 +06:00
parent 1bc25d467b
commit 172cf3cbfa
@@ -0,0 +1,231 @@
---
created: '2026-06-17'
status: draft
tags:
- zulip-router
- approval
- reactions
- hermes
- architecture
title: 'Zulip Router: Approval Reactions'
type: plan
updated: '2026-06-17'
---
# Zulip Router: Approval Reactions
## Проблема
Eagle (и Кит) не могут использовать reaction-based approval для dangerous commands.
### Root cause (3 слоя)
**Слой 1 — отсутствие `send_exec_approval` в WebhookAdapter:**
`WebhookAdapter` не имеет метода `send_exec_approval`. Когда приходит dangerous command, gateway видит что у адаптера нет этого метода (проверка `getattr(type(adapter), "send_exec_approval", None)` на строке 18000 run.py) и падает на текстовый fallback с "Reply `/approve`". Реакции не проставляются.
**Слой 2 — `/approve` через роутер обрывает approval вместо того чтобы подтвердить:**
Когда пользователь шлёт `/approve` (без @mention), оно приходит через роутер как обычный POST на webhook. В `GatewayMessageHandler._route_to_active_session()` (run.py, строка ~3480):
1. **Строка 3494:** `running_agent.interrupt(event.text)` — прерывает агента (interrupt = вставляет текст как новое сообщение пользователя)
2. **Строка 3504:** `interrupt_gateway_approvals(session_key)` — прерывает ожидающий approval (choice = "interrupted")
Только **после** interrupt (строки 7819-7826 в `_message_handler`) проверяется "а не `/approve` ли это?" и вызывается `_handle_approve_command`. Но approval уже прерван — `has_blocking_approval(session_key)` возвращает False, и `/approve` отвечает "no pending approvals".
**Слой 3 — реакции не обрабатываются:**
`_handle_reaction_event` в `zulip.py` вызывается только из `_poll_once`. У Eagle `ZULIP_POLLING_DISABLED=true` — поллинг выключен. zulip-router регистрирует event_types = ["message"] — реакции не получает.
### Диагноз (лаконично)
`_route_to_active_session` interrupt + interrupt_gateway_approvals убивает approval ДО того как `_message_handler` успевает вызвать `_handle_approve_command`. `/approve` приходит как текст → interrupt → approval прерван → `/approve` отвечает "нет ожидающих approval" → ран оборван.
## Архитектура решения
### Вариант А (минимальный фикс в run.py)
Добавить проверку на `/approve`/`/deny` в `_route_to_active_session()` **ДО** interrupt-логики. Если пришла команда аппрува — не прерывать агента, а сразу идти в `_handle_approve_command`.
**Изменение:** в `_route_to_active_session()`, до строки 3492 (interrupt), добавить:
```python
# /approve и /deny не должны прерывать approval и агента
cmd = event.get_command()
if cmd in {"approve", "deny"}:
return True # пропустить interrupt, approval обработается в _message_handler
```
**Плюсы:** минимальное изменение, чинит `/approve` без @mention через роутер
**Минусы:** не чинит реакции, не добавляет pre-seed реакции
### Вариант Б (через роутер)
zulip-router уже поллит Zulip Events API — добавить "reaction" в event_types, обрабатывать реакции и слать POST `/approve` или `/deny` на webhook бота.
**Изменения в роутере:**
1. Добавить `"reaction"` в `event_types`
2. При `reaction` event: определить кто автор сообщения (по message_id), emoji → choice, POST на webhook бота с телом `{ "message": {...}, "trigger": "approve:once" }`
3. Опционально: pre-seed реакции на approval-сообщения от ботов
**Плюсы:** единое место для reaction routing, не меняет Hermes код
**Минусы:** нужно менять роутер + всё равно нужен Вариант А для `/approve` текстом
### Вариант В (минимальный + реакции)
Вариант А + роутер: фикс `/approve` в run.py, реакции через роутер.
## Рекомендация
**Вариант А (для `/approve`)** и потом **Варианта Б (для реакций)**.
Вариант А чинит `/approve` сейчас — одно изменение в `_route_to_active_session()`.
## Изменения в zulip-router (для реакций)
### 1. Добавить `reaction` в event_types
```go
// main.go
eventTypes := []string{"message", "reaction"}
```
### 2. Добавить struct для реакции
```go
type ZulipEvent struct {
ID int64 `json:"id"`
Type string `json:"type"`
Timestamp int64 `json:"timestamp"`
Flags []string `json:"flags,omitempty"`
Message *Message `json:"message,omitempty"`
// Reaction fields (when type == "reaction")
Op string `json:"op,omitempty"` // "add" или "remove"
UserID int64 `json:"user_id,omitempty"`
MessageID int64 `json:"message_id,omitempty"`
EmojiName string `json:"emoji_name,omitempty"`
EmojiCode string `json:"emoji_code,omitempty"`
}
```
### 3. Обработка reaction в processEvent
```go
if ev.Type == "reaction" {
processReaction(cfg, store, fwd, ev)
return
}
```
```go
func processReaction(cfg, store, fwd, ev):
if ev.Op != "add" return
if int64SliceContains(cfg.BotIDs, ev.UserID) return
// emoji → choice
choice := ""
switch ev.EmojiName {
case "+1", "thumbs_up", "white_check_mark": choice = "once"
case "lock", "locked": choice = "session"
case "infinity": choice = "always"
case "-1", "thumbs_down", "cross_mark": choice = "deny"
}
if choice == "" return
// найти владельца сообщения
// нужно хранить message_id → bot_name
ownerName, ok := store.getMessageOwner(ev.MessageID)
if !ok return
// найти бота
bot := cfg.findBot(ownerName)
if bot == nil return
// отправить POST с trigger = "approve:" + choice
fwd.Forward(bot, message, "approve:"+choice)
```
### 4. Message owner store
Нужно хранить кто написал сообщение (bot).
```go
// ownership.go — добавить
type MessageOwnerStore struct {
mu sync.RWMutex
entries map[int64]messageOwner // message_id → owner info
}
type messageOwner struct {
BotName string `json:"bot_name"`
Timestamp time.Time `json:"timestamp"`
}
```
Заполнять при `processEvent` для сообщений от ботов.
### 5. Pre-seed реакций (опционально)
Роутер находит сообщения ботов с маркером `⚠️ **Command Approval Required**` или `⚠️ **Dangerous command requires approval:**` и ставит реакции 👍 🔒 ♾️ 👎.
```go
func seedApprovalReactions(z *ZulipClient, msg *Message) {
if !isApprovalMessage(msg.Content) {
return
}
reactions := []struct{name, code string}{
{"thumbs_up", "1f44d"},
{"locked", "1f512"},
{"infinity", "267e"},
{"thumbs_down", "1f44e"},
}
for _, r := range reactions {
z.addReaction(msg.ID, r.name, r.code)
}
}
```
### 6. addReaction в zulip.go
```go
func (z *ZulipClient) addReaction(messageID int64, emojiName, emojiCode string) error {
form := url.Values{}
form.Set("emoji_name", emojiName)
form.Set("emoji_code", emojiCode)
form.Set("reaction_type", "unicode_emoji")
req, _ := http.NewRequest("POST",
fmt.Sprintf("%s/api/v1/messages/%d/reactions", z.Server, messageID),
strings.NewReader(form.Encode()))
req.SetBasicAuth(z.Email, z.APIKey)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := z.HTTP.Do(req)
// ...
}
```
## Изменения в Hermes (для `/approve` текстом)
### run.py — _route_to_active_session
Перед interrupt-секцией (строка 3492), добавить:
```python
# /approve и /deny не должны прерывать approval
# Они обрабатываются в _message_handler (строка 7819)
if event.get_command() in {"approve", "deny"}:
return True
```
## TODO
### Phase 1: `/approve` текстом (срочно)
- [ ] Добавить guard в `_route_to_active_session()` — если команда approve/deny, не прерывать агента и approval
### Phase 2: Реакции через роутер
- [ ] Добавить `"reaction"` в event_types роутера
- [ ] Добавить обработку reaction в `processEvent`
- [ ] Реализовать message_id → bot_name маппинг
- [ ] Добавить `addReaction` в zulip.go
- [ ] Добавить pre-seed реакций на approval-сообщения
- [ ] Проверить что POST с trigger="approve:once" правильно обрабатывается Hermes