-
Notifications
You must be signed in to change notification settings - Fork 6
feat: add is_admin and is_moderator checks #111
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |
|
|
||
| if TYPE_CHECKING: | ||
| from .command import Command | ||
| from .context import Context | ||
|
|
||
|
|
||
| def cooldown(rate: int, period: float) -> Callable: | ||
|
|
@@ -27,3 +28,59 @@ def wrapper(cmd: "Command") -> "Command": | |
| return cmd | ||
|
|
||
| return wrapper | ||
|
|
||
|
|
||
| def is_admin() -> Callable: | ||
| """ | ||
| Decorator to restrict a command to room admins (power level >= 100). | ||
|
|
||
| ## Example | ||
|
|
||
| ```python | ||
| @is_admin() | ||
| @bot.command("ban") | ||
| async def ban(ctx: Context, user_id: str) -> None: | ||
| await ctx.room.ban_user(user_id) | ||
|
|
||
| @ban.error(CheckError) | ||
| async def ban_error(ctx: Context, error: CheckError) -> None: | ||
| await ctx.reply("You must be an admin to use this command.") | ||
| ``` | ||
| """ | ||
|
|
||
| async def _is_admin(ctx: "Context") -> bool: | ||
| return ctx.room.power_levels.get_user_level(ctx.sender) >= 100 # type: ignore[no-any-return] | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: You shouldn't need the It's a simple fix # command.py
Callback = Callable[..., Coroutine[Any, Any, Any]]
+ CheckCallback = Callable[["Context"], Coroutine[Any, Any, bool]]
ErrorCallback = Callable[["Context", Exception], Coroutine[Any, Any, Any]]
- def check(self, func: Callback) -> None:
+ def check(self, func: CheckCallback) -> None:Then: # checks.py
async def _is_admin(ctx: "Context") -> bool:
- return ctx.room.power_levels.get_user_level(ctx.sender) >= 100 # type: ignore[no-any-return
+ return bool(ctx.room.power_levels.get_user_level(ctx.sender) >= 100)
async def _is_moderator(ctx: "Context") -> bool:
- return ctx.room.power_levels.get_user_level(ctx.sender) >= 50 # type: ignore[no-any-return]
+ return bool(ctx.room.power_levels.get_user_level(ctx.sender) >= 50)
We still need to call bool but at least it's clearer than using Or we can: level: int = ctx.room.power_levels.get_user_level(ctx.sender)
return level >= 100I'm fine with both |
||
|
|
||
| def wrapper(cmd: "Command") -> "Command": | ||
| cmd.check(_is_admin) | ||
| return cmd | ||
|
|
||
| return wrapper | ||
|
|
||
|
|
||
| def is_moderator() -> Callable: | ||
| """ | ||
| Decorator to restrict a command to room moderators (power level >= 50). | ||
|
|
||
| ## Example | ||
|
|
||
| ```python | ||
| @is_moderator() | ||
| @bot.command("kick") | ||
| async def kick(ctx: Context, user_id: str) -> None: | ||
| await ctx.room.kick_user(user_id) | ||
|
|
||
| @kick.error(CheckError) | ||
| async def kick_error(ctx: Context, error: CheckError) -> None: | ||
| await ctx.reply("You must be a moderator to use this command.") | ||
| ``` | ||
| """ | ||
|
|
||
| async def _is_moderator(ctx: "Context") -> bool: | ||
| return ctx.room.power_levels.get_user_level(ctx.sender) >= 50 # type: ignore[no-any-return] | ||
|
|
||
| def wrapper(cmd: "Command") -> "Command": | ||
| cmd.check(_is_moderator) | ||
| return cmd | ||
|
|
||
| return wrapper | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| import pytest | ||
|
|
||
| from unittest.mock import AsyncMock, Mock | ||
| from nio import MatrixRoom, RoomMessageText, AsyncClient, PowerLevels | ||
|
|
||
| from matrix.checks import is_admin, is_moderator | ||
| from matrix.command import Command | ||
| from matrix.context import Context | ||
| from matrix.errors import CheckError | ||
| from matrix.room import Room | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def client(): | ||
| return AsyncMock(spec=AsyncClient) | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def bot(client): | ||
| bot = Mock() | ||
| bot.prefix = "!" | ||
| bot.client = client | ||
| bot.log = Mock() | ||
| bot.log.getChild = Mock(return_value=Mock()) | ||
| return bot | ||
|
|
||
|
|
||
| def make_event(sender: str) -> RoomMessageText: | ||
| return RoomMessageText.from_dict( | ||
| { | ||
| "content": {"body": "!restricted", "msgtype": "m.text"}, | ||
| "event_id": "$event123", | ||
| "origin_server_ts": 123456, | ||
| "sender": sender, | ||
| "type": "m.room.message", | ||
| } | ||
| ) | ||
|
|
||
|
|
||
| def make_context(bot, client, sender: str, level: int) -> Context: | ||
| """Builds a real Context whose room reports `level` as the sender's power level.""" | ||
| matrix_room = Mock(spec=MatrixRoom) | ||
| matrix_room.room_id = "!room:example.com" | ||
| matrix_room.power_levels = PowerLevels(users={sender: level}) | ||
|
|
||
| room = Room(matrix_room, client) | ||
| return Context(bot, room, make_event(sender)) | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| @pytest.mark.parametrize( | ||
| "level, expected", | ||
| [ | ||
| (100, True), | ||
| (99, False), | ||
| (50, False), | ||
| (0, False), | ||
| ], | ||
| ) | ||
| async def test_is_admin_check__respects_power_level_boundaries( | ||
| bot, client, level, expected | ||
| ): | ||
| async def my_command(ctx): | ||
| pass | ||
|
|
||
| cmd = Command(my_command) | ||
| is_admin()(cmd) | ||
|
|
||
| check = cmd.checks[-1] | ||
| ctx = make_context(bot, client, "@user:example.com", level) | ||
|
|
||
| assert await check(ctx) is expected | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| @pytest.mark.parametrize( | ||
| "level, expected", | ||
| [ | ||
| (100, True), | ||
| (50, True), | ||
| (49, False), | ||
| (0, False), | ||
| ], | ||
| ) | ||
| async def test_is_moderator_check__respects_power_level_boundaries( | ||
| bot, client, level, expected | ||
| ): | ||
| async def my_command(ctx): | ||
| pass | ||
|
|
||
| cmd = Command(my_command) | ||
| is_moderator()(cmd) | ||
|
|
||
| check = cmd.checks[-1] | ||
| ctx = make_context(bot, client, "@user:example.com", level) | ||
|
|
||
| assert await check(ctx) is expected | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_checks__fall_back_to_default_level_for_unlisted_sender(bot, client): | ||
| """A sender absent from power_levels.users gets users_default (0).""" | ||
| matrix_room = Mock(spec=MatrixRoom) | ||
| matrix_room.room_id = "!room:example.com" | ||
| matrix_room.power_levels = PowerLevels() | ||
|
|
||
| room = Room(matrix_room, client) | ||
| ctx = Context(bot, room, make_event("@user:example.com")) | ||
|
|
||
| for factory in (is_admin, is_moderator): | ||
|
|
||
| async def my_command(ctx): | ||
| pass | ||
|
|
||
| cmd = Command(my_command) | ||
| factory()(cmd) | ||
|
|
||
| assert await cmd.checks[-1](ctx) is False | ||
|
|
||
|
|
||
| def test_is_admin__returns_the_same_command(): | ||
| async def my_command(ctx): | ||
| pass | ||
|
|
||
| cmd = Command(my_command) | ||
| assert is_admin()(cmd) is cmd | ||
|
|
||
|
|
||
| def test_is_moderator__returns_the_same_command(): | ||
| async def my_command(ctx): | ||
| pass | ||
|
|
||
| cmd = Command(my_command) | ||
| assert is_moderator()(cmd) is cmd | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_is_admin__raises_check_error_when_not_admin(bot, client): | ||
| called = False | ||
|
|
||
| async def restricted(ctx): | ||
| nonlocal called | ||
| called = True | ||
|
|
||
| cmd = Command(restricted) | ||
| is_admin()(cmd) | ||
|
|
||
| caught: list[Exception] = [] | ||
|
|
||
| @cmd.error(CheckError) | ||
| async def on_check_error(ctx, error): | ||
| caught.append(error) | ||
|
|
||
| ctx = make_context(bot, client, "@user:example.com", 0) | ||
| await cmd(ctx) | ||
|
|
||
| assert called is False | ||
| assert len(caught) == 1 | ||
| assert isinstance(caught[0], CheckError) | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_is_moderator__allows_command_when_moderator(bot, client): | ||
| called = False | ||
|
|
||
| async def restricted(ctx): | ||
| nonlocal called | ||
| called = True | ||
|
|
||
| cmd = Command(restricted) | ||
| is_moderator()(cmd) | ||
|
|
||
| ctx = make_context(bot, client, "@mod:example.com", 50) | ||
| await cmd(ctx) | ||
|
|
||
| assert called is True |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: I think it would be great to add the reference (https://matrix.org/docs/communities/moderation/) so people reading this understand that it's not an arbitrary number.
Same thing for
is_moderator