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
2 changes: 1 addition & 1 deletion examples/config.yaml
Original file line number Diff line number Diff line change
@@ -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 !)"
PREFIX: "your_custom_prefix(default to !)"
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
88 changes: 82 additions & 6 deletions matrix/api.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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])
123 changes: 123 additions & 0 deletions matrix/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
6 changes: 6 additions & 0 deletions matrix/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
60 changes: 53 additions & 7 deletions matrix/message.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from collections.abc import AsyncIterator
from typing import TYPE_CHECKING, Self

from nio import (
Expand All @@ -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
Expand Down Expand Up @@ -76,20 +77,30 @@ 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,
event_id=self.event_id,
):
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.
Expand Down Expand Up @@ -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.

Expand Down
9 changes: 9 additions & 0 deletions matrix/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading