1"""Native request cards and exact Hermes callback correlation."""23from __future__ import annotations45from dataclasses import dataclass6import json7import time8from types import SimpleNamespace910from gateway.platforms.base import SendResult111213def tag(event, name):14 return next((row[1] for row in event.get("tags", []) if len(row) > 1 and row[0] == name), None)151617def _reject_duplicate_keys(pairs):18 result = {}19 for key, value in pairs:20 if key in result:21 raise ValueError("duplicate JSON key")22 result[key] = value23 return result24252627class Pending:28 room: str29 assignee: str30 session: str31 callback_id: str32 interaction: str33 selection: str34 options: dict[str, str]35 created_at: int36 expires: int37 metadata: dict38 request_kind: int = 9394041class InteractionMixin:42 async def send_exec_approval(self, chat_id, command, session_key, description="dangerous command",43 metadata=None, allow_permanent=True, allow_session=True, smart_denied=False):44 # Current Hermes centralizes prompt formatting; older releases call the same45 # public adapter hook without providing that base implementation.46 parent = getattr(super(), "send_exec_approval", None)47 if parent is not None:48 return await parent(chat_id=chat_id, command=command, session_key=session_key,49 description=description, metadata=metadata, allow_permanent=allow_permanent,50 allow_session=allow_session, smart_denied=smart_denied)51 actions = [("Approve once", "once", "primary")]52 if not smart_denied and allow_session:53 actions.append(("Approve for session", "session", "secondary"))54 if allow_permanent:55 actions.append(("Always approve", "always", "secondary"))56 actions.append(("Deny", "deny", "danger"))57 text = f"Command approval required\n\n```\n{command}\n```\n\nReason: {description}"58 if smart_denied:59 text += "\n\nAn override applies to this operation only."60 return await self._send_exec_approval_prompt(SimpleNamespace(61 chat_id=chat_id, command=command, session_key=session_key, text=text,62 metadata=metadata, actions=actions))6364 def _request_route(self, session_key):65 current = self._turn_route.get() or {}66 return current if current.get("session") == session_key else self._turn_routes.get(session_key, {})6768 def _assignee(self, session_key, metadata):69 return self._request_route(session_key).get("user_id") or self._session_assignees.get(session_key) or (metadata or {}).get("user_id", "")7071 async def _request(self, pending, content, subject=""):72 if not self._authorized(pending.assignee):73 return SendResult(success=False, error="Tiny request has no authorized human assignee")74 if len(pending.options) > 12:75 return SendResult(success=False, error="Tiny supports at most 12 choices per question")76 tags = [["tinyagent", "1"], ["request", "question"], ["interaction", pending.interaction],77 ["p", pending.assignee], ["selection", pending.selection],78 ["expiration", str(pending.expires)], ["subject", subject or content[:100]]]79 for key, label in pending.options.items():80 visible = label.encode("utf-8")[:200].decode("utf-8", errors="ignore")81 tags.append(["option", key, visible])82 if pending.interaction == "question" and pending.options:83 tags.append(["freeform", "true"])84 # Assignment and notification are separate even when the same key appears in both.85 if pending.assignee in self._mentions(content):86 tags.append(["mention", pending.assignee])87 reply_tags, request_kind = await self._reply_tags(pending.room, metadata=pending.metadata)88 pending.request_kind = request_kind89 result = await self._publish(pending.room, content, [*reply_tags, *tags], kind=request_kind)90 if result.success and result.message_id:91 self._pending[result.message_id] = pending92 return result9394 def _pending_request(self, room, session, callback, interaction, options, selection, metadata, ttl=300):95 now = int(time.time())96 route = self._request_route(session)97 metadata = {**(route if route.get("room") == room else {}), **(metadata or {})}98 return Pending(room, self._assignee(session, metadata), session, callback, interaction,99 selection, options, now, now + ttl, metadata)100101 async def send_clarify(self, chat_id, question, choices, clarify_id, session_key, metadata=None):102 from tools import clarify_gateway103 with clarify_gateway._lock:104 entry = clarify_gateway._entries.get(clarify_id)105 multiple = bool(entry and entry.multi_select)106 options = {f"c{index}": str(label) for index, label in enumerate(choices or [])}107 selection = "multiple" if multiple else "single" if choices else "text"108 pending = self._pending_request(chat_id, session_key, clarify_id, "question", options,109 selection, metadata, ttl=clarify_gateway.get_clarify_timeout() or 3600)110 return await self._request(pending, question)111112 async def _send_exec_approval_prompt(self, prompt):113 from tools.approval import list_gateway_approvals114 used = {entry.callback_id for entry in self._pending.values() if entry.interaction == "approval"}115 candidates = [entry for entry in list_gateway_approvals(prompt.session_key)116 if entry.get("request_id") and entry["request_id"] not in used]117 matching = [entry for entry in candidates if entry.get("command") == prompt.command]118 # Hermes redacts command previews. A single remaining waiter is still unambiguous;119 # multiple unmatched waiters require the gateway's standard fallback.120 entry = matching[0] if matching else candidates[0] if len(candidates) == 1 else None121 if entry is None:122 return SendResult(success=False, error="Tiny cannot identify the pending Hermes approval")123 options = {choice: label for label, choice, _style in prompt.actions}124 from tools.approval_context import _get_approval_timeout125 pending = self._pending_request(prompt.chat_id, prompt.session_key, entry["request_id"],126 "approval", options, "single", prompt.metadata,127 ttl=int(_get_approval_timeout()))128 return await self._request(pending, prompt.text, prompt.command[:200])129130 async def send_slash_confirm(self, chat_id, title, message, session_key, confirm_id, metadata=None):131 options = {"once": "Approve once", "always": "Always approve", "cancel": "Cancel"}132 pending = self._pending_request(chat_id, session_key, confirm_id, "confirmation", options,133 "single", metadata)134 return await self._request(pending, f"{title}\n\n{message}", title)135136 async def _on_answer(self, event):137 event_id = tag(event, "e")138 pending = self._pending.get(event_id)139 if pending is None or not self._answer_matches(event, event_id, pending):140 return141 response = self._answer_content(event.get("content", ""), pending)142 if response is None:143 return144 if pending.interaction == "question":145 from tools.clarify_gateway import resolve_gateway_clarify146 resolved = resolve_gateway_clarify(pending.callback_id, response)147 elif pending.interaction == "approval":148 from tools.approval import resolve_gateway_approval149 resolved = resolve_gateway_approval(pending.session, response, request_id=pending.callback_id) > 0150 else:151 from tools import slash_confirm152 current = slash_confirm.get_pending(pending.session)153 resolved = bool(current and current.get("confirm_id") == pending.callback_id)154 if resolved:155 result = await slash_confirm.resolve(pending.session, pending.callback_id, response)156 if result:157 await self.send(pending.room, result, metadata=pending.metadata)158 # Waiters are process-local in Hermes. Never apply a late answer to another request.159 self._pending.pop(event_id, None)160161 def _answer_matches(self, event, event_id, pending):162 now = int(time.time())163 if now >= pending.expires:164 self._pending.pop(event_id, None)165 return False166 if event.get("kind") != 1111 or event.get("pubkey") != pending.assignee:167 return False168 if not self._authorized(pending.assignee):169 return False170 created = event.get("created_at", 0)171 if not isinstance(created, int) or not pending.created_at <= created < pending.expires or created > now + 60:172 return False173 expected = {"h": pending.room, "e": event_id, "E": event_id,174 "k": str(pending.request_kind), "K": str(pending.request_kind),175 "p": self.pubkey, "P": self.pubkey}176 return all(tag(event, key) == value for key, value in expected.items())177178179 def _answer_content(content, pending):180 if not isinstance(content, str) or not content.strip() or len(content) > 8000:181 return None182 if pending.selection == "text":183 return content184 try:185 decoded = json.loads(content, object_pairs_hook=_reject_duplicate_keys)186 except ValueError:187 decoded = None188 if pending.interaction == "question" and isinstance(decoded, dict):189 keys = set(decoded)190 text = decoded.get("text")191 choices = decoded.get("choices")192 if not isinstance(text, str) or not text.strip() or len(text) > 8000:193 return None194 if keys == {"text"}:195 return text196 if keys != {"choices", "text"} or not isinstance(choices, list) or not choices:197 return None198 if any(not isinstance(key, str) for key in choices) or len(choices) != len(set(choices)):199 return None200 if pending.selection == "single" and len(choices) != 1:201 return None202 if pending.selection == "multiple" and any(key not in pending.options for key in choices):203 return None204 if pending.selection == "single" and choices[0] not in pending.options:205 return None206 selected = [pending.options[key] for key in choices]207 rendered = selected[0] if pending.selection == "single" else json.dumps(selected)208 return rendered + "\n" + text.strip()209 if pending.selection == "multiple":210 if not isinstance(decoded, list) or not decoded or any(not isinstance(key, str) for key in decoded):211 return None212 if len(decoded) != len(set(decoded)) or any(key not in pending.options for key in decoded):213 return None214 return json.dumps([pending.options[key] for key in decoded])215 if content not in pending.options:216 return None217 return pending.options[content] if pending.interaction == "question" else content