mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
fix(matrix): use member_count as DM signal for named DM rooms
Most Matrix clients auto-set a room name when creating a DM (e.g. "Alice & Bot" from participant display names), so the old `is_direct and not has_explicit_name` heuristic classified virtually all client-created DM rooms as "room", forcing require_mention gating in legitimate one-on-one DMs. member_count is now the primary DM signal: <=2 members means the room is necessarily a 1:1 conversation, regardless of m.direct or an explicit name. A room that grew to 3+ members but is still in stale m.direct is still classified as a room (conflict flag set). Falls back to the m.direct + name heuristic when the count is unavailable. Also hardens _get_room_member_count with a joined_members API fallback when the cache-backed state_store is empty. Salvaged from #48554 by @justemu onto the current plugin adapter path (gateway/platforms/matrix.py -> plugins/platforms/matrix/adapter.py). Fixes #48551
This commit is contained in:
parent
0ef86febe2
commit
4aa793345e
3 changed files with 78 additions and 21 deletions
|
|
@ -3573,21 +3573,29 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||
return None
|
||||
|
||||
async def _get_room_member_count(self, room_id: str) -> Optional[int]:
|
||||
# Tier 1: state_store (fast, cache-backed).
|
||||
state_store = (
|
||||
getattr(self._client, "state_store", None) if self._client else None
|
||||
)
|
||||
if not state_store:
|
||||
return None
|
||||
try:
|
||||
members = await state_store.get_members(room_id)
|
||||
except Exception:
|
||||
return None
|
||||
if members is None:
|
||||
return None
|
||||
try:
|
||||
return len(members)
|
||||
except TypeError:
|
||||
return None
|
||||
if state_store:
|
||||
try:
|
||||
members = await state_store.get_members(room_id)
|
||||
if members is not None:
|
||||
return len(members)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Tier 2: API fallback (direct server query) when the cache is empty.
|
||||
client = getattr(self, "_client", None)
|
||||
if client is not None and hasattr(client, "joined_members"):
|
||||
try:
|
||||
resp = await client.joined_members(room_id)
|
||||
if getattr(resp, "members", None) is not None:
|
||||
return len(resp.members)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
async def _get_room_name(self, room_id: str) -> Optional[str]:
|
||||
if not self._client or not hasattr(self._client, "get_state_event"):
|
||||
|
|
@ -3674,8 +3682,23 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||
member_count = await self._get_room_member_count(room_id)
|
||||
has_explicit_name = bool(room_name)
|
||||
is_direct = bool(self._dm_rooms.get(room_id, False))
|
||||
conflict = bool(is_direct and has_explicit_name)
|
||||
chat_type = "dm" if is_direct and not has_explicit_name else "room"
|
||||
# member_count is the primary DM signal: <=2 members means this is
|
||||
# necessarily a 1:1 conversation (or self-DM), regardless of m.direct
|
||||
# or room name. Most Matrix clients auto-name DM rooms (e.g.
|
||||
# "Alice & Bot"), so the old `not has_explicit_name` check
|
||||
# misclassified virtually all client-created DMs as rooms. Falls back
|
||||
# to the m.direct + name heuristic when the count is unavailable (e.g.
|
||||
# state_store and API query both fail). A room that grew to 3+ members
|
||||
# but is still in stale m.direct is correctly classified as a room.
|
||||
is_likely_dm = (member_count is not None and member_count <= 2) or (
|
||||
is_direct and not has_explicit_name
|
||||
)
|
||||
conflict = bool(
|
||||
is_direct
|
||||
and has_explicit_name
|
||||
and (member_count is None or member_count > 2)
|
||||
)
|
||||
chat_type = "dm" if is_likely_dm else "room"
|
||||
display_name = room_name or canonical_alias or room_id
|
||||
|
||||
identity = MatrixRoomIdentity(
|
||||
|
|
|
|||
|
|
@ -502,8 +502,9 @@ class TestMatrixDmDetection:
|
|||
assert await self.adapter._is_dm_room("!dm_room:ex.org") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_named_two_member_room_is_not_dm(self):
|
||||
"""A named two-member room must remain a room, not a DM."""
|
||||
async def test_named_two_member_room_is_dm_by_member_count(self):
|
||||
"""A named two-member room NOT in m.direct is treated as a DM because
|
||||
<=2 members means it's necessarily a 1:1 conversation."""
|
||||
self.adapter._joined_rooms = {"!project:ex.org"}
|
||||
self.adapter._dm_rooms = {}
|
||||
self.adapter._client = MagicMock()
|
||||
|
|
@ -519,14 +520,41 @@ class TestMatrixDmDetection:
|
|||
|
||||
identity = await self.adapter._resolve_room_identity("!project:ex.org")
|
||||
|
||||
assert identity.chat_type == "room"
|
||||
assert identity.chat_type == "dm"
|
||||
assert identity.display_name == "Project Room"
|
||||
assert identity.joined_member_count == 2
|
||||
assert await self.adapter._is_dm_room("!project:ex.org") is False
|
||||
assert await self.adapter._is_dm_room("!project:ex.org") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_named_two_member_dm_is_dm(self):
|
||||
"""A named two-member room in m.direct is a DM (not a room).
|
||||
|
||||
Most Matrix clients auto-name DM rooms (e.g. "Alice & Bot"), so the
|
||||
old `not has_explicit_name` override misclassified them as rooms.
|
||||
"""
|
||||
self.adapter._joined_rooms = {"!named_dm:ex.org"}
|
||||
self.adapter._dm_rooms = {"!named_dm:ex.org": True}
|
||||
self.adapter._client = MagicMock()
|
||||
self.adapter._client.get_state_event = AsyncMock(
|
||||
side_effect=lambda room_id, event_type: {"name": "Alice & Bot"}
|
||||
if event_type == "m.room.name"
|
||||
else (_ for _ in ()).throw(Exception("no alias"))
|
||||
)
|
||||
self.adapter._client.state_store = MagicMock()
|
||||
self.adapter._client.state_store.get_members = AsyncMock(
|
||||
return_value=["@bot:ex.org", "@alice:ex.org"]
|
||||
)
|
||||
|
||||
identity = await self.adapter._resolve_room_identity("!named_dm:ex.org")
|
||||
|
||||
assert identity.chat_type == "dm"
|
||||
assert identity.conflict is False
|
||||
assert identity.joined_member_count == 2
|
||||
assert await self.adapter._is_dm_room("!named_dm:ex.org") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_named_room_overrides_stale_dm_cache(self):
|
||||
"""Explicit room names should defeat stale/conflicting m.direct data."""
|
||||
"""Explicit room names defeat stale/conflicting m.direct data when 3+ members."""
|
||||
self.adapter._joined_rooms = {"!stale:ex.org"}
|
||||
self.adapter._dm_rooms = {"!stale:ex.org": True}
|
||||
self.adapter._client = MagicMock()
|
||||
|
|
@ -536,12 +564,15 @@ class TestMatrixDmDetection:
|
|||
else (_ for _ in ()).throw(Exception("no alias"))
|
||||
)
|
||||
self.adapter._client.state_store = MagicMock()
|
||||
self.adapter._client.state_store.get_members = AsyncMock(return_value=["@bot:ex.org", "@alice:ex.org"])
|
||||
self.adapter._client.state_store.get_members = AsyncMock(
|
||||
return_value=["@bot:ex.org", "@alice:ex.org", "@bob:ex.org"]
|
||||
)
|
||||
|
||||
identity = await self.adapter._resolve_room_identity("!stale:ex.org")
|
||||
|
||||
assert identity.chat_type == "room"
|
||||
assert identity.conflict is True
|
||||
assert identity.joined_member_count == 3
|
||||
assert await self.adapter._is_dm_room("!stale:ex.org") is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -2057,7 +2088,7 @@ class TestMatrixSyncLoop:
|
|||
fake_client.join_room.assert_awaited_once()
|
||||
assert "!room:example.org" in adapter._joined_rooms
|
||||
assert len(captured) == 1
|
||||
assert captured[0].source.chat_type == "group"
|
||||
assert captured[0].source.chat_type == "dm"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seconds_timestamp_is_not_treated_as_milliseconds(self):
|
||||
|
|
|
|||
|
|
@ -168,6 +168,9 @@ async def test_matrix_project_context_survives_sequential_messages():
|
|||
@pytest.mark.asyncio
|
||||
async def test_matrix_session_scope_auto_and_thread_preserve_synthetic_threads():
|
||||
adapter = _make_adapter()
|
||||
# Override member_count to 3 so the named project room is NOT classified as
|
||||
# a DM (the DM fix uses member_count <= 2 as the primary DM signal).
|
||||
adapter._get_room_member_count = AsyncMock(return_value=3)
|
||||
adapter._auto_thread = True
|
||||
adapter._matrix_session_scope = "auto"
|
||||
auto_source = await _source_for(adapter, PROJECT_B_ROOM_ID, "$auto")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue