From a43368675e76e98008122c05df038730f0f67465 Mon Sep 17 00:00:00 2001 From: Jonathan Keslin Date: Mon, 27 Oct 2025 01:21:28 -0700 Subject: [PATCH] Add thread and reaction support to Matrix (#147165) Co-authored-by: Paarth Shah --- homeassistant/components/matrix/__init__.py | 126 ++++++++++++++++-- homeassistant/components/matrix/const.py | 6 + homeassistant/components/matrix/icons.json | 3 + homeassistant/components/matrix/services.py | 27 ++++ homeassistant/components/matrix/services.yaml | 35 ++++- homeassistant/components/matrix/strings.json | 44 +++++- tests/components/matrix/conftest.py | 29 ++++ tests/components/matrix/test_commands.py | 56 +++++++- tests/components/matrix/test_matrix_bot.py | 7 +- tests/components/matrix/test_react.py | 35 +++++ tests/components/matrix/test_send_message.py | 19 ++- 11 files changed, 368 insertions(+), 19 deletions(-) create mode 100644 tests/components/matrix/test_react.py diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index f523de71f6a..08924645f62 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -8,11 +8,11 @@ import logging import mimetypes import os import re -from typing import Final, NewType, Required, TypedDict +from typing import Any, Final, NewType, Required, TypedDict import aiofiles.os from nio import AsyncClient, Event, MatrixRoom -from nio.events.room_events import RoomMessageText +from nio.events.room_events import ReactionEvent, RoomMessageText from nio.responses import ( ErrorResponse, JoinError, @@ -44,7 +44,17 @@ from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JsonObjectType, load_json_object -from .const import ATTR_FORMAT, ATTR_IMAGES, CONF_ROOMS_REGEX, DOMAIN, FORMAT_HTML +from .const import ( + ATTR_FORMAT, + ATTR_IMAGES, + ATTR_MESSAGE_ID, + ATTR_REACTION, + ATTR_ROOM, + ATTR_THREAD_ID, + CONF_ROOMS_REGEX, + DOMAIN, + FORMAT_HTML, +) from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -56,6 +66,7 @@ CONF_ROOMS: Final = "rooms" CONF_COMMANDS: Final = "commands" CONF_WORD: Final = "word" CONF_EXPRESSION: Final = "expression" +CONF_REACTION: Final = "reaction" CONF_USERNAME_REGEX = "^@[^:]*:.*" @@ -66,6 +77,7 @@ DEFAULT_CONTENT_TYPE = "application/octet-stream" WordCommand = NewType("WordCommand", str) ExpressionCommand = NewType("ExpressionCommand", re.Pattern) +ReactionCommand = NewType("ReactionCommand", str) RoomAlias = NewType("RoomAlias", str) # Starts with "#" RoomID = NewType("RoomID", str) # Starts with "!" RoomAnyID = RoomID | RoomAlias @@ -78,6 +90,7 @@ class ConfigCommand(TypedDict, total=False): rooms: list[RoomID] # CONF_ROOMS word: WordCommand # CONF_WORD expression: ExpressionCommand # CONF_EXPRESSION + reaction: ReactionCommand # CONF_REACTION COMMAND_SCHEMA = vol.All( @@ -85,13 +98,14 @@ COMMAND_SCHEMA = vol.All( { vol.Exclusive(CONF_WORD, "trigger"): cv.string, vol.Exclusive(CONF_EXPRESSION, "trigger"): cv.is_regex, + vol.Exclusive(CONF_REACTION, "trigger"): cv.string, vol.Required(CONF_NAME): cv.string, vol.Optional(CONF_ROOMS): vol.All( cv.ensure_list, [cv.matches_regex(CONF_ROOMS_REGEX)] ), } ), - cv.has_at_least_one_key(CONF_WORD, CONF_EXPRESSION), + cv.has_at_least_one_key(CONF_WORD, CONF_EXPRESSION, CONF_REACTION), ) CONFIG_SCHEMA = vol.Schema( @@ -167,6 +181,7 @@ class MatrixBot: self._listening_rooms: dict[RoomAnyID, RoomID] = {} self._word_commands: dict[RoomID, dict[WordCommand, ConfigCommand]] = {} self._expression_commands: dict[RoomID, list[ConfigCommand]] = {} + self._reaction_commands: dict[RoomID, dict[ReactionCommand, ConfigCommand]] = {} self._unparsed_commands = commands async def stop_client(event: HassEvent) -> None: @@ -189,7 +204,9 @@ class MatrixBot: await self._client.sync(timeout=30_000) _LOGGER.debug("Finished initial sync for %s", self._mx_id) - self._client.add_event_callback(self._handle_room_message, RoomMessageText) + self._client.add_event_callback( + self._handle_room_message, (ReactionEvent, RoomMessageText) + ) _LOGGER.debug("Starting sync_forever for %s", self._mx_id) self.hass.async_create_background_task( @@ -210,11 +227,15 @@ class MatrixBot: else: command[CONF_ROOMS] = list(self._listening_rooms.values()) - # COMMAND_SCHEMA guarantees that exactly one of CONF_WORD and CONF_EXPRESSION are set. + # COMMAND_SCHEMA guarantees that exactly one of CONF_WORD, CONF_EXPRESSION, or CONF_REACTION are set. if (word_command := command.get(CONF_WORD)) is not None: for room_id in command[CONF_ROOMS]: self._word_commands.setdefault(room_id, {}) self._word_commands[room_id][word_command] = command + elif (reaction_command := command.get(CONF_REACTION)) is not None: + for room_id in command[CONF_ROOMS]: + self._reaction_commands.setdefault(room_id, {}) + self._reaction_commands[room_id][reaction_command] = command else: for room_id in command[CONF_ROOMS]: self._expression_commands.setdefault(room_id, []) @@ -223,15 +244,33 @@ class MatrixBot: async def _handle_room_message(self, room: MatrixRoom, message: Event) -> None: """Handle a message sent to a Matrix room.""" # Corresponds to message type 'm.text' and NOT other RoomMessage subtypes, like 'm.notice' and 'm.emote'. - if not isinstance(message, RoomMessageText): + if not isinstance(message, (RoomMessageText, ReactionEvent)): return # Don't respond to our own messages. if message.sender == self._mx_id: return - _LOGGER.debug("Handling message: %s", message.body) room_id = RoomID(room.room_id) + if isinstance(message, ReactionEvent): + # Handle reactions + reaction = message.key + _LOGGER.debug("Handling reaction: %s", reaction) + if command := self._reaction_commands.get(room_id, {}).get(reaction): + message_data = { + "command": command[CONF_NAME], + "sender": message.sender, + "room": room_id, + "event_id": message.reacts_to, + "args": { + "reaction": message.key, + }, + } + self.hass.bus.async_fire(EVENT_MATRIX_COMMAND, message_data) + return + + _LOGGER.debug("Handling message: %s", message.body) + if message.body.startswith("!"): # Could trigger a single-word command. pieces = message.body.split() @@ -242,8 +281,12 @@ class MatrixBot: "command": command[CONF_NAME], "sender": message.sender, "room": room_id, + "event_id": message.event_id, "args": pieces[1:], + "thread_parent": self._get_thread_parent(message) + or message.event_id, } + self.hass.bus.async_fire(EVENT_MATRIX_COMMAND, message_data) # After single-word commands, check all regex commands in the room. @@ -255,10 +298,28 @@ class MatrixBot: "command": command[CONF_NAME], "sender": message.sender, "room": room_id, + "event_id": message.event_id, "args": match.groupdict(), + "thread_parent": self._get_thread_parent(message) or message.event_id, } + self.hass.bus.async_fire(EVENT_MATRIX_COMMAND, message_data) + def _get_thread_parent(self, message: RoomMessageText) -> str | None: + """Get the thread parent ID from a message, or None if not in a thread.""" + match message.source: + case { + "content": { + "m.relates_to": { + "rel_type": "m.thread", + "event_id": str() as event_id, + } + } + }: + return event_id + case _: + return None + async def _resolve_room_alias( self, room_alias_or_id: RoomAnyID ) -> dict[RoomAnyID, RoomID]: @@ -432,7 +493,7 @@ class MatrixBot: ) async def _send_image( - self, image_path: str, target_rooms: Sequence[RoomAnyID] + self, image_path: str, target_rooms: Sequence[RoomAnyID], thread_id: str | None ) -> None: """Upload an image, then send it to all target_rooms.""" _is_allowed_path = await self.hass.async_add_executor_job( @@ -480,6 +541,9 @@ class MatrixBot: "url": response.content_uri, } + if thread_id is not None: + content["m.relates_to"] = {"event_id": thread_id, "rel_type": "m.thread"} + await self._handle_multi_room_send( target_rooms=target_rooms, message_type="m.room.message", content=content ) @@ -488,9 +552,19 @@ class MatrixBot: self, message: str, target_rooms: list[RoomAnyID], data: dict | None ) -> None: """Send a message to the Matrix server.""" - content = {"msgtype": "m.text", "body": message} - if data is not None and data.get(ATTR_FORMAT) == FORMAT_HTML: - content |= {"format": "org.matrix.custom.html", "formatted_body": message} + content: dict[str, Any] = {"msgtype": "m.text", "body": message} + if data is not None: + thread_id: str | None = data.get(ATTR_THREAD_ID) + if data.get(ATTR_FORMAT) == FORMAT_HTML: + content |= { + "format": "org.matrix.custom.html", + "formatted_body": message, + } + if thread_id is not None: + content["m.relates_to"] = { + "event_id": thread_id, + "rel_type": "m.thread", + } await self._handle_multi_room_send( target_rooms=target_rooms, message_type="m.room.message", content=content @@ -503,12 +577,30 @@ class MatrixBot: ): image_tasks = [ self.hass.async_create_task( - self._send_image(image_path, target_rooms), eager_start=False + self._send_image( + image_path, target_rooms, data.get(ATTR_THREAD_ID) + ), + eager_start=False, ) for image_path in image_paths ] await asyncio.wait(image_tasks) + async def _send_reaction( + self, reaction: str, target_room: RoomAnyID, message_id: str + ) -> None: + """Send a reaction to the Matrix server.""" + content = { + "m.relates_to": { + "event_id": message_id, + "key": reaction, + "rel_type": "m.annotation", + } + } + await self._handle_room_send( + target_room=target_room, message_type="m.reaction", content=content + ) + async def handle_send_message(self, service: ServiceCall) -> None: """Handle the send_message service.""" await self._send_message( @@ -516,3 +608,11 @@ class MatrixBot: service.data[ATTR_TARGET], service.data.get(ATTR_DATA), ) + + async def handle_send_reaction(self, service: ServiceCall) -> None: + """Handle the react service.""" + await self._send_reaction( + service.data[ATTR_REACTION], + service.data[ATTR_ROOM], + service.data[ATTR_MESSAGE_ID], + ) diff --git a/homeassistant/components/matrix/const.py b/homeassistant/components/matrix/const.py index b4c926409e8..d18536e9482 100644 --- a/homeassistant/components/matrix/const.py +++ b/homeassistant/components/matrix/const.py @@ -3,11 +3,17 @@ DOMAIN = "matrix" SERVICE_SEND_MESSAGE = "send_message" +SERVICE_REACT = "react" FORMAT_HTML = "html" FORMAT_TEXT = "text" ATTR_FORMAT = "format" # optional message format ATTR_IMAGES = "images" # optional images +ATTR_THREAD_ID = "thread_id" # optional thread id + +ATTR_REACTION = "reaction" # reaction +ATTR_ROOM = "room" # room id +ATTR_MESSAGE_ID = "message_id" # message id CONF_ROOMS_REGEX = "^[!|#][^:]*:.*" diff --git a/homeassistant/components/matrix/icons.json b/homeassistant/components/matrix/icons.json index a8b83e67303..01026f5b6e1 100644 --- a/homeassistant/components/matrix/icons.json +++ b/homeassistant/components/matrix/icons.json @@ -1,5 +1,8 @@ { "services": { + "react": { + "service": "mdi:emoticon" + }, "send_message": { "service": "mdi:matrix" } diff --git a/homeassistant/components/matrix/services.py b/homeassistant/components/matrix/services.py index f89a9e7b7fc..45dab85b4e6 100644 --- a/homeassistant/components/matrix/services.py +++ b/homeassistant/components/matrix/services.py @@ -13,10 +13,15 @@ from homeassistant.helpers import config_validation as cv from .const import ( ATTR_FORMAT, ATTR_IMAGES, + ATTR_MESSAGE_ID, + ATTR_REACTION, + ATTR_ROOM, + ATTR_THREAD_ID, CONF_ROOMS_REGEX, DOMAIN, FORMAT_HTML, FORMAT_TEXT, + SERVICE_REACT, SERVICE_SEND_MESSAGE, ) @@ -36,6 +41,7 @@ SERVICE_SCHEMA_SEND_MESSAGE = vol.Schema( MESSAGE_FORMATS ), vol.Optional(ATTR_IMAGES): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_THREAD_ID): cv.string, }, vol.Required(ATTR_TARGET): vol.All( cv.ensure_list, [cv.matches_regex(CONF_ROOMS_REGEX)] @@ -43,6 +49,14 @@ SERVICE_SCHEMA_SEND_MESSAGE = vol.Schema( } ) +SERVICE_SCHEMA_REACT = vol.Schema( + { + vol.Required(ATTR_REACTION): cv.string, + vol.Required(ATTR_ROOM): cv.matches_regex(CONF_ROOMS_REGEX), + vol.Required(ATTR_MESSAGE_ID): cv.string, + } +) + async def _handle_send_message(call: ServiceCall) -> None: """Handle the send_message service call.""" @@ -50,6 +64,12 @@ async def _handle_send_message(call: ServiceCall) -> None: await matrix_bot.handle_send_message(call) +async def _handle_react(call: ServiceCall) -> None: + """Handle the react service call.""" + matrix_bot: MatrixBot = call.hass.data[DOMAIN] + await matrix_bot.handle_send_reaction(call) + + @callback def async_setup_services(hass: HomeAssistant) -> None: """Set up the Matrix bot component.""" @@ -60,3 +80,10 @@ def async_setup_services(hass: HomeAssistant) -> None: _handle_send_message, schema=SERVICE_SCHEMA_SEND_MESSAGE, ) + + hass.services.async_register( + DOMAIN, + SERVICE_REACT, + _handle_react, + schema=SERVICE_SCHEMA_REACT, + ) diff --git a/homeassistant/components/matrix/services.yaml b/homeassistant/components/matrix/services.yaml index f2ce72397d4..87e7611f9b9 100644 --- a/homeassistant/components/matrix/services.yaml +++ b/homeassistant/components/matrix/services.yaml @@ -11,6 +11,39 @@ send_message: selector: text: data: - example: "{'images': ['/tmp/test.jpg'], 'format': 'text'}" + example: "{'images': ['/tmp/test.jpg'], 'format': 'text', 'thread_id': '$-abcdeghij_klmnopqrstuvwxyz123'}" selector: object: + translation_key: send_message_data + fields: + images: + selector: + text: + multiple: true + format: + selector: + select: + options: + - text + - html + translation_key: send_message_data_format + thread_id: + selector: + text: +react: + fields: + reaction: + required: true + example: "👍" + selector: + text: + room: + required: true + example: "#hasstest:matrix.org" + selector: + text: + message_id: + required: true + example: "$-abcdeghij_klmnopqrstuvwxyz123" + selector: + text: diff --git a/homeassistant/components/matrix/strings.json b/homeassistant/components/matrix/strings.json index 03d4c5728a5..35351a33dbb 100644 --- a/homeassistant/components/matrix/strings.json +++ b/homeassistant/components/matrix/strings.json @@ -1,4 +1,28 @@ { + "selector": { + "send_message_data": { + "fields": { + "images": { + "name": "Images", + "description": "One or more image paths to attach to the message." + }, + "format": { + "name": "Message format", + "description": "Format of the message, either 'text' or 'html'." + }, + "thread_id": { + "name": "Thread ID", + "description": "An optional parent message ID to thread this message under." + } + } + }, + "send_message_data_format": { + "options": { + "text": "Plain text", + "html": "HTML" + } + } + }, "services": { "send_message": { "name": "Send message", @@ -14,7 +38,25 @@ }, "data": { "name": "Data", - "description": "Extended information of notification. Supports list of images. Supports message format. Optional." + "description": "Extended information of notification." + } + } + }, + "react": { + "name": "React", + "description": "Sends a reaction to a message.", + "fields": { + "message_id": { + "name": "Message ID", + "description": "The ID of the message to react to." + }, + "reaction": { + "name": "Reaction", + "description": "The reaction to send." + }, + "room": { + "name": "Room", + "description": "The room to send the reaction to." } } } diff --git a/tests/components/matrix/conftest.py b/tests/components/matrix/conftest.py index 8455d7b989c..368b13805f6 100644 --- a/tests/components/matrix/conftest.py +++ b/tests/components/matrix/conftest.py @@ -30,6 +30,7 @@ from homeassistant.components.matrix import ( CONF_COMMANDS, CONF_EXPRESSION, CONF_HOMESERVER, + CONF_REACTION, CONF_ROOMS, CONF_WORD, EVENT_MATRIX_COMMAND, @@ -152,6 +153,10 @@ MOCK_CONFIG_DATA = { CONF_EXPRESSION: "My name is (?P.*)", CONF_NAME: "ExpressionTriggerEventName", }, + { + CONF_REACTION: "😄", + CONF_NAME: "ReactionTriggerEventName", + }, { CONF_WORD: "WordTriggerSubset", CONF_NAME: "WordTriggerSubsetEventName", @@ -239,6 +244,30 @@ MOCK_EXPRESSION_COMMANDS = { ], } +MOCK_REACTION_COMMANDS = { + TEST_ROOM_A_ID: { + "😄": { + "reaction": "😄", + "name": "ReactionTriggerEventName", + "rooms": list(TEST_JOINABLE_ROOMS.values()), + } + }, + TEST_ROOM_B_ID: { + "😄": { + "reaction": "😄", + "name": "ReactionTriggerEventName", + "rooms": list(TEST_JOINABLE_ROOMS.values()), + }, + }, + TEST_ROOM_C_ID: { + "😄": { + "reaction": "😄", + "name": "ReactionTriggerEventName", + "rooms": list(TEST_JOINABLE_ROOMS.values()), + }, + }, +} + @pytest.fixture def mock_client(): diff --git a/tests/components/matrix/test_commands.py b/tests/components/matrix/test_commands.py index ea0805b920a..2827920fb06 100644 --- a/tests/components/matrix/test_commands.py +++ b/tests/components/matrix/test_commands.py @@ -8,11 +8,12 @@ from typing import Any from nio import MatrixRoom, RoomMessageText import pytest -from homeassistant.components.matrix import MatrixBot, RoomID +from homeassistant.components.matrix import MatrixBot, ReactionEvent, RoomID from homeassistant.core import Event, HomeAssistant from .conftest import ( MOCK_EXPRESSION_COMMANDS, + MOCK_REACTION_COMMANDS, MOCK_WORD_COMMANDS, TEST_MXID, TEST_ROOM_A_ID, @@ -32,7 +33,7 @@ class CommandTestParameters: """ room_id: RoomID - room_message: RoomMessageText + room_message: RoomMessageText | ReactionEvent expected_event_data_extra: dict[str, Any] | None @property @@ -68,6 +69,29 @@ word_command_global = partial( room_message=room_message_base(body="!WordTrigger arg1 arg2"), expected_event_data_extra={ "command": "WordTriggerEventName", + "event_id": "fake_event_id", + "thread_parent": "fake_event_id", + "args": ["arg1", "arg2"], + }, +) +thread_source = { + "event_id": "fake_event_id", + "sender": "@SomeUser:example.com", + "origin_server_ts": 123456789, + "content": { + "m.relates_to": { + "rel_type": "m.thread", + "event_id": "fake_thread_parent", + } + }, +} +word_command_global_in_thread = partial( + CommandTestParameters, + room_message=room_message_base(body="!WordTrigger arg1 arg2", source=thread_source), + expected_event_data_extra={ + "command": "WordTriggerEventName", + "event_id": "fake_event_id", + "thread_parent": "fake_thread_parent", "args": ["arg1", "arg2"], }, ) @@ -76,6 +100,8 @@ expr_command_global = partial( room_message=room_message_base(body="My name is FakeName"), expected_event_data_extra={ "command": "ExpressionTriggerEventName", + "event_id": "fake_event_id", + "thread_parent": "fake_event_id", "args": {"name": "FakeName"}, }, ) @@ -84,6 +110,8 @@ word_command_subset = partial( room_message=room_message_base(body="!WordTriggerSubset arg1 arg2"), expected_event_data_extra={ "command": "WordTriggerSubsetEventName", + "event_id": "fake_event_id", + "thread_parent": "fake_event_id", "args": ["arg1", "arg2"], }, ) @@ -92,6 +120,8 @@ expr_command_subset = partial( room_message=room_message_base(body="Your name is FakeName"), expected_event_data_extra={ "command": "ExpressionTriggerSubsetEventName", + "event_id": "fake_event_id", + "thread_parent": "fake_event_id", "args": {"name": "FakeName"}, }, ) @@ -114,15 +144,36 @@ self_command_global = partial( ), expected_event_data_extra=None, ) +reaction_base = partial( + ReactionEvent, + source={ + "sender": "@SomeUser:example.com", + "origin_server_ts": 123456789, + "event_id": "event_id", + }, +) +reaction_command_global = partial( + CommandTestParameters, + room_message=reaction_base(reacts_to="reacts_to_event_id", key="😄"), + expected_event_data_extra={ + "command": "ReactionTriggerEventName", + "event_id": "reacts_to_event_id", + "args": { + "reaction": "😄", + }, + }, +) @pytest.mark.parametrize( "command_params", chain( (word_command_global(room_id) for room_id in ALL_ROOMS), + (word_command_global_in_thread(room_id) for room_id in ALL_ROOMS), (expr_command_global(room_id) for room_id in ALL_ROOMS), (word_command_subset(room_id) for room_id in SUBSET_ROOMS), (expr_command_subset(room_id) for room_id in SUBSET_ROOMS), + (reaction_command_global(room_id) for room_id in ALL_ROOMS), ), ) async def test_commands( @@ -178,3 +229,4 @@ async def test_commands_parsing(hass: HomeAssistant, matrix_bot: MatrixBot) -> N await hass.async_start() assert matrix_bot._word_commands == MOCK_WORD_COMMANDS assert matrix_bot._expression_commands == MOCK_EXPRESSION_COMMANDS + assert matrix_bot._reaction_commands == MOCK_REACTION_COMMANDS diff --git a/tests/components/matrix/test_matrix_bot.py b/tests/components/matrix/test_matrix_bot.py index 6c25d570299..1f5f4fad913 100644 --- a/tests/components/matrix/test_matrix_bot.py +++ b/tests/components/matrix/test_matrix_bot.py @@ -1,7 +1,11 @@ """Configure and test MatrixBot.""" from homeassistant.components.matrix import MatrixBot -from homeassistant.components.matrix.const import DOMAIN, SERVICE_SEND_MESSAGE +from homeassistant.components.matrix.const import ( + DOMAIN, + SERVICE_REACT, + SERVICE_SEND_MESSAGE, +) from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.core import HomeAssistant @@ -16,6 +20,7 @@ async def test_services(hass: HomeAssistant, matrix_bot: MatrixBot) -> None: # Verify that the matrix service is registered assert (matrix_service := services.get(DOMAIN)) assert SERVICE_SEND_MESSAGE in matrix_service + assert SERVICE_REACT in matrix_service # Verify that the matrix notifier is registered assert (notify_service := services.get(NOTIFY_DOMAIN)) diff --git a/tests/components/matrix/test_react.py b/tests/components/matrix/test_react.py new file mode 100644 index 00000000000..b91ec4d8d60 --- /dev/null +++ b/tests/components/matrix/test_react.py @@ -0,0 +1,35 @@ +"""Test the react service.""" + +import pytest + +from homeassistant.components.matrix import DOMAIN, MatrixBot +from homeassistant.components.matrix.const import ( + ATTR_MESSAGE_ID, + ATTR_REACTION, + ATTR_ROOM, + SERVICE_REACT, +) +from homeassistant.core import Event, HomeAssistant + +from .conftest import TEST_JOINABLE_ROOMS + + +async def test_send_message( + hass: HomeAssistant, + matrix_bot: MatrixBot, + image_path, + matrix_events: list[Event], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the send_message service.""" + + await hass.async_start() + assert len(matrix_events) == 0 + await matrix_bot._login() + + # Send a reaction. + room = list(TEST_JOINABLE_ROOMS)[0] + data = {ATTR_MESSAGE_ID: "message_id", ATTR_ROOM: room, ATTR_REACTION: "👍"} + await hass.services.async_call(DOMAIN, SERVICE_REACT, data, blocking=True) + + assert f"Message delivered to room '{room}'" in caplog.messages diff --git a/tests/components/matrix/test_send_message.py b/tests/components/matrix/test_send_message.py index 7c7004f7796..dacb2887a4a 100644 --- a/tests/components/matrix/test_send_message.py +++ b/tests/components/matrix/test_send_message.py @@ -2,7 +2,13 @@ import pytest -from homeassistant.components.matrix import ATTR_FORMAT, ATTR_IMAGES, DOMAIN, MatrixBot +from homeassistant.components.matrix import ( + ATTR_FORMAT, + ATTR_IMAGES, + ATTR_THREAD_ID, + DOMAIN, + MatrixBot, +) from homeassistant.components.matrix.const import FORMAT_HTML, SERVICE_SEND_MESSAGE from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET from homeassistant.core import Event, HomeAssistant @@ -48,6 +54,17 @@ async def test_send_message( for room_alias_or_id in TEST_JOINABLE_ROOMS: assert f"Message delivered to room '{room_alias_or_id}'" in caplog.messages + # Send a message to a thread. + data = { + ATTR_MESSAGE: "Test message", + ATTR_TARGET: list(TEST_JOINABLE_ROOMS), + ATTR_DATA: {ATTR_THREAD_ID: "thread_id"}, + } + await hass.services.async_call(DOMAIN, SERVICE_SEND_MESSAGE, data, blocking=True) + + for room_alias_or_id in TEST_JOINABLE_ROOMS: + assert f"Message delivered to room '{room_alias_or_id}'" in caplog.messages + async def test_unsendable_message( hass: HomeAssistant,