diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index b6292b20aae..e8623d90a4a 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -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( diff --git a/tests/gateway/test_matrix.py b/tests/gateway/test_matrix.py index 6c6dd0513f8..ac78ecc526c 100644 --- a/tests/gateway/test_matrix.py +++ b/tests/gateway/test_matrix.py @@ -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): diff --git a/tests/gateway/test_matrix_project_context_isolation.py b/tests/gateway/test_matrix_project_context_isolation.py index 5094a06feb5..943a367d67b 100644 --- a/tests/gateway/test_matrix_project_context_isolation.py +++ b/tests/gateway/test_matrix_project_context_isolation.py @@ -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")