Skip to content
Closed
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
25 changes: 22 additions & 3 deletions gateway/platforms/matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -1135,7 +1135,10 @@ async def _resolve_message_context(
thread_id = relates_to.get("event_id")

formatted_body = source_content.get("formatted_body")
is_mentioned = self._is_bot_mentioned(body, formatted_body)
# m.mentions.user_ids (MSC3952 / Matrix v1.7) — authoritative mention signal.
mentions_block = source_content.get("m.mentions") or {}
mention_user_ids = mentions_block.get("user_ids") if isinstance(mentions_block, dict) else None
is_mentioned = self._is_bot_mentioned(body, formatted_body, mention_user_ids)

# Require-mention gating.
if not is_dm:
Expand Down Expand Up @@ -1822,8 +1825,24 @@ async def _refresh_dm_cache(self) -> None:
# Mention detection helpers
# ------------------------------------------------------------------

def _is_bot_mentioned(self, body: str, formatted_body: Optional[str] = None) -> bool:
"""Return True if the bot is mentioned in the message."""
def _is_bot_mentioned(
self,
body: str,
formatted_body: Optional[str] = None,
mention_user_ids: Optional[list] = None,
) -> bool:
"""Return True if the bot is mentioned in the message.

Per MSC3952, ``m.mentions.user_ids`` is the authoritative mention
signal in the Matrix spec. When the sender's client populates that
field with the bot's user-id, we trust it — even when the visible
body text does not contain an explicit ``@bot`` string (some clients
only render mention "pills" in ``formatted_body`` or use display
names).
"""
# m.mentions.user_ids — authoritative per MSC3952 / Matrix v1.7.
if mention_user_ids and self._user_id and self._user_id in mention_user_ids:
return True
if not body and not formatted_body:
return False
if self._user_id and self._user_id in body:
Expand Down
80 changes: 80 additions & 0 deletions tests/gateway/test_matrix_mention.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ def _make_event(
room_id="!room1:example.org",
formatted_body=None,
thread_id=None,
mention_user_ids=None,
):
"""Create a fake room message event.

Expand All @@ -60,6 +61,9 @@ def _make_event(
content["formatted_body"] = formatted_body
content["format"] = "org.matrix.custom.html"

if mention_user_ids is not None:
content["m.mentions"] = {"user_ids": mention_user_ids}

relates_to = {}
if thread_id:
relates_to["rel_type"] = "m.thread"
Expand Down Expand Up @@ -108,6 +112,44 @@ def test_partial_localpart_no_match(self):
# "hermesbot" should not match word-boundary check for "hermes"
assert not self.adapter._is_bot_mentioned("hermesbot is here")

# m.mentions.user_ids — MSC3952 / Matrix v1.7 authoritative mentions
# Ported from openclaw/openclaw#64796

def test_m_mentions_user_ids_authoritative(self):
"""m.mentions.user_ids alone is sufficient — no body text needed."""
assert self.adapter._is_bot_mentioned(
"please reply", # no @hermes anywhere in body
mention_user_ids=["@hermes:example.org"],
)

def test_m_mentions_user_ids_with_body_mention(self):
"""Both m.mentions and body mention — should still be True."""
assert self.adapter._is_bot_mentioned(
"hey @hermes:example.org help",
mention_user_ids=["@hermes:example.org"],
)

def test_m_mentions_user_ids_other_user_only(self):
"""m.mentions with a different user — bot is NOT mentioned."""
assert not self.adapter._is_bot_mentioned(
"hello",
mention_user_ids=["@alice:example.org"],
)

def test_m_mentions_user_ids_empty_list(self):
"""Empty user_ids list — falls through to text detection."""
assert not self.adapter._is_bot_mentioned(
"hello everyone",
mention_user_ids=[],
)

def test_m_mentions_user_ids_none(self):
"""None mention_user_ids — falls through to text detection."""
assert not self.adapter._is_bot_mentioned(
"hello everyone",
mention_user_ids=None,
)


class TestStripMention:
def setup_method(self):
Expand Down Expand Up @@ -176,6 +218,44 @@ async def test_require_mention_html_pill(monkeypatch):
adapter.handle_message.assert_awaited_once()


@pytest.mark.asyncio
async def test_require_mention_m_mentions_user_ids(monkeypatch):
"""m.mentions.user_ids is authoritative per MSC3952 — no body mention needed.

Ported from openclaw/openclaw#64796.
"""
monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")

adapter = _make_adapter()
# Body has NO mention, but m.mentions.user_ids includes the bot.
event = _make_event(
"please reply",
mention_user_ids=["@hermes:example.org"],
)

await adapter._on_room_message(event)
adapter.handle_message.assert_awaited_once()


@pytest.mark.asyncio
async def test_require_mention_m_mentions_other_user_ignored(monkeypatch):
"""m.mentions.user_ids mentioning another user should NOT activate the bot."""
monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")

adapter = _make_adapter()
event = _make_event(
"hey alice check this",
mention_user_ids=["@alice:example.org"],
)

await adapter._on_room_message(event)
adapter.handle_message.assert_not_awaited()


@pytest.mark.asyncio
async def test_require_mention_dm_always_responds(monkeypatch):
"""DMs always respond regardless of mention setting."""
Expand Down
Loading