diff --git a/matrix/bot.py b/matrix/bot.py index 4164f79..5291803 100644 --- a/matrix/bot.py +++ b/matrix/bot.py @@ -7,6 +7,9 @@ from nio import AsyncClient, Event, MatrixRoom +from matrix.message import Message +from matrix.types import File + from .room import Room, make_room from .space import Space from .group import Group @@ -423,3 +426,44 @@ async def _build_context(self, matrix_room: Room, event: Event) -> Context: ctx.command = cmd return ctx + + # ROOMS + + async def broadcast( + self, + rooms: list[Room], + content: str | None = None, + *, + raw: bool = False, + notice: bool = False, + file: File | None = None, + ) -> list[Message]: + """Broadcasts a message to the specified rooms. + + Supports text messages (with optional markdown formatting) + and file uploads (including images, videos, and audio). + If a space is provided, it is silently skipped. + + ## Example + + ```python + # Broadcast a markdown-formatted text message + await bot.broadcast([room1, room2, ...], "Hello **world**!") + + # Broadcast a notice message + await bot.broadcast([room1, room2, ...], "Event started", notice=True) + + # Broadcast a file + file = File(path="mxc://...", filename="document.pdf", mimetype="application/pdf") + await bot.broadcast([room1, room2, ...], file=file) + + # Broadcast an image + image = Image(path="mxc://...", filename="photo.jpg", mimetype="image/jpeg", width=800, height=600) + await bot.broadcast([room1, room2, ...], file=image) + ``` + """ + rooms = list(filter(lambda child: not isinstance(child, Space), rooms)) + async_send = [ + room.send(content, raw=raw, notice=notice, file=file) for room in rooms + ] + return await asyncio.gather(*async_send) diff --git a/matrix/space.py b/matrix/space.py index 525ee6f..f2babfd 100644 --- a/matrix/space.py +++ b/matrix/space.py @@ -1,6 +1,11 @@ +import asyncio + +from matrix.message import Message from typing import Self from matrix.room import Room, make_room +from matrix.types import File + class Space(Room, room_type="m.space"): def get_children(self, depth: int = 1) -> list[Room | Self]: @@ -43,3 +48,50 @@ def get_children(self, depth: int = 1) -> list[Room | Self]: children.extend(child.get_children(depth - 1)) return children + + async def broadcast( + self, + content: str | None = None, + *, + raw: bool = False, + notice: bool = False, + file: File | None = None, + depth: int = 1, + ) -> list[Message]: + """Broadcasts a message to all rooms in this space. + + Supports text messages (with optional markdown formatting) + and file uploads (including images, videos, and audio). + + Children the bot has not joined are silently omitted. Use `depth` to + recursively broadcast to children of sub-spaces. `depth=1` broadcasts + to direct children only (default). + + ## Example + + ```python + # Broadcast a markdown-formatted text message + await space.broadcast("Hello **world**!") + + # Broadcast a notice message + await space.broadcast("Event started", notice=True) + + # Broadcast a file + file = File(path="mxc://...", filename="document.pdf", mimetype="application/pdf") + await space.broadcast(file=file) + + # Broadcast an image + image = Image(path="mxc://...", filename="photo.jpg", mimetype="image/jpeg", width=800, height=600) + await space.broadcast(file=image) + + # Broadcast a notice message to space's rooms and the rooms of its subspaces + await space.broadcast("New Announcement", notice=True, depth=2) + ``` + """ + rooms = filter( + lambda room: not isinstance(room, Space), self.get_children(depth=depth) + ) + async_send = [ + room.send(content, raw=raw, notice=notice, file=file) for room in rooms + ] + return await asyncio.gather(*async_send) diff --git a/tests/test_bot.py b/tests/test_bot.py index 18b7f16..7747bb8 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -1,9 +1,11 @@ import pytest -from unittest.mock import AsyncMock, MagicMock, patch -from nio import MatrixRoom, RoomMessageText, LoginError +from unittest.mock import AsyncMock, MagicMock, Mock, patch +from nio import MatrixRoom, RoomMessageText, LoginError, Event from matrix import Bot, Config, Extension, Room, Space +from matrix.message import Message +from matrix.types import File from matrix.errors import ( CheckError, CommandNotFoundError, @@ -970,3 +972,124 @@ async def task(): job_names = [j.name for j in bot.scheduler.jobs] assert "task" in job_names + + +@pytest.fixture +def mock_send_response(bot): + """Set up client to return a mock event after room_send and fetch_event.""" + send_response = Mock() + send_response.event_id = "$event123" + bot._client.room_send = AsyncMock(return_value=send_response) + + mock_event = MagicMock(spec=Event) + mock_event.event_id = "$event123" + + get_event_response = Mock() + get_event_response.event = mock_event + bot._client.room_get_event = AsyncMock(return_value=get_event_response) + + return send_response + + +@pytest.fixture +def make_room(bot): + """Factory that creates a Room instance for a given room ID.""" + + def _make(room_id): + matrix_room = MatrixRoom(room_id=room_id, own_user_id="grace") + matrix_room.name = room_id + bot._client.rooms = {**bot._client.rooms, room_id: matrix_room} + return Room(matrix_room, bot.client) + + return _make + + +@pytest.mark.asyncio +async def test_broadcast__expect_message_sent_to_all_rooms( + bot, make_room, mock_send_response +): + room1 = make_room("!room1:example.com") + room2 = make_room("!room2:example.com") + + results = await bot.broadcast([room1, room2], "Hello!") + + assert len(results) == 2 + assert all(isinstance(msg, Message) for msg in results) + assert bot._client.room_send.await_count == 2 + + +@pytest.mark.asyncio +async def test_broadcast__with_empty_list__expect_no_messages(bot, mock_send_response): + results = await bot.broadcast([], "Hello!") + + assert results == [] + bot._client.room_send.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_broadcast__with_space_in_list__expect_space_skipped( + bot, make_room, mock_send_response +): + room = make_room("!room:example.com") + space_matrix = MatrixRoom(room_id="!space:example.com", own_user_id="grace") + space_matrix.name = "Test Space" + space_matrix.room_type = "m.space" + bot._client.rooms = {**bot._client.rooms, "!space:example.com": space_matrix} + space = Space(space_matrix, bot.client) + + results = await bot.broadcast([room, space], "Hello!") + + assert len(results) == 1 + assert bot._client.room_send.await_count == 1 + sent_room_ids = { + call.kwargs["room_id"] for call in bot._client.room_send.await_args_list + } + assert sent_room_ids == {"!room:example.com"} + + +@pytest.mark.asyncio +async def test_broadcast_raw__expect_unformatted_messages( + bot, make_room, mock_send_response +): + room = make_room("!room:example.com") + + await bot.broadcast([room], "Hello world!", raw=True) + + call_args = bot._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_broadcast_notice__expect_notice_message_type( + bot, make_room, mock_send_response +): + room = make_room("!room:example.com") + + await bot.broadcast([room], "Special Event started!", notice=True) + + call_args = bot._client.room_send.call_args + content = call_args.kwargs["content"] + assert content["msgtype"] == "m.notice" + assert content["body"] == "Special Event started!" + + +@pytest.mark.asyncio +async def test_broadcast_file__expect_file_message(bot, make_room, mock_send_response): + room = make_room("!room:example.com") + + file = File( + path="mxc://example.com/abc123", + filename="document.pdf", + mimetype="application/pdf", + ) + + await bot.broadcast([room], file=file) + + call_args = bot._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" diff --git a/tests/test_space.py b/tests/test_space.py index 444b6df..800fb3e 100644 --- a/tests/test_space.py +++ b/tests/test_space.py @@ -1,8 +1,9 @@ import pytest -from unittest.mock import AsyncMock, Mock -from nio import MatrixRoom +from unittest.mock import AsyncMock, Mock, MagicMock +from nio import MatrixRoom, Event from matrix.room import Room from matrix.space import Space +from matrix.message import Message @pytest.fixture @@ -23,6 +24,23 @@ def space(matrix_space, client): return Space(matrix_space, client) +@pytest.fixture +def mock_send_response(client): + """Set up client to return a mock event after room_send and fetch_event.""" + send_response = Mock() + send_response.event_id = "$event123" + client.room_send = AsyncMock(return_value=send_response) + + mock_event = MagicMock(spec=Event) + mock_event.event_id = "$event123" + + get_event_response = Mock() + get_event_response.event = mock_event + client.room_get_event = AsyncMock(return_value=get_event_response) + + return send_response + + def test_get_children__with_room_child__expect_room_instance( space, matrix_space, client ): @@ -149,3 +167,271 @@ def test_get_children__with_depth_two__expect_recursive_children( ids = [r.room_id for r in result] assert "!subspace:example.com" in ids assert "!nested:example.com" in ids + + +@pytest.mark.asyncio +async def test_broadcast__expect_message_sent_to_all_children( + space, matrix_space, client, mock_send_response +): + child1 = MatrixRoom(room_id="!child1:example.com", own_user_id="@bot:example.com") + child1.name = "Child 1" + child2 = MatrixRoom(room_id="!child2:example.com", own_user_id="@bot:example.com") + child2.name = "Child 2" + matrix_space.children = {"!child1:example.com", "!child2:example.com"} + client.rooms = { + "!child1:example.com": child1, + "!child2:example.com": child2, + } + + results = await space.broadcast("Hello!") + + assert len(results) == 2 + assert all(isinstance(msg, Message) for msg in results) + assert client.room_send.await_count == 2 + + +@pytest.mark.asyncio +async def test_broadcast__with_no_children__expect_empty_list( + space, matrix_space, client +): + matrix_space.children = set() + client.rooms = {} + + results = await space.broadcast("Hello!") + + assert results == [] + client.room_send.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_broadcast__with_unjoined_children__expect_empty_list( + space, matrix_space, client +): + matrix_space.children = {"!unknown:example.com"} + client.rooms = {} + + results = await space.broadcast("Hello!") + + assert results == [] + client.room_send.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_broadcast_raw__expect_unformatted_messages( + space, matrix_space, client, mock_send_response +): + child = MatrixRoom(room_id="!child:example.com", own_user_id="@bot:example.com") + child.name = "Child" + matrix_space.children = {"!child:example.com"} + client.rooms = {"!child:example.com": child} + + await space.broadcast("Hello world!", raw=True) + + 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_broadcast_notice__expect_notice_message_type( + space, matrix_space, client, mock_send_response +): + child = MatrixRoom(room_id="!child:example.com", own_user_id="@bot:example.com") + child.name = "Child" + matrix_space.children = {"!child:example.com"} + client.rooms = {"!child:example.com": child} + + await space.broadcast("Special Event started!", notice=True) + + call_args = client.room_send.call_args + content = call_args.kwargs["content"] + assert content["msgtype"] == "m.notice" + assert content["body"] == "Special Event started!" + + +@pytest.mark.asyncio +async def test_broadcast_file__expect_file_message( + space, matrix_space, client, mock_send_response +): + from matrix.types import File + + child = MatrixRoom(room_id="!child:example.com", own_user_id="@bot:example.com") + child.name = "Child" + matrix_space.children = {"!child:example.com"} + client.rooms = {"!child:example.com": child} + + file = File( + path="mxc://example.com/abc123", + filename="document.pdf", + mimetype="application/pdf", + ) + + await space.broadcast(file=file) + + 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" + + +@pytest.mark.asyncio +async def test_broadcast__with_mixed_children__expect_message_to_all( + space, matrix_space, client, mock_send_response +): + room_child = MatrixRoom(room_id="!room:example.com", own_user_id="@bot:example.com") + room_child.name = "Room Child" + space_child = MatrixRoom(room_id="!sub:example.com", own_user_id="@bot:example.com") + space_child.name = "Sub Space" + space_child.room_type = "m.space" + matrix_space.children = {"!room:example.com", "!sub:example.com"} + client.rooms = { + "!room:example.com": room_child, + "!sub:example.com": space_child, + } + + results = await space.broadcast("Hello!") + + assert len(results) == 1 + assert client.room_send.await_count == 1 + sent_room_ids = { + call.kwargs["room_id"] for call in client.room_send.await_args_list + } + assert sent_room_ids == {"!room:example.com"} + + +@pytest.mark.asyncio +async def test_broadcast__with_mixed_children_and_nested_room__expect_only_top_level_room_at_depth_one( + space, matrix_space, client, mock_send_response +): + room_child = MatrixRoom(room_id="!room:example.com", own_user_id="@bot:example.com") + room_child.name = "Room Child" + space_child = MatrixRoom(room_id="!sub:example.com", own_user_id="@bot:example.com") + space_child.name = "Sub Space" + space_child.room_type = "m.space" + nested = MatrixRoom(room_id="!nested:example.com", own_user_id="@bot:example.com") + nested.name = "Nested Room" + space_child.children = {"!nested:example.com"} + matrix_space.children = {"!room:example.com", "!sub:example.com"} + client.rooms = { + "!room:example.com": room_child, + "!sub:example.com": space_child, + "!nested:example.com": nested, + } + + results = await space.broadcast("Hello!", depth=1) + + assert len(results) == 1 + assert client.room_send.await_count == 1 + sent_room_ids = { + call.kwargs["room_id"] for call in client.room_send.await_args_list + } + assert sent_room_ids == {"!room:example.com"} + + +@pytest.mark.asyncio +async def test_broadcast__with_depth_two__expect_message_to_nested_children( + space, matrix_space, client, mock_send_response +): + subspace = MatrixRoom( + room_id="!subspace:example.com", own_user_id="@bot:example.com" + ) + subspace.name = "Sub Space" + subspace.room_type = "m.space" + nested = MatrixRoom(room_id="!nested:example.com", own_user_id="@bot:example.com") + nested.name = "Nested Room" + subspace.children = {"!nested:example.com"} + matrix_space.children = {"!subspace:example.com"} + client.rooms = { + "!subspace:example.com": subspace, + "!nested:example.com": nested, + } + + results = await space.broadcast("Hello!", depth=2) + + assert len(results) == 1 + assert client.room_send.await_count == 1 + sent_room_ids = { + call.kwargs["room_id"] for call in client.room_send.await_args_list + } + assert sent_room_ids == {"!nested:example.com"} + + +@pytest.mark.asyncio +async def test_broadcast__with_depth_two_and_top_level_room__expect_both_rooms( + space, matrix_space, client, mock_send_response +): + room_child = MatrixRoom(room_id="!room:example.com", own_user_id="@bot:example.com") + room_child.name = "Room Child" + subspace = MatrixRoom( + room_id="!subspace:example.com", own_user_id="@bot:example.com" + ) + subspace.name = "Sub Space" + subspace.room_type = "m.space" + nested = MatrixRoom(room_id="!nested:example.com", own_user_id="@bot:example.com") + nested.name = "Nested Room" + subspace.children = {"!nested:example.com"} + matrix_space.children = {"!room:example.com", "!subspace:example.com"} + client.rooms = { + "!room:example.com": room_child, + "!subspace:example.com": subspace, + "!nested:example.com": nested, + } + + results = await space.broadcast("Hello!", depth=2) + + assert len(results) == 2 + assert client.room_send.await_count == 2 + sent_room_ids = { + call.kwargs["room_id"] for call in client.room_send.await_args_list + } + assert sent_room_ids == {"!room:example.com", "!nested:example.com"} + + +@pytest.mark.asyncio +async def test_broadcast__with_depth_one__expect_no_nested_children( + space, matrix_space, client, mock_send_response +): + subspace = MatrixRoom( + room_id="!subspace:example.com", own_user_id="@bot:example.com" + ) + subspace.name = "Sub Space" + subspace.room_type = "m.space" + nested = MatrixRoom(room_id="!nested:example.com", own_user_id="@bot:example.com") + nested.name = "Nested Room" + subspace.children = {"!nested:example.com"} + matrix_space.children = {"!subspace:example.com"} + client.rooms = { + "!subspace:example.com": subspace, + "!nested:example.com": nested, + } + + results = await space.broadcast("Hello!", depth=1) + + assert results == [] + client.room_send.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_broadcast__with_depth_zero__expect_no_messages( + space, matrix_space, client, mock_send_response +): + child = MatrixRoom(room_id="!child:example.com", own_user_id="@bot:example.com") + child.name = "Child" + matrix_space.children = {"!child:example.com"} + client.rooms = {"!child:example.com": child} + + results = await space.broadcast("Hello!", depth=0) + + assert results == [] + client.room_send.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_broadcast__with_negative_depth__expect_value_error( + space, matrix_space, client +): + with pytest.raises(ValueError, match="depth must be a non-negative integer"): + await space.broadcast("Hello!", depth=-1)