diff --git a/matrix/bot.py b/matrix/bot.py index 2dfb7cd..f5aa11c 100644 --- a/matrix/bot.py +++ b/matrix/bot.py @@ -337,15 +337,9 @@ def wrapper(func: ErrorCallback) -> Callable: return wrapper def get_room(self, room_id: str) -> Room: - """ - Retrieve a Room instance based on the room_id. - - :param room_id: The ID of the room to retrieve. - :type room_id: str - :return: An instance of the Room class. - :rtype: Room - """ - return Room(room_id=room_id, bot=self) + """Retrieve a Room instance based on the room_id.""" + matrix_room = self.client.rooms[room_id] + return Room(matrix_room=matrix_room, client=self.client) def _auto_register_events(self) -> None: for attr in dir(self): @@ -390,8 +384,9 @@ async def _process_commands(self, room: MatrixRoom, event: Event) -> None: await ctx.command(ctx) - async def _build_context(self, room: MatrixRoom, event: Event) -> Context: + async def _build_context(self, matrix_room: MatrixRoom, event: Event) -> Context: """Builds the base context and extracts the command from the event""" + room = self.get_room(matrix_room.room_id) ctx = Context(bot=self, room=room, event=event) if not self.prefix or not ctx.body.startswith(self.prefix): diff --git a/matrix/content.py b/matrix/content.py new file mode 100644 index 0000000..05e8fa1 --- /dev/null +++ b/matrix/content.py @@ -0,0 +1,183 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from markdown import markdown +from typing import Any + + +class BaseMessageContent(ABC): + """Base class for outgoing message payloads.""" + + msgtype: str + + @abstractmethod + def build(self) -> dict[str, Any]: + pass + + +@dataclass +class TextContent(BaseMessageContent): + msgtype = "m.text" + body: str + + def build(self) -> dict: + return {"msgtype": self.msgtype, "body": self.body} + + +@dataclass +class MarkdownMessage(TextContent): + def build(self) -> dict: + return { + "msgtype": self.msgtype, + "body": self.body, + "format": "org.matrix.custom.html", + "formatted_body": markdown(self.body, extensions=["nl2br"]), + } + + +@dataclass +class NoticeContent(TextContent): + msgtype = "m.notice" + + +@dataclass +class ReplyContent(TextContent): + reply_to_event_id: str + + def build(self) -> dict: + return { + "msgtype": self.msgtype, + "body": self.body, + "m.relates_to": {"m.in_reply_to": {"event_id": self.reply_to_event_id}}, + } + + +@dataclass +class EditContent(TextContent): + original_event_id: str + + def build(self) -> dict: + return { + "msgtype": self.msgtype, + "body": f"* {self.body}", + "m.new_content": { + "msgtype": "m.text", + "body": self.body, + }, + "m.relates_to": { + "rel_type": "m.replace", + "event_id": self.original_event_id, + }, + } + + +@dataclass +class FileContent(BaseMessageContent): + msgtype = "m.file" + filename: str + url: str + mimetype: str + + def build(self) -> dict: + return { + "msgtype": self.msgtype, + "body": self.filename, + "url": self.url, + "info": {"mimetype": self.mimetype}, + } + + +@dataclass +class ImageContent(BaseMessageContent): + msgtype = "m.image" + filename: str + url: str + mimetype: str + height: int = 0 + width: int = 0 + + def build(self) -> dict: + return { + "msgtype": self.msgtype, + "body": self.filename, + "url": self.url, + "info": { + "mimetype": self.mimetype, + "h": self.height, + "w": self.width, + }, + } + + +@dataclass +class AudioContent(BaseMessageContent): + msgtype = "m.audio" + filename: str + url: str + mimetype: str + duration: int = 0 + + def build(self) -> dict: + return { + "msgtype": self.msgtype, + "body": self.filename, + "url": self.url, + "info": { + "mimetype": self.mimetype, + "duration": self.duration, + }, + } + + +@dataclass +class VideoContent(BaseMessageContent): + msgtype = "m.video" + filename: str + url: str + mimetype: str + height: int = 0 + width: int = 0 + duration: int = 0 + + def build(self) -> dict: + return { + "msgtype": self.msgtype, + "body": self.filename, + "url": self.url, + "info": { + "mimetype": self.mimetype, + "h": self.height, + "w": self.width, + "duration": self.duration, + }, + } + + +@dataclass +class LocationContent(BaseMessageContent): + msgtype = "m.location" + geo_uri: str + description: str = "" + + def build(self) -> dict: + return { + "msgtype": self.msgtype, + "body": self.description or self.geo_uri, + "geo_uri": self.geo_uri, + } + + +@dataclass +class ReactionContent(BaseMessageContent): + """For sending reactions to an event.""" + + event_id: str + emoji: str + + def build(self) -> dict: + return { + "m.relates_to": { + "rel_type": "m.annotation", + "event_id": self.event_id, + "key": self.emoji, + } + } diff --git a/matrix/context.py b/matrix/context.py index 274d40d..c1a7d86 100644 --- a/matrix/context.py +++ b/matrix/context.py @@ -1,10 +1,12 @@ import shlex -from nio import Event, MatrixRoom +from nio import Event from typing import TYPE_CHECKING, Optional, Any, List from .errors import MatrixError from .message import Message +from .room import Room +from .types import File, Image if TYPE_CHECKING: from .bot import Bot # pragma: no cover @@ -12,22 +14,12 @@ class Context: - """ - Represents the context in which a command is executed. Provides + """Represents the context in which a command is executed. Provides access to the bot instance, room and event metadata, parsed arguments, and other utilities. - - :param bot: The bot instance executing the command. - :type bot: Bot - :param room: The Matrix room where the event occurred. - :type room: MatrixRoom - :param event: The event that triggered the command or message. - :type event: Event - - :raises MatrixError: If a Matrix operation fails. """ - def __init__(self, bot: "Bot", room: MatrixRoom, event: Event): + def __init__(self, bot: "Bot", room: Room, event: Event): self.bot = bot self.room = room self.event = event @@ -35,11 +27,7 @@ def __init__(self, bot: "Bot", room: MatrixRoom, event: Event): self.body: str = getattr(event, "body", "") self.sender: str = event.sender - # Room metadata. - self.room_id: str = room.room_id - self.room_name: str = room.name - - # Command metdata + # Command metadata self.prefix: str = bot.prefix self.command: Optional[Command] = None self.subcommand: Optional[Command] = None @@ -66,25 +54,94 @@ def args(self) -> List[str]: @property def logger(self) -> Any: """Logger for instance specific to the current room or event.""" - return self.bot.log.getChild(self.room_id) - - async def reply(self, message: str) -> None: - """ - Send a message to the Matrix room. - - :param message: The message to send. - :type message: str - - :return: None + return self.bot.log.getChild(self.room.room_id) + + async def reply( + self, + content: str | None = None, + *, + raw: bool = False, + notice: bool = False, + file: File | None = None, + image: Image | None = None, + ) -> Message: + """Reply to the command with a message. + + ## Example + + ```python + @bot.command() + async def hello(ctx: Context): + # Send a markdown-formatted reply + await ctx.reply("Hello **world**!") + + @bot.command() + async def status(ctx: Context): + # Send a notice message + await ctx.reply("Bot is online!", notice=True) + + @bot.command() + async def document(ctx: Context): + # Upload and send a file + with open("report.pdf", "rb") as f: + resp, _ = await ctx.room.client.upload(f, content_type="application/pdf") + + file = File( + filename="report.pdf", + path=resp.content_uri, + mimetype="application/pdf" + ) + await ctx.reply(file=file) + + @bot.command() + async def cat(ctx: Context): + # Upload and send an image + from PIL import Image as PILImage + + with PILImage.open("cat.jpg") as img: + width, height = img.size + + with open("cat.jpg", "rb") as f: + resp, _ = await ctx.room.client.upload(f, content_type="image/jpeg") + + image = Image( + filename="cat.jpg", + path=resp.content_uri, + mimetype="image/jpeg", + width=width, + height=height + ) + await ctx.reply(image=image) + ``` """ try: - c = Message(self.bot) - await c.send(room_id=self.room_id, message=message) + return await self.room.send( + content, + raw=raw, + notice=notice, + file=file, + image=image, + ) except Exception as e: raise MatrixError(f"Failed to send message: {e}") async def send_help(self) -> None: + """Send help from the current command context. + + Displays help text for the current subcommand, command, or the bot's + general help menu depending on what's available in the context. The help + hierarchy is: subcommand help > command help > bot help. + + ## Example + + ```python + @bot.group() + async def config(ctx: Context): + # If user runs just "!config" with no subcommand + await ctx.send_help() + ``` + """ if self.subcommand: await self.reply(self.subcommand.help) return diff --git a/matrix/group.py b/matrix/group.py index 3447878..8500bd0 100644 --- a/matrix/group.py +++ b/matrix/group.py @@ -77,7 +77,7 @@ def register_command(self, cmd: Command) -> Command: return cmd async def invoke(self, ctx: "Context") -> None: - if subcommand := ctx.args.pop(0): + if ctx.args and (subcommand := ctx.args.pop(0)): ctx.subcommand = self.get_command(subcommand) await ctx.subcommand(ctx) else: diff --git a/matrix/message.py b/matrix/message.py index 2de4366..f52cc8f 100644 --- a/matrix/message.py +++ b/matrix/message.py @@ -1,134 +1,131 @@ +from typing import TYPE_CHECKING +from nio import AsyncClient +from matrix.content import ReactionContent, EditContent from matrix.errors import MatrixError -import markdown -from typing import TYPE_CHECKING, Dict, Optional -from nio import Event if TYPE_CHECKING: - from .bot import Bot # pragma: no cover + from .room import Room # pragma: no cover class Message: - """ - Handle sending messages in a Matrix room. + """Represents a Matrix message with methods to interact with it.""" - This class provides methods to send messages to a Matrix room, including - formatting the message content as either plain text or HTML. + def __init__( + self, *, room: "Room", event_id: str, body: str | None, client: AsyncClient + ) -> None: + self._room = room + self._event_id = event_id + self._body = body + self._client = client + + @property + def room(self) -> "Room": + """The room this message was sent in.""" + return self._room + + @property + def id(self) -> str: + """The event ID of this message.""" + return self._event_id + + @property + def event_id(self) -> str: + """The event ID of this message (alias for id).""" + return self._event_id + + @property + def body(self) -> str | None: + """The text content of this message.""" + return self._body + + @property + def client(self) -> AsyncClient: + """The Matrix client.""" + return self._client + + async def reply(self, body: str) -> "Message": + """Reply to this message. + + Creates a threaded reply to this message in the same room. + + ## Example + ```python + @bot.command() + async def echo(ctx: Context): + msg = await ctx.reply("Echo!") + await msg.reply("Replying to my own message") + ``` + """ + try: + return await self.room.send_text(content=body, reply_to=self.id) + except Exception as e: + raise MatrixError(f"Failed to send reply: {e}") + + async def react(self, emoji: str) -> None: + """Add a reaction emoji to this message. + + ## Example + ```python + @bot.command() + async def thumbsup(ctx: Context): + msg = await ctx.reply("React to this!") + await msg.react("👍") + ``` + """ + content = ReactionContent(event_id=self.id, emoji=emoji) - :param bot: The bot instance to use for messages. - :type bot: Bot - """ + try: + await self.client.room_send( + room_id=self.room.room_id, + message_type="m.reaction", + content=content.build(), + ) + except Exception as e: + raise MatrixError(f"Failed to add reaction: {e}") - MESSAGE_TYPE = "m.room.message" - MATRIX_CUSTOM_HTML = "org.matrix.custom.html" - TEXT_MESSAGE_TYPE = "m.text" + async def edit(self, new_body: str) -> None: + """Updates the message content to the new text. - def __init__(self, bot: "Bot") -> None: - self.bot = bot + ## Example - async def _send_to_room( - self, room_id: str, content: Dict, message_type: str = MESSAGE_TYPE - ) -> None: + ```python + @bot.command() + async def typo(ctx: Context): + msg = await ctx.reply("Helo world!") + await msg.edit("Hello world!") + ``` """ - Send a message to the Matrix room. + content = EditContent(new_body, original_event_id=self.id) - :param room_id: The ID of the room to send the message to. - :type room_id: str - :param content: The matrix JSON payload. - :type content: Dict - :param message_type: The type of the message. - :type message_type: str - - :raise MatrixError: If sending the message fails. - """ try: - await self.bot.client.room_send( - room_id=room_id, - message_type=message_type, - content=content, + await self.client.room_send( + room_id=self.room.room_id, + message_type="m.room.message", + content=content.build(), ) + self._body = new_body except Exception as e: - raise MatrixError(f"Failed to send message: {e}") - - def _make_content( - self, - body: str = "", - html: Optional[bool] = None, - reaction: Optional[bool] = None, - event_id: Optional[str] = None, - key: Optional[str] = None, - ) -> Dict: - """ - Create the content dictionary for a message. - - :param body: The body of the message. - :type body: str - :param html: Wheter to format the message as HTML. - :type html: Optional[bool] - :param reaction: Wheter to format the context with a reaction event. - :type reaction: Optional[bool] - :param event_id: The ID of the event to react to. - :type event_id: Optional[str] - :param key: The reaction to the message. - :type key: Optional[str] - - :return: The content of the dictionary. - """ + raise MatrixError(f"Failed to edit message: {e}") - base: Dict = { - "msgtype": self.TEXT_MESSAGE_TYPE, - "body": body, - } - if html: - html_body = markdown.markdown(body, extensions=["nl2br"]) - base["format"] = self.MATRIX_CUSTOM_HTML - base["formatted_body"] = html_body - - if reaction: - base["m.relates_to"] = { - "event_id": event_id, - "key": key, - "rel_type": "m.annotation", - } - - return base - - async def send( - self, room_id: str, message: str, format_markdown: Optional[bool] = True - ) -> None: - """ - Send a message to a Matrix room. - - :param room_id: The ID of the room to send the message to. - :type room_id: str - :param message: The message to send. - :type message: str - :param format_markdown: Whether to format the message as Markdown - (default to True). - :type format_markdown: Optional[bool] - """ - await self._send_to_room( - room_id=room_id, - content=self._make_content(body=str(message), html=format_markdown), - ) + async def delete(self) -> None: + """Removes the message content from the room. This action cannot be undone. - async def send_reaction(self, room_id: str, event: Event, key: str) -> None: - """ - Send a reaction to a message from a user in a Matrix room. - - :param room_id: The ID of the room to send the message to. - :type room_id: str - :param event: The event object to react to. - :type event: Event - :param key: The reaction to the message. - :type key: str + ## Example + + ```python + @bot.command() + async def oops(ctx: Context): + msg = await ctx.reply("Secret info!") + await msg.delete() + ``` """ - if isinstance(event, Event): - event_id = event.event_id - else: - event_id = event - - await self._send_to_room( - room_id=room_id, - content=self._make_content(event_id=event_id, key=key, reaction=True), - message_type="m.reaction", - ) + try: + await self.client.room_redact( + room_id=self.room.room_id, + event_id=self.id, + ) + except Exception as e: + raise MatrixError(f"Failed to delete message: {e}") + + def __repr__(self) -> str: + return f"" diff --git a/matrix/room.py b/matrix/room.py index 91e747f..9f8b64d 100644 --- a/matrix/room.py +++ b/matrix/room.py @@ -1,118 +1,333 @@ +from typing import Any + +from nio import AsyncClient, MatrixRoom + from matrix.errors import MatrixError from matrix.message import Message -from typing import TYPE_CHECKING, Optional -from nio import Event - -if TYPE_CHECKING: - from matrix.bot import Bot # pragma: no cover +from matrix.content import ( + TextContent, + MarkdownMessage, + NoticeContent, + FileContent, + ImageContent, + BaseMessageContent, + ReplyContent, +) +from matrix.types import File, Image class Room: - """ - Represents a Matrix room and provides methods to interact with it. + """Represents a Matrix room and provides methods to interact with it.""" + + def __init__(self, matrix_room: MatrixRoom, client: AsyncClient) -> None: + self._matrix_room: MatrixRoom = matrix_room + self._client: AsyncClient = client + + @property + def matrix_room(self) -> MatrixRoom: + """Access to underlying MatrixRoom object.""" + return self._matrix_room + + @property + def client(self) -> AsyncClient: + """Access to the Matrix client.""" + return self._client + + @property + def name(self) -> str | None: + """Room display name.""" + return self._matrix_room.name # type: ignore[no-any-return] + + @property + def room_id(self) -> str: + """Room ID.""" + return self._matrix_room.room_id # type: ignore[no-any-return] - :param room_id: The unique identifier of the room. - :type room_id: str - :param bot: The bot instance used to send messages. - :type bot: Bot - """ + @property + def display_name(self) -> str: + """Room display name (alias for name).""" + return self._matrix_room.display_name # type: ignore[no-any-return] - def __init__(self, room_id: str, bot: "Bot") -> None: - self.room_id = room_id - self.bot = bot + @property + def topic(self) -> str | None: + """Room topic.""" + return self._matrix_room.topic # type: ignore[no-any-return] + + @property + def member_count(self) -> int: + """Number of members in the room.""" + return self._matrix_room.member_count # type: ignore[no-any-return] + + @property + def encrypted(self) -> bool: + """Whether the room is encrypted.""" + return self._matrix_room.encrypted # type: ignore[no-any-return] + + def __getattr__(self, name: str) -> Any: + """ + Fallback to MatrixRoom for attributes not explicitly defined. + + This allows access to any MatrixRoom attribute not wrapped by this class. + See matrix-nio's MatrixRoom documentation for available attributes. + + https://matrix-nio.readthedocs.io/en/latest/nio.html#nio.rooms.MatrixRoom + """ + try: + return getattr(self._matrix_room, name) + except AttributeError: + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) from None async def send( self, - message: str = "", - markdown: Optional[bool] = True, - event: Optional[Event] = None, - key: Optional[str] = None, - ) -> None: + content: str | None = None, + *, + raw: bool = False, + notice: bool = False, + file: File | None = None, + image: Image | None = None, + ) -> Message: + """Send a message to the room. + + This is a convenience method that automatically routes to the appropriate + send method based on the provided arguments. Supports text messages (with + optional markdown formatting), file uploads, and image uploads. + + ## Example + + ```python + # Send a markdown-formatted text message + await room.send("Hello **world**!") + + # Send raw text without markdown + await room.send("Hello world!", raw=True) + + # Send a notice message + await room.send("Bot is starting up...", notice=True) + + # Send a file + file = File(filename="document.pdf", path="mxc://...", mimetype="application/pdf") + await room.send(file=file) + + # Send an image + image = Image(filename="photo.jpg", path="mxc://...", mimetype="image/jpeg", width=800, height=600) + await room.send(image=image) + ``` + """ + if content: + return await self.send_text(content, raw=raw, notice=notice) + + if file: + return await self.send_file(file) + + if image: + return await self.send_image(image) + raise ValueError("You must provide content, file, or image to send.") + + async def send_text( + self, + content: str, + *, + raw: bool = False, + notice: bool = False, + reply_to: str | None = None, + ) -> Message: + """Send a text message to the room. + + By default, messages are formatted using Markdown. You can send raw unformatted + text with `raw=True`, or send a notice message (typically used for bot status + updates) with `notice=True`. + + ## Example + + ```python + # Send markdown-formatted message + await room.send_text("**Bold** and *italic* text") + + # Send raw text without formatting + await room.send_text("This is plain text", raw=True) + + # Send a notice message + await room.send_text("Bot restarted successfully", notice=True) + + # Reply to another message + await room.send_text("Bot restarted successfully", replay_to=message.id) + ``` + """ + payload: TextContent + + if reply_to: + payload = ReplyContent(content, reply_to_event_id=reply_to) + elif notice: + payload = NoticeContent(content) + elif raw: + payload = TextContent(content) + else: + payload = MarkdownMessage(content) + + return await self._send_payload(payload) + + async def send_file(self, file: File) -> Message: + """Send a file to the room. + + The file must be uploaded to the Matrix content repository before sending. + Use the room's client upload method to get the MXC URI for the file. + + ## Example + + ```python + # Upload file first, then send + with open("document.pdf", "rb") as f: + resp, _ = await room.client.upload(f, content_type="application/pdf") + + file = File( + filename="document.pdf", + path=resp.content_uri, + mimetype="application/pdf" + ) + await room.send_file(file) + ``` """ - Send a message to the room. - - :param message: The message to send. - :type message: str - :param markdown: Whether to format the message as Markdown. - :type markdown: Optional[bool] - :param event: An event object to react to. - :type event: Optional[Event] - :param key: The reaction to the message. - :type key: Optional[str] - - :raises MatrixError: If sending the message fails. + payload = FileContent( + filename=file.filename, url=file.path, mimetype=file.mimetype + ) + return await self._send_payload(payload) + + async def send_image(self, image: Image) -> Message: + """Send an image to the room. + + The image must be uploaded to the Matrix content repository before sending. + Use the room's client upload method to get the MXC URI for the image. + + ## Example + + ```python + from PIL import Image as PILImage + + # Get image dimensions + with PILImage.open("photo.jpg") as img: + width, height = img.size + + # Upload image first + with open("photo.jpg", "rb") as f: + resp, _ = await room.client.upload(f, content_type="image/jpeg") + + image = Image( + filename="photo.jpg", + path=resp.content_uri, + mimetype="image/jpeg", + width=width, + height=height + ) + await room.send_image(image) + ``` """ + payload = ImageContent( + filename=image.filename, + url=image.path, + mimetype=image.mimetype, + height=image.height, + width=image.width, + ) + return await self._send_payload(payload) + + async def _send_payload(self, payload: BaseMessageContent) -> Message: + """Send a BaseMessageContent payload and return a Message object.""" try: - msg = Message(self.bot) - if key: - await msg.send_reaction(self.room_id, event, key) - else: - await msg.send(self.room_id, message, markdown) + resp = await self.client.room_send( + room_id=self.room_id, + message_type="m.room.message", + content=payload.build(), + ) + + return Message( + room=self, + event_id=resp.event_id, + body=getattr(payload, "body", None), + client=self.client, + ) except Exception as e: raise MatrixError(f"Failed to send message: {e}") async def invite_user(self, user_id: str) -> None: - """ - Invite a user to the room. + """Invite a user to the room. + + The bot must have permission to invite users to the room. The user will + receive an invitation that they can accept or decline. + + ## Example - :param user_id: The ID of the user to invite. - :raises MatrixError: If inviting the user fails. + ```python + # Invite a user by their Matrix ID + await room.invite_user("@alice:example.com") + ``` """ try: - # TODO: Abstract this to Context? - # EX: await Context.invite_user_to_room(user_id) - await self.bot.client.room_invite(room_id=self.room_id, user_id=user_id) + await self.client.room_invite(room_id=self.room_id, user_id=user_id) except Exception as e: raise MatrixError(f"Failed to invite user: {e}") - async def ban_user(self, user_id: str, reason: Optional[str] = None) -> None: - """ - Ban a user from a room. + async def ban_user(self, user_id: str, reason: str | None = None) -> None: + """Ban a user from the room. + + The bot must have permission to ban users. Banned users cannot rejoin + the room until they are unbanned. Optionally provide a reason for the ban. - :param user_id: The ID of the user to ban of the room. - :type user_id: str - :param reason: The reason to ban the user. - :type reason: Optional[str] + ## Example - :raises MatrixError: If banning the user fails. + ```python + # Ban a user without a reason + await room.ban_user("@spammer:example.com") + + # Ban a user with a reason + await room.ban_user("@spammer:example.com", reason="Spam and harassment") + ``` """ try: - # TODO: Abstract this to Context? - await self.bot.client.room_ban( + await self.client.room_ban( room_id=self.room_id, user_id=user_id, reason=reason ) except Exception as e: raise MatrixError(f"Failed to ban user: {e}") async def unban_user(self, user_id: str) -> None: - """ - Unban a user from a room. + """Unban a user from the room. - :param user_id: The ID of the user to unban of the room. - :type user_id: str + The bot must have permission to unban users. This removes the ban, + allowing the user to rejoin the room if invited or if the room is public. - :raises MatrixError: If unbanning the user fails. + ## Example + + ```python + # Unban a previously banned user + await room.unban_user("@alice:example.com") + ``` """ try: - # TODO: Abstract this to Context? - await self.bot.client.room_unban(room_id=self.room_id, user_id=user_id) + await self.client.room_unban(room_id=self.room_id, user_id=user_id) except Exception as e: raise MatrixError(f"Failed to unban user: {e}") - async def kick_user(self, user_id: str, reason: Optional[str] = None) -> None: - """ - Kick a user from a room. + async def kick_user(self, user_id: str, reason: str | None = None) -> None: + """Kick a user from the room. + + The bot must have permission to kick users. Unlike banning, kicked users + can rejoin the room if they have an invite or if the room is public. + Optionally provide a reason for the kick. + + ## Example - :param user_id: The ID of the user to kick of the room. - :type user_id: str - :param reason: The reason to kick the user. - :type reason: Optional[str] + ```python + # Kick a user without a reason + await room.kick_user("@troublemaker:example.com") - :raises MatrixError: If kicking the user fails. + # Kick a user with a reason + await room.kick_user("@troublemaker:example.com", reason="Violating room rules") + ``` """ try: - # TODO: Abstract this to Context? - await self.bot.client.room_kick( + await self.client.room_kick( room_id=self.room_id, user_id=user_id, reason=reason ) except Exception as e: diff --git a/matrix/types.py b/matrix/types.py new file mode 100644 index 0000000..c439847 --- /dev/null +++ b/matrix/types.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass + + +@dataclass +class File: + path: str + filename: str + mimetype: str + + +@dataclass +class Image: + path: str + filename: str + mimetype: str + height: int + width: int diff --git a/tests/test_context.py b/tests/test_context.py index 7354579..91bf25b 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,28 +1,43 @@ import pytest -from unittest.mock import MagicMock, AsyncMock -from nio import MatrixRoom, RoomMessageText +from unittest.mock import AsyncMock, Mock +from nio import MatrixRoom, RoomMessageText, AsyncClient from matrix.errors import MatrixError -from matrix.bot import Bot from matrix.context import Context +from matrix.room import Room +from matrix.message import Message @pytest.fixture -def bot(): - bot = Bot(config="tests/config_fixture.yaml") +def matrix_room(): + room = Mock(spec=MatrixRoom) + room.room_id = "!room:example.com" + room.name = "Test Room" + room.display_name = "Test Room" + room.topic = "A test room" + room.member_count = 5 + room.encrypted = False + return room - bot.client = MagicMock() - bot.client.room_send = AsyncMock() - bot.log = MagicMock() - bot.log.getChild.return_value = MagicMock() - return bot +@pytest.fixture +def client(): + client = AsyncMock(spec=AsyncClient) + return client @pytest.fixture -def room(): - room = MatrixRoom(room_id="!room:id", own_user_id="grace") - room.name = "Test Room" - return room +def room(matrix_room, client): + return Room(matrix_room, client) + + +@pytest.fixture +def bot(client): + bot = Mock() + bot.prefix = "!" + bot.client = client + bot.log = Mock() + bot.log.getChild = Mock(return_value=Mock()) + return bot @pytest.fixture @@ -30,7 +45,7 @@ def event(): return RoomMessageText.from_dict( { "content": {"body": "!echo hello world", "msgtype": "m.text"}, - "event_id": "$id", + "event_id": "$event123", "origin_server_ts": 123456, "sender": "@user:matrix.org", "type": "m.room.message", @@ -38,59 +53,173 @@ def event(): ) -def test_context_initialization(bot, room, event): - ctx = Context(bot, room, event) +@pytest.fixture +def context(bot, room, event): + return Context(bot, room, event) + + +def test_context_initialization__expect_correct_properties(context, bot, room, event): + assert context.bot is bot + assert context.room is room + assert context.event is event + assert context.body == "!echo hello world" + assert context.sender == "@user:matrix.org" + assert context.prefix == "!" + assert context.command is None + assert context.subcommand is None + assert context._args == ["!echo", "hello", "world"] + - assert ctx.bot == bot - assert ctx.room == room - assert ctx.event == event - assert ctx.body == "!echo hello world" - assert ctx.sender == "@user:matrix.org" - assert ctx.room_id == "!room:id" - assert ctx.room_name == "Test Room" - assert ctx.prefix == "!" - assert ctx.command is None - assert ctx._args == ["!echo", "hello", "world"] +def test_args_without_command__expect_full_args_list(context): + assert context.args == ["!echo", "hello", "world"] -def test_args_without_command(bot, room, event): - ctx = Context(bot, room, event) - assert ctx.args == ["!echo", "hello", "world"] +def test_args_with_command__expect_args_without_command_name(context): + context.command = Mock() + assert context.args == ["hello", "world"] -def test_args_with_command(bot, room, event): - ctx = Context(bot, room, event) - ctx.command = MagicMock() - assert ctx.args == ["hello", "world"] +def test_args_with_subcommand__expect_args_without_command_and_subcommand(context): + context.command = Mock() + context.subcommand = Mock() + assert context.args == ["world"] -def test_logger_property(bot, room, event): - ctx = Context(bot, room, event) - logger = ctx.logger +def test_logger_property__expect_room_specific_logger(context): + logger = context.logger assert logger is not None + context.bot.log.getChild.assert_called_once_with("!room:example.com") + + +@pytest.mark.asyncio +async def test_reply__expect_message_sent_to_room(context, client): + mock_response = Mock() + mock_response.event_id = "$reply123" + client.room_send = AsyncMock(return_value=mock_response) + + msg = await context.reply("Hello!") + + client.room_send.assert_awaited_once() + call_args = client.room_send.call_args + assert call_args.kwargs["room_id"] == "!room:example.com" + assert call_args.kwargs["message_type"] == "m.room.message" + content = call_args.kwargs["content"] + assert content["body"] == "Hello!" + assert isinstance(msg, Message) + + +@pytest.mark.asyncio +async def test_reply_with_raw__expect_unformatted_message(context, client): + mock_response = Mock() + mock_response.event_id = "$reply123" + client.room_send = AsyncMock(return_value=mock_response) + + await context.reply("Plain text", raw=True) + + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["msgtype"] == "m.text" + assert content["body"] == "Plain text" + assert "formatted_body" not in content + + +@pytest.mark.asyncio +async def test_reply_with_notice__expect_notice_message_type(context, client): + mock_response = Mock() + mock_response.event_id = "$reply123" + client.room_send = AsyncMock(return_value=mock_response) + + await context.reply("Notice!", notice=True) + + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["msgtype"] == "m.notice" + assert content["body"] == "Notice!" + + +@pytest.mark.asyncio +async def test_reply_with_file__expect_file_message_sent(context, client): + from matrix.types import File + + mock_response = Mock() + mock_response.event_id = "$reply123" + client.room_send = AsyncMock(return_value=mock_response) + + file = File( + filename="doc.pdf", path="mxc://example.com/abc", mimetype="application/pdf" + ) + + await context.reply(file=file) + + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["msgtype"] == "m.file" + assert content["body"] == "doc.pdf" @pytest.mark.asyncio -async def test_send_success(bot, room, event): - ctx = Context(bot, room, event) - await ctx.reply("Hello!") - - bot.client.room_send.assert_awaited_once_with( - room_id="!room:id", - message_type="m.room.message", - content={ - "msgtype": "m.text", - "body": "Hello!", - "format": "org.matrix.custom.html", - "formatted_body": "

