From 9be24088bd9b01d16ea1a04f9569fac12b883a4c Mon Sep 17 00:00:00 2001 From: penguinboi Date: Mon, 16 Feb 2026 23:07:35 -0500 Subject: [PATCH 1/3] Improved how file are sent --- matrix/content.py | 15 ++------- matrix/room.py | 85 +++++++++++++++++++++-------------------------- matrix/types.py | 17 +++++++--- 3 files changed, 53 insertions(+), 64 deletions(-) diff --git a/matrix/content.py b/matrix/content.py index 05e8fa1..7b3ca05 100644 --- a/matrix/content.py +++ b/matrix/content.py @@ -87,11 +87,8 @@ def build(self) -> dict: @dataclass -class ImageContent(BaseMessageContent): +class ImageContent(FileContent): msgtype = "m.image" - filename: str - url: str - mimetype: str height: int = 0 width: int = 0 @@ -109,11 +106,8 @@ def build(self) -> dict: @dataclass -class AudioContent(BaseMessageContent): +class AudioContent(FileContent): msgtype = "m.audio" - filename: str - url: str - mimetype: str duration: int = 0 def build(self) -> dict: @@ -129,11 +123,8 @@ def build(self) -> dict: @dataclass -class VideoContent(BaseMessageContent): +class VideoContent(FileContent): msgtype = "m.video" - filename: str - url: str - mimetype: str height: int = 0 width: int = 0 duration: int = 0 diff --git a/matrix/room.py b/matrix/room.py index 9f8b64d..32efee3 100644 --- a/matrix/room.py +++ b/matrix/room.py @@ -5,15 +5,17 @@ from matrix.errors import MatrixError from matrix.message import Message from matrix.content import ( + BaseMessageContent, TextContent, MarkdownMessage, NoticeContent, + ReplyContent, FileContent, ImageContent, - BaseMessageContent, - ReplyContent, + AudioContent, + VideoContent, ) -from matrix.types import File, Image +from matrix.types import File, Image, Audio, Video class Room: @@ -86,7 +88,6 @@ async def send( raw: bool = False, notice: bool = False, file: File | None = None, - image: Image | None = None, ) -> Message: """Send a message to the room. @@ -112,7 +113,7 @@ async def send( # Send an image image = Image(filename="photo.jpg", path="mxc://...", mimetype="image/jpeg", width=800, height=600) - await room.send(image=image) + await room.send(file=image) ``` """ if content: @@ -120,9 +121,6 @@ async def send( 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( @@ -189,47 +187,38 @@ async def send_file(self, file: File) -> Message: await room.send_file(file) ``` """ - 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") + payload: FileContent + + match file: + case Image(): + payload = ImageContent( + filename=file.filename, + url=file.path, + mimetype=file.mimetype, + height=file.height, + width=file.width, + ) + case Audio(): + payload = AudioContent( + filename=file.filename, + url=file.path, + mimetype=file.mimetype, + duration=file.duration, + ) + case Video(): + payload = VideoContent( + filename=file.filename, + url=file.path, + mimetype=file.mimetype, + height=file.height, + width=file.width, + duration=file.duration, + ) + case _: + payload = FileContent( + filename=file.filename, url=file.path, mimetype=file.mimetype + ) - 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: diff --git a/matrix/types.py b/matrix/types.py index c439847..5c3257d 100644 --- a/matrix/types.py +++ b/matrix/types.py @@ -9,9 +9,18 @@ class File: @dataclass -class Image: - path: str - filename: str - mimetype: str +class Image(File): height: int width: int + + +@dataclass +class Audio(File): + duration: int = 0 + + +@dataclass +class Video(File): + width: int = 0 + height: int = 0 + duration: int = 0 From 44b2a5cebc3909e093cbf37aea639c8a25e4dcbf Mon Sep 17 00:00:00 2001 From: penguinboi Date: Mon, 16 Feb 2026 23:20:10 -0500 Subject: [PATCH 2/3] Adjusted tests --- matrix/context.py | 2 -- tests/test_context.py | 2 +- tests/test_room.py | 66 ++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/matrix/context.py b/matrix/context.py index c1a7d86..614b2b1 100644 --- a/matrix/context.py +++ b/matrix/context.py @@ -63,7 +63,6 @@ async def reply( raw: bool = False, notice: bool = False, file: File | None = None, - image: Image | None = None, ) -> Message: """Reply to the command with a message. @@ -121,7 +120,6 @@ async def cat(ctx: Context): raw=raw, notice=notice, file=file, - image=image, ) except Exception as e: raise MatrixError(f"Failed to send message: {e}") diff --git a/tests/test_context.py b/tests/test_context.py index 91bf25b..cccf5d7 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -173,7 +173,7 @@ async def test_reply_with_image__expect_image_message_sent(context, client): height=600, ) - await context.reply(image=image) + await context.reply(file=image) call_args = client.room_send.call_args content = call_args.kwargs["content"] diff --git a/tests/test_room.py b/tests/test_room.py index ae61ed5..3ad2514 100644 --- a/tests/test_room.py +++ b/tests/test_room.py @@ -110,8 +110,8 @@ async def test_send_file__expect_file_message(room, client): client.room_send.return_value = mock_response file = File( - filename="document.pdf", path="mxc://example.com/abc123", + filename="document.pdf", mimetype="application/pdf", ) @@ -135,14 +135,14 @@ async def test_send_image__expect_image_message_with_dimensions(room, client): client.room_send.return_value = mock_response image = Image( - filename="photo.jpg", path="mxc://example.com/xyz789", + filename="photo.jpg", mimetype="image/jpeg", width=800, height=600, ) - await room.send(image=image) + await room.send(file=image) client.room_send.assert_awaited_once() call_args = client.room_send.call_args @@ -154,9 +154,67 @@ async def test_send_image__expect_image_message_with_dimensions(room, client): assert content["info"]["h"] == 600 +@pytest.mark.asyncio +async def test_send_video__expect_video_message_with_metadata(room, client): + from matrix.types import Video + + client.room_send = AsyncMock() + mock_response = Mock() + mock_response.event_id = "$event123" + client.room_send.return_value = mock_response + + video = Video( + path="mxc://example.com/video123", + filename="clip.mp4", + mimetype="video/mp4", + width=1920, + height=1080, + duration=30000, + ) + + await room.send(file=video) + + client.room_send.assert_awaited_once() + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["msgtype"] == "m.video" + assert content["body"] == "clip.mp4" + assert content["url"] == "mxc://example.com/video123" + assert content["info"]["w"] == 1920 + assert content["info"]["h"] == 1080 + assert content["info"]["duration"] == 30000 + + +@pytest.mark.asyncio +async def test_send_audio__expect_audio_message_with_duration(room, client): + from matrix.types import Audio + + client.room_send = AsyncMock() + mock_response = Mock() + mock_response.event_id = "$event123" + client.room_send.return_value = mock_response + + audio = Audio( + path="mxc://example.com/audio123", + filename="song.mp3", + mimetype="audio/mpeg", + duration=180000, + ) + + await room.send(file=audio) + + client.room_send.assert_awaited_once() + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["msgtype"] == "m.audio" + assert content["body"] == "song.mp3" + assert content["url"] == "mxc://example.com/audio123" + assert content["info"]["duration"] == 180000 + + @pytest.mark.asyncio async def test_send_no_content__expect_value_error(room): - with pytest.raises(ValueError, match="You must provide content, file, or image"): + with pytest.raises(ValueError, match="You must provide content or file."): await room.send() From 305f4fbd04c4dbb744c356c3cf9c0406cb558015 Mon Sep 17 00:00:00 2001 From: penguinboi Date: Mon, 16 Feb 2026 23:22:20 -0500 Subject: [PATCH 3/3] Updated doc --- matrix/context.py | 25 +++------ matrix/room.py | 132 ++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 119 insertions(+), 38 deletions(-) diff --git a/matrix/context.py b/matrix/context.py index 614b2b1..1fe15f4 100644 --- a/matrix/context.py +++ b/matrix/context.py @@ -66,32 +66,23 @@ async def reply( ) -> Message: """Reply to the command with a message. + This is a convenience method that sends a message to the room where the + command was invoked. Supports text messages (with optional markdown + formatting) and file uploads (including images, videos, and audio). + + See `Room.send()` for detailed usage examples and documentation. + ## 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 @@ -104,13 +95,13 @@ async def cat(ctx: Context): resp, _ = await ctx.room.client.upload(f, content_type="image/jpeg") image = Image( - filename="cat.jpg", path=resp.content_uri, + filename="cat.jpg", mimetype="image/jpeg", width=width, height=height ) - await ctx.reply(image=image) + await ctx.reply(file=image) ``` """ diff --git a/matrix/room.py b/matrix/room.py index 32efee3..4780d7a 100644 --- a/matrix/room.py +++ b/matrix/room.py @@ -93,7 +93,10 @@ async def send( 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. + optional markdown formatting) and file uploads (including images, videos, and audio). + + For detailed text message examples, see `Room.send_text()`. + For detailed file upload examples, see `Room.send_file()`. ## Example @@ -101,18 +104,12 @@ async def send( # 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") + file = File(path="mxc://...", filename="document.pdf", 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) + image = Image(path="mxc://...", filename="photo.jpg", mimetype="image/jpeg", width=800, height=600) await room.send(file=image) ``` """ @@ -121,7 +118,7 @@ async def send( if file: return await self.send_file(file) - raise ValueError("You must provide content, file, or image to send.") + raise ValueError("You must provide content or file.") async def send_text( self, @@ -133,9 +130,9 @@ async def send_text( ) -> 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`. + 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`. Use `reply_to` to create a threaded reply. ## Example @@ -150,7 +147,7 @@ async def send_text( await room.send_text("Bot restarted successfully", notice=True) # Reply to another message - await room.send_text("Bot restarted successfully", replay_to=message.id) + await room.send_text("Replying to you!", reply_to="$event_id") ``` """ payload: TextContent @@ -167,24 +164,117 @@ async def send_text( return await self._send_payload(payload) async def send_file(self, file: File) -> Message: - """Send a file to the room. + """Send a file, image, video, or audio to the room. + + Accepts any File object or its subclasses (Image, Video, Audio). The file must + be uploaded to the Matrix content repository before sending. Use the room's + client upload method to get the MXC URI. - 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. + The method automatically detects the file type and sends it with the appropriate + Matrix message type (m.file, m.image, m.video, or m.audio). + + For more information on the upload method, see the matrix-nio documentation: + https://matrix-nio.readthedocs.io/en/latest/nio.html#nio.AsyncClient.upload ## Example ```python - # Upload file first, then send - with open("document.pdf", "rb") as f: - resp, _ = await room.client.upload(f, content_type="application/pdf") + import os + + # Send a document + file_path = "document.pdf" + file_size = os.path.getsize(file_path) + + with open(file_path, "rb") as f: + resp, _ = await room.client.upload( + f, + content_type="application/pdf", + filesize=file_size + ) file = File( - filename="document.pdf", path=resp.content_uri, + filename="document.pdf", mimetype="application/pdf" ) await room.send_file(file) + + # Send an image + from PIL import Image as PILImage + + image_path = "photo.jpg" + + with PILImage.open(image_path) as img: + width, height = img.size + + file_size = os.path.getsize(image_path) + + with open(image_path, "rb") as f: + resp, _ = await room.client.upload( + f, + content_type="image/jpeg", + filesize=file_size + ) + + image = Image( + path=resp.content_uri, + filename="photo.jpg", + mimetype="image/jpeg", + width=width, + height=height + ) + await room.send_file(image) + + # Send a video + import cv2 + + video_path = "video.mp4" + + cap = cv2.VideoCapture(video_path) + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + fps = cap.get(cv2.CAP_PROP_FPS) + frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + duration = int((frame_count / fps) * 1000) + cap.release() + + file_size = os.path.getsize(video_path) + + with open(video_path, "rb") as f: + resp, _ = await room.client.upload( + f, + content_type="video/mp4", + filesize=file_size + ) + + video = Video( + path=resp.content_uri, + filename="video.mp4", + mimetype="video/mp4", + width=width, + height=height, + duration=duration + ) + await room.send_file(video) + + # Send audio + audio_path = "audio.mp3" + file_size = os.path.getsize(audio_path) + + with open(audio_path, "rb") as f: + resp, _ = await room.client.upload( + f, + content_type="audio/mpeg", + filesize=file_size + ) + + audio = Audio( + path=resp.content_uri, + filename="audio.mp3", + mimetype="audio/mpeg", + duration=180000 # 3 minutes in milliseconds + ) + await room.send_file(audio) ``` """ payload: FileContent