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:
@@ -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],
|
||||
)
|
||||
|
||||
@@ -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 = "^[!|#][^:]*:.*"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"services": {
|
||||
"react": {
|
||||
"service": "mdi:emoticon"
|
||||
},
|
||||
"send_message": {
|
||||
"service": "mdi:matrix"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
35
tests/components/matrix/test_react.py
Normal file
35
tests/components/matrix/test_react.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user