diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 47aff244f8..ba39951364 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -2432,11 +2432,15 @@ class BasePlatformAdapter(ABC): # Send the text portion if text_content: 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( chat_id=event.source.chat_id, content=text_content, reply_to=event.message_id, - metadata=_thread_metadata, + metadata=send_metadata, ) _record_delivery(result) diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index c5685fddb6..544130654f 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -32,6 +32,8 @@ import mimetypes import os import re import time +from dataclasses import dataclass + from html import escape as _html_escape from pathlib import Path from typing import Any, Dict, Optional, Set @@ -104,6 +106,18 @@ from gateway.platforms.helpers import ThreadParticipationTracker 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 # but clients render poorly above this). MAX_MESSAGE_LENGTH = 4000 @@ -367,6 +381,18 @@ class MatrixAdapter(BasePlatformAdapter): self._pending_text_batches: Dict[str, MessageEvent] = {} 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: """Return True if this event was already processed. Tracks the ID otherwise.""" if not event_id: @@ -853,13 +879,33 @@ class MatrixAdapter(BasePlatformAdapter): if not content: return SendResult(success=True) + mention_user_id = (metadata or {}).get("mention_user_id") + formatted = self.format_message(content) chunks = self.truncate_message(formatted, MAX_MESSAGE_LENGTH) last_event_id = None - for chunk in chunks: + for i, chunk in enumerate(chunks): 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'' + f"{mention_user_id}" + ) + 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. if 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 ) + 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: """Pass-through — Matrix supports standard Markdown natively.""" # Strip image markdown; media is uploaded separately. @@ -1922,6 +2018,51 @@ class MatrixAdapter(BasePlatformAdapter): 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) # ------------------------------------------------------------------ diff --git a/gateway/run.py b/gateway/run.py index cd2a5ab114..827ec01be3 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -10041,7 +10041,7 @@ class GatewayRunner: # Bridge sync status_callback → async adapter.send for context pressure _status_adapter = self.adapters.get(source.platform) _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: if not _status_adapter or not _run_still_current(): diff --git a/tests/gateway/test_matrix_exec_approval.py b/tests/gateway/test_matrix_exec_approval.py new file mode 100644 index 0000000000..8fde89c406 --- /dev/null +++ b/tests/gateway/test_matrix_exec_approval.py @@ -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