diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index 0b663f1b7..adb880b4d 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -2947,6 +2947,19 @@ class MatrixAdapter(BasePlatformAdapter): return True except Exception as exc: logger.warning("Matrix: error joining %s: %s", room_id, exc) + # Abandoned rooms (no current members) surface as "no servers + # in the room have been provided" or "room not found". The + # pending invite keeps retrying every startup unless we + # explicitly leave it. The match is narrow enough that + # transient failures still leave the invite untouched for the + # next try. + msg = str(exc).lower() + if ("no servers" in msg) or ("room not found" in msg): + try: + await self._client.leave_room(RoomID(room_id)) + logger.info("Matrix: declined dead invite to %s", room_id) + except Exception: + pass return False def _schedule_invite_join( diff --git a/tests/gateway/test_matrix.py b/tests/gateway/test_matrix.py index 43aea0d0e..7481e66b3 100644 --- a/tests/gateway/test_matrix.py +++ b/tests/gateway/test_matrix.py @@ -4527,3 +4527,87 @@ class TestCreateMatrixSession: assert session.connector is fake_connector finally: await session.close() + + +class TestMatrixDeadInviteHandling: + """Tests for _join_room_by_id auto-leaving dead/abandoned rooms. + + Regression: when a room had no current members, ``join_room`` raised + ``MUnknown: Can't join remote room because no servers that are in the + room have been provided``. The pending invite stayed in the bot's view + of the world, so every gateway restart re-attempted the join and + re-emitted the warning indefinitely. There was no path that ever + cleared the invite. + """ + + def setup_method(self): + self.adapter = _make_adapter() + self.adapter._refresh_dm_cache = AsyncMock() + + @pytest.mark.asyncio + async def test_no_servers_error_triggers_leave(self): + join_err = Exception( + "Can't join remote room because no servers that are in the " + "room have been provided." + ) + self.adapter._client = types.SimpleNamespace( + join_room=AsyncMock(side_effect=join_err), + leave_room=AsyncMock(), + ) + + result = await self.adapter._join_room_by_id("!dead:example.org") + + assert result is False + self.adapter._client.leave_room.assert_awaited_once() + # leave_room receives a RoomID-wrapped value; verify the underlying str. + leave_arg = self.adapter._client.leave_room.await_args.args[0] + assert str(leave_arg) == "!dead:example.org" + + @pytest.mark.asyncio + async def test_room_not_found_error_triggers_leave(self): + join_err = Exception("M_NOT_FOUND: Room not found") + self.adapter._client = types.SimpleNamespace( + join_room=AsyncMock(side_effect=join_err), + leave_room=AsyncMock(), + ) + + await self.adapter._join_room_by_id("!gone:example.org") + self.adapter._client.leave_room.assert_awaited_once() + + @pytest.mark.asyncio + async def test_transient_error_does_not_trigger_leave(self): + """A network blip or 5xx must NOT decline the invite — the bot + should retry on the next sync cycle.""" + join_err = Exception("Connection reset by peer") + self.adapter._client = types.SimpleNamespace( + join_room=AsyncMock(side_effect=join_err), + leave_room=AsyncMock(), + ) + + result = await self.adapter._join_room_by_id("!transient:example.org") + assert result is False + self.adapter._client.leave_room.assert_not_awaited() + + @pytest.mark.asyncio + async def test_successful_join_does_not_attempt_leave(self): + self.adapter._client = types.SimpleNamespace( + join_room=AsyncMock(return_value=None), + leave_room=AsyncMock(), + ) + + result = await self.adapter._join_room_by_id("!alive:example.org") + assert result is True + assert "!alive:example.org" in self.adapter._joined_rooms + self.adapter._client.leave_room.assert_not_awaited() + + @pytest.mark.asyncio + async def test_leave_room_failure_is_swallowed(self): + """If leave_room itself fails (e.g. server returns 500), the helper + must still return False cleanly rather than re-raise.""" + self.adapter._client = types.SimpleNamespace( + join_room=AsyncMock(side_effect=Exception("no servers in the room")), + leave_room=AsyncMock(side_effect=Exception("500 internal")), + ) + + result = await self.adapter._join_room_by_id("!brokenleave:example.org") + assert result is False