mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
feat(matrix): reaction-based exec approval + mention_user_id
Add Matrix reaction-based exec approval (✅/❎) and mention_user_id support for push notifications in muted rooms. - matrix.py: _MatrixApprovalPrompt, send_exec_approval, reaction approval handling, bot seed reaction redaction, mention pill in send - base.py: inject mention_user_id into send metadata - run.py: inject mention_user_id into status thread metadata - tests for approval prompt registration and reaction resolution
This commit is contained in:
parent
6c70ac8eef
commit
38a6bada92
4 changed files with 206 additions and 3 deletions
|
|
@ -2432,11 +2432,15 @@ class BasePlatformAdapter(ABC):
|
||||||
# Send the text portion
|
# Send the text portion
|
||||||
if text_content:
|
if text_content:
|
||||||
logger.info("[%s] Sending response (%d chars) to %s", self.name, len(text_content), event.source.chat_id)
|
logger.info("[%s] Sending response (%d chars) to %s", self.name, len(text_content), event.source.chat_id)
|
||||||
|
# Build send metadata: thread_id + mention target for platforms that need it
|
||||||
|
send_metadata = dict(_thread_metadata) if _thread_metadata else {}
|
||||||
|
if event.source.user_id:
|
||||||
|
send_metadata["mention_user_id"] = event.source.user_id
|
||||||
result = await self._send_with_retry(
|
result = await self._send_with_retry(
|
||||||
chat_id=event.source.chat_id,
|
chat_id=event.source.chat_id,
|
||||||
content=text_content,
|
content=text_content,
|
||||||
reply_to=event.message_id,
|
reply_to=event.message_id,
|
||||||
metadata=_thread_metadata,
|
metadata=send_metadata,
|
||||||
)
|
)
|
||||||
_record_delivery(result)
|
_record_delivery(result)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ import mimetypes
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from html import escape as _html_escape
|
from html import escape as _html_escape
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional, Set
|
from typing import Any, Dict, Optional, Set
|
||||||
|
|
@ -104,6 +106,18 @@ from gateway.platforms.helpers import ThreadParticipationTracker
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _MatrixApprovalPrompt:
|
||||||
|
"""Tracks a pending Matrix reaction-based exec approval prompt."""
|
||||||
|
|
||||||
|
def __init__(self, session_key: str, chat_id: str, message_id: str, resolved: bool = False):
|
||||||
|
self.session_key = session_key
|
||||||
|
self.chat_id = chat_id
|
||||||
|
self.message_id = message_id
|
||||||
|
self.resolved = resolved
|
||||||
|
self.bot_reaction_events: dict[str, str] = {} # emoji -> event_id
|
||||||
|
|
||||||
# Matrix message size limit (4000 chars practical, spec has no hard limit
|
# Matrix message size limit (4000 chars practical, spec has no hard limit
|
||||||
# but clients render poorly above this).
|
# but clients render poorly above this).
|
||||||
MAX_MESSAGE_LENGTH = 4000
|
MAX_MESSAGE_LENGTH = 4000
|
||||||
|
|
@ -367,6 +381,18 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||||
self._pending_text_batches: Dict[str, MessageEvent] = {}
|
self._pending_text_batches: Dict[str, MessageEvent] = {}
|
||||||
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
|
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
|
||||||
|
|
||||||
|
# Matrix reaction-based dangerous command approvals.
|
||||||
|
self._approval_reaction_map = {
|
||||||
|
"✅": "once",
|
||||||
|
"❎": "deny",
|
||||||
|
}
|
||||||
|
self._approval_prompts_by_event: Dict[str, _MatrixApprovalPrompt] = {}
|
||||||
|
self._approval_prompt_by_session: Dict[str, str] = {}
|
||||||
|
allowed_users_raw = os.getenv("MATRIX_ALLOWED_USERS", "")
|
||||||
|
self._allowed_user_ids: Set[str] = {
|
||||||
|
u.strip() for u in allowed_users_raw.split(",") if u.strip()
|
||||||
|
}
|
||||||
|
|
||||||
def _is_duplicate_event(self, event_id) -> bool:
|
def _is_duplicate_event(self, event_id) -> bool:
|
||||||
"""Return True if this event was already processed. Tracks the ID otherwise."""
|
"""Return True if this event was already processed. Tracks the ID otherwise."""
|
||||||
if not event_id:
|
if not event_id:
|
||||||
|
|
@ -853,13 +879,33 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||||
if not content:
|
if not content:
|
||||||
return SendResult(success=True)
|
return SendResult(success=True)
|
||||||
|
|
||||||
|
mention_user_id = (metadata or {}).get("mention_user_id")
|
||||||
|
|
||||||
formatted = self.format_message(content)
|
formatted = self.format_message(content)
|
||||||
chunks = self.truncate_message(formatted, MAX_MESSAGE_LENGTH)
|
chunks = self.truncate_message(formatted, MAX_MESSAGE_LENGTH)
|
||||||
|
|
||||||
last_event_id = None
|
last_event_id = None
|
||||||
for chunk in chunks:
|
for i, chunk in enumerate(chunks):
|
||||||
msg_content = self._build_text_message_content(chunk)
|
msg_content = self._build_text_message_content(chunk)
|
||||||
|
|
||||||
|
# Append @mention pill to the last chunk for push notifications
|
||||||
|
# in muted rooms (mention-only mode).
|
||||||
|
if mention_user_id and i == len(chunks) - 1:
|
||||||
|
mention_html = (
|
||||||
|
f'<a href="https://matrix.to/#/{mention_user_id}">'
|
||||||
|
f"{mention_user_id}</a>"
|
||||||
|
)
|
||||||
|
msg_content["body"] = chunk + f" @{mention_user_id}"
|
||||||
|
base_html = msg_content.get("formatted_body", chunk)
|
||||||
|
msg_content["format"] = "org.matrix.custom.html"
|
||||||
|
msg_content["formatted_body"] = base_html + " " + mention_html
|
||||||
|
# m.mentions for MSC3952 push reliability.
|
||||||
|
existing_mentions = msg_content.get("m.mentions", {}).get("user_ids", [])
|
||||||
|
if mention_user_id not in existing_mentions:
|
||||||
|
msg_content["m.mentions"] = {
|
||||||
|
"user_ids": existing_mentions + [mention_user_id]
|
||||||
|
}
|
||||||
|
|
||||||
# Reply-to support.
|
# Reply-to support.
|
||||||
if reply_to:
|
if reply_to:
|
||||||
msg_content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to}}
|
msg_content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to}}
|
||||||
|
|
@ -1105,6 +1151,56 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||||
chat_id, video_path, "m.video", caption, reply_to, metadata=metadata
|
chat_id, video_path, "m.video", caption, reply_to, metadata=metadata
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def send_exec_approval(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
command: str,
|
||||||
|
session_key: str,
|
||||||
|
description: str = "dangerous command",
|
||||||
|
metadata: Optional[dict] = None,
|
||||||
|
) -> SendResult:
|
||||||
|
"""Send a reaction-based exec approval prompt for Matrix."""
|
||||||
|
if not self._client:
|
||||||
|
return SendResult(success=False, error="Not connected")
|
||||||
|
|
||||||
|
cmd_preview = command[:2000] + "..." if len(command) > 2000 else command
|
||||||
|
text = (
|
||||||
|
"⚠️ **Dangerous command requires approval**\n"
|
||||||
|
f"```\n{cmd_preview}\n```\n"
|
||||||
|
f"Reason: {description}\n\n"
|
||||||
|
"Reply `/approve` to execute, `/approve session` to approve this pattern for the session, "
|
||||||
|
"`/approve always` to approve permanently, or `/deny` to cancel.\n\n"
|
||||||
|
"You can also click the reaction to approve:\n"
|
||||||
|
"✅ = /approve\n"
|
||||||
|
"❎ = /deny"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self.send(chat_id, text, metadata=metadata)
|
||||||
|
if not result.success or not result.message_id:
|
||||||
|
return result
|
||||||
|
|
||||||
|
prompt = _MatrixApprovalPrompt(
|
||||||
|
session_key=session_key,
|
||||||
|
chat_id=chat_id,
|
||||||
|
message_id=result.message_id,
|
||||||
|
)
|
||||||
|
old_event = self._approval_prompt_by_session.get(session_key)
|
||||||
|
if old_event:
|
||||||
|
self._approval_prompts_by_event.pop(old_event, None)
|
||||||
|
self._approval_prompts_by_event[result.message_id] = prompt
|
||||||
|
self._approval_prompt_by_session[session_key] = result.message_id
|
||||||
|
|
||||||
|
for emoji in ("✅", "❎"):
|
||||||
|
try:
|
||||||
|
reaction_result = await self._send_reaction(chat_id, result.message_id, emoji)
|
||||||
|
# Save the bot's reaction event_id for later cleanup
|
||||||
|
if reaction_result:
|
||||||
|
prompt.bot_reaction_events[emoji] = str(reaction_result)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Matrix: failed to add approval reaction %s: %s", emoji, exc)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
def format_message(self, content: str) -> str:
|
def format_message(self, content: str) -> str:
|
||||||
"""Pass-through — Matrix supports standard Markdown natively."""
|
"""Pass-through — Matrix supports standard Markdown natively."""
|
||||||
# Strip image markdown; media is uploaded separately.
|
# Strip image markdown; media is uploaded separately.
|
||||||
|
|
@ -1922,6 +2018,51 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||||
room_id,
|
room_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check if this reaction resolves a pending approval prompt.
|
||||||
|
prompt = self._approval_prompts_by_event.get(reacts_to)
|
||||||
|
if prompt and not prompt.resolved:
|
||||||
|
if room_id != prompt.chat_id:
|
||||||
|
return
|
||||||
|
if self._allowed_user_ids and sender not in self._allowed_user_ids:
|
||||||
|
logger.info(
|
||||||
|
"Matrix: ignoring approval reaction from unauthorized user %s on %s",
|
||||||
|
sender, reacts_to,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
choice = self._approval_reaction_map.get(key)
|
||||||
|
if not choice:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from tools.approval import resolve_gateway_approval
|
||||||
|
|
||||||
|
count = resolve_gateway_approval(prompt.session_key, choice)
|
||||||
|
if count:
|
||||||
|
prompt.resolved = True
|
||||||
|
self._approval_prompts_by_event.pop(reacts_to, None)
|
||||||
|
self._approval_prompt_by_session.pop(prompt.session_key, None)
|
||||||
|
logger.info(
|
||||||
|
"Matrix reaction resolved %d approval(s) for session %s "
|
||||||
|
"(choice=%s, user=%s)",
|
||||||
|
count, prompt.session_key, choice, sender,
|
||||||
|
)
|
||||||
|
# Redact bot's seed reactions, leaving only the user's
|
||||||
|
await self._redact_bot_approval_reactions(room_id, prompt)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to resolve gateway approval from Matrix reaction: %s", exc)
|
||||||
|
|
||||||
|
async def _redact_bot_approval_reactions(
|
||||||
|
self,
|
||||||
|
room_id: str,
|
||||||
|
prompt: "_MatrixApprovalPrompt",
|
||||||
|
) -> None:
|
||||||
|
"""Redact the bot's seed ✅/❎ reactions, leaving only the user's reaction."""
|
||||||
|
for emoji, evt_id in prompt.bot_reaction_events.items():
|
||||||
|
try:
|
||||||
|
await self.redact_message(room_id, evt_id, "approval resolved")
|
||||||
|
logger.debug("Matrix: redacted bot reaction %s (%s)", emoji, evt_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Matrix: failed to redact bot reaction %s: %s", emoji, exc)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Text message aggregation (handles Matrix client-side splits)
|
# Text message aggregation (handles Matrix client-side splits)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -10041,7 +10041,7 @@ class GatewayRunner:
|
||||||
# Bridge sync status_callback → async adapter.send for context pressure
|
# Bridge sync status_callback → async adapter.send for context pressure
|
||||||
_status_adapter = self.adapters.get(source.platform)
|
_status_adapter = self.adapters.get(source.platform)
|
||||||
_status_chat_id = source.chat_id
|
_status_chat_id = source.chat_id
|
||||||
_status_thread_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None
|
_status_thread_metadata = {"thread_id": _progress_thread_id, "mention_user_id": source.user_id} if _progress_thread_id else {"mention_user_id": source.user_id}
|
||||||
|
|
||||||
def _status_callback_sync(event_type: str, message: str) -> None:
|
def _status_callback_sync(event_type: str, message: str) -> None:
|
||||||
if not _status_adapter or not _run_still_current():
|
if not _status_adapter or not _run_still_current():
|
||||||
|
|
|
||||||
58
tests/gateway/test_matrix_exec_approval.py
Normal file
58
tests/gateway/test_matrix_exec_approval.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import types
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from gateway.config import PlatformConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TestMatrixExecApprovalReactions:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_exec_approval_registers_prompt_and_seeds_reactions(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("MATRIX_ALLOWED_USERS", "@liizfq:liizfq.top")
|
||||||
|
from gateway.platforms.matrix import MatrixAdapter
|
||||||
|
|
||||||
|
adapter = MatrixAdapter(PlatformConfig(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.org"}))
|
||||||
|
adapter._client = types.SimpleNamespace()
|
||||||
|
adapter.send = AsyncMock(return_value=types.SimpleNamespace(success=True, message_id="$evt1"))
|
||||||
|
adapter._send_reaction = AsyncMock(return_value="$r")
|
||||||
|
|
||||||
|
result = await adapter.send_exec_approval(
|
||||||
|
chat_id="!room:example.org",
|
||||||
|
command="rm -rf /tmp/test",
|
||||||
|
session_key="sess-1",
|
||||||
|
description="dangerous",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert adapter._approval_prompt_by_session["sess-1"] == "$evt1"
|
||||||
|
assert adapter._approval_prompts_by_event["$evt1"].session_key == "sess-1"
|
||||||
|
assert adapter._send_reaction.await_count == 2
|
||||||
|
emojis = [call.args[2] for call in adapter._send_reaction.await_args_list]
|
||||||
|
assert emojis == ["✅", "❎"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reaction_resolves_pending_approval(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("MATRIX_ALLOWED_USERS", "@liizfq:liizfq.top")
|
||||||
|
from gateway.platforms.matrix import MatrixAdapter, _MatrixApprovalPrompt
|
||||||
|
|
||||||
|
adapter = MatrixAdapter(PlatformConfig(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.org"}))
|
||||||
|
adapter._approval_prompts_by_event["$target"] = _MatrixApprovalPrompt(
|
||||||
|
session_key="sess-1", chat_id="!room:example.org", message_id="$target"
|
||||||
|
)
|
||||||
|
adapter._approval_prompt_by_session["sess-1"] = "$target"
|
||||||
|
|
||||||
|
content = {"m.relates_to": {"event_id": "$target", "key": "✅"}}
|
||||||
|
event = types.SimpleNamespace(
|
||||||
|
sender="@liizfq:liizfq.top",
|
||||||
|
event_id="$react1",
|
||||||
|
room_id="!room:example.org",
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
|
||||||
|
await adapter._on_reaction(event)
|
||||||
|
|
||||||
|
mock_resolve.assert_called_once_with("sess-1", "once")
|
||||||
|
assert "$target" not in adapter._approval_prompts_by_event
|
||||||
|
assert "sess-1" not in adapter._approval_prompt_by_session
|
||||||
Loading…
Add table
Add a link
Reference in a new issue