1"""Native request cards and exact Hermes callback correlation."""23from __future__ import annotations45from dataclasses import dataclass6import json7import time89from gateway.platforms.base import SendResult101112def tag(event, name):13 return next((row[1] for row in event.get("tags", []) if len(row) > 1 and row[0] == name), None)14151617class Pending:18 room: str19 assignee: str20 session: str21 callback_id: str22 interaction: str23 selection: str24 options: dict[str, str]25 created_at: int26 expires: int272829class InteractionMixin:30 def _assignee(self, session_key, metadata):31 return self._session_assignees.get(session_key) or (metadata or {}).get("user_id", "")3233 async def _request(self, pending, content, subject=""):34 if not self._authorized(pending.assignee):35 return SendResult(success=False, error="Tiny request has no authorized human assignee")36 if len(pending.options) > 12:37 return SendResult(success=False, error="Tiny supports at most 12 choices per question")38 tags = [["tinyagent", "1"], ["request", "question"], ["interaction", pending.interaction],39 ["p", pending.assignee], ["selection", pending.selection],40 ["expiration", str(pending.expires)], ["subject", subject or content[:100]]]41 for key, label in pending.options.items():42 visible = label.encode("utf-8")[:200].decode("utf-8", errors="ignore")43 tags.append(["option", key, visible])44 if pending.interaction == "question" and pending.options:45 tags.append(["freeform", "true"])46 # Assignment and notification are separate even when the same key appears in both.47 if pending.assignee in self._mentions(content):48 tags.append(["mention", pending.assignee])49 result = await self._publish(pending.room, content, tags)50 if result.success and result.message_id:51 self._pending[result.message_id] = pending52 return result5354 def _pending_request(self, room, session, callback, interaction, options, selection, metadata, ttl=300):55 now = int(time.time())56 return Pending(room, self._assignee(session, metadata), session, callback, interaction,57 selection, options, now, now + ttl)5859 async def send_clarify(self, chat_id, question, choices, clarify_id, session_key, metadata=None):60 from tools import clarify_gateway61 with clarify_gateway._lock:62 entry = clarify_gateway._entries.get(clarify_id)63 multiple = bool(entry and entry.multi_select)64 options = {f"c{index}": str(label) for index, label in enumerate(choices or [])}65 selection = "multiple" if multiple else "single" if choices else "text"66 pending = self._pending_request(chat_id, session_key, clarify_id, "question", options,67 selection, metadata, ttl=clarify_gateway.get_clarify_timeout() or 3600)68 return await self._request(pending, question)6970 async def _send_exec_approval_prompt(self, prompt):71 from tools.approval import list_gateway_approvals72 used = {entry.callback_id for entry in self._pending.values() if entry.interaction == "approval"}73 candidates = [entry for entry in list_gateway_approvals(prompt.session_key)74 if entry.get("request_id") and entry["request_id"] not in used]75 matching = [entry for entry in candidates if entry.get("command") == prompt.command]76 # Hermes redacts command previews. A single remaining waiter is still unambiguous;77 # multiple unmatched waiters require the gateway's standard fallback.78 entry = matching[0] if matching else candidates[0] if len(candidates) == 1 else None79 if entry is None:80 return SendResult(success=False, error="Tiny cannot identify the pending Hermes approval")81 options = {choice: label for label, choice, _style in prompt.actions}82 from tools.approval_context import _get_approval_timeout83 pending = self._pending_request(prompt.chat_id, prompt.session_key, entry["request_id"],84 "approval", options, "single", prompt.metadata,85 ttl=int(_get_approval_timeout()))86 return await self._request(pending, prompt.text, prompt.command[:200])8788 async def send_slash_confirm(self, chat_id, title, message, session_key, confirm_id, metadata=None):89 options = {"once": "Approve once", "always": "Always approve", "cancel": "Cancel"}90 pending = self._pending_request(chat_id, session_key, confirm_id, "confirmation", options,91 "single", metadata)92 return await self._request(pending, f"{title}\n\n{message}", title)9394 async def _on_answer(self, event):95 event_id = tag(event, "e")96 pending = self._pending.get(event_id)97 if pending is None or not self._answer_matches(event, event_id, pending):98 return99 response = self._answer_content(event.get("content", ""), pending)100 if response is None:101 return102 if pending.interaction == "question":103 from tools.clarify_gateway import resolve_gateway_clarify104 resolved = resolve_gateway_clarify(pending.callback_id, response)105 elif pending.interaction == "approval":106 from tools.approval import resolve_gateway_approval107 resolved = resolve_gateway_approval(pending.session, response, request_id=pending.callback_id) > 0108 else:109 from tools import slash_confirm110 current = slash_confirm.get_pending(pending.session)111 resolved = bool(current and current.get("confirm_id") == pending.callback_id)112 if resolved:113 result = await slash_confirm.resolve(pending.session, pending.callback_id, response)114 if result:115 await self.send(pending.room, result)116 # Waiters are process-local in Hermes. Never apply a late answer to another request.117 self._pending.pop(event_id, None)118119 def _answer_matches(self, event, event_id, pending):120 now = int(time.time())121 if now >= pending.expires:122 self._pending.pop(event_id, None)123 return False124 if event.get("kind") != 1111 or event.get("pubkey") != pending.assignee:125 return False126 if not self._authorized(pending.assignee):127 return False128 created = event.get("created_at", 0)129 if not isinstance(created, int) or not pending.created_at <= created < pending.expires or created > now + 60:130 return False131 expected = {"h": pending.room, "e": event_id, "E": event_id, "k": "9", "K": "9",132 "p": self.pubkey, "P": self.pubkey}133 return all(tag(event, key) == value for key, value in expected.items())134135136 def _answer_content(content, pending):137 if not isinstance(content, str) or not content.strip() or len(content) > 8000:138 return None139 if pending.selection == "text":140 return content141 try:142 decoded = json.loads(content)143 except ValueError:144 decoded = None145 if pending.interaction == "question" and isinstance(decoded, dict):146 text = decoded.get("text")147 return text if set(decoded) == {"text"} and isinstance(text, str) and text.strip() else None148 if pending.selection == "multiple":149 if not isinstance(decoded, list) or not decoded or any(not isinstance(key, str) for key in decoded):150 return None151 if len(decoded) != len(set(decoded)) or any(key not in pending.options for key in decoded):152 return None153 return json.dumps([pending.options[key] for key in decoded])154 if content not in pending.options:155 return None156 return pending.options[content] if pending.interaction == "question" else content