88import mimetypes
99import os
1010import re
11- from typing import Final , NewType , Required , TypedDict
11+ from typing import Any , Final , NewType , Required , TypedDict
1212
1313import aiofiles .os
1414from nio import AsyncClient , Event , MatrixRoom
15- from nio .events .room_events import RoomMessageText
15+ from nio .events .room_events import ReactionEvent , RoomMessageText
1616from nio .responses import (
1717 ErrorResponse ,
1818 JoinError ,
4444from homeassistant .helpers .typing import ConfigType
4545from 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+ )
4858from .services import async_setup_services
4959
5060_LOGGER = logging .getLogger (__name__ )
5666CONF_COMMANDS : Final = "commands"
5767CONF_WORD : Final = "word"
5868CONF_EXPRESSION : Final = "expression"
69+ CONF_REACTION : Final = "reaction"
5970
6071CONF_USERNAME_REGEX = "^@[^:]*:.*"
6172
6677
6778WordCommand = NewType ("WordCommand" , str )
6879ExpressionCommand = NewType ("ExpressionCommand" , re .Pattern )
80+ ReactionCommand = NewType ("ReactionCommand" , str )
6981RoomAlias = NewType ("RoomAlias" , str ) # Starts with "#"
7082RoomID = NewType ("RoomID" , str ) # Starts with "!"
7183RoomAnyID = 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
8396COMMAND_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
97111CONFIG_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+ )
0 commit comments