fix(matrix): trust m.mentions.user_ids as authoritative mention signal

Port from openclaw/openclaw#64796: Per MSC3952 / Matrix v1.7, the
m.mentions.user_ids field is the authoritative mention signal.  Non-
OpenClaw Matrix clients (Element, matrix-bot-sdk bots, etc.) commonly
send messages with proper m.mentions.user_ids metadata but without
duplicating the @bot text in the message body.

Before this change, _is_bot_mentioned() relied entirely on text-based
detection (body string matching and HTML pill detection), causing
messages from these clients to be silently dropped when
MATRIX_REQUIRE_MENTION=true.

Now, if the bot's user_id appears in m.mentions.user_ids, that alone
is sufficient to register a mention — matching the Matrix spec.
Text-based fallback remains for backwards compatibility with older
clients that don't populate m.mentions.
This commit is contained in:
Teknium 2026-04-12 17:07:28 -07:00
parent 0d0d27d45e
commit 65214ceeac
No known key found for this signature in database
2 changed files with 102 additions and 3 deletions

View file

@ -1135,7 +1135,10 @@ class MatrixAdapter(BasePlatformAdapter):
thread_id = relates_to.get("event_id") thread_id = relates_to.get("event_id")
formatted_body = source_content.get("formatted_body") 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. # Require-mention gating.
if not is_dm: if not is_dm:
@ -1822,8 +1825,24 @@ class MatrixAdapter(BasePlatformAdapter):
# Mention detection helpers # Mention detection helpers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _is_bot_mentioned(self, body: str, formatted_body: Optional[str] = None) -> bool: def _is_bot_mentioned(
"""Return True if the bot is mentioned in the message.""" 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: if not body and not formatted_body:
return False return False
if self._user_id and self._user_id in body: if self._user_id and self._user_id in body:

View file

@ -48,6 +48,7 @@ def _make_event(
room_id="!room1:example.org", room_id="!room1:example.org",
formatted_body=None, formatted_body=None,
thread_id=None, thread_id=None,
mention_user_ids=None,
): ):
"""Create a fake room message event. """Create a fake room message event.
@ -60,6 +61,9 @@ def _make_event(
content["formatted_body"] = formatted_body content["formatted_body"] = formatted_body
content["format"] = "org.matrix.custom.html" content["format"] = "org.matrix.custom.html"
if mention_user_ids is not None:
content["m.mentions"] = {"user_ids": mention_user_ids}
relates_to = {} relates_to = {}
if thread_id: if thread_id:
relates_to["rel_type"] = "m.thread" relates_to["rel_type"] = "m.thread"
@ -108,6 +112,44 @@ class TestIsBotMentioned:
# "hermesbot" should not match word-boundary check for "hermes" # "hermesbot" should not match word-boundary check for "hermes"
assert not self.adapter._is_bot_mentioned("hermesbot is here") 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: class TestStripMention:
def setup_method(self): def setup_method(self):
@ -176,6 +218,44 @@ async def test_require_mention_html_pill(monkeypatch):
adapter.handle_message.assert_awaited_once() 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 @pytest.mark.asyncio
async def test_require_mention_dm_always_responds(monkeypatch): async def test_require_mention_dm_always_responds(monkeypatch):
"""DMs always respond regardless of mention setting.""" """DMs always respond regardless of mention setting."""