Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 83 additions & 6 deletions plugins/platforms/matrix/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,11 @@ def __init__(self, config: PlatformConfig):
# if that changes, add a config.yaml entry rather than an env var.
self._reaction_redaction_delay_seconds = 5.0
self._reaction_redaction_tasks: Set[asyncio.Task] = set()
# Agent-facing reactions (send_message action="react"): the most
# recent inbound event per room (the default reaction target), and
# the annotation events we posted so unreact can redact them.
self._last_inbound_by_room: dict[str, str] = {}
self._agent_reactions: dict[tuple[str, str], str] = {}

# Proxy support — resolve once at init, reuse for all HTTP traffic.
self._proxy_url: str | None = resolve_proxy_url(platform_env_var="MATRIX_PROXY")
Expand Down Expand Up @@ -3960,6 +3965,72 @@ async def _redact_reaction(
"""Remove a reaction by redacting its event."""
return await self.redact_message(room_id, reaction_event_id, reason)

# -- Agent-facing reactions (send_message action="react") ---------------
#
# Unlike the lifecycle hooks below, these are deliberate agent intents,
# so they are NOT gated by MATRIX_REACTIONS (that env var exists to mute
# the automatic per-message tapback noise, not explicit requests).
# Mirrors the photon adapter's pair; ``send_message`` discovers them via
# ``getattr(adapter, "add_reaction")``.

async def add_reaction(
self,
chat_id: str,
emoji: str,
message_id: Optional[str] = None,
) -> Dict[str, Any]:
"""React ``emoji`` onto a message in room ``chat_id``.

Without ``message_id``, targets the room's most recent inbound
message (typically the one the agent is responding to). Posts a
native ``m.reaction`` annotation, so any Matrix client renders it.
"""
target = message_id or self._last_inbound_by_room.get(str(chat_id))
if not target:
return {
"success": False,
"error": "no message to react to — pass message_id (no "
"inbound message seen in this room since the gateway "
"started)",
}
reaction_event_id = await self._send_reaction(
str(chat_id), str(target), emoji
)
if not reaction_event_id:
return {
"success": False,
"error": "reaction send failed (see gateway debug log)",
}
self._agent_reactions[(str(chat_id), str(target))] = reaction_event_id
return {"success": True, "message_id": str(target)}

async def remove_reaction(
self, chat_id: str, message_id: Optional[str] = None
) -> Dict[str, Any]:
"""Retract our reaction from a message (best-effort).

Only reactions placed through :meth:`add_reaction` in this process
are tracked; the lifecycle tapbacks manage their own redaction.
"""
target = message_id or self._last_inbound_by_room.get(str(chat_id))
if not target:
return {
"success": False,
"error": "no message to unreact — pass message_id",
}
reaction_event_id = self._agent_reactions.pop(
(str(chat_id), str(target)), None
)
if not reaction_event_id:
return {
"success": False,
"error": "no reaction of ours recorded on that message",
}
ok = await self._redact_reaction(
str(chat_id), reaction_event_id, "reaction retracted"
)
return {"success": bool(ok), "message_id": str(target)}

def _schedule_reaction_redaction(
self,
room_id: str,
Expand Down Expand Up @@ -3991,14 +4062,20 @@ async def _redact_later() -> None:

async def on_processing_start(self, event: MessageEvent) -> None:
"""Add eyes reaction when the agent starts processing a message."""
if not self._reactions_enabled:
return
msg_id = event.message_id
room_id = event.source.chat_id
if msg_id and room_id:
reaction_event_id = await self._send_reaction(room_id, msg_id, "\U0001f440")
if reaction_event_id:
self._pending_reactions[(room_id, msg_id)] = reaction_event_id
if not msg_id or not room_id:
return
# Remember the triggering message so add_reaction() can default to
# "the message being answered" (photon's _last_inbound_by_chat
# precedent). Recorded before the MATRIX_REACTIONS gate — muting the
# lifecycle tapbacks must not blind deliberate agent reactions.
self._last_inbound_by_room[str(room_id)] = str(msg_id)
if not self._reactions_enabled:
return
reaction_event_id = await self._send_reaction(room_id, msg_id, "\U0001f440")
if reaction_event_id:
self._pending_reactions[(room_id, msg_id)] = reaction_event_id

async def on_processing_complete(
self,
Expand Down
178 changes: 178 additions & 0 deletions tests/gateway/test_matrix_agent_reactions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"""Tests for the Matrix adapter's agent-facing add_reaction/remove_reaction.

``send_message(action="react")`` dispatches by duck-typing public
``add_reaction``/``remove_reaction`` coroutines on the live adapter
(tools/send_message_tool.py). Photon ships the pair; Matrix had only the
private ``_send_reaction`` machinery, so reacting on Matrix errored
"Platform 'matrix' does not support message reactions" despite the adapter
being able to post native ``m.reaction`` annotations. These tests pin the
public pair: default-target resolution, explicit ``message_id``, unreact
redaction of our own annotation, and the no-target error path.
"""

import asyncio
import sys
import types
from types import SimpleNamespace
from unittest.mock import AsyncMock

import pytest


# ---------------------------------------------------------------------------
# Stub mautrix so plugins.platforms.matrix.adapter can be imported without the SDK.
# ---------------------------------------------------------------------------

def _stub_mautrix():
stub = types.ModuleType("mautrix")
for sub in ("mautrix.types", "mautrix.client", "mautrix.client.api",
"mautrix.errors", "mautrix.crypto", "mautrix.util",
"mautrix.util.config"):
sys.modules.setdefault(sub, types.ModuleType(sub))
sys.modules.setdefault("mautrix", stub)
m = sys.modules["mautrix.types"]

class EventType:
ROOM_MESSAGE = "m.room.message"
REACTION = "m.reaction"
ROOM_ENCRYPTED = "m.room.encrypted"
ROOM_NAME = "m.room.name"

class PaginationDirection:
BACKWARD = "b"
FORWARD = "f"

class PresenceState:
ONLINE = "online"
OFFLINE = "offline"
UNAVAILABLE = "unavailable"

class RoomCreatePreset:
PRIVATE = "private_chat"
PUBLIC = "public_chat"
TRUSTED_PRIVATE = "trusted_private_chat"

class TrustState:
UNVERIFIED = 0
VERIFIED = 1

for attr in ("ContentURI", "EventID", "RoomID", "SyncToken", "UserID"):
setattr(m, attr, str)
m.EventType = EventType
m.PaginationDirection = PaginationDirection
m.PresenceState = PresenceState
m.RoomCreatePreset = RoomCreatePreset
m.TrustState = TrustState


_stub_mautrix()

from plugins.platforms.matrix.adapter import MatrixAdapter # noqa: E402


ROOM = "!testroom:matrix.org"


def _make_adapter():
"""Construct a MatrixAdapter with only the reaction state under test."""
adapter = object.__new__(MatrixAdapter)
adapter._reactions_enabled = True
adapter._pending_reactions = {}
adapter._last_inbound_by_room = {}
adapter._agent_reactions = {}
adapter._send_reaction = AsyncMock(return_value="$reaction-1")
adapter._redact_reaction = AsyncMock(return_value=True)
return adapter


def _run(coro):
return asyncio.run(coro)


def test_add_reaction_defaults_to_last_inbound():
adapter = _make_adapter()
adapter._last_inbound_by_room[ROOM] = "$inbound-42"
result = _run(adapter.add_reaction(chat_id=ROOM, emoji="❤️"))
assert result == {"success": True, "message_id": "$inbound-42"}
adapter._send_reaction.assert_awaited_once_with(ROOM, "$inbound-42", "❤️")
assert adapter._agent_reactions[(ROOM, "$inbound-42")] == "$reaction-1"


def test_add_reaction_explicit_message_id_wins():
adapter = _make_adapter()
adapter._last_inbound_by_room[ROOM] = "$inbound-42"
result = _run(adapter.add_reaction(chat_id=ROOM, emoji="🔥", message_id="$older-7"))
assert result["success"] is True
adapter._send_reaction.assert_awaited_once_with(ROOM, "$older-7", "🔥")


def test_add_reaction_without_target_errors():
adapter = _make_adapter()
result = _run(adapter.add_reaction(chat_id=ROOM, emoji="👍"))
assert result["success"] is False
assert "message_id" in result["error"]
adapter._send_reaction.assert_not_awaited()


def test_add_reaction_send_failure_is_reported():
adapter = _make_adapter()
adapter._last_inbound_by_room[ROOM] = "$inbound-42"
adapter._send_reaction = AsyncMock(return_value=None)
result = _run(adapter.add_reaction(chat_id=ROOM, emoji="❤️"))
assert result["success"] is False
assert (ROOM, "$inbound-42") not in adapter._agent_reactions


def test_remove_reaction_redacts_own_annotation():
adapter = _make_adapter()
adapter._last_inbound_by_room[ROOM] = "$inbound-42"
_run(adapter.add_reaction(chat_id=ROOM, emoji="❤️"))
result = _run(adapter.remove_reaction(chat_id=ROOM))
assert result == {"success": True, "message_id": "$inbound-42"}
adapter._redact_reaction.assert_awaited_once()
args = adapter._redact_reaction.await_args.args
assert args[0] == ROOM
assert args[1] == "$reaction-1"
assert adapter._agent_reactions == {}


def test_remove_reaction_without_own_reaction_errors():
adapter = _make_adapter()
adapter._last_inbound_by_room[ROOM] = "$inbound-42"
result = _run(adapter.remove_reaction(chat_id=ROOM))
assert result["success"] is False
adapter._redact_reaction.assert_not_awaited()


def test_processing_start_records_target_even_when_reactions_muted():
"""MATRIX_REACTIONS=false mutes the lifecycle tapbacks, not agent intent."""
adapter = _make_adapter()
adapter._reactions_enabled = False
event = SimpleNamespace(
message_id="$inbound-99",
source=SimpleNamespace(chat_id=ROOM),
)
_run(adapter.on_processing_start(event))
assert adapter._last_inbound_by_room[ROOM] == "$inbound-99"
adapter._send_reaction.assert_not_awaited()


def test_send_message_react_dispatches_to_matrix_adapter():
"""The generic dispatch resolves matrix's new public pair end to end."""
import json
from unittest.mock import patch

import tools.send_message_tool as smt
from gateway.config import Platform

adapter = _make_adapter()
adapter._last_inbound_by_room[ROOM] = "$inbound-42"
runner = SimpleNamespace(adapters={Platform("matrix"): adapter})
with patch("gateway.run._gateway_runner_ref", lambda: runner):
result = json.loads(
smt.send_message_tool(
{"action": "react", "target": f"matrix:{ROOM}", "emoji": "❤️"}
)
)
assert result["success"] is True
assert result["message_id"] == "$inbound-42"
Loading