From 3f7265707c5626c65be4ba72995c4947c4020a3d Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 15 Jun 2026 17:12:30 -0700 Subject: [PATCH 01/19] remove vscode settings file --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c8d90b3..be5dfb5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ build/ logs/*.log config/*.local.yaml .idea -*.db \ No newline at end of file +*.db +settings.json \ No newline at end of file From 945eede6064bb10a73c614a26d5e7de7e66ecd62 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 15 Jun 2026 17:18:16 -0700 Subject: [PATCH 02/19] add kick extension with command and result formatting --- bot/extensions/moderation/__init__.py | 0 bot/extensions/moderation/kick_extension.py | 44 +++++++++++++++++++++ bot/extensions/moderation/models.py | 11 ++++++ 3 files changed, 55 insertions(+) create mode 100644 bot/extensions/moderation/__init__.py create mode 100644 bot/extensions/moderation/kick_extension.py create mode 100644 bot/extensions/moderation/models.py diff --git a/bot/extensions/moderation/__init__.py b/bot/extensions/moderation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/extensions/moderation/kick_extension.py b/bot/extensions/moderation/kick_extension.py new file mode 100644 index 0000000..ed8b41f --- /dev/null +++ b/bot/extensions/moderation/kick_extension.py @@ -0,0 +1,44 @@ +from matrix import Context, Extension +from matrix.errors import MatrixError + +from .kick_service import kick_from_context +from .models import KickResult + +extension = Extension("kick") + + +def format_kick_result(result: KickResult) -> str: + if result.scope == "room": + return "\n".join( + [ + f"Target: `{result.target_user_id}`", + f"Reason: {result.reason}", + ] + ) + + lines = [ + f"Space: `{result.space_id}`", + f"Target: `{result.target_user_id}`", + f"Kicked from: `{len(result.kicked_room_ids)}` rooms", + f"Reason: {result.reason}", + ] + + return "\n".join(lines) + + +@extension.command( + "kick", + description="Kick a user from all rooms in the current space", +) +async def kick( + ctx: Context, + user_id: str, + reason: str = "No reason provided", +) -> None: + try: + result = await kick_from_context(ctx, user_id, reason) + except MatrixError as e: + await ctx.reply(f"Could not complete kick operation: {e}") + return + + await ctx.reply(format_kick_result(result)) diff --git a/bot/extensions/moderation/models.py b/bot/extensions/moderation/models.py new file mode 100644 index 0000000..f271c4d --- /dev/null +++ b/bot/extensions/moderation/models.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass +from typing import Literal + + +@dataclass(frozen=True) +class KickResult: + target_user_id: str + reason: str + scope: Literal["room", "space"] + space_id: str | None + kicked_room_ids: list[str] From 40fde3b50d2eaf61db81323d696ef7998e8116d1 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 15 Jun 2026 17:18:59 -0700 Subject: [PATCH 03/19] add kick and space service modules for user management --- bot/extensions/moderation/kick_service.py | 47 ++++++++++++++++ bot/extensions/moderation/space_service.py | 65 ++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 bot/extensions/moderation/kick_service.py create mode 100644 bot/extensions/moderation/space_service.py diff --git a/bot/extensions/moderation/kick_service.py b/bot/extensions/moderation/kick_service.py new file mode 100644 index 0000000..6c2e4a5 --- /dev/null +++ b/bot/extensions/moderation/kick_service.py @@ -0,0 +1,47 @@ +from matrix import Context + +from .models import KickResult +from .space_service import get_parent_space_id, get_space_child_room_ids + + +async def kick_from_context( + ctx: Context, + user_id: str, + reason: str, +) -> KickResult: + space_id = get_parent_space_id(ctx) + + if space_id is None: + await ctx.room.kick_user(user_id, reason=reason) + + room_id = getattr(ctx.room, "room_id", None) or getattr(ctx.room, "id", None) + + return KickResult( + target_user_id=user_id, + reason=reason, + scope="room", + space_id=None, + kicked_room_ids=[room_id] if room_id else [], + ) + + room_ids = await get_space_child_room_ids(ctx, space_id) + + kicked: list[str] = [] + + for room_id in room_ids: + response = await ctx.bot.client.room_kick( + room_id=room_id, + user_id=user_id, + reason=reason, + ) + + if response: + kicked.append(room_id) + + return KickResult( + target_user_id=user_id, + reason=reason, + scope="space", + space_id=space_id, + kicked_room_ids=kicked, + ) diff --git a/bot/extensions/moderation/space_service.py b/bot/extensions/moderation/space_service.py new file mode 100644 index 0000000..658e2bb --- /dev/null +++ b/bot/extensions/moderation/space_service.py @@ -0,0 +1,65 @@ +from matrix import Context +from matrix.errors import MatrixError + + +def get_response_attr(obj, name: str, default=None): + """Handle both dict-style and object-style response items.""" + if isinstance(obj, dict): + return obj.get(name, default) + + return getattr(obj, name, default) + + +def get_parent_space_id(ctx: Context) -> str | None: + matrix_room = getattr(ctx.room, "_matrix_room", None) + + if matrix_room is None: + return None + + parents: set[str] = getattr(matrix_room, "parents", set()) or set() + + if not parents: + return None + + return next(iter(parents), None) + + +async def get_space_child_room_ids(ctx: Context, space_id: str) -> list[str]: + """Get non-space child rooms from a Matrix space.""" + client = ctx.bot.client + + room_ids: list[str] = [] + seen: set[str] = set() + next_batch = None + + while True: + response = await client.space_get_hierarchy( + space_id=space_id, + from_page=next_batch, + max_depth=10, + suggested_only=False, + ) + + if not hasattr(response, "rooms"): + raise MatrixError(f"Could not read space hierarchy: {response}") + + for item in response.rooms: + room_id = get_response_attr(item, "room_id") + room_type = get_response_attr(item, "room_type", "") + + if not room_id: + continue + + if room_type == "m.space": + continue + + if room_id not in seen: + seen.add(room_id) + room_ids.append(room_id) + + next_batch = getattr(response, "next_batch", None) + + if not next_batch: + break + + return room_ids From fc31cd529484e5c8103d80671504e85a392e1949 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 15 Jun 2026 17:32:44 -0700 Subject: [PATCH 04/19] update kick result model to remove scope --- bot/extensions/moderation/kick_extension.py | 2 +- bot/extensions/moderation/kick_service.py | 2 -- bot/extensions/moderation/models.py | 2 -- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/bot/extensions/moderation/kick_extension.py b/bot/extensions/moderation/kick_extension.py index ed8b41f..0234ea5 100644 --- a/bot/extensions/moderation/kick_extension.py +++ b/bot/extensions/moderation/kick_extension.py @@ -8,7 +8,7 @@ def format_kick_result(result: KickResult) -> str: - if result.scope == "room": + if result.space_id is None: return "\n".join( [ f"Target: `{result.target_user_id}`", diff --git a/bot/extensions/moderation/kick_service.py b/bot/extensions/moderation/kick_service.py index 6c2e4a5..162dca3 100644 --- a/bot/extensions/moderation/kick_service.py +++ b/bot/extensions/moderation/kick_service.py @@ -19,7 +19,6 @@ async def kick_from_context( return KickResult( target_user_id=user_id, reason=reason, - scope="room", space_id=None, kicked_room_ids=[room_id] if room_id else [], ) @@ -41,7 +40,6 @@ async def kick_from_context( return KickResult( target_user_id=user_id, reason=reason, - scope="space", space_id=space_id, kicked_room_ids=kicked, ) diff --git a/bot/extensions/moderation/models.py b/bot/extensions/moderation/models.py index f271c4d..c2f3ade 100644 --- a/bot/extensions/moderation/models.py +++ b/bot/extensions/moderation/models.py @@ -1,11 +1,9 @@ from dataclasses import dataclass -from typing import Literal @dataclass(frozen=True) class KickResult: target_user_id: str reason: str - scope: Literal["room", "space"] space_id: str | None kicked_room_ids: list[str] From 312ebb93659512b76db40ed22e6c22daa5c864df Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 16 Jun 2026 19:01:38 -0700 Subject: [PATCH 05/19] added permission at bot level --- bot/permissions.py | 22 ++++++++++++++++++++++ config/development.yaml | 3 +++ config/production.yaml | 1 + 3 files changed, 26 insertions(+) create mode 100644 bot/permissions.py diff --git a/bot/permissions.py b/bot/permissions.py new file mode 100644 index 0000000..973ca8b --- /dev/null +++ b/bot/permissions.py @@ -0,0 +1,22 @@ +from matrix import Context + + +def _configured_moderators(ctx: Context) -> list[str]: + moderators = ctx.bot.config.get( + "moderators", + section="bot", + default=[], + ) + + if isinstance(moderators, str): + return [ + moderator.strip() + for moderator in moderators.split(",") + if moderator.strip() + ] + + return moderators + + +async def is_moderator(ctx: Context) -> bool: + return ctx.sender in _configured_moderators(ctx) diff --git a/config/development.yaml b/config/development.yaml index 350b58e..d132b51 100644 --- a/config/development.yaml +++ b/config/development.yaml @@ -10,6 +10,9 @@ sqlalchemy_echo: True bot: main_room: "${ADA_MAIN_ROOM|}" + moderators: + - "@astranebula:matrix.org" + - "@penguinboi:matrix.org" extensions: welcome: diff --git a/config/production.yaml b/config/production.yaml index 78c0a63..1bbd8cf 100644 --- a/config/production.yaml +++ b/config/production.yaml @@ -9,6 +9,7 @@ database_url: $DATABASE_URL bot: main_room: "!OaOPoyVKqbmPEcMBbt:matrix.org" + moderators: "${ADA_MODERATORS|}" extensions: welcome: From 19235547ee1d5e5c3689da61df9413d817d81384 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 16 Jun 2026 19:14:42 -0700 Subject: [PATCH 06/19] add SpaceNotFoundError exception and update kick result formatting --- bot/extensions/moderation/errors.py | 2 + bot/extensions/moderation/kick_extension.py | 57 ++++++++++++--------- 2 files changed, 36 insertions(+), 23 deletions(-) create mode 100644 bot/extensions/moderation/errors.py diff --git a/bot/extensions/moderation/errors.py b/bot/extensions/moderation/errors.py new file mode 100644 index 0000000..8930cf9 --- /dev/null +++ b/bot/extensions/moderation/errors.py @@ -0,0 +1,2 @@ +class SpaceNotFoundError(Exception): + pass diff --git a/bot/extensions/moderation/kick_extension.py b/bot/extensions/moderation/kick_extension.py index 0234ea5..8b59412 100644 --- a/bot/extensions/moderation/kick_extension.py +++ b/bot/extensions/moderation/kick_extension.py @@ -1,29 +1,30 @@ from matrix import Context, Extension -from matrix.errors import MatrixError +from matrix.errors import CheckError +from bot.permissions import is_moderator from .kick_service import kick_from_context from .models import KickResult +from .errors import SpaceNotFoundError extension = Extension("kick") +KICK_RESULT_TEMPLATE = ( + "{space}" + "Target: `{target}`\n" + "Kicked from: `{kicked}` rooms\n" + "Failed in: `{failed}` rooms\n" + "Reason: {reason}" +) -def format_kick_result(result: KickResult) -> str: - if result.space_id is None: - return "\n".join( - [ - f"Target: `{result.target_user_id}`", - f"Reason: {result.reason}", - ] - ) - - lines = [ - f"Space: `{result.space_id}`", - f"Target: `{result.target_user_id}`", - f"Kicked from: `{len(result.kicked_room_ids)}` rooms", - f"Reason: {result.reason}", - ] - return "\n".join(lines) +def format_kick_result(result: KickResult) -> str: + return KICK_RESULT_TEMPLATE.format( + space=f"Space: `{result.space_id}`\n" if result.space_id else "", + target=result.target_user_id, + kicked=len(result.kicked_room_ids), + failed=len(result.failed_room_ids), + reason=result.reason, + ) @extension.command( @@ -35,10 +36,20 @@ async def kick( user_id: str, reason: str = "No reason provided", ) -> None: - try: - result = await kick_from_context(ctx, user_id, reason) - except MatrixError as e: - await ctx.reply(f"Could not complete kick operation: {e}") - return - + result = await kick_from_context(ctx, user_id, reason) await ctx.reply(format_kick_result(result)) + + +@kick.error(SpaceNotFoundError) +async def kick_space_error(ctx: Context, error: SpaceNotFoundError) -> None: + await ctx.reply(f"Could not complete kick operation: {error}") + + +@kick.error(CheckError) +async def kick_check_error(ctx: Context, error: CheckError) -> None: + await ctx.reply(f"Could not complete kick operation: {error}") + + +@kick.check +async def can_kick(ctx: Context) -> bool: + return await is_moderator(ctx) From 4d6d312c14643574298da7a7cbe1143999d54c5d Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 16 Jun 2026 19:14:55 -0700 Subject: [PATCH 07/19] refactor kick service to handle errors and improve room collection logic --- bot/extensions/moderation/kick_service.py | 50 +++++++++--- bot/extensions/moderation/models.py | 1 + bot/extensions/moderation/space_service.py | 93 ++++++++-------------- 3 files changed, 75 insertions(+), 69 deletions(-) diff --git a/bot/extensions/moderation/kick_service.py b/bot/extensions/moderation/kick_service.py index 162dca3..353e875 100644 --- a/bot/extensions/moderation/kick_service.py +++ b/bot/extensions/moderation/kick_service.py @@ -1,7 +1,12 @@ from matrix import Context +from matrix.errors import MatrixError +from .errors import SpaceNotFoundError from .models import KickResult -from .space_service import get_parent_space_id, get_space_child_room_ids +from .space_service import ( + get_parent_space_id, + collect_space_child_room_ids, +) async def kick_from_context( @@ -12,34 +17,57 @@ async def kick_from_context( space_id = get_parent_space_id(ctx) if space_id is None: - await ctx.room.kick_user(user_id, reason=reason) + room_id = ctx.room.room_id - room_id = getattr(ctx.room, "room_id", None) or getattr(ctx.room, "id", None) + try: + await ctx.room.kick_user(user_id, reason=reason) + except MatrixError: + return KickResult( + target_user_id=user_id, + reason=reason, + space_id=None, + kicked_room_ids=[], + failed_room_ids=[room_id] if room_id else [], + ) return KickResult( target_user_id=user_id, reason=reason, space_id=None, kicked_room_ids=[room_id] if room_id else [], + failed_room_ids=[room_id] if not room_id else [], ) - room_ids = await get_space_child_room_ids(ctx, space_id) + space = ctx.bot.get_room(space_id) + if space is None: + raise SpaceNotFoundError( + f"Could not find parent space in room cache: {space_id}" + ) + + room_ids: list[str] = [] + await collect_space_child_room_ids(ctx, space, room_ids, set()) kicked: list[str] = [] + failed: list[str] = [] for room_id in room_ids: - response = await ctx.bot.client.room_kick( - room_id=room_id, - user_id=user_id, - reason=reason, - ) + room = ctx.bot.get_room(room_id) + if room is None: + failed.append(room_id) + continue + + try: + await room.kick_user(user_id, reason=reason) + except MatrixError: + failed.append(room_id) + continue - if response: - kicked.append(room_id) + kicked.append(room_id) return KickResult( target_user_id=user_id, reason=reason, space_id=space_id, kicked_room_ids=kicked, + failed_room_ids=failed, ) diff --git a/bot/extensions/moderation/models.py b/bot/extensions/moderation/models.py index c2f3ade..d3038fd 100644 --- a/bot/extensions/moderation/models.py +++ b/bot/extensions/moderation/models.py @@ -7,3 +7,4 @@ class KickResult: reason: str space_id: str | None kicked_room_ids: list[str] + failed_room_ids: list[str] diff --git a/bot/extensions/moderation/space_service.py b/bot/extensions/moderation/space_service.py index 658e2bb..7f43bb1 100644 --- a/bot/extensions/moderation/space_service.py +++ b/bot/extensions/moderation/space_service.py @@ -1,65 +1,42 @@ -from matrix import Context -from matrix.errors import MatrixError - - -def get_response_attr(obj, name: str, default=None): - """Handle both dict-style and object-style response items.""" - if isinstance(obj, dict): - return obj.get(name, default) - - return getattr(obj, name, default) +from matrix import Context, Room def get_parent_space_id(ctx: Context) -> str | None: - matrix_room = getattr(ctx.room, "_matrix_room", None) - - if matrix_room is None: - return None - - parents: set[str] = getattr(matrix_room, "parents", set()) or set() - - if not parents: - return None + matrix_room = ctx.room.matrix_room + parents: set[str] = matrix_room.parents return next(iter(parents), None) -async def get_space_child_room_ids(ctx: Context, space_id: str) -> list[str]: - """Get non-space child rooms from a Matrix space.""" - client = ctx.bot.client - - room_ids: list[str] = [] - seen: set[str] = set() - next_batch = None - - while True: - response = await client.space_get_hierarchy( - space_id=space_id, - from_page=next_batch, - max_depth=10, - suggested_only=False, - ) - - if not hasattr(response, "rooms"): - raise MatrixError(f"Could not read space hierarchy: {response}") - - for item in response.rooms: - room_id = get_response_attr(item, "room_id") - room_type = get_response_attr(item, "room_type", "") - - if not room_id: - continue - - if room_type == "m.space": - continue - - if room_id not in seen: - seen.add(room_id) - room_ids.append(room_id) - - next_batch = getattr(response, "next_batch", None) - - if not next_batch: - break - - return room_ids +async def collect_space_child_room_ids( + ctx: Context, + room: Room, + room_ids: list[str], + seen: set[str], + depth: int = 0, +) -> None: + """Collect non-space child room IDs from a Matrix space.""" + if depth >= 10: + return + + for child_room_id in room.children: + if child_room_id in seen: + continue + + seen.add(child_room_id) + + child_room = ctx.bot.get_room(child_room_id) + if child_room is None: + continue + + if child_room.room_type == "m.space": + await collect_space_child_room_ids( + ctx, + child_room, + room_ids, + seen, + depth + 1, + ) + continue + + room_ids.append(child_room_id) From 828d2219b10327be78857782610baad4b5d2bb90 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 16 Jun 2026 19:15:11 -0700 Subject: [PATCH 08/19] added unit test for moderations --- tests/extensions/moderation/__init__.py | 0 .../moderation/test_kick_extension.py | 99 +++++++++++++++++++ tests/test_permissions.py | 37 +++++++ 3 files changed, 136 insertions(+) create mode 100644 tests/extensions/moderation/__init__.py create mode 100644 tests/extensions/moderation/test_kick_extension.py create mode 100644 tests/test_permissions.py diff --git a/tests/extensions/moderation/__init__.py b/tests/extensions/moderation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/extensions/moderation/test_kick_extension.py b/tests/extensions/moderation/test_kick_extension.py new file mode 100644 index 0000000..bc13feb --- /dev/null +++ b/tests/extensions/moderation/test_kick_extension.py @@ -0,0 +1,99 @@ +import pytest +from matrix.errors import MatrixError +from types import SimpleNamespace +from typing import cast +from unittest.mock import AsyncMock, MagicMock, patch + +from bot.extensions.moderation.kick_service import kick_from_context +from matrix import Context + + +@pytest.mark.asyncio +async def test_kick_from_context_kicks_current_room_when_not_in_space() -> None: + ctx = SimpleNamespace( + room=SimpleNamespace( + room_id="!room:example.com", + kick_user=AsyncMock(), + ), + ) + + with patch( + "bot.extensions.moderation.kick_service.get_parent_space_id", + return_value=None, + ): + result = await kick_from_context( + cast(Context, ctx), "@target:example.com", "spam" + ) + + ctx.room.kick_user.assert_awaited_once_with("@target:example.com", reason="spam") + assert result.kicked_room_ids == ["!room:example.com"] + assert result.failed_room_ids == [] + + +@pytest.mark.asyncio +async def test_kick_from_context_records_current_room_failure() -> None: + ctx = SimpleNamespace( + room=SimpleNamespace( + room_id="!room:example.com", + kick_user=AsyncMock(side_effect=MatrixError("not allowed")), + ), + ) + + with patch( + "bot.extensions.moderation.kick_service.get_parent_space_id", + return_value=None, + ): + result = await kick_from_context( + cast(Context, ctx), "@target:example.com", "spam" + ) + + assert result.kicked_room_ids == [] + assert result.failed_room_ids == ["!room:example.com"] + + +@pytest.mark.asyncio +async def test_kick_from_context_collects_space_successes_and_failures() -> None: + space = SimpleNamespace(room_id="!space:example.com") + successful_room = SimpleNamespace(kick_user=AsyncMock()) + failed_room = SimpleNamespace( + kick_user=AsyncMock(side_effect=MatrixError("denied")) + ) + + bot = MagicMock() + bot.get_room.side_effect = lambda room_id: { + "!space:example.com": space, + "!success:example.com": successful_room, + "!failed:example.com": failed_room, + "!missing:example.com": None, + }[room_id] + ctx = SimpleNamespace(bot=bot) + + async def collect_room_ids(_ctx, _space, room_ids, _seen): + room_ids.extend( + [ + "!success:example.com", + "!failed:example.com", + "!missing:example.com", + ] + ) + + with ( + patch( + "bot.extensions.moderation.kick_service.get_parent_space_id", + return_value="!space:example.com", + ), + patch( + "bot.extensions.moderation.kick_service.collect_space_child_room_ids", + side_effect=collect_room_ids, + ), + ): + result = await kick_from_context( + cast(Context, ctx), "@target:example.com", "spam" + ) + + successful_room.kick_user.assert_awaited_once_with( + "@target:example.com", reason="spam" + ) + failed_room.kick_user.assert_awaited_once_with("@target:example.com", reason="spam") + assert result.kicked_room_ids == ["!success:example.com"] + assert result.failed_room_ids == ["!failed:example.com", "!missing:example.com"] diff --git a/tests/test_permissions.py b/tests/test_permissions.py new file mode 100644 index 0000000..2bdfef3 --- /dev/null +++ b/tests/test_permissions.py @@ -0,0 +1,37 @@ +import pytest +from types import SimpleNamespace + +from bot.permissions import is_moderator + + +@pytest.mark.asyncio +async def test_is_moderator_accepts_configured_list() -> None: + config = SimpleNamespace( + get=lambda _key, section, default: [ + "@admin:example.com", + "@mod:example.com", + ] + ) + ctx = SimpleNamespace(bot=SimpleNamespace(config=config), sender="@mod:example.com") + + assert await is_moderator(ctx) is True # type: ignore[arg-type] + + +@pytest.mark.asyncio +async def test_is_moderator_accepts_comma_separated_config_string() -> None: + config = SimpleNamespace( + get=lambda _key, section, default: ("@admin:example.com, @mod:example.com, ") + ) + ctx = SimpleNamespace(bot=SimpleNamespace(config=config), sender="@mod:example.com") + + assert await is_moderator(ctx) is True # type: ignore[arg-type] + + +@pytest.mark.asyncio +async def test_is_moderator_rejects_unconfigured_user() -> None: + config = SimpleNamespace(get=lambda _key, section, default: ["@admin:example.com"]) + ctx = SimpleNamespace( + bot=SimpleNamespace(config=config), sender="@user:example.com" + ) + + assert await is_moderator(ctx) is False # type: ignore[arg-type] From 16ed488dcb36c3f8db156996cb29b02e9e7d1653 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 23 Jun 2026 16:40:34 -0700 Subject: [PATCH 09/19] migrate unit tests files in isolation to its extension --- .../moderation => bot/extensions/moderation/tests}/__init__.py | 0 .../extensions/moderation/tests}/test_kick_extension.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename {tests/extensions/moderation => bot/extensions/moderation/tests}/__init__.py (100%) rename {tests/extensions/moderation => bot/extensions/moderation/tests}/test_kick_extension.py (100%) diff --git a/tests/extensions/moderation/__init__.py b/bot/extensions/moderation/tests/__init__.py similarity index 100% rename from tests/extensions/moderation/__init__.py rename to bot/extensions/moderation/tests/__init__.py diff --git a/tests/extensions/moderation/test_kick_extension.py b/bot/extensions/moderation/tests/test_kick_extension.py similarity index 100% rename from tests/extensions/moderation/test_kick_extension.py rename to bot/extensions/moderation/tests/test_kick_extension.py From ba64c6b4c643a9159eb377a3494058ed60f9e68e Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 23 Jun 2026 18:27:49 -0700 Subject: [PATCH 10/19] added more test for depth space + fix test naming --- .../moderation/tests/test_kick_extension.py | 93 ++++++++++++++++--- 1 file changed, 78 insertions(+), 15 deletions(-) diff --git a/bot/extensions/moderation/tests/test_kick_extension.py b/bot/extensions/moderation/tests/test_kick_extension.py index bc13feb..020e912 100644 --- a/bot/extensions/moderation/tests/test_kick_extension.py +++ b/bot/extensions/moderation/tests/test_kick_extension.py @@ -9,7 +9,9 @@ @pytest.mark.asyncio -async def test_kick_from_context_kicks_current_room_when_not_in_space() -> None: +async def test_kick_from_context__with_no_parent_space__expect_current_room_kicked() -> ( + None +): ctx = SimpleNamespace( room=SimpleNamespace( room_id="!room:example.com", @@ -31,7 +33,9 @@ async def test_kick_from_context_kicks_current_room_when_not_in_space() -> None: @pytest.mark.asyncio -async def test_kick_from_context_records_current_room_failure() -> None: +async def test_kick_from_context__with_current_room_matrix_error__expect_current_room_recorded_failed() -> ( + None +): ctx = SimpleNamespace( room=SimpleNamespace( room_id="!room:example.com", @@ -52,8 +56,10 @@ async def test_kick_from_context_records_current_room_failure() -> None: @pytest.mark.asyncio -async def test_kick_from_context_collects_space_successes_and_failures() -> None: - space = SimpleNamespace(room_id="!space:example.com") +async def test_kick_from_context__with_parent_space_targets__expect_successes_and_failures_recorded() -> ( + None +): + space = SimpleNamespace(room_id="!space:example.com", kick_user=AsyncMock()) successful_room = SimpleNamespace(kick_user=AsyncMock()) failed_room = SimpleNamespace( kick_user=AsyncMock(side_effect=MatrixError("denied")) @@ -66,16 +72,17 @@ async def test_kick_from_context_collects_space_successes_and_failures() -> None "!failed:example.com": failed_room, "!missing:example.com": None, }[room_id] - ctx = SimpleNamespace(bot=bot) - - async def collect_room_ids(_ctx, _space, room_ids, _seen): - room_ids.extend( - [ - "!success:example.com", - "!failed:example.com", - "!missing:example.com", - ] - ) + ctx = SimpleNamespace( + room=SimpleNamespace(room_id="!current:example.com"), + bot=bot, + ) + + async def collect_room_ids(_ctx, _space): + return [ + "!success:example.com", + "!failed:example.com", + "!missing:example.com", + ] with ( patch( @@ -95,5 +102,61 @@ async def collect_room_ids(_ctx, _space, room_ids, _seen): "@target:example.com", reason="spam" ) failed_room.kick_user.assert_awaited_once_with("@target:example.com", reason="spam") - assert result.kicked_room_ids == ["!success:example.com"] + space.kick_user.assert_awaited_once_with("@target:example.com", reason="spam") + assert result.kicked_room_ids == ["!success:example.com", "!space:example.com"] assert result.failed_room_ids == ["!failed:example.com", "!missing:example.com"] + + +@pytest.mark.asyncio +async def test_kick_from_context__with_nested_space_children__expect_nested_targets_and_parent_space_kicked() -> ( + None +): + root_space = SimpleNamespace( + room_id="!root-space:example.com", + room_type="m.space", + children=["!nested-space:example.com"], + kick_user=AsyncMock(), + ) + nested_space = SimpleNamespace( + room_id="!nested-space:example.com", + room_type="m.space", + children=["!leaf-room:example.com"], + kick_user=AsyncMock(), + ) + leaf_room = SimpleNamespace( + room_id="!leaf-room:example.com", + room_type=None, + children=[], + kick_user=AsyncMock(), + ) + + bot = MagicMock() + bot.get_room.side_effect = lambda room_id: { + "!root-space:example.com": root_space, + "!nested-space:example.com": nested_space, + "!leaf-room:example.com": leaf_room, + }[room_id] + ctx = SimpleNamespace( + room=SimpleNamespace(room_id="!current:example.com"), + bot=bot, + ) + + with patch( + "bot.extensions.moderation.kick_service.get_parent_space_id", + return_value="!root-space:example.com", + ): + result = await kick_from_context( + cast(Context, ctx), "@target:example.com", "spam" + ) + + nested_space.kick_user.assert_awaited_once_with( + "@target:example.com", reason="spam" + ) + leaf_room.kick_user.assert_awaited_once_with("@target:example.com", reason="spam") + root_space.kick_user.assert_awaited_once_with("@target:example.com", reason="spam") + assert result.kicked_room_ids == [ + "!nested-space:example.com", + "!leaf-room:example.com", + "!root-space:example.com", + ] + assert result.failed_room_ids == [] From 830e92e0f44741717b790f4b2a5b0379dc411aba Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 30 Jun 2026 15:30:16 -0700 Subject: [PATCH 11/19] use power level moderation instead of env var --- bot/permissions.py | 22 ++++------------------ config/development.yaml | 3 --- config/production.yaml | 1 - tests/test_permissions.py | 39 +++++++++++++++++++++------------------ 4 files changed, 25 insertions(+), 40 deletions(-) diff --git a/bot/permissions.py b/bot/permissions.py index 973ca8b..dc63f77 100644 --- a/bot/permissions.py +++ b/bot/permissions.py @@ -1,22 +1,8 @@ from matrix import Context -def _configured_moderators(ctx: Context) -> list[str]: - moderators = ctx.bot.config.get( - "moderators", - section="bot", - default=[], - ) - - if isinstance(moderators, str): - return [ - moderator.strip() - for moderator in moderators.split(",") - if moderator.strip() - ] - - return moderators - - async def is_moderator(ctx: Context) -> bool: - return ctx.sender in _configured_moderators(ctx) + power_levels = ctx.room.matrix_room.power_levels + sender_level = power_levels.get_user_level(ctx.sender) + + return sender_level >= power_levels.defaults.kick diff --git a/config/development.yaml b/config/development.yaml index d132b51..350b58e 100644 --- a/config/development.yaml +++ b/config/development.yaml @@ -10,9 +10,6 @@ sqlalchemy_echo: True bot: main_room: "${ADA_MAIN_ROOM|}" - moderators: - - "@astranebula:matrix.org" - - "@penguinboi:matrix.org" extensions: welcome: diff --git a/config/production.yaml b/config/production.yaml index 1bbd8cf..78c0a63 100644 --- a/config/production.yaml +++ b/config/production.yaml @@ -9,7 +9,6 @@ database_url: $DATABASE_URL bot: main_room: "!OaOPoyVKqbmPEcMBbt:matrix.org" - moderators: "${ADA_MODERATORS|}" extensions: welcome: diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 2bdfef3..0887188 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -4,34 +4,37 @@ from bot.permissions import is_moderator -@pytest.mark.asyncio -async def test_is_moderator_accepts_configured_list() -> None: - config = SimpleNamespace( - get=lambda _key, section, default: [ - "@admin:example.com", - "@mod:example.com", - ] +def make_ctx(sender: str, sender_level: int, required_level: int = 50): + power_levels = SimpleNamespace( + defaults=SimpleNamespace(kick=required_level), + get_user_level=lambda user_id: sender_level, + ) + + room = SimpleNamespace( + matrix_room=SimpleNamespace( + power_levels=power_levels, + ) ) - ctx = SimpleNamespace(bot=SimpleNamespace(config=config), sender="@mod:example.com") + + return SimpleNamespace(sender=sender, room=room) + + +@pytest.mark.asyncio +async def test_is_moderator_accepts_user_at_required_power_level() -> None: + ctx = make_ctx("@mod:example.com", sender_level=50) assert await is_moderator(ctx) is True # type: ignore[arg-type] @pytest.mark.asyncio -async def test_is_moderator_accepts_comma_separated_config_string() -> None: - config = SimpleNamespace( - get=lambda _key, section, default: ("@admin:example.com, @mod:example.com, ") - ) - ctx = SimpleNamespace(bot=SimpleNamespace(config=config), sender="@mod:example.com") +async def test_is_moderator_accepts_user_above_required_power_level() -> None: + ctx = make_ctx("@admin:example.com", sender_level=100) assert await is_moderator(ctx) is True # type: ignore[arg-type] @pytest.mark.asyncio -async def test_is_moderator_rejects_unconfigured_user() -> None: - config = SimpleNamespace(get=lambda _key, section, default: ["@admin:example.com"]) - ctx = SimpleNamespace( - bot=SimpleNamespace(config=config), sender="@user:example.com" - ) +async def test_is_moderator_rejects_user_below_required_power_level() -> None: + ctx = make_ctx("@user:example.com", sender_level=0) assert await is_moderator(ctx) is False # type: ignore[arg-type] From fc7b3d9e47b8fedb366dfcaf090844a1965f8ff5 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 30 Jun 2026 15:31:41 -0700 Subject: [PATCH 12/19] fix recursion side effect for collecting space parent --- bot/extensions/moderation/errors.py | 4 +++ bot/extensions/moderation/kick_extension.py | 7 ++++- bot/extensions/moderation/space_service.py | 30 ++++++++++++++------- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/bot/extensions/moderation/errors.py b/bot/extensions/moderation/errors.py index 8930cf9..160f416 100644 --- a/bot/extensions/moderation/errors.py +++ b/bot/extensions/moderation/errors.py @@ -1,2 +1,6 @@ class SpaceNotFoundError(Exception): pass + + +class MaxDepthReachedError(Exception): + pass diff --git a/bot/extensions/moderation/kick_extension.py b/bot/extensions/moderation/kick_extension.py index 8b59412..b9ecef3 100644 --- a/bot/extensions/moderation/kick_extension.py +++ b/bot/extensions/moderation/kick_extension.py @@ -4,7 +4,7 @@ from bot.permissions import is_moderator from .kick_service import kick_from_context from .models import KickResult -from .errors import SpaceNotFoundError +from .errors import SpaceNotFoundError, MaxDepthReachedError extension = Extension("kick") @@ -45,6 +45,11 @@ async def kick_space_error(ctx: Context, error: SpaceNotFoundError) -> None: await ctx.reply(f"Could not complete kick operation: {error}") +@kick.error(MaxDepthReachedError) +async def space_depth_error(ctx: Context, error: MaxDepthReachedError) -> None: + await ctx.reply(f"Could not complete the operation: {error}") + + @kick.error(CheckError) async def kick_check_error(ctx: Context, error: CheckError) -> None: await ctx.reply(f"Could not complete kick operation: {error}") diff --git a/bot/extensions/moderation/space_service.py b/bot/extensions/moderation/space_service.py index 7f43bb1..80ca104 100644 --- a/bot/extensions/moderation/space_service.py +++ b/bot/extensions/moderation/space_service.py @@ -1,4 +1,5 @@ from matrix import Context, Room +from .errors import MaxDepthReachedError def get_parent_space_id(ctx: Context) -> str | None: @@ -11,13 +12,20 @@ def get_parent_space_id(ctx: Context) -> str | None: async def collect_space_child_room_ids( ctx: Context, room: Room, - room_ids: list[str], - seen: set[str], + seen: set[str] | None = None, depth: int = 0, -) -> None: - """Collect non-space child room IDs from a Matrix space.""" - if depth >= 10: - return +) -> list[str]: + """Collect child room IDs from a Matrix space, including nested spaces.""" + if seen is None: + seen = set() + + if depth > 3: + raise MaxDepthReachedError( + f"Recursion depth reached {depth} while processing room " + f"'{room.display_name}' ({room.room_id})" + ) + + room_ids: list[str] = [] for child_room_id in room.children: if child_room_id in seen: @@ -30,13 +38,15 @@ async def collect_space_child_room_ids( continue if child_room.room_type == "m.space": - await collect_space_child_room_ids( + room_ids.append(child_room_id) + space_children = await collect_space_child_room_ids( ctx, child_room, - room_ids, seen, depth + 1, ) - continue + room_ids.extend(space_children) + else: + room_ids.append(child_room_id) - room_ids.append(child_room_id) + return room_ids From 2172ebc62cf58eb9175d1fc6c500abcfe65173f3 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 30 Jun 2026 15:32:39 -0700 Subject: [PATCH 13/19] refactor kick from context method to simplify returned mesage --- bot/extensions/moderation/kick_service.py | 33 +++++++++++------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/bot/extensions/moderation/kick_service.py b/bot/extensions/moderation/kick_service.py index 353e875..b30f384 100644 --- a/bot/extensions/moderation/kick_service.py +++ b/bot/extensions/moderation/kick_service.py @@ -16,26 +16,25 @@ async def kick_from_context( ) -> KickResult: space_id = get_parent_space_id(ctx) - if space_id is None: - room_id = ctx.room.room_id + kicked: list[str] = [] + failed: list[str] = [] + if space_id is None: + current_room_id = ctx.room.room_id try: await ctx.room.kick_user(user_id, reason=reason) + if current_room_id: + kicked.append(current_room_id) except MatrixError: - return KickResult( - target_user_id=user_id, - reason=reason, - space_id=None, - kicked_room_ids=[], - failed_room_ids=[room_id] if room_id else [], - ) + if current_room_id: + failed.append(current_room_id) return KickResult( target_user_id=user_id, reason=reason, space_id=None, - kicked_room_ids=[room_id] if room_id else [], - failed_room_ids=[room_id] if not room_id else [], + kicked_room_ids=kicked, + failed_room_ids=failed, ) space = ctx.bot.get_room(space_id) @@ -44,11 +43,9 @@ async def kick_from_context( f"Could not find parent space in room cache: {space_id}" ) - room_ids: list[str] = [] - await collect_space_child_room_ids(ctx, space, room_ids, set()) - - kicked: list[str] = [] - failed: list[str] = [] + room_ids: list[str] = await collect_space_child_room_ids(ctx, space) + if space_id not in room_ids: + room_ids.append(space_id) for room_id in room_ids: room = ctx.bot.get_room(room_id) @@ -61,8 +58,8 @@ async def kick_from_context( except MatrixError: failed.append(room_id) continue - - kicked.append(room_id) + else: + kicked.append(room_id) return KickResult( target_user_id=user_id, From 53ec9401dd330af0346b78ec397618f4a7eac4f1 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 30 Jun 2026 15:41:29 -0700 Subject: [PATCH 14/19] simplify adding room ids to list --- bot/extensions/moderation/space_service.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/extensions/moderation/space_service.py b/bot/extensions/moderation/space_service.py index 80ca104..4a4bbc2 100644 --- a/bot/extensions/moderation/space_service.py +++ b/bot/extensions/moderation/space_service.py @@ -38,7 +38,6 @@ async def collect_space_child_room_ids( continue if child_room.room_type == "m.space": - room_ids.append(child_room_id) space_children = await collect_space_child_room_ids( ctx, child_room, @@ -46,7 +45,7 @@ async def collect_space_child_room_ids( depth + 1, ) room_ids.extend(space_children) - else: - room_ids.append(child_room_id) + + room_ids.append(child_room_id) return room_ids From 0c6c866e5249229f33145c732a831a9834ccde9c Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 30 Jun 2026 15:48:17 -0700 Subject: [PATCH 15/19] fix test by giving room ids order agnostic --- bot/extensions/moderation/tests/test_kick_extension.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/extensions/moderation/tests/test_kick_extension.py b/bot/extensions/moderation/tests/test_kick_extension.py index 020e912..f56d5df 100644 --- a/bot/extensions/moderation/tests/test_kick_extension.py +++ b/bot/extensions/moderation/tests/test_kick_extension.py @@ -154,9 +154,9 @@ async def test_kick_from_context__with_nested_space_children__expect_nested_targ ) leaf_room.kick_user.assert_awaited_once_with("@target:example.com", reason="spam") root_space.kick_user.assert_awaited_once_with("@target:example.com", reason="spam") - assert result.kicked_room_ids == [ + assert set(result.kicked_room_ids) == { "!nested-space:example.com", "!leaf-room:example.com", "!root-space:example.com", - ] + } assert result.failed_room_ids == [] From fc76afeb532bc807e3fa831902e3659455feab58 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 2 Jul 2026 16:57:11 -0700 Subject: [PATCH 16/19] remove unused custom errors --- bot/extensions/moderation/errors.py | 6 ------ bot/extensions/moderation/kick_extension.py | 11 ----------- 2 files changed, 17 deletions(-) delete mode 100644 bot/extensions/moderation/errors.py diff --git a/bot/extensions/moderation/errors.py b/bot/extensions/moderation/errors.py deleted file mode 100644 index 160f416..0000000 --- a/bot/extensions/moderation/errors.py +++ /dev/null @@ -1,6 +0,0 @@ -class SpaceNotFoundError(Exception): - pass - - -class MaxDepthReachedError(Exception): - pass diff --git a/bot/extensions/moderation/kick_extension.py b/bot/extensions/moderation/kick_extension.py index b9ecef3..204524e 100644 --- a/bot/extensions/moderation/kick_extension.py +++ b/bot/extensions/moderation/kick_extension.py @@ -4,7 +4,6 @@ from bot.permissions import is_moderator from .kick_service import kick_from_context from .models import KickResult -from .errors import SpaceNotFoundError, MaxDepthReachedError extension = Extension("kick") @@ -40,16 +39,6 @@ async def kick( await ctx.reply(format_kick_result(result)) -@kick.error(SpaceNotFoundError) -async def kick_space_error(ctx: Context, error: SpaceNotFoundError) -> None: - await ctx.reply(f"Could not complete kick operation: {error}") - - -@kick.error(MaxDepthReachedError) -async def space_depth_error(ctx: Context, error: MaxDepthReachedError) -> None: - await ctx.reply(f"Could not complete the operation: {error}") - - @kick.error(CheckError) async def kick_check_error(ctx: Context, error: CheckError) -> None: await ctx.reply(f"Could not complete kick operation: {error}") From da8a2879d6aebb3b2f8892ce5c29f94c23a3e0c2 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 2 Jul 2026 17:16:11 -0700 Subject: [PATCH 17/19] refactor get_parent_space function to simplify parent ID retrieval --- bot/extensions/moderation/space_service.py | 54 +++------------------- 1 file changed, 7 insertions(+), 47 deletions(-) diff --git a/bot/extensions/moderation/space_service.py b/bot/extensions/moderation/space_service.py index 4a4bbc2..0295dd0 100644 --- a/bot/extensions/moderation/space_service.py +++ b/bot/extensions/moderation/space_service.py @@ -1,51 +1,11 @@ -from matrix import Context, Room -from .errors import MaxDepthReachedError +from matrix import Context, Space -def get_parent_space_id(ctx: Context) -> str | None: - matrix_room = ctx.room.matrix_room - parents: set[str] = matrix_room.parents +def get_parent_space(ctx: Context) -> Space | None: + parent_ids: set[str] = ctx.room.matrix_room.parents + parent_id = next(iter(parent_ids), None) - return next(iter(parents), None) + if not parent_id: + return None - -async def collect_space_child_room_ids( - ctx: Context, - room: Room, - seen: set[str] | None = None, - depth: int = 0, -) -> list[str]: - """Collect child room IDs from a Matrix space, including nested spaces.""" - if seen is None: - seen = set() - - if depth > 3: - raise MaxDepthReachedError( - f"Recursion depth reached {depth} while processing room " - f"'{room.display_name}' ({room.room_id})" - ) - - room_ids: list[str] = [] - - for child_room_id in room.children: - if child_room_id in seen: - continue - - seen.add(child_room_id) - - child_room = ctx.bot.get_room(child_room_id) - if child_room is None: - continue - - if child_room.room_type == "m.space": - space_children = await collect_space_child_room_ids( - ctx, - child_room, - seen, - depth + 1, - ) - room_ids.extend(space_children) - - room_ids.append(child_room_id) - - return room_ids + return ctx.bot.get_space(parent_id) From 5182313e1a0810e025c8a1b93807c88eb52264b8 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 2 Jul 2026 17:16:28 -0700 Subject: [PATCH 18/19] refactor kick functionality to handle multiple rooms and update KickResult model based on new matrix.py release --- bot/extensions/moderation/kick_service.py | 96 +++++++++++------------ bot/extensions/moderation/models.py | 2 +- 2 files changed, 49 insertions(+), 49 deletions(-) diff --git a/bot/extensions/moderation/kick_service.py b/bot/extensions/moderation/kick_service.py index b30f384..cee7f79 100644 --- a/bot/extensions/moderation/kick_service.py +++ b/bot/extensions/moderation/kick_service.py @@ -1,70 +1,70 @@ -from matrix import Context +from matrix import Context, Space, Room from matrix.errors import MatrixError -from .errors import SpaceNotFoundError from .models import KickResult -from .space_service import ( - get_parent_space_id, - collect_space_child_room_ids, -) +from .space_service import get_parent_space -async def kick_from_context( - ctx: Context, +async def kick_from_rooms( user_id: str, - reason: str, + rooms: list[Room], + *, + reason: str | None = None, + space: Space | None = None, ) -> KickResult: - space_id = get_parent_space_id(ctx) - kicked: list[str] = [] failed: list[str] = [] + seen: set[str] = set() - if space_id is None: - current_room_id = ctx.room.room_id - try: - await ctx.room.kick_user(user_id, reason=reason) - if current_room_id: - kicked.append(current_room_id) - except MatrixError: - if current_room_id: - failed.append(current_room_id) - - return KickResult( - target_user_id=user_id, - reason=reason, - space_id=None, - kicked_room_ids=kicked, - failed_room_ids=failed, - ) - - space = ctx.bot.get_room(space_id) - if space is None: - raise SpaceNotFoundError( - f"Could not find parent space in room cache: {space_id}" - ) - - room_ids: list[str] = await collect_space_child_room_ids(ctx, space) - if space_id not in room_ids: - room_ids.append(space_id) - - for room_id in room_ids: - room = ctx.bot.get_room(room_id) - if room is None: - failed.append(room_id) + for room in rooms: + if room.room_id in seen: continue + seen.add(room.room_id) try: + members = await room.get_members() + if user_id not in members: + # this will skip rooms where the user is not a member + continue + await room.kick_user(user_id, reason=reason) + + members_after = await room.get_members() + if user_id in members_after: + # if the user is still in the room after the kick, + # we consider it a failure (should not happen) + failed.append(room.room_id) + else: + kicked.append(room.room_id) + except MatrixError: - failed.append(room_id) - continue - else: - kicked.append(room_id) + failed.append(room.room_id) return KickResult( target_user_id=user_id, reason=reason, - space_id=space_id, + space_id=space.room_id if space else None, kicked_room_ids=kicked, failed_room_ids=failed, ) + + +async def kick_from_context( + ctx: Context, + user_id: str, + reason: str | None = None, +) -> KickResult: + space: Space | None = get_parent_space(ctx) + + if space: + children = space.get_children(depth=3) + children.append(space) + + return await kick_from_rooms( + user_id, + children, + reason=reason, + space=space, + ) + + return await kick_from_rooms(user_id, [ctx.room], reason=reason) diff --git a/bot/extensions/moderation/models.py b/bot/extensions/moderation/models.py index d3038fd..9ab20b1 100644 --- a/bot/extensions/moderation/models.py +++ b/bot/extensions/moderation/models.py @@ -4,7 +4,7 @@ @dataclass(frozen=True) class KickResult: target_user_id: str - reason: str + reason: str | None space_id: str | None kicked_room_ids: list[str] failed_room_ids: list[str] From 3f7aa453495629d32d7a44c7875986cf2c63bec9 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 2 Jul 2026 17:16:39 -0700 Subject: [PATCH 19/19] refactor test_kick_extension to improve mock functions and improve test coverage for kick_from_rooms and kick_from_context --- .../moderation/tests/test_kick_extension.py | 241 +++++++++--------- 1 file changed, 121 insertions(+), 120 deletions(-) diff --git a/bot/extensions/moderation/tests/test_kick_extension.py b/bot/extensions/moderation/tests/test_kick_extension.py index f56d5df..d24bf83 100644 --- a/bot/extensions/moderation/tests/test_kick_extension.py +++ b/bot/extensions/moderation/tests/test_kick_extension.py @@ -1,162 +1,163 @@ import pytest from matrix.errors import MatrixError from types import SimpleNamespace -from typing import cast +from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch -from bot.extensions.moderation.kick_service import kick_from_context -from matrix import Context +from matrix import Context, Room, Space +from bot.extensions.moderation.kick_service import kick_from_context, kick_from_rooms -@pytest.mark.asyncio -async def test_kick_from_context__with_no_parent_space__expect_current_room_kicked() -> ( - None -): - ctx = SimpleNamespace( - room=SimpleNamespace( - room_id="!room:example.com", - kick_user=AsyncMock(), - ), - ) +def as_async_mock(value: Any) -> AsyncMock: + return cast(AsyncMock, value) - with patch( - "bot.extensions.moderation.kick_service.get_parent_space_id", - return_value=None, - ): - result = await kick_from_context( - cast(Context, ctx), "@target:example.com", "spam" + +def as_mock(value: Any) -> MagicMock: + return cast(MagicMock, value) + + +def mock_room( + room_id: str, + *, + kick_error: Exception | None = None, +) -> Room: + room = MagicMock(spec=Room) + room.room_id = room_id + room.name = room_id + + if kick_error: + cast(Any, room).get_members = AsyncMock(return_value=["@target:example.com"]) + else: + cast(Any, room).get_members = AsyncMock( + side_effect=[ + ["@target:example.com"], + [], + ] ) - ctx.room.kick_user.assert_awaited_once_with("@target:example.com", reason="spam") - assert result.kicked_room_ids == ["!room:example.com"] - assert result.failed_room_ids == [] + cast(Any, room).kick_user = AsyncMock(side_effect=kick_error) + + return cast(Room, room) + + +def mock_space(room_id: str, children: list[Room | Space]) -> Space: + space = MagicMock(spec=Space) + space.room_id = room_id + space.name = room_id + cast(Any, space).get_children = MagicMock(return_value=children) + cast(Any, space).kick_user = AsyncMock() + return cast(Space, space) @pytest.mark.asyncio -async def test_kick_from_context__with_current_room_matrix_error__expect_current_room_recorded_failed() -> ( - None -): - ctx = SimpleNamespace( - room=SimpleNamespace( - room_id="!room:example.com", - kick_user=AsyncMock(side_effect=MatrixError("not allowed")), - ), +async def test_kick_from_rooms__skips_when_user_is_not_member() -> None: + room = mock_room("!room:example.com") + cast(Any, room).get_members = AsyncMock(return_value=[]) + + result = await kick_from_rooms( + "@target:example.com", + [room], + reason="spam", ) - with patch( - "bot.extensions.moderation.kick_service.get_parent_space_id", - return_value=None, - ): - result = await kick_from_context( - cast(Context, ctx), "@target:example.com", "spam" - ) - + as_async_mock(room.kick_user).assert_not_awaited() assert result.kicked_room_ids == [] - assert result.failed_room_ids == ["!room:example.com"] + assert result.failed_room_ids == [] @pytest.mark.asyncio -async def test_kick_from_context__with_parent_space_targets__expect_successes_and_failures_recorded() -> ( - None -): - space = SimpleNamespace(room_id="!space:example.com", kick_user=AsyncMock()) - successful_room = SimpleNamespace(kick_user=AsyncMock()) - failed_room = SimpleNamespace( - kick_user=AsyncMock(side_effect=MatrixError("denied")) +async def test_kick_from_rooms__records_successes_failures_and_dedupes() -> None: + successful_room = mock_room("!success:example.com") + failed_room = mock_room( + "!failed:example.com", + kick_error=MatrixError("denied"), + ) + + result = await kick_from_rooms( + "@target:example.com", + [successful_room, failed_room, successful_room], + reason="spam", + ) + + as_async_mock(successful_room.kick_user).assert_awaited_once_with( + "@target:example.com", reason="spam" + ) + as_async_mock(failed_room.kick_user).assert_awaited_once_with( + "@target:example.com", reason="spam" ) - bot = MagicMock() - bot.get_room.side_effect = lambda room_id: { - "!space:example.com": space, - "!success:example.com": successful_room, - "!failed:example.com": failed_room, - "!missing:example.com": None, - }[room_id] - ctx = SimpleNamespace( - room=SimpleNamespace(room_id="!current:example.com"), - bot=bot, + assert result.target_user_id == "@target:example.com" + assert result.reason == "spam" + assert result.space_id is None + assert result.kicked_room_ids == ["!success:example.com"] + assert result.failed_room_ids == ["!failed:example.com"] + + +@pytest.mark.asyncio +async def test_kick_from_rooms__includes_space_id_when_space_provided() -> None: + room = mock_room("!room:example.com") + space = mock_space("!space:example.com", []) + + result = await kick_from_rooms( + "@target:example.com", + [room], + reason="spam", + space=space, ) - async def collect_room_ids(_ctx, _space): - return [ - "!success:example.com", - "!failed:example.com", - "!missing:example.com", - ] - - with ( - patch( - "bot.extensions.moderation.kick_service.get_parent_space_id", - return_value="!space:example.com", - ), - patch( - "bot.extensions.moderation.kick_service.collect_space_child_room_ids", - side_effect=collect_room_ids, - ), + assert result.space_id == "!space:example.com" + assert result.kicked_room_ids == ["!room:example.com"] + assert result.failed_room_ids == [] + + +@pytest.mark.asyncio +async def test_kick_from_context__without_parent_space__kicks_current_room() -> None: + current_room = mock_room("!current:example.com") + ctx = SimpleNamespace(room=current_room) + + with patch( + "bot.extensions.moderation.kick_service.get_parent_space", + return_value=None, ): result = await kick_from_context( - cast(Context, ctx), "@target:example.com", "spam" + cast(Context, ctx), + "@target:example.com", + "spam", ) - successful_room.kick_user.assert_awaited_once_with( + as_async_mock(current_room.kick_user).assert_awaited_once_with( "@target:example.com", reason="spam" ) - failed_room.kick_user.assert_awaited_once_with("@target:example.com", reason="spam") - space.kick_user.assert_awaited_once_with("@target:example.com", reason="spam") - assert result.kicked_room_ids == ["!success:example.com", "!space:example.com"] - assert result.failed_room_ids == ["!failed:example.com", "!missing:example.com"] + assert result.kicked_room_ids == ["!current:example.com"] + assert result.failed_room_ids == [] @pytest.mark.asyncio -async def test_kick_from_context__with_nested_space_children__expect_nested_targets_and_parent_space_kicked() -> ( - None -): - root_space = SimpleNamespace( - room_id="!root-space:example.com", - room_type="m.space", - children=["!nested-space:example.com"], - kick_user=AsyncMock(), - ) - nested_space = SimpleNamespace( - room_id="!nested-space:example.com", - room_type="m.space", - children=["!leaf-room:example.com"], - kick_user=AsyncMock(), - ) - leaf_room = SimpleNamespace( - room_id="!leaf-room:example.com", - room_type=None, - children=[], - kick_user=AsyncMock(), - ) - - bot = MagicMock() - bot.get_room.side_effect = lambda room_id: { - "!root-space:example.com": root_space, - "!nested-space:example.com": nested_space, - "!leaf-room:example.com": leaf_room, - }[room_id] - ctx = SimpleNamespace( - room=SimpleNamespace(room_id="!current:example.com"), - bot=bot, +async def test_kick_from_context__with_parent_space__kicks_room_children_only() -> None: + child_room = mock_room("!child-room:example.com") + child_space = mock_space("!child-space:example.com", []) + parent_space = mock_space( + "!parent-space:example.com", + [child_room, child_space], ) + ctx = SimpleNamespace(room=mock_room("!current:example.com")) with patch( - "bot.extensions.moderation.kick_service.get_parent_space_id", - return_value="!root-space:example.com", + "bot.extensions.moderation.kick_service.get_parent_space", + return_value=parent_space, ): result = await kick_from_context( - cast(Context, ctx), "@target:example.com", "spam" + cast(Context, ctx), + "@target:example.com", + "spam", ) - nested_space.kick_user.assert_awaited_once_with( + as_mock(parent_space.get_children).assert_called_once_with(depth=3) + as_async_mock(child_room.kick_user).assert_awaited_once_with( "@target:example.com", reason="spam" ) - leaf_room.kick_user.assert_awaited_once_with("@target:example.com", reason="spam") - root_space.kick_user.assert_awaited_once_with("@target:example.com", reason="spam") - assert set(result.kicked_room_ids) == { - "!nested-space:example.com", - "!leaf-room:example.com", - "!root-space:example.com", - } + as_async_mock(child_space.kick_user).assert_not_awaited() + + assert result.space_id == "!parent-space:example.com" + assert result.kicked_room_ids == ["!child-room:example.com"] assert result.failed_room_ids == []