clone

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

feat/tinyagent
tinyrelay / tinyagent / hermes / adapter.py
adapter.py 16.5K
1"""Native Tinyrelay adapter for Hermes Agent.23Tiny is a Nostr relay connection. The adapter intentionally uses structured events for4interactive prompts; no Buzz compatibility or prose parsing is involved.5"""67from __future__ import annotations89import asyncio10import json11import mimetypes12import hashlib13import os14import re15import time16from datetime import datetime17from pathlib import Path18from typing import Any1920from gateway.config import Platform, PlatformConfig21from gateway.platforms.base import BasePlatformAdapter, ExecApprovalPrompt, SendResult22from gateway.platforms.event import MessageEvent, MessageType2324try:25 from .client import TinyRPC26 from .interactions import InteractionMixin27 from .media import incoming_media28 from .ingress import EventTracker, event_thread, reply_parent29except ImportError: # Hermes plugin loader may import adapter.py as a top-level module.30 from client import TinyRPC31 from interactions import InteractionMixin32 from media import incoming_media33 from ingress import EventTracker, event_thread, reply_parent3435_HEX = re.compile(r"\b[0-9a-f]{64}\b", re.I)36_MENTION = re.compile(r"(?<![0-9A-Za-z])@([0-9a-f]{64})(?![0-9A-Za-z])", re.I)373839def _tags(event: dict, name: str) -> list[list[str]]:40 return [tag for tag in event.get("tags", []) if len(tag) >= 2 and tag[0] == name]414243def _tag(event: dict, name: str) -> str | None:44 found = _tags(event, name)45 return found[0][1] if found else None464748def _explicit_mentions(text: str) -> list[str]:49 """Return only public keys explicitly written as @mentions in text."""50 return list(dict.fromkeys(match.group(1).lower() for match in _MENTION.finditer(text or "")))515253def _mention_tags(text: str) -> list[list[str]]:54 return [["p", pubkey] for pubkey in _explicit_mentions(text)]555657class TinyAdapter(InteractionMixin, BasePlatformAdapter):58 """Native room messages and interactions for Hermes."""5960 supports_code_blocks = True61 supports_async_delivery = True62 MAX_MESSAGE_LENGTH = 1200063 MAX_INTERACTION_OPTIONS = 126465 def __init__(self, config: PlatformConfig):66 super().__init__(config, Platform("tiny"))67 extra = config.extra or {}68 self.relay_url = str(extra.get("relay_url") or os.environ.get("TINY_RELAY_URL", "")).strip()69 self.cli_path = str(extra.get("cli_path") or os.environ.get("TINY_CLI_PATH", "tinyagent"))70 self.rooms = [x.strip() for x in str(extra.get("rooms") or extra.get("channels") or os.environ.get("TINY_CHANNELS", "")).split(",") if x.strip()]71 home = extra.get("home_channel") or os.environ.get("TINY_HOME_CHANNEL", "")72 self.home_room = str(home).strip() or (self.rooms[0] if self.rooms else "")73 self.allowed = {x.strip().lower() for x in str(extra.get("allowed_users") or os.environ.get("TINY_ALLOWED_USERS", "")).split(",") if x.strip()}74 self.rpc: TinyRPC | None = None75 self.pubkey = ""76 self._subscription = "tinyagent"77 self._loop_task: asyncio.Task | None = None78 # request event id -> (room, Hermes key, interaction kind, expiry, option ids)79 self._pending = {}80 self._tracker = None81 self._sent = {}82 self._session_assignees: dict[str, str] = {}83 self._processing: set[str] = set()8485 @property86 def name(self) -> str:87 return "Tiny"8889 async def connect(self, *, is_reconnect: bool = False) -> bool:90 self.rpc = TinyRPC(self.relay_url, cli_path=self.cli_path)91 identity = await self.rpc.call("identity")92 self.pubkey = str(identity.get("pubkey", ""))93 if not self.pubkey:94 raise RuntimeError("Tiny identity did not return a public key")95 self._tracker = EventTracker(self.relay_url, self.pubkey, self.rooms)96 filter: dict[str, Any] = {"kinds": [9, 11, 12, 1111], **self._tracker.filter_since()}97 if self.rooms:98 filter["#h"] = self.rooms99 await self.rpc.subscribe(self._subscription, filter, self._on_event)100 self._mark_connected()101 return True102103 async def disconnect(self) -> None:104 if self.rpc:105 with_context = self.rpc.unsubscribe(self._subscription)106 await asyncio.gather(with_context, return_exceptions=True)107 await self.rpc.close()108 self.rpc = None109 self._mark_disconnected()110111 async def _on_event(self, event: dict) -> None:112 event_id = event.get("id") if isinstance(event, dict) else None113 if event_id in self._processing:114 return115 self._processing.add(event_id)116 try:117 await self._receive_event(event)118 finally:119 self._processing.discard(event_id)120121 async def _receive_event(self, event: dict) -> None:122 if not isinstance(event, dict) or event.get("pubkey", "").lower() == self.pubkey.lower():123 return124 if event.get("kind") in (1111, 7):125 await self._on_answer(event)126 return127 room = _tag(event, "h")128 author = str(event.get("pubkey", ""))129 if not room or (self.rooms and room not in self.rooms) or not self._authorized(author):130 return131 if self._tracker and not self._tracker.should_accept(event, now=int(time.time())):132 return133 root = event_thread(event)134 parent_id = reply_parent(event)135 parent = None136 mentioned = self.pubkey in [row[1] for row in _tags(event, "p")]137 if parent_id:138 rows = await self.rpc.query({"ids": [parent_id], "#h": [room], "limit": 1})139 parent = rows[0] if rows else None140 own_reply = bool(parent and parent.get("pubkey") == self.pubkey)141 if not mentioned and not own_reply:142 return143 event_id = str(event.get("id", ""))144 # Only thread kinds create a separate thread session. Kind 9 replies stay in the room.145 thread_id = event_id if event.get("kind") == 11 else root if event.get("kind") == 12 else None146 source = self.build_source(chat_id=room, chat_name=room, chat_type="group", user_id=author,147 thread_id=thread_id, message_id=event_id)148 media_urls, media_types = await incoming_media(self.rpc, event, self.relay_url)149 message_type = MessageType.TEXT150 if media_types:151 major = media_types[0].split("/", 1)[0]152 message_type = {"image": MessageType.PHOTO, "audio": MessageType.AUDIO,153 "video": MessageType.VIDEO}.get(major, MessageType.DOCUMENT)154 message = MessageEvent(text=str(event.get("content", "")), message_type=message_type, source=source,155 raw_message=event, message_id=event_id,156 media_urls=media_urls, media_types=media_types,157 media_text_inlined=[False] * len(media_urls),158 timestamp=datetime.fromtimestamp(int(event.get("created_at", time.time()))),159 reply_to_message_id=parent_id, reply_to_is_own_message=own_reply,160 reply_to_text=parent.get("content") if parent else None)161 self._session_assignees[self._event_session_key(message)] = author162 if self._tracker:163 self._tracker.mark(event)164 await self.handle_message(message)165166 def _authorized(self, author: str) -> bool:167 return author.lower() in self.allowed168169 @staticmethod170 def _mentions(content: str) -> list[str]:171 return _explicit_mentions(content)172173 async def _publish(self, room: str, content: str, tags: list[list[str]], *, kind: int = 9) -> SendResult:174 if not self.rpc:175 return SendResult(success=False, error="Tiny is disconnected", retryable=True)176 try:177 result = await self.rpc.publish({"kind": kind, "content": content, "tags": [["h", room], *tags]})178 event = result if isinstance(result, dict) else {}179 return SendResult(success=True, message_id=event.get("id"), raw_response=event)180 except Exception as exc:181 return SendResult(success=False, error=str(exc), retryable=True)182183 async def send(self, chat_id: str, content: str, reply_to: str | None = None, metadata: dict | None = None) -> SendResult:184 tags: list[list[str]] = []185 if reply_to:186 tags.append(["e", str(reply_to)])187 mentions = _explicit_mentions(content)188 for pubkey in (metadata or {}).get("mentions", []):189 if _HEX.fullmatch(str(pubkey)):190 mentions.append(str(pubkey).lower())191 for pubkey in dict.fromkeys(mentions):192 tags.append(["p", pubkey])193 tags.append(["mention", pubkey])194 thread_id = (metadata or {}).get("thread_id")195 if thread_id:196 tags.insert(0, ["e", str(thread_id), "", "root"])197 result = await self._publish(chat_id, content, tags, kind=12 if thread_id else 9)198 if result.success:199 self._sent[result.message_id] = tags200 if len(self._sent) > 1000:201 self._sent.pop(next(iter(self._sent)))202 return result203204 async def edit_message(self, chat_id: str, message_id: str, content: str, *, finalize: bool = False) -> SendResult:205 tags = [["e", message_id]]206 for key in _explicit_mentions(content):207 tags.extend([["p", key], ["mention", key]])208 result = await self._publish(chat_id, content, tags, kind=40003)209 if result.success:210 result.message_id = message_id211 return result212213 async def delete_message(self, chat_id: str, message_id: str) -> bool:214 # Tiny's room deletion is a moderator action; this member connector cannot claim deletion.215 return False216217 async def _send_file(self, chat_id: str, file_path: str, caption: str | None = None,218 media_type: str | None = None, metadata: dict | None = None,219 reply_to: str | None = None, file_name: str | None = None) -> SendResult:220 if not self.rpc:221 return SendResult(success=False, error="Tiny is disconnected", retryable=True)222 try:223 path = Path(file_path)224 if not 0 < path.stat().st_size <= 32 << 20:225 return SendResult(success=False, error="Tiny attachments must be between 1 byte and 32 MiB")226 data = await asyncio.to_thread(path.read_bytes)227 mime = media_type or mimetypes.guess_type(path.name)[0] or "application/octet-stream"228 filename = Path(file_name).name if file_name else path.name229 descriptor = await self.rpc.upload(chat_id, filename, mime, data)230 target = descriptor.get("url") or descriptor.get("path") or descriptor.get("sha256")231 if not target:232 return SendResult(success=False, error="Tiny upload returned no descriptor")233 digest = descriptor.get("sha256") or descriptor.get("hash")234 if digest != hashlib.sha256(data).hexdigest():235 return SendResult(success=False, error="Tiny returned a different attachment hash")236 fields = ["url " + str(target), "m " + mime, "filename " + filename]237 if digest:238 fields.append("x " + str(digest))239 fields.append("size " + str(len(data)))240 tags = [["imeta", *fields]]241 thread_id = (metadata or {}).get("thread_id")242 if thread_id:243 tags.insert(0, ["e", str(thread_id), "", "root"])244 if reply_to:245 tags.append(["e", str(reply_to), "", "reply"])246 for key in _explicit_mentions(caption or ""):247 tags.extend([["p", key], ["mention", key]])248 return await self._publish(chat_id, caption or "", tags, kind=12 if thread_id else 9)249 except Exception as exc:250 return SendResult(success=False, error=str(exc), retryable=True)251252 async def send_image_file(self, chat_id: str, image_path: str, caption: str | None = None,253 reply_to=None, metadata=None, **kwargs) -> SendResult:254 return await self._send_file(chat_id, image_path, caption,255 mimetypes.guess_type(image_path)[0] or "image/png", metadata, reply_to)256257 async def send_document(self, chat_id: str, file_path: str, caption: str | None = None,258 file_name=None, reply_to=None, metadata=None, **kwargs) -> SendResult:259 return await self._send_file(chat_id, file_path, caption, metadata=metadata, reply_to=reply_to, file_name=file_name)260261 async def send_video(self, chat_id: str, video_path: str, caption: str | None = None,262 reply_to=None, metadata=None, **kwargs) -> SendResult:263 return await self._send_file(chat_id, video_path, caption, metadata=metadata, reply_to=reply_to)264265 async def send_voice(self, chat_id: str, audio_path: str, caption: str | None = None,266 reply_to=None, metadata=None, **kwargs) -> SendResult:267 return await self._send_file(chat_id, audio_path, caption,268 mimetypes.guess_type(audio_path)[0] or "audio/ogg", metadata, reply_to)269270 async def send_typing(self, chat_id: str, metadata=None) -> None:271 await self._publish(chat_id, "", [["expiration", str(int(time.time()) + 30)], ["status", "typing"]], kind=20002)272273 async def get_chat_info(self, chat_id: str) -> dict[str, Any]:274 return {"name": chat_id, "type": "group"}275276277def check_requirements() -> bool:278 return bool(os.environ.get("TINY_RELAY_URL") and os.environ.get("TINY_PRIVATE_KEY"))279280281def validate_config(config: PlatformConfig) -> bool:282 extra = config.extra or {}283 return bool(extra.get("relay_url") or os.environ.get("TINY_RELAY_URL"))284285286def _env_enablement() -> dict | None:287 relay = os.environ.get("TINY_RELAY_URL", "").strip()288 key = os.environ.get("TINY_PRIVATE_KEY", "").strip()289 if not relay or not key:290 return None291 result = {"relay_url": relay}292 for env, key_name in (("TINY_HOME_CHANNEL", "home_channel"), ("TINY_ALLOWED_USERS", "allowed_users"),293 ("TINY_CLI_PATH", "cli_path")):294 if os.environ.get(env):295 result[key_name] = os.environ[env]296 return result297298299async def _standalone_send(pconfig, chat_id: str, message: str, *, thread_id=None, media_files=None,300 force_document=False):301 extra = getattr(pconfig, "extra", {}) or {}302 rpc = TinyRPC(str(extra.get("relay_url") or os.environ.get("TINY_RELAY_URL", "")),303 cli_path=str(extra.get("cli_path") or os.environ.get("TINY_CLI_PATH", "tinyagent")))304 try:305 await rpc.connect()306 adapter = TinyAdapter(pconfig)307 adapter.rpc = rpc308 media = list(media_files or [])309 last_id = None310 if message and len(media) != 1:311 result = await adapter.send(chat_id, message, metadata={"thread_id": thread_id})312 if not result.success:313 return {"error": result.error or "Tiny send failed"}314 last_id = result.message_id315 for item in media:316 path = str(item[0] if isinstance(item, (list, tuple)) else item)317 is_voice = bool(item[1]) if isinstance(item, (list, tuple)) and len(item) > 1 else False318 mime = mimetypes.guess_type(path)[0] or ("audio/ogg" if is_voice else "application/octet-stream")319 if force_document:320 mime = "application/octet-stream"321 result = await adapter._send_file(chat_id, path, message if len(media) == 1 else None,322 mime, {"thread_id": thread_id})323 if not result.success:324 return {"error": result.error or "Tiny media send failed"}325 last_id = result.message_id or last_id326 return {"success": True, "message_id": last_id, "media_delivered": bool(media)}327 except Exception as exc:328 return {"error": str(exc)}329 finally:330 await rpc.close()331332333def register(ctx):334 ctx.register_platform(335 name="tiny", label="Tiny", adapter_factory=TinyAdapter,336 check_fn=check_requirements, validate_config=validate_config,337 required_env=["TINY_RELAY_URL", "TINY_PRIVATE_KEY"],338 install_hint="Requires the tinyagent helper binary on PATH or at TINY_CLI_PATH",339 env_enablement_fn=_env_enablement, cron_deliver_env_var="TINY_HOME_CHANNEL",340 standalone_sender_fn=_standalone_send, allowed_users_env="TINY_ALLOWED_USERS",341 max_message_length=12000, platform_hint="You are collaborating through Tinyrelay. Only explicit @ mentions notify a person. Use @ followed by their 64-character public key when you need their attention; ordinary updates stay quiet.",342 emoji="◈", pii_safe=False, allow_update_command=True,343 )