Hello!

", - }, +async def test_reply_with_image__expect_image_message_sent(context, client): + from matrix.types import Image + + mock_response = Mock() + mock_response.event_id = "$reply123" + client.room_send = AsyncMock(return_value=mock_response) + + image = Image( + filename="pic.jpg", + path="mxc://example.com/xyz", + mimetype="image/jpeg", + width=800, + height=600, ) + await context.reply(image=image) + + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["msgtype"] == "m.image" + assert content["body"] == "pic.jpg" + @pytest.mark.asyncio -async def test_send_failure_raises_matrix_error(bot, room, event): - bot.client.room_send.side_effect = Exception("API failure") - ctx = Context(bot, room, event) +async def test_reply_with_error__expect_matrix_error(context, client): + client.room_send = AsyncMock(side_effect=Exception("Network failure")) + + with pytest.raises(MatrixError, match="Failed to send message"): + await context.reply("This will fail") + + +@pytest.mark.asyncio +async def test_send_help_with_subcommand__expect_subcommand_help(context): + context.subcommand = Mock() + context.subcommand.help = "Subcommand help text" + context.room.send = AsyncMock() + + await context.send_help() + + context.room.send.assert_awaited_once() + call_args = context.room.send.call_args + assert call_args.args[0] == "Subcommand help text" + + +@pytest.mark.asyncio +async def test_send_help_with_command__expect_command_help(context): + context.command = Mock() + context.command.help = "Command help text" + context.room.send = AsyncMock() + + await context.send_help() + + context.room.send.assert_awaited_once() + call_args = context.room.send.call_args + assert call_args.args[0] == "Command help text" + + +@pytest.mark.asyncio +async def test_send_help_without_command__expect_bot_help_executed(context): + context.bot.help = Mock() + context.bot.help.execute = AsyncMock() + + await context.send_help() - with pytest.raises(MatrixError, match="Failed to send message: API failure"): - await ctx.reply("Test failure") + context.bot.help.execute.assert_awaited_once_with(context) diff --git a/tests/test_message.py b/tests/test_message.py index ac39a7e..3b70583 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -1,91 +1,139 @@ import pytest -from matrix.bot import Bot -from matrix.message import Message +from unittest.mock import AsyncMock, Mock +from nio import MatrixRoom, AsyncClient from matrix.errors import MatrixError -from unittest.mock import AsyncMock, MagicMock -from nio import RoomMessageText +from matrix.message import Message +from matrix.room import Room @pytest.fixture -def message_default(): - bot = Bot(config="tests/config_fixture.yaml") +def matrix_room(): + room = Mock(spec=MatrixRoom) + room.room_id = "!room:example.com" + room.name = "Test Room" + room.display_name = "Test Room" + room.topic = "A test room" + room.member_count = 5 + room.encrypted = False + return room - bot.client = MagicMock() - bot.client.room_send = AsyncMock() - bot.log = MagicMock() - bot.log.getChild.return_value = MagicMock() - return Message(bot) +@pytest.fixture +def client(): + client = AsyncMock(spec=AsyncClient) + return client @pytest.fixture -def event(): - return RoomMessageText.from_dict( - { - "content": {"body": "hello", "msgtype": "m.text"}, - "event_id": "$id", - "origin_server_ts": 123456, - "sender": "@user:matrix.org", - "type": "m.room.message", - } - ) +def room(matrix_room, client): + return Room(matrix_room, client) + + +@pytest.fixture +def message(room, client): + return Message(room=room, event_id="$event123", body="Hello world!", client=client) @pytest.mark.asyncio -async def test_send_message_success(message_default): - room_id = "!room:id" - message = "Hello, world!" +async def test_reply__expect_threaded_reply_message(message, client): + mock_response = Mock() + mock_response.event_id = "$reply456" + client.room_send = AsyncMock(return_value=mock_response) - await message_default.send(room_id, message) - message_default.bot.client.room_send.assert_awaited_once() + result = await message.reply("Replying to you!") + + client.room_send.assert_awaited_once() + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["body"] == "Replying to you!" + assert "m.relates_to" in content + assert content["m.relates_to"]["m.in_reply_to"]["event_id"] == "$event123" + assert isinstance(result, Message) + assert result.id == "$reply456" @pytest.mark.asyncio -async def test_send_message_failure(message_default): - room_id = "!room:id" - message = "Hello, world!" +async def test_reply_with_error__expect_matrix_error(message, client): + client.room_send = AsyncMock(side_effect=Exception("Network error")) - message_default.bot.client.room_send.side_effect = Exception( - "Failed to send message" - ) - with pytest.raises(MatrixError, match="Failed to send message"): - await message_default.send(room_id, message) + with pytest.raises(MatrixError, match="Failed to send reply"): + await message.reply("This will fail") + + +@pytest.mark.asyncio +async def test_react__expect_reaction_sent(message, client): + client.room_send = AsyncMock() + + await message.react("👍") + client.room_send.assert_awaited_once() + call_args = client.room_send.call_args + assert call_args.kwargs["message_type"] == "m.reaction" + content = call_args.kwargs["content"] + assert content["m.relates_to"]["rel_type"] == "m.annotation" + assert content["m.relates_to"]["event_id"] == "$event123" + assert content["m.relates_to"]["key"] == "👍" -def test_make_content_with_html(message_default): - body = "# Hello, world!" - content = message_default._make_content(body, True) - assert content["msgtype"] == message_default.TEXT_MESSAGE_TYPE - assert content["body"] == body - assert content["format"] == message_default.MATRIX_CUSTOM_HTML - assert "formatted_body" in content +@pytest.mark.asyncio +async def test_react_with_error__expect_matrix_error(message, client): + client.room_send = AsyncMock(side_effect=Exception("Failed to react")) + + with pytest.raises(MatrixError, match="Failed to add reaction"): + await message.react("😀") -def test_make_content_without_html(message_default): - body = "Hello, world!" - content = message_default._make_content(body, False) +@pytest.mark.asyncio +async def test_edit__expect_message_updated(message, client): + client.room_send = AsyncMock() + + await message.edit("Updated content") - assert content["msgtype"] == message_default.TEXT_MESSAGE_TYPE - assert content["body"] == body - assert "format" not in content - assert "formatted_body" not in content + client.room_send.assert_awaited_once() + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["body"] == "* Updated content" + assert content["m.new_content"]["body"] == "Updated content" + assert content["m.relates_to"]["rel_type"] == "m.replace" + assert content["m.relates_to"]["event_id"] == "$event123" + assert message.body == "Updated content" @pytest.mark.asyncio -async def test_send_reaction_success(message_default, event): - room_id = "!room:id" +async def test_edit_with_error__expect_matrix_error(message, client): + client.room_send = AsyncMock(side_effect=Exception("Failed to edit")) - await message_default.send_reaction(room_id, event, "hi") - message_default.bot.client.room_send.assert_awaited_once() + with pytest.raises(MatrixError, match="Failed to edit message"): + await message.edit("New content") @pytest.mark.asyncio -async def test_send_reaction_failure(message_default, event): - room_id = "!room:id" +async def test_delete__expect_message_redacted(message, client): + client.room_redact = AsyncMock() - message_default.bot.client.room_send.side_effect = Exception( - "Failed to send message" + await message.delete() + + client.room_redact.assert_awaited_once_with( + room_id="!room:example.com", event_id="$event123" ) - with pytest.raises(MatrixError, match="Failed to send message"): - await message_default.send_reaction(room_id, event, "🙏") + + +@pytest.mark.asyncio +async def test_delete_with_error__expect_matrix_error(message, client): + client.room_redact = AsyncMock(side_effect=Exception("Failed to redact")) + + with pytest.raises(MatrixError, match="Failed to delete message"): + await message.delete() + + +def test_message_properties__expect_correct_values(message, room, client): + assert message.id == "$event123" + assert message.event_id == "$event123" + assert message.body == "Hello world!" + assert message.room is room + assert message.client is client + + +def test_message_repr__expect_formatted_string(message): + result = repr(message) + assert result == "" diff --git a/tests/test_room.py b/tests/test_room.py index a8ff1ba..ae61ed5 100644 --- a/tests/test_room.py +++ b/tests/test_room.py @@ -1,151 +1,298 @@ import pytest -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock +from nio import MatrixRoom from matrix.errors import MatrixError from matrix.room import Room +from matrix.message import Message @pytest.fixture -def room_default(): - bot = AsyncMock() - room_id = "!room:id" - return Room(room_id, bot) +def matrix_room(): + room = Mock(spec=MatrixRoom) + room.room_id = "!room:example.com" + room.name = "Test Room" + room.display_name = "Test Room" + room.topic = "A test room" + room.member_count = 5 + room.encrypted = False + return room @pytest.fixture -def message(): - message_mock = AsyncMock() - message_mock.send = AsyncMock() - return message_mock +def client(): + client = AsyncMock() + return client + + +@pytest.fixture +def room(matrix_room, client): + return Room(matrix_room, client) + + +@pytest.mark.asyncio +async def test_send_markdown__expect_formatted_message(room, client): + client.room_send = AsyncMock() + mock_response = Mock() + mock_response.event_id = "$event123" + client.room_send.return_value = mock_response + + msg = await room.send("Hello **world**!") + + client.room_send.assert_awaited_once() + call_args = client.room_send.call_args + assert call_args.kwargs["room_id"] == "!room:example.com" + assert call_args.kwargs["message_type"] == "m.room.message" + assert call_args.kwargs["content"]["msgtype"] == "m.text" + assert call_args.kwargs["content"]["body"] == "Hello **world**!" + assert "formatted_body" in call_args.kwargs["content"] + assert isinstance(msg, Message) + assert msg.id == "$event123" + + +@pytest.mark.asyncio +async def test_send_raw__expect_unformatted_message(room, client): + client.room_send = AsyncMock() + mock_response = Mock() + mock_response.event_id = "$event123" + client.room_send.return_value = mock_response + + await room.send("Hello world!", raw=True) + + client.room_send.assert_awaited_once() + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["msgtype"] == "m.text" + assert content["body"] == "Hello world!" + assert "formatted_body" not in content + + +@pytest.mark.asyncio +async def test_send_notice__expect_notice_message_type(room, client): + client.room_send = AsyncMock() + mock_response = Mock() + mock_response.event_id = "$event123" + client.room_send.return_value = mock_response + + await room.send("Bot starting up...", notice=True) + + client.room_send.assert_awaited_once() + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["msgtype"] == "m.notice" + assert content["body"] == "Bot starting up..." + + +@pytest.mark.asyncio +async def test_send_text_with_reply_to__expect_threaded_reply(room, client): + client.room_send = AsyncMock() + mock_response = Mock() + mock_response.event_id = "$event123" + client.room_send.return_value = mock_response + + await room.send_text("Replying!", reply_to="$original_event") + + client.room_send.assert_awaited_once() + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["msgtype"] == "m.text" + assert content["body"] == "Replying!" + assert "m.relates_to" in content + assert content["m.relates_to"]["m.in_reply_to"]["event_id"] == "$original_event" @pytest.mark.asyncio -async def test_send_message_room_success(room_default, message): - message.send.return_value = None +async def test_send_file__expect_file_message(room, client): + from matrix.types import File + + client.room_send = AsyncMock() + mock_response = Mock() + mock_response.event_id = "$event123" + client.room_send.return_value = mock_response + + file = File( + filename="document.pdf", + path="mxc://example.com/abc123", + mimetype="application/pdf", + ) - room_default.bot.client.room_send = message.send + await room.send(file=file) - msg_to_send = "Hello, world!" + client.room_send.assert_awaited_once() + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["msgtype"] == "m.file" + assert content["body"] == "document.pdf" + assert content["url"] == "mxc://example.com/abc123" - await room_default.send(msg_to_send) - message.send.assert_awaited_once_with( - room_id=room_default.room_id, - message_type="m.room.message", - content={ - "msgtype": "m.text", - "body": msg_to_send, - "format": "org.matrix.custom.html", - "formatted_body": f"

