From 3f7265707c5626c65be4ba72995c4947c4020a3d Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 15 Jun 2026 17:12:30 -0700 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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]