mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
0d0d27d45e
commit
65214ceeac
2 changed files with 102 additions and 3 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue