Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion matrix/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from .context import Context
from .command import Command
from .help import HelpCommand
from .checks import cooldown
from .checks import cooldown, is_admin, is_moderator
from .room import Room
from .space import Space
from .message import Message
Expand All @@ -28,6 +28,8 @@
"Context",
"HelpCommand",
"cooldown",
"is_admin",
"is_moderator",
"Room",
"Space",
"Message",
Expand Down
57 changes: 57 additions & 0 deletions matrix/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

if TYPE_CHECKING:
from .command import Command
from .context import Context


def cooldown(rate: int, period: float) -> Callable:
Expand All @@ -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).

Copy link
Copy Markdown
Contributor

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


## 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]

@PenguinBoi12 PenguinBoi12 Jul 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: You shouldn't need the # type: ignore[no-any-return] but I see why you need it... If you want to you can fix the real issue which is that Command.check takes a Callback when it should probably have its own type alias.

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 # type: ignore[no-any-return]

Or we can:

level: int = ctx.room.power_levels.get_user_level(ctx.sender)
return level >= 100

I'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
176 changes: 176 additions & 0 deletions tests/test_checks.py
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
Loading