Skip to content

Commit a433686

Browse files
Add thread and reaction support to Matrix (home-assistant#147165)
Co-authored-by: Paarth Shah <[email protected]>
1 parent 39d76a2 commit a433686

File tree

11 files changed

+368
-19
lines changed

11 files changed

+368
-19
lines changed

homeassistant/components/matrix/__init__.py

Lines changed: 113 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88
import mimetypes
99
import os
1010
import re
11-
from typing import Final, NewType, Required, TypedDict
11+
from typing import Any, Final, NewType, Required, TypedDict
1212

1313
import aiofiles.os
1414
from nio import AsyncClient, Event, MatrixRoom
15-
from nio.events.room_events import RoomMessageText
15+
from nio.events.room_events import ReactionEvent, RoomMessageText
1616
from nio.responses import (
1717
ErrorResponse,
1818
JoinError,
@@ -44,7 +44,17 @@
4444
from homeassistant.helpers.typing import ConfigType
4545
from homeassistant.util.json import JsonObjectType, load_json_object
4646

47-
from .const import ATTR_FORMAT, ATTR_IMAGES, CONF_ROOMS_REGEX, DOMAIN, FORMAT_HTML
47+
from .const import (
48+
ATTR_FORMAT,
49+
ATTR_IMAGES,
50+
ATTR_MESSAGE_ID,
51+
ATTR_REACTION,
52+
ATTR_ROOM,
53+
ATTR_THREAD_ID,
54+
CONF_ROOMS_REGEX,
55+
DOMAIN,
56+
FORMAT_HTML,
57+
)
4858
from .services import async_setup_services
4959

5060
_LOGGER = logging.getLogger(__name__)
@@ -56,6 +66,7 @@
5666
CONF_COMMANDS: Final = "commands"
5767
CONF_WORD: Final = "word"
5868
CONF_EXPRESSION: Final = "expression"
69+
CONF_REACTION: Final = "reaction"
5970

6071
CONF_USERNAME_REGEX = "^@[^:]*:.*"
6172

@@ -66,6 +77,7 @@
6677

6778
WordCommand = NewType("WordCommand", str)
6879
ExpressionCommand = NewType("ExpressionCommand", re.Pattern)
80+
ReactionCommand = NewType("ReactionCommand", str)
6981
RoomAlias = NewType("RoomAlias", str) # Starts with "#"
7082
RoomID = NewType("RoomID", str) # Starts with "!"
7183
RoomAnyID = RoomID | RoomAlias
@@ -78,20 +90,22 @@ class ConfigCommand(TypedDict, total=False):
7890
rooms: list[RoomID] # CONF_ROOMS
7991
word: WordCommand # CONF_WORD
8092
expression: ExpressionCommand # CONF_EXPRESSION
93+
reaction: ReactionCommand # CONF_REACTION
8194

8295

8396
COMMAND_SCHEMA = vol.All(
8497
vol.Schema(
8598
{
8699
vol.Exclusive(CONF_WORD, "trigger"): cv.string,
87100
vol.Exclusive(CONF_EXPRESSION, "trigger"): cv.is_regex,
101+
vol.Exclusive(CONF_REACTION, "trigger"): cv.string,
88102
vol.Required(CONF_NAME): cv.string,
89103
vol.Optional(CONF_ROOMS): vol.All(
90104
cv.ensure_list, [cv.matches_regex(CONF_ROOMS_REGEX)]
91105
),
92106
}
93107
),
94-
cv.has_at_least_one_key(CONF_WORD, CONF_EXPRESSION),
108+
cv.has_at_least_one_key(CONF_WORD, CONF_EXPRESSION, CONF_REACTION),
95109
)
96110

97111
CONFIG_SCHEMA = vol.Schema(
@@ -167,6 +181,7 @@ def __init__(
167181
self._listening_rooms: dict[RoomAnyID, RoomID] = {}
168182
self._word_commands: dict[RoomID, dict[WordCommand, ConfigCommand]] = {}
169183
self._expression_commands: dict[RoomID, list[ConfigCommand]] = {}
184+
self._reaction_commands: dict[RoomID, dict[ReactionCommand, ConfigCommand]] = {}
170185
self._unparsed_commands = commands
171186

172187
async def stop_client(event: HassEvent) -> None:
@@ -189,7 +204,9 @@ async def handle_startup(event: HassEvent) -> None:
189204
await self._client.sync(timeout=30_000)
190205
_LOGGER.debug("Finished initial sync for %s", self._mx_id)
191206

192-
self._client.add_event_callback(self._handle_room_message, RoomMessageText)
207+
self._client.add_event_callback(
208+
self._handle_room_message, (ReactionEvent, RoomMessageText)
209+
)
193210

194211
_LOGGER.debug("Starting sync_forever for %s", self._mx_id)
195212
self.hass.async_create_background_task(
@@ -210,11 +227,15 @@ def _load_commands(self, commands: list[ConfigCommand]) -> None:
210227
else:
211228
command[CONF_ROOMS] = list(self._listening_rooms.values())
212229

213-
# COMMAND_SCHEMA guarantees that exactly one of CONF_WORD and CONF_EXPRESSION are set.
230+
# COMMAND_SCHEMA guarantees that exactly one of CONF_WORD, CONF_EXPRESSION, or CONF_REACTION are set.
214231
if (word_command := command.get(CONF_WORD)) is not None:
215232
for room_id in command[CONF_ROOMS]:
216233
self._word_commands.setdefault(room_id, {})
217234
self._word_commands[room_id][word_command] = command
235+
elif (reaction_command := command.get(CONF_REACTION)) is not None:
236+
for room_id in command[CONF_ROOMS]:
237+
self._reaction_commands.setdefault(room_id, {})
238+
self._reaction_commands[room_id][reaction_command] = command
218239
else:
219240
for room_id in command[CONF_ROOMS]:
220241
self._expression_commands.setdefault(room_id, [])
@@ -223,15 +244,33 @@ def _load_commands(self, commands: list[ConfigCommand]) -> None:
223244
async def _handle_room_message(self, room: MatrixRoom, message: Event) -> None:
224245
"""Handle a message sent to a Matrix room."""
225246
# Corresponds to message type 'm.text' and NOT other RoomMessage subtypes, like 'm.notice' and 'm.emote'.
226-
if not isinstance(message, RoomMessageText):
247+
if not isinstance(message, (RoomMessageText, ReactionEvent)):
227248
return
228249
# Don't respond to our own messages.
229250
if message.sender == self._mx_id:
230251
return
231-
_LOGGER.debug("Handling message: %s", message.body)
232252

233253
room_id = RoomID(room.room_id)
234254

255+
if isinstance(message, ReactionEvent):
256+
# Handle reactions
257+
reaction = message.key
258+
_LOGGER.debug("Handling reaction: %s", reaction)
259+
if command := self._reaction_commands.get(room_id, {}).get(reaction):
260+
message_data = {
261+
"command": command[CONF_NAME],
262+
"sender": message.sender,
263+
"room": room_id,
264+
"event_id": message.reacts_to,
265+
"args": {
266+
"reaction": message.key,
267+
},
268+
}
269+
self.hass.bus.async_fire(EVENT_MATRIX_COMMAND, message_data)
270+
return
271+
272+
_LOGGER.debug("Handling message: %s", message.body)
273+
235274
if message.body.startswith("!"):
236275
# Could trigger a single-word command.
237276
pieces = message.body.split()
@@ -242,8 +281,12 @@ async def _handle_room_message(self, room: MatrixRoom, message: Event) -> None:
242281
"command": command[CONF_NAME],
243282
"sender": message.sender,
244283
"room": room_id,
284+
"event_id": message.event_id,
245285
"args": pieces[1:],
286+
"thread_parent": self._get_thread_parent(message)
287+
or message.event_id,
246288
}
289+
247290
self.hass.bus.async_fire(EVENT_MATRIX_COMMAND, message_data)
248291

249292
# After single-word commands, check all regex commands in the room.
@@ -255,10 +298,28 @@ async def _handle_room_message(self, room: MatrixRoom, message: Event) -> None:
255298
"command": command[CONF_NAME],
256299
"sender": message.sender,
257300
"room": room_id,
301+
"event_id": message.event_id,
258302
"args": match.groupdict(),
303+
"thread_parent": self._get_thread_parent(message) or message.event_id,
259304
}
305+
260306
self.hass.bus.async_fire(EVENT_MATRIX_COMMAND, message_data)
261307

308+
def _get_thread_parent(self, message: RoomMessageText) -> str | None:
309+
"""Get the thread parent ID from a message, or None if not in a thread."""
310+
match message.source:
311+
case {
312+
"content": {
313+
"m.relates_to": {
314+
"rel_type": "m.thread",
315+
"event_id": str() as event_id,
316+
}
317+
}
318+
}:
319+
return event_id
320+
case _:
321+
return None
322+
262323
async def _resolve_room_alias(
263324
self, room_alias_or_id: RoomAnyID
264325
) -> dict[RoomAnyID, RoomID]:
@@ -432,7 +493,7 @@ async def _handle_multi_room_send(
432493
)
433494

434495
async def _send_image(
435-
self, image_path: str, target_rooms: Sequence[RoomAnyID]
496+
self, image_path: str, target_rooms: Sequence[RoomAnyID], thread_id: str | None
436497
) -> None:
437498
"""Upload an image, then send it to all target_rooms."""
438499
_is_allowed_path = await self.hass.async_add_executor_job(
@@ -480,6 +541,9 @@ async def _send_image(
480541
"url": response.content_uri,
481542
}
482543

544+
if thread_id is not None:
545+
content["m.relates_to"] = {"event_id": thread_id, "rel_type": "m.thread"}
546+
483547
await self._handle_multi_room_send(
484548
target_rooms=target_rooms, message_type="m.room.message", content=content
485549
)
@@ -488,9 +552,19 @@ async def _send_message(
488552
self, message: str, target_rooms: list[RoomAnyID], data: dict | None
489553
) -> None:
490554
"""Send a message to the Matrix server."""
491-
content = {"msgtype": "m.text", "body": message}
492-
if data is not None and data.get(ATTR_FORMAT) == FORMAT_HTML:
493-
content |= {"format": "org.matrix.custom.html", "formatted_body": message}
555+
content: dict[str, Any] = {"msgtype": "m.text", "body": message}
556+
if data is not None:
557+
thread_id: str | None = data.get(ATTR_THREAD_ID)
558+
if data.get(ATTR_FORMAT) == FORMAT_HTML:
559+
content |= {
560+
"format": "org.matrix.custom.html",
561+
"formatted_body": message,
562+
}
563+
if thread_id is not None:
564+
content["m.relates_to"] = {
565+
"event_id": thread_id,
566+
"rel_type": "m.thread",
567+
}
494568

495569
await self._handle_multi_room_send(
496570
target_rooms=target_rooms, message_type="m.room.message", content=content
@@ -503,16 +577,42 @@ async def _send_message(
503577
):
504578
image_tasks = [
505579
self.hass.async_create_task(
506-
self._send_image(image_path, target_rooms), eager_start=False
580+
self._send_image(
581+
image_path, target_rooms, data.get(ATTR_THREAD_ID)
582+
),
583+
eager_start=False,
507584
)
508585
for image_path in image_paths
509586
]
510587
await asyncio.wait(image_tasks)
511588

589+
async def _send_reaction(
590+
self, reaction: str, target_room: RoomAnyID, message_id: str
591+
) -> None:
592+
"""Send a reaction to the Matrix server."""
593+
content = {
594+
"m.relates_to": {
595+
"event_id": message_id,
596+
"key": reaction,
597+
"rel_type": "m.annotation",
598+
}
599+
}
600+
await self._handle_room_send(
601+
target_room=target_room, message_type="m.reaction", content=content
602+
)
603+
512604
async def handle_send_message(self, service: ServiceCall) -> None:
513605
"""Handle the send_message service."""
514606
await self._send_message(
515607
service.data[ATTR_MESSAGE],
516608
service.data[ATTR_TARGET],
517609
service.data.get(ATTR_DATA),
518610
)
611+
612+
async def handle_send_reaction(self, service: ServiceCall) -> None:
613+
"""Handle the react service."""
614+
await self._send_reaction(
615+
service.data[ATTR_REACTION],
616+
service.data[ATTR_ROOM],
617+
service.data[ATTR_MESSAGE_ID],
618+
)

homeassistant/components/matrix/const.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@
33
DOMAIN = "matrix"
44

55
SERVICE_SEND_MESSAGE = "send_message"
6+
SERVICE_REACT = "react"
67

78
FORMAT_HTML = "html"
89
FORMAT_TEXT = "text"
910

1011
ATTR_FORMAT = "format" # optional message format
1112
ATTR_IMAGES = "images" # optional images
13+
ATTR_THREAD_ID = "thread_id" # optional thread id
14+
15+
ATTR_REACTION = "reaction" # reaction
16+
ATTR_ROOM = "room" # room id
17+
ATTR_MESSAGE_ID = "message_id" # message id
1218

1319
CONF_ROOMS_REGEX = "^[!|#][^:]*:.*"

homeassistant/components/matrix/icons.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
{
22
"services": {
3+
"react": {
4+
"service": "mdi:emoticon"
5+
},
36
"send_message": {
47
"service": "mdi:matrix"
58
}

homeassistant/components/matrix/services.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,15 @@
1313
from .const import (
1414
ATTR_FORMAT,
1515
ATTR_IMAGES,
16+
ATTR_MESSAGE_ID,
17+
ATTR_REACTION,
18+
ATTR_ROOM,
19+
ATTR_THREAD_ID,
1620
CONF_ROOMS_REGEX,
1721
DOMAIN,
1822
FORMAT_HTML,
1923
FORMAT_TEXT,
24+
SERVICE_REACT,
2025
SERVICE_SEND_MESSAGE,
2126
)
2227

@@ -36,20 +41,35 @@
3641
MESSAGE_FORMATS
3742
),
3843
vol.Optional(ATTR_IMAGES): vol.All(cv.ensure_list, [cv.string]),
44+
vol.Optional(ATTR_THREAD_ID): cv.string,
3945
},
4046
vol.Required(ATTR_TARGET): vol.All(
4147
cv.ensure_list, [cv.matches_regex(CONF_ROOMS_REGEX)]
4248
),
4349
}
4450
)
4551

52+
SERVICE_SCHEMA_REACT = vol.Schema(
53+
{
54+
vol.Required(ATTR_REACTION): cv.string,
55+
vol.Required(ATTR_ROOM): cv.matches_regex(CONF_ROOMS_REGEX),
56+
vol.Required(ATTR_MESSAGE_ID): cv.string,
57+
}
58+
)
59+
4660

4761
async def _handle_send_message(call: ServiceCall) -> None:
4862
"""Handle the send_message service call."""
4963
matrix_bot: MatrixBot = call.hass.data[DOMAIN]
5064
await matrix_bot.handle_send_message(call)
5165

5266

67+
async def _handle_react(call: ServiceCall) -> None:
68+
"""Handle the react service call."""
69+
matrix_bot: MatrixBot = call.hass.data[DOMAIN]
70+
await matrix_bot.handle_send_reaction(call)
71+
72+
5373
@callback
5474
def async_setup_services(hass: HomeAssistant) -> None:
5575
"""Set up the Matrix bot component."""
@@ -60,3 +80,10 @@ def async_setup_services(hass: HomeAssistant) -> None:
6080
_handle_send_message,
6181
schema=SERVICE_SCHEMA_SEND_MESSAGE,
6282
)
83+
84+
hass.services.async_register(
85+
DOMAIN,
86+
SERVICE_REACT,
87+
_handle_react,
88+
schema=SERVICE_SCHEMA_REACT,
89+
)

0 commit comments

Comments
 (0)