diff --git a/personal/projects/zulip-router-approval-reactions.md b/personal/projects/zulip-router-approval-reactions.md new file mode 100644 index 00000000..42897aea --- /dev/null +++ b/personal/projects/zulip-router-approval-reactions.md @@ -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