diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 8855c386d..654d77070 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -1135,7 +1135,10 @@ class MatrixAdapter(BasePlatformAdapter): 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: @@ -1822,8 +1825,24 @@ class MatrixAdapter(BasePlatformAdapter): # 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: diff --git a/tests/gateway/test_matrix_mention.py b/tests/gateway/test_matrix_mention.py index 873b873c2..b5db0da7c 100644 --- a/tests/gateway/test_matrix_mention.py +++ b/tests/gateway/test_matrix_mention.py @@ -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. @@ -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" @@ -108,6 +112,44 @@ class TestIsBotMentioned: # "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): @@ -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."""