1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-15 07:36:16 +00:00

Add thread and reaction support to Matrix (#147165)

Co-authored-by: Paarth Shah <mail@shahpaarth.com>
This commit is contained in:
Jonathan Keslin
2025-10-27 01:21:28 -07:00
committed by GitHub
parent 39d76a24db
commit a43368675e
11 changed files with 368 additions and 19 deletions

View File

@@ -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],
)

View File

@@ -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 = "^[!|#][^:]*:.*"

View File

@@ -1,5 +1,8 @@
{
"services": {
"react": {
"service": "mdi:emoticon"
},
"send_message": {
"service": "mdi:matrix"
}

View File

@@ -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,
)

View File

@@ -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:

View File

@@ -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."
}
}
}

View File

@@ -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<name>.*)",
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():

View File

@@ -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

View File

@@ -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))

View File

@@ -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

View File

@@ -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,