clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

main
tinyrelay / tinyagent / hermes / interactions.py
interactions.py 12.0K
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 SendResult1112# Open request cards kept per adapter; the oldest are dropped past this.13MAX_PENDING = 500141516def tag(event, name):17 return next((row[1] for row in event.get("tags", []) if len(row) > 1 and row[0] == name), None)181920def _reject_duplicate_keys(pairs):21 result = {}22 for key, value in pairs:23 if key in result:24 raise ValueError("duplicate JSON key")25 result[key] = value26 return result272829@dataclass30class Pending:31 room: str32 assignee: str33 session: str34 callback_id: str35 interaction: str36 selection: str37 options: dict[str, str]38 created_at: int39 expires: int40 metadata: dict41 request_kind: int = 9424344class InteractionMixin:45 async def send_exec_approval(self, chat_id, command, session_key, description="dangerous command",46 metadata=None, allow_permanent=True, allow_session=True, smart_denied=False):47 # Current Hermes centralizes prompt formatting; older releases call the same48 # public adapter hook without providing that base implementation.49 parent = getattr(super(), "send_exec_approval", None)50 if parent is not None:51 return await parent(chat_id=chat_id, command=command, session_key=session_key,52 description=description, metadata=metadata, allow_permanent=allow_permanent,53 allow_session=allow_session, smart_denied=smart_denied)54 actions = [("Approve once", "once", "primary")]55 if not smart_denied and allow_session:56 actions.append(("Approve for session", "session", "secondary"))57 if allow_permanent:58 actions.append(("Always approve", "always", "secondary"))59 actions.append(("Deny", "deny", "danger"))60 text = f"Command approval required\n\n```\n{command}\n```\n\nReason: {description}"61 if smart_denied:62 text += "\n\nAn override applies to this operation only."63 return await self._send_exec_approval_prompt(SimpleNamespace(64 chat_id=chat_id, command=command, session_key=session_key, text=text,65 metadata=metadata, actions=actions))6667 def _request_route(self, session_key):68 current = self._turn_route.get() or {}69 return current if current.get("session") == session_key else self._turn_routes.get(session_key, {})7071 def _assignee(self, session_key, metadata):72 return self._request_route(session_key).get("user_id") or self._session_assignees.get(session_key) or (metadata or {}).get("user_id", "")7374 async def _request(self, pending, content, subject=""):75 if not self._authorized(pending.assignee):76 return SendResult(success=False, error="Tiny request has no authorized human assignee")77 if len(pending.options) > 12:78 return SendResult(success=False, error="Tiny supports at most 12 choices per question")79 tags = [["tinyagent", "1"], ["request", "question"], ["interaction", pending.interaction],80 ["p", pending.assignee], ["selection", pending.selection],81 ["expiration", str(pending.expires)], ["subject", subject or content[:100]]]82 for key, label in pending.options.items():83 visible = label.encode("utf-8")[:200].decode("utf-8", errors="ignore")84 tags.append(["option", key, visible])85 if pending.interaction == "question" and pending.options:86 tags.append(["freeform", "true"])87 # Assignment and notification are separate even when the same key appears in both.88 if pending.assignee in self._mentions(content):89 tags.append(["mention", pending.assignee])90 reply_tags, request_kind = await self._reply_tags(pending.room, metadata=pending.metadata)91 pending.request_kind = request_kind92 result = await self._publish(pending.room, content, [*reply_tags, *tags], kind=request_kind)93 if result.success and result.message_id:94 self._sweep_pending()95 self._pending[result.message_id] = pending96 while len(self._pending) > MAX_PENDING:97 self._pending.pop(next(iter(self._pending)))98 return result99100 def _sweep_pending(self, now=None):101 """Drop expired request cards; the adapter also calls this from its receive path."""102 now = int(time.time()) if now is None else now103 for event_id in [key for key, entry in self._pending.items() if now >= entry.expires]:104 self._pending.pop(event_id, None)105106 def _pending_request(self, room, session, callback, interaction, options, selection, metadata, ttl=300):107 now = int(time.time())108 route = self._request_route(session)109 metadata = {**(route if route.get("room") == room else {}), **(metadata or {})}110 return Pending(room, self._assignee(session, metadata), session, callback, interaction,111 selection, options, now, now + ttl, metadata)112113 async def send_clarify(self, chat_id, question, choices, clarify_id, session_key, metadata=None):114 from tools import clarify_gateway115 with clarify_gateway._lock:116 entry = clarify_gateway._entries.get(clarify_id)117 multiple = bool(entry and entry.multi_select)118 options = {f"c{index}": str(label) for index, label in enumerate(choices or [])}119 selection = "multiple" if multiple else "single" if choices else "text"120 pending = self._pending_request(chat_id, session_key, clarify_id, "question", options,121 selection, metadata, ttl=clarify_gateway.get_clarify_timeout() or 3600)122 return await self._request(pending, question)123124 async def _send_exec_approval_prompt(self, prompt):125 from tools.approval import list_gateway_approvals126 used = {entry.callback_id for entry in self._pending.values() if entry.interaction == "approval"}127 candidates = [entry for entry in list_gateway_approvals(prompt.session_key)128 if entry.get("request_id") and entry["request_id"] not in used]129 matching = [entry for entry in candidates if entry.get("command") == prompt.command]130 # Hermes redacts command previews. A single remaining waiter is still unambiguous;131 # multiple unmatched waiters require the gateway's standard fallback.132 entry = matching[0] if matching else candidates[0] if len(candidates) == 1 else None133 if entry is None:134 return SendResult(success=False, error="Tiny cannot identify the pending Hermes approval")135 options = {choice: label for label, choice, _style in prompt.actions}136 from tools.approval_context import _get_approval_timeout137 pending = self._pending_request(prompt.chat_id, prompt.session_key, entry["request_id"],138 "approval", options, "single", prompt.metadata,139 ttl=int(_get_approval_timeout()))140 return await self._request(pending, prompt.text, prompt.command[:200])141142 async def send_slash_confirm(self, chat_id, title, message, session_key, confirm_id, metadata=None):143 options = {"once": "Approve once", "always": "Always approve", "cancel": "Cancel"}144 pending = self._pending_request(chat_id, session_key, confirm_id, "confirmation", options,145 "single", metadata)146 return await self._request(pending, f"{title}\n\n{message}", title)147148 async def _on_answer(self, event):149 self._sweep_pending()150 event_id = tag(event, "e")151 pending = self._pending.get(event_id)152 if pending is None or not self._answer_matches(event, event_id, pending):153 return154 response = self._answer_content(event.get("content", ""), pending)155 if response is None:156 return157 if pending.interaction == "question":158 from tools.clarify_gateway import resolve_gateway_clarify159 resolved = resolve_gateway_clarify(pending.callback_id, response)160 elif pending.interaction == "approval":161 from tools.approval import resolve_gateway_approval162 resolved = resolve_gateway_approval(pending.session, response, request_id=pending.callback_id) > 0163 else:164 from tools import slash_confirm165 current = slash_confirm.get_pending(pending.session)166 resolved = bool(current and current.get("confirm_id") == pending.callback_id)167 if resolved:168 result = await slash_confirm.resolve(pending.session, pending.callback_id, response)169 if result:170 await self.send(pending.room, result, metadata=pending.metadata)171 # Waiters are process-local in Hermes. Never apply a late answer to another request.172 self._pending.pop(event_id, None)173 if pending.interaction in ("approval", "confirmation"):174 # Hermes pauses the typing indicator while it waits for an approval.175 self.resume_typing_for_chat(pending.room)176177 def _answer_matches(self, event, event_id, pending):178 now = int(time.time())179 if now >= pending.expires:180 self._pending.pop(event_id, None)181 return False182 if event.get("kind") != 1111 or event.get("pubkey") != pending.assignee:183 return False184 if not self._authorized(pending.assignee):185 return False186 created = event.get("created_at", 0)187 if not isinstance(created, int) or not pending.created_at <= created < pending.expires or created > now + 60:188 return False189 expected = {"h": pending.room, "e": event_id, "E": event_id,190 "k": str(pending.request_kind), "K": str(pending.request_kind),191 "p": self.pubkey, "P": self.pubkey}192 return all(tag(event, key) == value for key, value in expected.items())193194 @staticmethod195 def _answer_content(content, pending):196 if not isinstance(content, str) or not content.strip() or len(content) > 8000:197 return None198 if pending.selection == "text":199 return content200 try:201 decoded = json.loads(content, object_pairs_hook=_reject_duplicate_keys)202 except ValueError:203 decoded = None204 if pending.interaction == "question" and isinstance(decoded, dict):205 keys = set(decoded)206 text = decoded.get("text")207 choices = decoded.get("choices")208 if not isinstance(text, str) or not text.strip() or len(text) > 8000:209 return None210 if keys == {"text"}:211 return text212 if keys != {"choices", "text"} or not isinstance(choices, list) or not choices:213 return None214 if any(not isinstance(key, str) for key in choices) or len(choices) != len(set(choices)):215 return None216 if pending.selection == "single" and len(choices) != 1:217 return None218 if pending.selection == "multiple" and any(key not in pending.options for key in choices):219 return None220 if pending.selection == "single" and choices[0] not in pending.options:221 return None222 selected = [pending.options[key] for key in choices]223 rendered = selected[0] if pending.selection == "single" else json.dumps(selected)224 return rendered + "\n" + text.strip()225 if pending.selection == "multiple":226 if not isinstance(decoded, list) or not decoded or any(not isinstance(key, str) for key in decoded):227 return None228 if len(decoded) != len(set(decoded)) or any(key not in pending.options for key in decoded):229 return None230 return json.dumps([pending.options[key] for key in decoded])231 if content not in pending.options:232 return None233 return pending.options[content] if pending.interaction == "question" else content