diff --git a/examples/config.yaml b/examples/config.yaml index 6edd561..79207e1 100644 --- a/examples/config.yaml +++ b/examples/config.yaml @@ -1,4 +1,4 @@ HOMESERVER: "your_matrix_server(default to matrix.org)" USERNAME: "your_bot_username" PASSWORD: "your_Password" -PREFIX: "your_custom_prefix(default to !)" \ No newline at end of file +PREFIX: "your_custom_prefix(default to !)" diff --git a/matrix/__init__.py b/matrix/__init__.py index 1845a21..0ebc2fe 100644 --- a/matrix/__init__.py +++ b/matrix/__init__.py @@ -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 @@ -28,6 +28,8 @@ "Context", "HelpCommand", "cooldown", + "is_admin", + "is_moderator", "Room", "Space", "Message", diff --git a/matrix/api.py b/matrix/api.py index 34a5c57..b078bf4 100644 --- a/matrix/api.py +++ b/matrix/api.py @@ -1,19 +1,23 @@ -from typing import Awaitable, TypeVar +import asyncio +from typing import Awaitable, Callable, Iterable, TypeVar from nio import ErrorResponse, Response -from matrix.errors import MatrixError +from matrix.errors import MatrixApiError T = TypeVar("T", bound=Response) +R = TypeVar("R") async def matrix_call(coro: Awaitable[T], /, *, error_message: str) -> T: - """Await `coro`, translating any failure into a `MatrixError`. + """Await `coro`, translating any failure into a `MatrixApiError`. matrix-nio's `AsyncClient` methods don't raise on API-level errors; they return an `ErrorResponse` instead of raising. This wraps a single call so both transport-level exceptions and nio `ErrorResponse` results become a - `MatrixError` carrying `error_message`. + `MatrixApiError` carrying `error_message`. When the `ErrorResponse` is a + rate limit with a `retry_after_ms`, that value is carried through + unchanged on `MatrixApiError.retry_after_ms`. ## Example @@ -27,9 +31,81 @@ async def matrix_call(coro: Awaitable[T], /, *, error_message: str) -> T: try: response = await coro except Exception as e: - raise MatrixError(f"{error_message}: {e}") from e + raise MatrixApiError(f"{error_message}: {e}") from e if isinstance(response, ErrorResponse): - raise MatrixError(f"{error_message}: {response}") + raise MatrixApiError( + f"{error_message}: {response}", retry_after_ms=response.retry_after_ms + ) return response + + +async def with_retry( + func: Callable[[], Awaitable[R]], + /, + *, + retries: int = 3, + base_delay: float = 1.0, +) -> R: + """Call `func`, retrying on `MatrixApiError` with backoff. + + Retries up to `retries` times after the initial attempt (so up to + `retries + 1` total calls). If the raised `MatrixApiError` carries a + `retry_after_ms` (from the Matrix server), that wait is used exactly + (converted to seconds); otherwise falls back to exponential delay + (`base_delay * 2 ** attempt`). Only `MatrixApiError` triggers a retry; + any other exception propagates. + + ## Example + + ```python + message = await with_retry(lambda: room.send(content, notice=notice)) + ``` + """ + for attempt in range(retries + 1): + try: + return await func() + except MatrixApiError as e: + if attempt == retries: + raise + + delay = base_delay * 2**attempt + if e.retry_after_ms is not None: + delay = e.retry_after_ms / 1000 + + await asyncio.sleep(delay) + + raise AssertionError("unreachable") + + +async def bounded_gather( + coros: Iterable[Awaitable[R]], + /, + *, + max_concurrent: int = 8, +) -> list[R]: + """Run `coros` concurrently, limited to `max_concurrent` at a time. + + Behaves like `asyncio.gather`, but never runs more than `max_concurrent` + awaitables at once. Useful for fanning out many API calls (e.g. + broadcasting to many rooms) without overwhelming a shared rate limit by + firing every request at once. The first exception raised by any + coroutine cancels the rest and propagates immediately, same as + `asyncio.gather`'s default behavior. + + ## Example + + ```python + messages = await bounded_gather( + (room.send(content) for room in rooms), max_concurrent=8 + ) + ``` + """ + semaphore = asyncio.Semaphore(max_concurrent) + + async def _run(coro: Awaitable[R]) -> R: + async with semaphore: + return await coro + + return await asyncio.gather(*[_run(coro) for coro in coros]) diff --git a/matrix/checks.py b/matrix/checks.py index d673ca7..387232c 100644 --- a/matrix/checks.py +++ b/matrix/checks.py @@ -2,6 +2,11 @@ if TYPE_CHECKING: from .command import Command + from .context import Context + + +ADMIN_POWER_LEVEL: int = 100 +MODERATOR_POWER_LEVEL: int = 50 def cooldown(rate: int, period: float) -> Callable: @@ -27,3 +32,121 @@ def wrapper(cmd: "Command") -> "Command": return cmd return wrapper + + +def is_admin() -> Callable: + """ + Decorator to restrict a command to room admins + (power level >= `ADMIN_POWER_LEVEL`). + + ## 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: + level = ctx.room.power_levels.get_user_level(ctx.sender) + return level >= ADMIN_POWER_LEVEL # type: ignore[no-any-return] + + 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 >= `MODERATOR_POWER_LEVEL`). + + ## 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: + level = ctx.room.power_levels.get_user_level(ctx.sender) + return level >= MODERATOR_POWER_LEVEL # type: ignore[no-any-return] + + def wrapper(cmd: "Command") -> "Command": + cmd.check(_is_moderator) + return cmd + + return wrapper + + +def is_room_encrypted() -> Callable: + """ + Decorator to restrict a command to encrypted rooms. + + ## Example + + ```python + @is_room_encrypted() + @bot.command("secret") + async def secret(ctx: Context) -> None: + await ctx.reply("This room is encrypted!") + + @secret.error(CheckError) + async def secret_error(ctx: Context, error: CheckError) -> None: + await ctx.reply("This command can only be used in an encrypted room.") + ``` + """ + + async def _is_room_encrypted(ctx: "Context") -> bool: + return ctx.room.encrypted + + def wrapper(cmd: "Command") -> "Command": + cmd.check(_is_room_encrypted) + return cmd + + return wrapper + + +def has_power_level(level: int) -> Callable: + """ + Decorator to restrict a command to users with a power level + greater than or equal to `level`. + + ## Example + + ```python + @has_power_level(75) + @bot.command("pin") + async def pin(ctx: Context, event_id: str) -> None: + await ctx.reply(f"Pinned {event_id}!") + + @pin.error(CheckError) + async def pin_error(ctx: Context, error: CheckError) -> None: + await ctx.reply("You don't have the required power level.") + ``` + """ + + async def _has_power_level(ctx: "Context") -> bool: + user_level = ctx.room.power_levels.get_user_level(ctx.sender) + return user_level >= level # type: ignore[no-any-return] + + def wrapper(cmd: "Command") -> "Command": + cmd.check(_has_power_level) + return cmd + + return wrapper diff --git a/matrix/errors.py b/matrix/errors.py index c9cca60..f3f23cf 100644 --- a/matrix/errors.py +++ b/matrix/errors.py @@ -13,6 +13,12 @@ class MatrixError(Exception): pass +class MatrixApiError(MatrixError): + def __init__(self, message: str, *, retry_after_ms: int | None = None): + super().__init__(message) + self.retry_after_ms = retry_after_ms + + class RoomNotFoundError(MatrixError): pass diff --git a/matrix/message.py b/matrix/message.py index 0188787..c5fcc90 100644 --- a/matrix/message.py +++ b/matrix/message.py @@ -1,3 +1,4 @@ +from collections.abc import AsyncIterator from typing import TYPE_CHECKING, Self from nio import ( @@ -7,7 +8,7 @@ RoomGetStateEventResponse, ) -from matrix.types import Reaction +from matrix.types import Reaction, ReactionEvent from matrix.content import ReactionContent, EditContent from matrix.errors import MatrixError from matrix.api import matrix_call @@ -76,6 +77,13 @@ async def reactions(ctx: Context): """ raw: dict[str, list[str]] = {} + async for reaction_event in self._iter_reaction_events(): + raw.setdefault(reaction_event.emoji, []).append(reaction_event.sender) + + return [Reaction(key=emoji, senders=senders) for emoji, senders in raw.items()] + + async def _iter_reaction_events(self) -> AsyncIterator[ReactionEvent]: + """Yield complete reaction relation events for this message.""" try: async for event in self.client.room_get_event_relations( room_id=self.room.room_id, @@ -83,13 +91,16 @@ async def reactions(ctx: Context): ): emoji = getattr(event, "key", None) sender = getattr(event, "sender", None) - - if emoji and sender: - raw.setdefault(emoji, []).append(sender) + event_id = getattr(event, "event_id", None) + + if emoji and sender and event_id: + yield ReactionEvent( + emoji=emoji, + sender=sender, + event_id=event_id, + ) except Exception as e: - raise MatrixError(f"Failed to fetch reactions: {e}") - - return [Reaction(key=emoji, senders=senders) for emoji, senders in raw.items()] + raise MatrixError(f"Failed to fetch reactions: {e}") from e async def reply(self, body: str) -> "Message": """Reply to this message. @@ -131,6 +142,41 @@ async def thumbsup(ctx: Context): error_message="Failed to add reaction", ) + async def unreact(self, emoji: str) -> None: + """Remove this client's reaction emoji from the message. + + If the client has not reacted with the requested emoji, this method + does nothing. + + ## Example + ```python + @bot.command() + async def toggle(ctx: Context): + msg = await ctx.reply("React to this!") + await msg.react("👍") + await msg.unreact("👍") + ``` + """ + reaction_event_id = None + async for reaction_event in self._iter_reaction_events(): + if ( + reaction_event.emoji == emoji + and reaction_event.sender == self.client.user_id + ): + reaction_event_id = reaction_event.event_id + break + + if reaction_event_id is None: + return + + await matrix_call( + self.client.room_redact( + room_id=self.room.room_id, + event_id=reaction_event_id, + ), + error_message="Failed to remove reaction", + ) + async def edit(self, new_body: str) -> None: """Updates the message content to the new text. diff --git a/matrix/types.py b/matrix/types.py index 8da5243..36ac3ac 100644 --- a/matrix/types.py +++ b/matrix/types.py @@ -30,3 +30,12 @@ class Video(File): class Reaction: key: str senders: list[str] + + +@dataclass +class ReactionEvent: + """Details for a single reaction relation event.""" + + emoji: str + sender: str + event_id: str diff --git a/tests/test_api.py b/tests/test_api.py index fa1ed7f..9098919 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,8 +1,12 @@ +import asyncio +import inspect +from unittest.mock import AsyncMock, patch + import pytest from nio import RoomSendResponse, RoomSendError -from matrix.errors import MatrixError -from matrix.api import matrix_call +from matrix.errors import ConfigError, MatrixApiError, MatrixError +from matrix.api import bounded_gather, matrix_call, with_retry @pytest.mark.asyncio @@ -16,11 +20,11 @@ async def call(): @pytest.mark.asyncio -async def test_matrix_call_with_transport_exception__expect_matrix_error(): +async def test_matrix_call_with_transport_exception__expect_matrix_api_error(): async def call(): raise Exception("Network error") - with pytest.raises(MatrixError, match="Failed to send message: Network error"): + with pytest.raises(MatrixApiError, match="Failed to send message: Network error"): await matrix_call(call(), error_message="Failed to send message") @@ -33,6 +37,30 @@ async def call(): await matrix_call(call(), error_message="Failed to send message") +@pytest.mark.asyncio +async def test_matrix_call_with_rate_limited_error_response__expect_retry_after_ms_set(): + async def call(): + return RoomSendError( + "too many requests", "M_LIMIT_EXCEEDED", retry_after_ms=5000 + ) + + with pytest.raises(MatrixApiError) as exc_info: + await matrix_call(call(), error_message="Failed to send message") + + assert exc_info.value.retry_after_ms == 5000 + + +@pytest.mark.asyncio +async def test_matrix_call_with_error_response_without_retry_after__expect_retry_after_ms_none(): + async def call(): + return RoomSendError("not allowed", "M_FORBIDDEN") + + with pytest.raises(MatrixApiError) as exc_info: + await matrix_call(call(), error_message="Failed to send message") + + assert exc_info.value.retry_after_ms is None + + def test_matrix_call_requires_keyword_error_message__expect_type_error(): with pytest.raises(TypeError): matrix_call(None, "Failed to send message") @@ -41,3 +69,152 @@ def test_matrix_call_requires_keyword_error_message__expect_type_error(): def test_matrix_call_requires_positional_coro__expect_type_error(): with pytest.raises(TypeError): matrix_call(coro=None, error_message="Failed to send message") + + +@pytest.mark.asyncio +async def test_with_retry_with_success_first_try__expect_response_returned(): + call = AsyncMock(return_value="ok") + + result = await with_retry(lambda: call()) + + assert result == "ok" + assert call.await_count == 1 + + +@pytest.mark.asyncio +async def test_with_retry_with_transient_matrix_error_then_success__expect_response_returned_after_retry(): + call = AsyncMock(side_effect=[MatrixApiError("rate limited"), "ok"]) + + with patch("matrix.api.asyncio.sleep", new_callable=AsyncMock) as sleep: + result = await with_retry(lambda: call()) + + assert result == "ok" + assert call.await_count == 2 + sleep.assert_awaited_once_with(1.0) + + +@pytest.mark.asyncio +async def test_with_retry_exhausts_retries__expect_matrix_api_error_raised(): + call = AsyncMock(side_effect=MatrixApiError("still failing")) + + with patch("matrix.api.asyncio.sleep", new_callable=AsyncMock): + with pytest.raises(MatrixApiError, match="still failing"): + await with_retry(lambda: call(), retries=2) + + assert call.await_count == 3 + + +@pytest.mark.asyncio +async def test_with_retry_with_non_matrix_exception__expect_immediate_raise_no_retry(): + call = AsyncMock(side_effect=Exception("boom")) + + with patch("matrix.api.asyncio.sleep", new_callable=AsyncMock) as sleep: + with pytest.raises(Exception, match="boom"): + await with_retry(lambda: call()) + + assert call.await_count == 1 + sleep.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_with_retry_with_non_api_matrix_error__expect_immediate_raise_no_retry(): + call = AsyncMock(side_effect=ConfigError("token")) + + with patch("matrix.api.asyncio.sleep", new_callable=AsyncMock) as sleep: + with pytest.raises(ConfigError): + await with_retry(lambda: call()) + + assert call.await_count == 1 + sleep.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_with_retry_uses_exponential_backoff__expect_increasing_delays(): + call = AsyncMock( + side_effect=[ + MatrixApiError("fail 1"), + MatrixApiError("fail 2"), + MatrixApiError("fail 3"), + "ok", + ] + ) + + with patch("matrix.api.asyncio.sleep", new_callable=AsyncMock) as sleep: + result = await with_retry(lambda: call(), retries=3, base_delay=1.0) + + assert result == "ok" + assert [call_args.args[0] for call_args in sleep.await_args_list] == [ + 1.0, + 2.0, + 4.0, + ] + + +@pytest.mark.asyncio +async def test_with_retry_with_retry_after_ms__expect_server_delay_honored(): + call = AsyncMock( + side_effect=[MatrixApiError("rate limited", retry_after_ms=2500), "ok"] + ) + + with patch("matrix.api.asyncio.sleep", new_callable=AsyncMock) as sleep: + result = await with_retry(lambda: call()) + + assert result == "ok" + sleep.assert_awaited_once_with(2.5) + + +def test_with_retry_requires_positional_func__expect_type_error(): + with pytest.raises(TypeError): + with_retry(func=lambda: None) + + +@pytest.mark.asyncio +async def test_bounded_gather_with_success__expect_results_in_order(): + async def value(n): + return n + + results = await bounded_gather([value(1), value(2), value(3)]) + + assert results == [1, 2, 3] + + +@pytest.mark.asyncio +async def test_bounded_gather_limits_concurrency__expect_never_exceeds_max_concurrent(): + active = 0 + peak = 0 + + async def task(): + nonlocal active, peak + active += 1 + peak = max(peak, active) + await asyncio.sleep(0.01) + active -= 1 + return "done" + + results = await bounded_gather([task() for _ in range(10)], max_concurrent=3) + + assert results == ["done"] * 10 + assert peak == 3 + + +@pytest.mark.asyncio +async def test_bounded_gather_with_exception__expect_it_propagates(): + async def ok(): + return "ok" + + async def fail(): + raise ValueError("boom") + + with pytest.raises(ValueError, match="boom"): + await bounded_gather([ok(), fail(), ok()]) + + +def test_bounded_gather_default_max_concurrent__expect_eight(): + default = inspect.signature(bounded_gather).parameters["max_concurrent"].default + + assert default == 8 + + +def test_bounded_gather_requires_positional_coros__expect_type_error(): + with pytest.raises(TypeError): + bounded_gather(coros=[]) diff --git a/tests/test_checks.py b/tests/test_checks.py new file mode 100644 index 0000000..995833d --- /dev/null +++ b/tests/test_checks.py @@ -0,0 +1,334 @@ +import pytest + +from unittest.mock import AsyncMock, Mock +from nio import MatrixRoom, RoomMessageText, AsyncClient, PowerLevels + +from matrix.checks import ( + ADMIN_POWER_LEVEL, + MODERATOR_POWER_LEVEL, + has_power_level, + is_admin, + is_moderator, + is_room_encrypted, +) +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, encrypted: bool = False +) -> 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}) + matrix_room.encrypted = encrypted + + room = Room(matrix_room, client) + return Context(bot, room, make_event(sender)) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "level, expected", + [ + (ADMIN_POWER_LEVEL, True), + (ADMIN_POWER_LEVEL - 1, False), + (MODERATOR_POWER_LEVEL, 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", + [ + (ADMIN_POWER_LEVEL, True), + (MODERATOR_POWER_LEVEL, True), + (MODERATOR_POWER_LEVEL - 1, 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", MODERATOR_POWER_LEVEL) + await cmd(ctx) + + assert called is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize("encrypted", [True, False]) +async def test_is_room_encrypted_check__reflects_room_encryption( + bot, client, encrypted +): + async def my_command(ctx): + pass + + cmd = Command(my_command) + is_room_encrypted()(cmd) + + check = cmd.checks[-1] + ctx = make_context(bot, client, "@user:example.com", 0, encrypted=encrypted) + + assert await check(ctx) is encrypted + + +def test_is_room_encrypted__returns_the_same_command(): + async def my_command(ctx): + pass + + cmd = Command(my_command) + assert is_room_encrypted()(cmd) is cmd + + +@pytest.mark.asyncio +async def test_is_room_encrypted__raises_check_error_when_not_encrypted(bot, client): + called = False + + async def restricted(ctx): + nonlocal called + called = True + + cmd = Command(restricted) + is_room_encrypted()(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, encrypted=False) + await cmd(ctx) + + assert called is False + assert len(caught) == 1 + assert isinstance(caught[0], CheckError) + + +@pytest.mark.asyncio +async def test_is_room_encrypted__allows_command_when_encrypted(bot, client): + called = False + + async def restricted(ctx): + nonlocal called + called = True + + cmd = Command(restricted) + is_room_encrypted()(cmd) + + ctx = make_context(bot, client, "@user:example.com", 0, encrypted=True) + await cmd(ctx) + + assert called is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "level, expected", + [ + (75, True), + (76, True), + (74, False), + (0, False), + ], +) +async def test_has_power_level_check__respects_required_level_boundary( + bot, client, level, expected +): + async def my_command(ctx): + pass + + cmd = Command(my_command) + has_power_level(75)(cmd) + + check = cmd.checks[-1] + ctx = make_context(bot, client, "@user:example.com", level) + + assert await check(ctx) is expected + + +def test_has_power_level__returns_the_same_command(): + async def my_command(ctx): + pass + + cmd = Command(my_command) + assert has_power_level(50)(cmd) is cmd + + +@pytest.mark.asyncio +async def test_has_power_level__raises_check_error_when_below_level(bot, client): + called = False + + async def restricted(ctx): + nonlocal called + called = True + + cmd = Command(restricted) + has_power_level(50)(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", 49) + await cmd(ctx) + + assert called is False + assert len(caught) == 1 + assert isinstance(caught[0], CheckError) + + +@pytest.mark.asyncio +async def test_has_power_level__allows_command_when_level_is_sufficient(bot, client): + called = False + + async def restricted(ctx): + nonlocal called + called = True + + cmd = Command(restricted) + has_power_level(50)(cmd) + + ctx = make_context(bot, client, "@user:example.com", 50) + await cmd(ctx) + + assert called is True + + +def test_power_level_constants__match_matrix_defaults(): + """The thresholds follow the defaults of `m.room.power_levels`.""" + assert ADMIN_POWER_LEVEL == 100 + assert MODERATOR_POWER_LEVEL == 50 diff --git a/tests/test_message.py b/tests/test_message.py index 092b50d..fc252b4 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -29,7 +29,9 @@ def matrix_room(): @pytest.fixture def client(): - return AsyncMock(spec=AsyncClient) + client = AsyncMock(spec=AsyncClient) + client.user_id = "@bot:matrix.org" + return client @pytest.fixture @@ -109,6 +111,72 @@ async def test_react_with_api_error__expect_matrix_error(message, client): await message.react("😀") +@pytest.mark.asyncio +async def test_unreact__expect_own_matching_reaction_redacted(message, client): + own_reaction = MagicMock(event_id="$reaction1", key="👍", sender="@bot:matrix.org") + other_reaction = MagicMock( + event_id="$reaction2", key="👍", sender="@alice:matrix.org" + ) + + async def mock_relations(*args, **kwargs): + for reaction in [other_reaction, own_reaction]: + yield reaction + + client.room_get_event_relations = mock_relations + client.room_redact = AsyncMock() + + await message.unreact("👍") + + client.room_redact.assert_awaited_once_with( + room_id="!room:example.com", event_id="$reaction1" + ) + + +@pytest.mark.asyncio +async def test_unreact_without_matching_reaction__expect_no_redaction(message, client): + other_reaction = MagicMock( + event_id="$reaction2", key="👍", sender="@alice:matrix.org" + ) + + async def mock_relations(*args, **kwargs): + yield other_reaction + + client.room_get_event_relations = mock_relations + client.room_redact = AsyncMock() + + await message.unreact("👍") + + client.room_redact.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_unreact_with_relations_error__expect_matrix_error(message, client): + async def mock_relations(*args, **kwargs): + raise Exception("Network error") + yield + + client.room_get_event_relations = mock_relations + + with pytest.raises(MatrixError, match="Failed to fetch reactions: Network error"): + await message.unreact("👍") + + +@pytest.mark.asyncio +async def test_unreact_with_api_error__expect_matrix_error(message, client): + own_reaction = MagicMock(event_id="$reaction1", key="👍", sender="@bot:matrix.org") + + async def mock_relations(*args, **kwargs): + yield own_reaction + + client.room_get_event_relations = mock_relations + client.room_redact = AsyncMock( + return_value=RoomRedactError("not allowed", "M_FORBIDDEN") + ) + + with pytest.raises(MatrixError, match="Failed to remove reaction"): + await message.unreact("👍") + + @pytest.mark.asyncio async def test_edit__expect_message_updated(message, client): client.room_send = AsyncMock()