clone

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

fix(tinyagent): support installed Hermes and room triggers

Dami committed 1e42b93
Use the public approval hook across Hermes versions and preserve configured mention requirements for incoming messages. Verify exact approval callbacks and trigger behavior against mainline and Hermes 0.21.1.
commit
1e42b938a8831675a48bf0f911175677196522a5
parent
b5fc550b1e8bc4163e6e04bc730d611a5071fdd3
author
Dami <dami.etoile@gmail.com>
5 files changed, +83 -7
Mtinyagent/README.md2+
Mtinyagent/hermes/adapter.py8+++--
Mtinyagent/hermes/interactions.py23++++++++++++++
Mtinyagent/hermes/plugin.yaml8+++++
Mtinyagent/hermes/tests/test_adapter.py49++++++++++++++++++++++++++++--
M tinyagent/README.md +2 -0
M tinyagent/hermes/adapter.py +5 -3
@@ -18,7 +18,7 @@ from pathlib import Path
1818 from typing import Any1919 2020 from gateway.config import Platform, PlatformConfig21-from gateway.platforms.base import BasePlatformAdapter, ExecApprovalPrompt, SendResult21+from gateway.platforms.base import BasePlatformAdapter, SendResult2222 from gateway.platforms.event import MessageEvent, MessageType2323 2424 try:
@@ -71,6 +71,8 @@ class TinyAdapter(InteractionMixin, BasePlatformAdapter):
7171 home = extra.get("home_channel") or os.environ.get("TINY_HOME_CHANNEL", "")7272 self.home_room = str(home).strip() or (self.rooms[0] if self.rooms else "")7373 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+ require_mention = extra.get("require_mention", os.environ.get("TINY_REQUIRE_MENTION", "true"))75+ self.require_mention = str(require_mention).strip().lower() not in {"false", "0", "no", "off"}7476 self.rpc: TinyRPC | None = None7577 self.pubkey = ""7678 self._subscription = "tinyagent"
@@ -138,7 +140,7 @@ class TinyAdapter(InteractionMixin, BasePlatformAdapter):
138140 rows = await self.rpc.query({"ids": [parent_id], "#h": [room], "limit": 1})139141 parent = rows[0] if rows else None140142 own_reply = bool(parent and parent.get("pubkey") == self.pubkey)141- if not mentioned and not own_reply:143+ if self.require_mention and not mentioned and not own_reply:142144 return143145 event_id = str(event.get("id", ""))144146 # Only thread kinds create a separate thread session. Kind 9 replies stay in the room.
@@ -290,7 +292,7 @@ def _env_enablement() -> dict | None:
290292 return None291293 result = {"relay_url": relay}292294 for env, key_name in (("TINY_HOME_CHANNEL", "home_channel"), ("TINY_ALLOWED_USERS", "allowed_users"),293- ("TINY_CLI_PATH", "cli_path")):295+ ("TINY_CLI_PATH", "cli_path"), ("TINY_REQUIRE_MENTION", "require_mention")):294296 if os.environ.get(env):295297 result[key_name] = os.environ[env]296298 return result
M tinyagent/hermes/interactions.py +23 -0
@@ -5,6 +5,7 @@ from __future__ import annotations
55 from dataclasses import dataclass66 import json77 import time8+from types import SimpleNamespace89 910 from gateway.platforms.base import SendResult1011
@@ -27,6 +28,28 @@ class Pending:
2728 2829 2930 class InteractionMixin:31+ async def send_exec_approval(self, chat_id, command, session_key, description="dangerous command",32+ metadata=None, allow_permanent=True, allow_session=True, smart_denied=False):33+ # Current Hermes centralizes prompt formatting; older releases call the same34+ # public adapter hook without providing that base implementation.35+ parent = getattr(super(), "send_exec_approval", None)36+ if parent is not None:37+ return await parent(chat_id=chat_id, command=command, session_key=session_key,38+ description=description, metadata=metadata, allow_permanent=allow_permanent,39+ allow_session=allow_session, smart_denied=smart_denied)40+ actions = [("Approve once", "once", "primary")]41+ if not smart_denied and allow_session:42+ actions.append(("Approve for session", "session", "secondary"))43+ if allow_permanent:44+ actions.append(("Always approve", "always", "secondary"))45+ actions.append(("Deny", "deny", "danger"))46+ text = f"Command approval required\n\n```\n{command}\n```\n\nReason: {description}"47+ if smart_denied:48+ text += "\n\nAn override applies to this operation only."49+ return await self._send_exec_approval_prompt(SimpleNamespace(50+ chat_id=chat_id, command=command, session_key=session_key, text=text,51+ metadata=metadata, actions=actions))52+3053 def _assignee(self, session_key, metadata):3154 return self._session_assignees.get(session_key) or (metadata or {}).get("user_id", "")3255
M tinyagent/hermes/plugin.yaml +8 -0
M tinyagent/hermes/tests/test_adapter.py +45 -4
@@ -12,7 +12,6 @@ sys.path.insert(0, str(Path(__file__).parents[1]))
1212 from adapter import TinyAdapter1313 from gateway.config import PlatformConfig1414 from gateway.platform_registry import PlatformEntry, platform_registry15-from gateway.platforms.base import ExecApprovalPrompt1615 from tools import approval, clarify_gateway, slash_confirm1716 from tools.approval_gateway_wait import _ApprovalEntry1817
@@ -123,9 +122,8 @@ def test_approval_uses_exact_request_and_offered_scope(adapter):
123122 first = _ApprovalEntry({"command": "first", "request_id": "first-id"})124123 second = _ApprovalEntry({"command": "second", "request_id": "second-id"})125124 approval._gateway_queues["session"] = [first, second]126- prompt = ExecApprovalPrompt("room", "session", "Allow second?", [("Once", "once", "primary"),127- ("Deny", "deny", "danger")], "second", "description", False)128- assert (await adapter._send_exec_approval_prompt(prompt)).success125+ assert (await adapter.send_exec_approval("room", "second", "session",126+ allow_permanent=False, allow_session=False)).success129127 request = adapter.rpc.events[-1]130128 await adapter._on_answer(response(request, "always"))131129 assert not first.event.is_set() and not second.event.is_set()
@@ -243,3 +241,46 @@ def test_duplicate_callbacks_cannot_race_before_media_intake(adapter, monkeypatc
243241 async def run(): await asyncio.gather(adapter._on_event(event), adapter._on_event(event))244242 asyncio.run(run())245243 assert len(received) == 1244+245+246+@pytest.mark.parametrize('setting,expected', [('true', 0), ('false', 1), (False, 1), ('0', 1)])247+def test_room_message_trigger_respects_require_mention(adapter, setting, expected):248+ instance = TinyAdapter(PlatformConfig(enabled=True, extra={249+ 'rooms': 'room', 'allowed_users': OWNER, 'require_mention': setting}))250+ instance.pubkey = BOT251+ instance.rpc = adapter.rpc252+ received = []253+ async def handle(message): received.append(message)254+ instance.handle_message = handle255+ async def run():256+ for author in (OWNER, OTHER):257+ await instance._on_event({'kind': 9, 'id': author, 'pubkey': author,258+ 'created_at': int(time.time()), 'content': 'hello', 'tags': [['h', 'room']]})259+ asyncio.run(run())260+ assert len(received) == expected261+ assert all(message.source.user_id == OWNER for message in received)262+263+264+@pytest.mark.parametrize('legacy', [False, True])265+@pytest.mark.parametrize('session,permanent,smart,expected', [266+ (True, True, False, {'once', 'session', 'always', 'deny'}),267+ (True, False, False, {'once', 'session', 'deny'}),268+ (False, True, False, {'once', 'deny'}),269+ (True, True, True, {'once', 'deny'}),270+])271+def test_public_approval_hook_preserves_scopes_on_older_hermes(adapter, monkeypatch, legacy, session, permanent, smart, expected):272+ from gateway.platforms.base import BasePlatformAdapter273+ if legacy:274+ monkeypatch.delattr(BasePlatformAdapter, 'send_exec_approval', raising=False)275+ async def run():276+ entry = _ApprovalEntry({'command': 'safe preview', 'request_id': 'scoped-id'})277+ approval._gateway_queues['session'] = [entry]278+ result = await adapter.send_exec_approval('room', 'safe preview', 'session',279+ allow_session=session, allow_permanent=permanent, smart_denied=smart)280+ assert result.success281+ request = adapter.rpc.events[-1]282+ assert {t[1] for t in request['tags'] if t[0] == 'option'} == expected283+ assert not any(t[0] == 'mention' for t in request['tags'])284+ await adapter._on_answer(response(request, 'once'))285+ assert entry.event.is_set() and entry.result == 'once'286+ asyncio.run(run())