{msg_to_send}

", - }, + +@pytest.mark.asyncio +async def test_send_image__expect_image_message_with_dimensions(room, client): + from matrix.types import Image + + client.room_send = AsyncMock() + mock_response = Mock() + mock_response.event_id = "$event123" + client.room_send.return_value = mock_response + + image = Image( + filename="photo.jpg", + path="mxc://example.com/xyz789", + mimetype="image/jpeg", + width=800, + height=600, ) + await room.send(image=image) + + client.room_send.assert_awaited_once() + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["msgtype"] == "m.image" + assert content["body"] == "photo.jpg" + assert content["url"] == "mxc://example.com/xyz789" + assert content["info"]["w"] == 800 + assert content["info"]["h"] == 600 + @pytest.mark.asyncio -async def test_send_message_room_failure(room_default, message): - message.send.side_effect = Exception("Failed to send message") +async def test_send_no_content__expect_value_error(room): + with pytest.raises(ValueError, match="You must provide content, file, or image"): + await room.send() + - room_default.bot.client.room_send = message.send +@pytest.mark.asyncio +async def test_send_message_with_network_error__expect_matrix_error(room, client): + client.room_send = AsyncMock() + client.room_send.side_effect = Exception("Network error") with pytest.raises(MatrixError, match="Failed to send message"): - await room_default.send("Hello, world!") + await room.send("Hello, world!") @pytest.mark.asyncio -async def test_invite_user_success(room_default): - room_default.bot.client.room_invite = AsyncMock() - room_default.bot.client.room_invite.return_value = None +async def test_invite_user__expect_successful_invitation(room, client): + client.room_invite = AsyncMock() + client.room_invite.return_value = None - await room_default.invite_user("@user:matrix.org") - room_default.bot.client.room_invite.assert_awaited_once_with( - room_id=room_default.room_id, user_id="@user:matrix.org" + await room.invite_user("@alice:example.com") + + client.room_invite.assert_awaited_once_with( + room_id="!room:example.com", user_id="@alice:example.com" ) @pytest.mark.asyncio -async def test_invite_user_failure(room_default): - room_default.bot.client.room_invite = AsyncMock() - room_default.bot.client.room_invite.side_effect = Exception("Failed to invite user") +async def test_invite_user_with_error__expect_matrix_error(room, client): + client.room_invite = AsyncMock() + client.room_invite.side_effect = Exception("User not found") with pytest.raises(MatrixError, match="Failed to invite user"): - await room_default.invite_user("@user:matrix.org") + await room.invite_user("@alice:example.com") @pytest.mark.asyncio -async def test_ban_user_success_without_reason(room_default): - room_default.bot.client.room_ban = AsyncMock() - room_default.bot.client.room_ban.return_value = None +async def test_ban_user_without_reason__expect_successful_ban(room, client): + client.room_ban = AsyncMock() + client.room_ban.return_value = None + + await room.ban_user("@spammer:example.com") - await room_default.ban_user("@user:matrix.org") - room_default.bot.client.room_ban.assert_awaited_once_with( - room_id=room_default.room_id, user_id="@user:matrix.org", reason=None + client.room_ban.assert_awaited_once_with( + room_id="!room:example.com", user_id="@spammer:example.com", reason=None ) @pytest.mark.asyncio -async def test_ban_user_success_with_reason(room_default): - room_default.bot.client.room_ban = AsyncMock() - room_default.bot.client.room_ban.return_value = None +async def test_ban_user_with_reason__expect_successful_ban_with_reason(room, client): + client.room_ban = AsyncMock() + client.room_ban.return_value = None - await room_default.ban_user("@user:matrix.org", "Test Ban") - room_default.bot.client.room_ban.assert_awaited_once_with( - room_id=room_default.room_id, user_id="@user:matrix.org", reason="Test Ban" + await room.ban_user("@spammer:example.com", "Spam and harassment") + + client.room_ban.assert_awaited_once_with( + room_id="!room:example.com", + user_id="@spammer:example.com", + reason="Spam and harassment", ) @pytest.mark.asyncio -async def test_ban_user_failure(room_default): - room_default.bot.client.room_ban = AsyncMock() - room_default.bot.client.room_ban.side_effect = Exception("Failed to ban user") +async def test_ban_user_with_error__expect_matrix_error(room, client): + client.room_ban = AsyncMock() + client.room_ban.side_effect = Exception("Insufficient permissions") with pytest.raises(MatrixError, match="Failed to ban user"): - await room_default.ban_user("@user:matrix.org") + await room.ban_user("@spammer:example.com") @pytest.mark.asyncio -async def test_unban_user_success(room_default): - room_default.bot.client.room_unban = AsyncMock() - room_default.bot.client.room_unban.return_value = None +async def test_unban_user__expect_successful_unban(room, client): + client.room_unban = AsyncMock() + client.room_unban.return_value = None + + await room.unban_user("@alice:example.com") - await room_default.unban_user("@user:matrix.org") - room_default.bot.client.room_unban.assert_awaited_once_with( - room_id=room_default.room_id, user_id="@user:matrix.org" + client.room_unban.assert_awaited_once_with( + room_id="!room:example.com", user_id="@alice:example.com" ) @pytest.mark.asyncio -async def test_unban_user_failure(room_default): - room_default.bot.client.room_unban = AsyncMock() - room_default.bot.client.room_unban.side_effect = Exception("Failed to unban user") +async def test_unban_user_with_error__expect_matrix_error(room, client): + client.room_unban = AsyncMock() + client.room_unban.side_effect = Exception("User not banned") with pytest.raises(MatrixError, match="Failed to unban user"): - await room_default.unban_user("@user:matrix.org") + await room.unban_user("@alice:example.com") @pytest.mark.asyncio -async def test_kick_user_success_without_reason(room_default): - room_default.bot.client.room_kick = AsyncMock() - room_default.bot.client.room_kick.return_value = None +async def test_kick_user_without_reason__expect_successful_kick(room, client): + client.room_kick = AsyncMock() + client.room_kick.return_value = None + + await room.kick_user("@troublemaker:example.com") - await room_default.kick_user("@user:matrix.org") - room_default.bot.client.room_kick.assert_awaited_once_with( - room_id=room_default.room_id, user_id="@user:matrix.org", reason=None + client.room_kick.assert_awaited_once_with( + room_id="!room:example.com", user_id="@troublemaker:example.com", reason=None ) @pytest.mark.asyncio -async def test_kick_user_success_with_reason(room_default): - room_default.bot.client.room_kick = AsyncMock() - room_default.bot.client.room_kick.return_value = None +async def test_kick_user_with_reason__expect_successful_kick_with_reason(room, client): + client.room_kick = AsyncMock() + client.room_kick.return_value = None - await room_default.kick_user("@user:matrix.org", "Test Kick") - room_default.bot.client.room_kick.assert_awaited_once_with( - room_id=room_default.room_id, user_id="@user:matrix.org", reason="Test Kick" + await room.kick_user("@troublemaker:example.com", "Violating rules") + + client.room_kick.assert_awaited_once_with( + room_id="!room:example.com", + user_id="@troublemaker:example.com", + reason="Violating rules", ) @pytest.mark.asyncio -async def test_kick_user_failure(room_default): - room_default.bot.client.room_kick = AsyncMock() - room_default.bot.client.room_kick.side_effect = Exception("Failed to kick user") +async def test_kick_user_with_error__expect_matrix_error(room, client): + client.room_kick = AsyncMock() + client.room_kick.side_effect = Exception("User not in room") with pytest.raises(MatrixError, match="Failed to kick user"): - await room_default.kick_user("@user:matrix.org") + await room.kick_user("@troublemaker:example.com") + + +def test_room_properties__expect_correct_delegation_to_matrix_room(room, matrix_room): + assert room.room_id == "!room:example.com" + assert room.name == "Test Room" + assert room.display_name == "Test Room" + assert room.topic == "A test room" + assert room.member_count == 5 + assert room.encrypted is False + + +def test_room_matrix_room_property__expect_underlying_matrix_room(room, matrix_room): + assert room.matrix_room is matrix_room + + +def test_room_client_property__expect_async_client(room, client): + assert room.client is client