diff --git a/.coveragerc b/.coveragerc index bf3dd5f4a00..d5a491a330f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -705,7 +705,8 @@ omit = homeassistant/components/mailgun/notify.py homeassistant/components/map/* homeassistant/components/mastodon/notify.py - homeassistant/components/matrix/* + homeassistant/components/matrix/__init__.py + homeassistant/components/matrix/notify.py homeassistant/components/matter/__init__.py homeassistant/components/meater/__init__.py homeassistant/components/meater/sensor.py diff --git a/.strict-typing b/.strict-typing index e8bca0a1abd..3059c42f33f 100644 --- a/.strict-typing +++ b/.strict-typing @@ -213,6 +213,7 @@ homeassistant.components.lookin.* homeassistant.components.luftdaten.* homeassistant.components.mailbox.* homeassistant.components.mastodon.* +homeassistant.components.matrix.* homeassistant.components.matter.* homeassistant.components.media_extractor.* homeassistant.components.media_player.* diff --git a/CODEOWNERS b/CODEOWNERS index 65a36205518..bf6fdaf9fc5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -723,6 +723,8 @@ build.json @home-assistant/supervisor /homeassistant/components/lyric/ @timmo001 /tests/components/lyric/ @timmo001 /homeassistant/components/mastodon/ @fabaff +/homeassistant/components/matrix/ @PaarthShah +/tests/components/matrix/ @PaarthShah /homeassistant/components/matter/ @home-assistant/matter /tests/components/matter/ @home-assistant/matter /homeassistant/components/mazda/ @bdr99 diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index febafc367f1..cf7bcce7b3c 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -1,10 +1,28 @@ """The Matrix bot component.""" -from functools import partial +from __future__ import annotations + +import asyncio import logging import mimetypes import os +import re +from typing import NewType, TypedDict -from matrix_client.client import MatrixClient, MatrixRequestError +import aiofiles.os +from nio import AsyncClient, Event, MatrixRoom +from nio.events.room_events import RoomMessageText +from nio.responses import ( + ErrorResponse, + JoinError, + JoinResponse, + LoginError, + Response, + UploadError, + UploadResponse, + WhoamiError, + WhoamiResponse, +) +from PIL import Image import voluptuous as vol from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET @@ -16,8 +34,8 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import Event as HassEvent, HomeAssistant, ServiceCall +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType @@ -35,23 +53,37 @@ CONF_COMMANDS = "commands" CONF_WORD = "word" CONF_EXPRESSION = "expression" +EVENT_MATRIX_COMMAND = "matrix_command" + DEFAULT_CONTENT_TYPE = "application/octet-stream" MESSAGE_FORMATS = [FORMAT_HTML, FORMAT_TEXT] DEFAULT_MESSAGE_FORMAT = FORMAT_TEXT -EVENT_MATRIX_COMMAND = "matrix_command" - ATTR_FORMAT = "format" # optional message format ATTR_IMAGES = "images" # optional images +WordCommand = NewType("WordCommand", str) +ExpressionCommand = NewType("ExpressionCommand", re.Pattern) +RoomID = NewType("RoomID", str) + + +class ConfigCommand(TypedDict, total=False): + """Corresponds to a single COMMAND_SCHEMA.""" + + name: str # CONF_NAME + rooms: list[RoomID] | None # CONF_ROOMS + word: WordCommand | None # CONF_WORD + expression: ExpressionCommand | None # CONF_EXPRESSION + + COMMAND_SCHEMA = vol.All( vol.Schema( { vol.Exclusive(CONF_WORD, "trigger"): cv.string, vol.Exclusive(CONF_EXPRESSION, "trigger"): cv.is_regex, vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_ROOMS, default=[]): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_ROOMS): vol.All(cv.ensure_list, [cv.string]), } ), cv.has_at_least_one_key(CONF_WORD, CONF_EXPRESSION), @@ -75,7 +107,6 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) - SERVICE_SCHEMA_SEND_MESSAGE = vol.Schema( { vol.Required(ATTR_MESSAGE): cv.string, @@ -90,30 +121,26 @@ SERVICE_SCHEMA_SEND_MESSAGE = vol.Schema( ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Matrix bot component.""" config = config[DOMAIN] - try: - bot = MatrixBot( - hass, - os.path.join(hass.config.path(), SESSION_FILE), - config[CONF_HOMESERVER], - config[CONF_VERIFY_SSL], - config[CONF_USERNAME], - config[CONF_PASSWORD], - config[CONF_ROOMS], - config[CONF_COMMANDS], - ) - hass.data[DOMAIN] = bot - except MatrixRequestError as exception: - _LOGGER.error("Matrix failed to log in: %s", str(exception)) - return False + matrix_bot = MatrixBot( + hass, + os.path.join(hass.config.path(), SESSION_FILE), + config[CONF_HOMESERVER], + config[CONF_VERIFY_SSL], + config[CONF_USERNAME], + config[CONF_PASSWORD], + config[CONF_ROOMS], + config[CONF_COMMANDS], + ) + hass.data[DOMAIN] = matrix_bot - hass.services.register( + hass.services.async_register( DOMAIN, SERVICE_SEND_MESSAGE, - bot.handle_send_message, + matrix_bot.handle_send_message, schema=SERVICE_SCHEMA_SEND_MESSAGE, ) @@ -123,164 +150,141 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: class MatrixBot: """The Matrix Bot.""" + _client: AsyncClient + def __init__( self, - hass, - config_file, - homeserver, - verify_ssl, - username, - password, - listening_rooms, - commands, - ): + hass: HomeAssistant, + config_file: str, + homeserver: str, + verify_ssl: bool, + username: str, + password: str, + listening_rooms: list[RoomID], + commands: list[ConfigCommand], + ) -> None: """Set up the client.""" self.hass = hass self._session_filepath = config_file - self._auth_tokens = self._get_auth_tokens() + self._access_tokens: JsonObjectType = {} self._homeserver = homeserver self._verify_tls = verify_ssl self._mx_id = username self._password = password + self._client = AsyncClient( + homeserver=self._homeserver, user=self._mx_id, ssl=self._verify_tls + ) + self._listening_rooms = listening_rooms - # We have to fetch the aliases for every room to make sure we don't - # join it twice by accident. However, fetching aliases is costly, - # so we only do it once per room. - self._aliases_fetched_for = set() + self._word_commands: dict[RoomID, dict[WordCommand, ConfigCommand]] = {} + self._expression_commands: dict[RoomID, list[ConfigCommand]] = {} + self._load_commands(commands) - # Word commands are stored dict-of-dict: First dict indexes by room ID - # / alias, second dict indexes by the word - self._word_commands = {} + async def stop_client(event: HassEvent) -> None: + """Run once when Home Assistant stops.""" + if self._client is not None: + await self._client.close() - # Regular expression commands are stored as a list of commands per - # room, i.e., a dict-of-list - self._expression_commands = {} + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_client) + async def handle_startup(event: HassEvent) -> None: + """Run once when Home Assistant finished startup.""" + self._access_tokens = await self._get_auth_tokens() + await self._login() + await self._join_rooms() + # Sync once so that we don't respond to past events. + await self._client.sync(timeout=30_000) + + self._client.add_event_callback(self._handle_room_message, RoomMessageText) + + await self._client.sync_forever( + timeout=30_000, + loop_sleep_time=1_000, + ) # milliseconds. + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, handle_startup) + + def _load_commands(self, commands: list[ConfigCommand]) -> None: for command in commands: - if not command.get(CONF_ROOMS): - command[CONF_ROOMS] = listening_rooms + # Set the command for all listening_rooms, unless otherwise specified. + command.setdefault(CONF_ROOMS, self._listening_rooms) # type: ignore[misc] - if command.get(CONF_WORD): - for room_id in command[CONF_ROOMS]: - if room_id not in self._word_commands: - self._word_commands[room_id] = {} - self._word_commands[room_id][command[CONF_WORD]] = command + # COMMAND_SCHEMA guarantees that exactly one of CONF_WORD and CONF_expression are set. + if (word_command := command.get(CONF_WORD)) is not None: + for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] + self._word_commands.setdefault(room_id, {}) + self._word_commands[room_id][word_command] = command # type: ignore[index] else: - for room_id in command[CONF_ROOMS]: - if room_id not in self._expression_commands: - self._expression_commands[room_id] = [] + for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] + self._expression_commands.setdefault(room_id, []) self._expression_commands[room_id].append(command) - # Log in. This raises a MatrixRequestError if login is unsuccessful - self._client = self._login() - - def handle_matrix_exception(exception): - """Handle exceptions raised inside the Matrix SDK.""" - _LOGGER.error("Matrix exception:\n %s", str(exception)) - - self._client.start_listener_thread(exception_handler=handle_matrix_exception) - - def stop_client(_): - """Run once when Home Assistant stops.""" - self._client.stop_listener_thread() - - self.hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_client) - - # Joining rooms potentially does a lot of I/O, so we defer it - def handle_startup(_): - """Run once when Home Assistant finished startup.""" - self._join_rooms() - - self.hass.bus.listen_once(EVENT_HOMEASSISTANT_START, handle_startup) - - def _handle_room_message(self, room_id, room, event): + async def _handle_room_message(self, room: MatrixRoom, message: Event) -> None: """Handle a message sent to a Matrix room.""" - if event["content"]["msgtype"] != "m.text": + # Corresponds to message type 'm.text' and NOT other RoomMessage subtypes, like 'm.notice' and 'm.emote'. + if not isinstance(message, RoomMessageText): return - - if event["sender"] == self._mx_id: + # Don't respond to our own messages. + if message.sender == self._mx_id: return + _LOGGER.debug("Handling message: %s", message.body) - _LOGGER.debug("Handling message: %s", event["content"]["body"]) + room_id = RoomID(room.room_id) - if event["content"]["body"][0] == "!": - # Could trigger a single-word command - pieces = event["content"]["body"].split(" ") - cmd = pieces[0][1:] + if message.body.startswith("!"): + # Could trigger a single-word command. + pieces = message.body.split() + word = WordCommand(pieces[0].lstrip("!")) - command = self._word_commands.get(room_id, {}).get(cmd) - if command: - event_data = { + if command := self._word_commands.get(room_id, {}).get(word): + message_data = { "command": command[CONF_NAME], - "sender": event["sender"], + "sender": message.sender, "room": room_id, "args": pieces[1:], } - self.hass.bus.fire(EVENT_MATRIX_COMMAND, event_data) + self.hass.bus.async_fire(EVENT_MATRIX_COMMAND, message_data) - # After single-word commands, check all regex commands in the room + # After single-word commands, check all regex commands in the room. for command in self._expression_commands.get(room_id, []): - match = command[CONF_EXPRESSION].match(event["content"]["body"]) + match: re.Match = command[CONF_EXPRESSION].match(message.body) # type: ignore[literal-required] if not match: continue - event_data = { + message_data = { "command": command[CONF_NAME], - "sender": event["sender"], + "sender": message.sender, "room": room_id, "args": match.groupdict(), } - self.hass.bus.fire(EVENT_MATRIX_COMMAND, event_data) + self.hass.bus.async_fire(EVENT_MATRIX_COMMAND, message_data) - def _join_or_get_room(self, room_id_or_alias): - """Join a room or get it, if we are already in the room. + async def _join_room(self, room_id_or_alias: str) -> None: + """Join a room or do nothing if already joined.""" + join_response = await self._client.join(room_id_or_alias) - We can't just always call join_room(), since that seems to crash - the client if we're already in the room. - """ - rooms = self._client.get_rooms() - if room_id_or_alias in rooms: - _LOGGER.debug("Already in room %s", room_id_or_alias) - return rooms[room_id_or_alias] + if isinstance(join_response, JoinResponse): + _LOGGER.debug("Joined or already in room '%s'", room_id_or_alias) + elif isinstance(join_response, JoinError): + _LOGGER.error( + "Could not join room '%s': %s", + room_id_or_alias, + join_response, + ) - for room in rooms.values(): - if room.room_id not in self._aliases_fetched_for: - room.update_aliases() - self._aliases_fetched_for.add(room.room_id) - - if ( - room_id_or_alias in room.aliases - or room_id_or_alias == room.canonical_alias - ): - _LOGGER.debug( - "Already in room %s (known as %s)", room.room_id, room_id_or_alias - ) - return room - - room = self._client.join_room(room_id_or_alias) - _LOGGER.info("Joined room %s (known as %s)", room.room_id, room_id_or_alias) - return room - - def _join_rooms(self): + async def _join_rooms(self) -> None: """Join the Matrix rooms that we listen for commands in.""" - for room_id in self._listening_rooms: - try: - room = self._join_or_get_room(room_id) - room.add_listener( - partial(self._handle_room_message, room_id), "m.room.message" - ) + rooms = [ + self.hass.async_create_task(self._join_room(room_id)) + for room_id in self._listening_rooms + ] + await asyncio.wait(rooms) - except MatrixRequestError as ex: - _LOGGER.error("Could not join room %s: %s", room_id, ex) - - def _get_auth_tokens(self) -> JsonObjectType: - """Read sorted authentication tokens from disk. - - Returns the auth_tokens dictionary. - """ + async def _get_auth_tokens(self) -> JsonObjectType: + """Read sorted authentication tokens from disk.""" try: return load_json_object(self._session_filepath) except HomeAssistantError as ex: @@ -291,116 +295,179 @@ class MatrixBot: ) return {} - def _store_auth_token(self, token): + async def _store_auth_token(self, token: str) -> None: """Store authentication token to session and persistent storage.""" - self._auth_tokens[self._mx_id] = token + self._access_tokens[self._mx_id] = token - save_json(self._session_filepath, self._auth_tokens) + await self.hass.async_add_executor_job( + save_json, self._session_filepath, self._access_tokens, True # private=True + ) - def _login(self): - """Login to the Matrix homeserver and return the client instance.""" - # Attempt to generate a valid client using either of the two possible - # login methods: - client = None + async def _login(self) -> None: + """Log in to the Matrix homeserver. - # If we have an authentication token - if self._mx_id in self._auth_tokens: - try: - client = self._login_by_token() - _LOGGER.debug("Logged in using stored token") + Attempts to use the stored access token. + If that fails, then tries using the password. + If that also fails, raises LocalProtocolError. + """ - except MatrixRequestError as ex: + # If we have an access token + if (token := self._access_tokens.get(self._mx_id)) is not None: + _LOGGER.debug("Restoring login from stored access token") + self._client.restore_login( + user_id=self._client.user_id, + device_id=self._client.device_id, + access_token=token, + ) + response = await self._client.whoami() + if isinstance(response, WhoamiError): _LOGGER.warning( - "Login by token failed, falling back to password: %d, %s", - ex.code, - ex.content, + "Restoring login from access token failed: %s, %s", + response.status_code, + response.message, + ) + self._client.access_token = ( + "" # Force a soft-logout if the homeserver didn't. + ) + elif isinstance(response, WhoamiResponse): + _LOGGER.debug( + "Successfully restored login from access token: user_id '%s', device_id '%s'", + response.user_id, + response.device_id, ) - # If we still don't have a client try password - if not client: - try: - client = self._login_by_password() - _LOGGER.debug("Logged in using password") + # If the token login did not succeed + if not self._client.logged_in: + response = await self._client.login(password=self._password) + _LOGGER.debug("Logging in using password") - except MatrixRequestError as ex: - _LOGGER.error( - "Login failed, both token and username/password invalid: %d, %s", - ex.code, - ex.content, + if isinstance(response, LoginError): + _LOGGER.warning( + "Login by password failed: %s, %s", + response.status_code, + response.message, ) - # Re-raise the error so _setup can catch it - raise - return client + if not self._client.logged_in: + raise ConfigEntryAuthFailed( + "Login failed, both token and username/password are invalid" + ) - def _login_by_token(self): - """Login using authentication token and return the client.""" - return MatrixClient( - base_url=self._homeserver, - token=self._auth_tokens[self._mx_id], - user_id=self._mx_id, - valid_cert_check=self._verify_tls, + await self._store_auth_token(self._client.access_token) + + async def _handle_room_send( + self, target_room: RoomID, message_type: str, content: dict + ) -> None: + """Wrap _client.room_send and handle ErrorResponses.""" + response: Response = await self._client.room_send( + room_id=target_room, + message_type=message_type, + content=content, ) + if isinstance(response, ErrorResponse): + _LOGGER.error( + "Unable to deliver message to room '%s': %s", + target_room, + response, + ) + else: + _LOGGER.debug("Message delivered to room '%s'", target_room) - def _login_by_password(self): - """Login using password authentication and return the client.""" - _client = MatrixClient( - base_url=self._homeserver, valid_cert_check=self._verify_tls + async def _handle_multi_room_send( + self, target_rooms: list[RoomID], message_type: str, content: dict + ) -> None: + """Wrap _handle_room_send for multiple target_rooms.""" + _tasks = [] + for target_room in target_rooms: + _tasks.append( + self.hass.async_create_task( + self._handle_room_send( + target_room=target_room, + message_type=message_type, + content=content, + ) + ) + ) + await asyncio.wait(_tasks) + + async def _send_image(self, image_path: str, target_rooms: list[RoomID]) -> None: + """Upload an image, then send it to all target_rooms.""" + _is_allowed_path = await self.hass.async_add_executor_job( + self.hass.config.is_allowed_path, image_path ) - - _client.login_with_password(self._mx_id, self._password) - - self._store_auth_token(_client.token) - - return _client - - def _send_image(self, img, target_rooms): - _LOGGER.debug("Uploading file from path, %s", img) - - if not self.hass.config.is_allowed_path(img): - _LOGGER.error("Path not allowed: %s", img) + if not _is_allowed_path: + _LOGGER.error("Path not allowed: %s", image_path) return - with open(img, "rb") as upfile: - imgfile = upfile.read() - content_type = mimetypes.guess_type(img)[0] - mxc = self._client.upload(imgfile, content_type) - for target_room in target_rooms: - try: - room = self._join_or_get_room(target_room) - room.send_image(mxc, img, mimetype=content_type) - except MatrixRequestError as ex: - _LOGGER.error( - "Unable to deliver message to room '%s': %d, %s", - target_room, - ex.code, - ex.content, - ) - def _send_message(self, message, data, target_rooms): - """Send the message to the Matrix server.""" - for target_room in target_rooms: - try: - room = self._join_or_get_room(target_room) - if message is not None: - if data.get(ATTR_FORMAT) == FORMAT_HTML: - _LOGGER.debug(room.send_html(message)) - else: - _LOGGER.debug(room.send_text(message)) - except MatrixRequestError as ex: - _LOGGER.error( - "Unable to deliver message to room '%s': %d, %s", - target_room, - ex.code, - ex.content, - ) - if ATTR_IMAGES in data: - for img in data.get(ATTR_IMAGES, []): - self._send_image(img, target_rooms) + # Get required image metadata. + image = await self.hass.async_add_executor_job(Image.open, image_path) + (width, height) = image.size + mime_type = mimetypes.guess_type(image_path)[0] + file_stat = await aiofiles.os.stat(image_path) - def handle_send_message(self, service: ServiceCall) -> None: - """Handle the send_message service.""" - self._send_message( - service.data.get(ATTR_MESSAGE), - service.data.get(ATTR_DATA), - service.data[ATTR_TARGET], + _LOGGER.debug("Uploading file from path, %s", image_path) + async with aiofiles.open(image_path, "r+b") as image_file: + response, _ = await self._client.upload( + image_file, + content_type=mime_type, + filename=os.path.basename(image_path), + filesize=file_stat.st_size, + ) + if isinstance(response, UploadError): + _LOGGER.error("Unable to upload image to the homeserver: %s", response) + return + if isinstance(response, UploadResponse): + _LOGGER.debug("Successfully uploaded image to the homeserver") + else: + _LOGGER.error( + "Unknown response received when uploading image to homeserver: %s", + response, + ) + return + + content = { + "body": os.path.basename(image_path), + "info": { + "size": file_stat.st_size, + "mimetype": mime_type, + "w": width, + "h": height, + }, + "msgtype": "m.image", + "url": response.content_uri, + } + + await self._handle_multi_room_send( + target_rooms=target_rooms, message_type="m.room.message", content=content + ) + + async def _send_message( + self, message: str, target_rooms: list[RoomID], 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} + + await self._handle_multi_room_send( + target_rooms=target_rooms, message_type="m.room.message", content=content + ) + + if ( + data is not None + and (image_paths := data.get(ATTR_IMAGES, [])) + and len(target_rooms) > 0 + ): + image_tasks = [ + self.hass.async_create_task(self._send_image(image_path, target_rooms)) + for image_path in image_paths + ] + await asyncio.wait(image_tasks) + + async def handle_send_message(self, service: ServiceCall) -> None: + """Handle the send_message service.""" + await self._send_message( + service.data[ATTR_MESSAGE], + service.data[ATTR_TARGET], + service.data.get(ATTR_DATA), ) diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index 4bded80a711..74bb97d10fc 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -1,9 +1,9 @@ { "domain": "matrix", "name": "Matrix", - "codeowners": [], + "codeowners": ["@PaarthShah"], "documentation": "https://www.home-assistant.io/integrations/matrix", "iot_class": "cloud_push", "loggers": ["matrix_client"], - "requirements": ["matrix-client==0.4.0"] + "requirements": ["matrix-nio==0.21.2", "Pillow==10.0.0"] } diff --git a/homeassistant/components/matrix/notify.py b/homeassistant/components/matrix/notify.py index 3c90e9afbc0..c71f91eb582 100644 --- a/homeassistant/components/matrix/notify.py +++ b/homeassistant/components/matrix/notify.py @@ -1,6 +1,8 @@ """Support for Matrix notifications.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.notify import ( @@ -14,6 +16,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import RoomID from .const import DOMAIN, SERVICE_SEND_MESSAGE CONF_DEFAULT_ROOM = "default_room" @@ -33,16 +36,14 @@ def get_service( class MatrixNotificationService(BaseNotificationService): """Send notifications to a Matrix room.""" - def __init__(self, default_room): + def __init__(self, default_room: RoomID) -> None: """Set up the Matrix notification service.""" self._default_room = default_room - def send_message(self, message="", **kwargs): + def send_message(self, message: str = "", **kwargs: Any) -> None: """Send the message to the Matrix server.""" - target_rooms = kwargs.get(ATTR_TARGET) or [self._default_room] + target_rooms: list[RoomID] = kwargs.get(ATTR_TARGET) or [self._default_room] service_data = {ATTR_TARGET: target_rooms, ATTR_MESSAGE: message} if (data := kwargs.get(ATTR_DATA)) is not None: service_data[ATTR_DATA] = data - return self.hass.services.call( - DOMAIN, SERVICE_SEND_MESSAGE, service_data=service_data - ) + self.hass.services.call(DOMAIN, SERVICE_SEND_MESSAGE, service_data=service_data) diff --git a/mypy.ini b/mypy.ini index 82cce328c6a..9802c26c3c6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1892,6 +1892,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.matrix.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.matter.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index f7355ea9483..4c5497ae98c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -37,6 +37,7 @@ Mastodon.py==1.5.1 # homeassistant.components.doods # homeassistant.components.generic # homeassistant.components.image_upload +# homeassistant.components.matrix # homeassistant.components.proxy # homeassistant.components.qrcode # homeassistant.components.seven_segments @@ -1177,7 +1178,7 @@ lxml==4.9.3 mac-vendor-lookup==0.1.12 # homeassistant.components.matrix -matrix-client==0.4.0 +matrix-nio==0.21.2 # homeassistant.components.maxcube maxcube-api==0.4.3 diff --git a/requirements_test.txt b/requirements_test.txt index a2533d0ef2b..89db04a5db8 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -33,6 +33,7 @@ requests_mock==1.11.0 respx==0.20.2 syrupy==4.2.1 tqdm==4.66.1 +types-aiofiles==22.1.0 types-atomicwrites==1.4.5.1 types-croniter==1.0.6 types-backports==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3e3c6aab866..18e4f21914e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -33,6 +33,7 @@ HATasmota==0.7.0 # homeassistant.components.doods # homeassistant.components.generic # homeassistant.components.image_upload +# homeassistant.components.matrix # homeassistant.components.proxy # homeassistant.components.qrcode # homeassistant.components.seven_segments @@ -899,6 +900,9 @@ lxml==4.9.3 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.12 +# homeassistant.components.matrix +matrix-nio==0.21.2 + # homeassistant.components.maxcube maxcube-api==0.4.3 diff --git a/tests/components/matrix/__init__.py b/tests/components/matrix/__init__.py new file mode 100644 index 00000000000..a520f7e7c23 --- /dev/null +++ b/tests/components/matrix/__init__.py @@ -0,0 +1 @@ +"""Tests for the Matrix component.""" diff --git a/tests/components/matrix/conftest.py b/tests/components/matrix/conftest.py new file mode 100644 index 00000000000..d0970b96019 --- /dev/null +++ b/tests/components/matrix/conftest.py @@ -0,0 +1,248 @@ +"""Define fixtures available for all tests.""" +from __future__ import annotations + +import re +import tempfile +from unittest.mock import patch + +from nio import ( + AsyncClient, + ErrorResponse, + JoinError, + JoinResponse, + LocalProtocolError, + LoginError, + LoginResponse, + Response, + UploadResponse, + WhoamiError, + WhoamiResponse, +) +from PIL import Image +import pytest + +from homeassistant.components.matrix import ( + CONF_COMMANDS, + CONF_EXPRESSION, + CONF_HOMESERVER, + CONF_ROOMS, + CONF_WORD, + EVENT_MATRIX_COMMAND, + MatrixBot, + RoomID, +) +from homeassistant.components.matrix.const import DOMAIN as MATRIX_DOMAIN +from homeassistant.components.matrix.notify import CONF_DEFAULT_ROOM +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_PLATFORM, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import async_capture_events + +TEST_NOTIFIER_NAME = "matrix_notify" + +TEST_DEFAULT_ROOM = "!DefaultNotificationRoom:example.com" +TEST_JOINABLE_ROOMS = ["!RoomIdString:example.com", "#RoomAliasString:example.com"] +TEST_BAD_ROOM = "!UninvitedRoom:example.com" +TEST_MXID = "@user:example.com" +TEST_DEVICE_ID = "FAKEID" +TEST_PASSWORD = "password" +TEST_TOKEN = "access_token" + +NIO_IMPORT_PREFIX = "homeassistant.components.matrix.nio." + + +class _MockAsyncClient(AsyncClient): + """Mock class to simulate MatrixBot._client's I/O methods.""" + + async def close(self): + return None + + async def join(self, room_id: RoomID): + if room_id in TEST_JOINABLE_ROOMS: + return JoinResponse(room_id=room_id) + else: + return JoinError(message="Not allowed to join this room.") + + async def login(self, *args, **kwargs): + if kwargs.get("password") == TEST_PASSWORD or kwargs.get("token") == TEST_TOKEN: + self.access_token = TEST_TOKEN + return LoginResponse( + access_token=TEST_TOKEN, + device_id="test_device", + user_id=TEST_MXID, + ) + else: + self.access_token = "" + return LoginError(message="LoginError", status_code="status_code") + + async def logout(self, *args, **kwargs): + self.access_token = "" + + async def whoami(self): + if self.access_token == TEST_TOKEN: + self.user_id = TEST_MXID + self.device_id = TEST_DEVICE_ID + return WhoamiResponse( + user_id=TEST_MXID, device_id=TEST_DEVICE_ID, is_guest=False + ) + else: + self.access_token = "" + return WhoamiError( + message="Invalid access token passed.", status_code="M_UNKNOWN_TOKEN" + ) + + async def room_send(self, *args, **kwargs): + if not self.logged_in: + raise LocalProtocolError + if kwargs["room_id"] in TEST_JOINABLE_ROOMS: + return Response() + else: + return ErrorResponse(message="Cannot send a message in this room.") + + async def sync(self, *args, **kwargs): + return None + + async def sync_forever(self, *args, **kwargs): + return None + + async def upload(self, *args, **kwargs): + return UploadResponse(content_uri="mxc://example.com/randomgibberish"), None + + +MOCK_CONFIG_DATA = { + MATRIX_DOMAIN: { + CONF_HOMESERVER: "https://matrix.example.com", + CONF_USERNAME: TEST_MXID, + CONF_PASSWORD: TEST_PASSWORD, + CONF_VERIFY_SSL: True, + CONF_ROOMS: TEST_JOINABLE_ROOMS, + CONF_COMMANDS: [ + { + CONF_WORD: "WordTrigger", + CONF_NAME: "WordTriggerEventName", + }, + { + CONF_EXPRESSION: "My name is (?P.*)", + CONF_NAME: "ExpressionTriggerEventName", + }, + ], + }, + NOTIFY_DOMAIN: { + CONF_NAME: TEST_NOTIFIER_NAME, + CONF_PLATFORM: MATRIX_DOMAIN, + CONF_DEFAULT_ROOM: TEST_DEFAULT_ROOM, + }, +} + +MOCK_WORD_COMMANDS = { + "!RoomIdString:example.com": { + "WordTrigger": { + "word": "WordTrigger", + "name": "WordTriggerEventName", + "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + } + }, + "#RoomAliasString:example.com": { + "WordTrigger": { + "word": "WordTrigger", + "name": "WordTriggerEventName", + "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + } + }, +} + +MOCK_EXPRESSION_COMMANDS = { + "!RoomIdString:example.com": [ + { + "expression": re.compile("My name is (?P.*)"), + "name": "ExpressionTriggerEventName", + "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + } + ], + "#RoomAliasString:example.com": [ + { + "expression": re.compile("My name is (?P.*)"), + "name": "ExpressionTriggerEventName", + "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + } + ], +} + + +@pytest.fixture +def mock_client(): + """Return mocked AsyncClient.""" + with patch("homeassistant.components.matrix.AsyncClient", _MockAsyncClient) as mock: + yield mock + + +@pytest.fixture +def mock_save_json(): + """Prevent saving test access_tokens.""" + with patch("homeassistant.components.matrix.save_json") as mock: + yield mock + + +@pytest.fixture +def mock_load_json(): + """Mock loading access_tokens from a file.""" + with patch( + "homeassistant.components.matrix.load_json_object", + return_value={TEST_MXID: TEST_TOKEN}, + ) as mock: + yield mock + + +@pytest.fixture +def mock_allowed_path(): + """Allow using NamedTemporaryFile for mock image.""" + with patch("homeassistant.core.Config.is_allowed_path", return_value=True) as mock: + yield mock + + +@pytest.fixture +async def matrix_bot( + hass: HomeAssistant, mock_client, mock_save_json, mock_allowed_path +) -> MatrixBot: + """Set up Matrix and Notify component. + + The resulting MatrixBot will have a mocked _client. + """ + + assert await async_setup_component(hass, MATRIX_DOMAIN, MOCK_CONFIG_DATA) + assert await async_setup_component(hass, NOTIFY_DOMAIN, MOCK_CONFIG_DATA) + await hass.async_block_till_done() + assert isinstance(matrix_bot := hass.data[MATRIX_DOMAIN], MatrixBot) + + await hass.async_start() + + return matrix_bot + + +@pytest.fixture +def matrix_events(hass: HomeAssistant): + """Track event calls.""" + return async_capture_events(hass, MATRIX_DOMAIN) + + +@pytest.fixture +def command_events(hass: HomeAssistant): + """Track event calls.""" + return async_capture_events(hass, EVENT_MATRIX_COMMAND) + + +@pytest.fixture +def image_path(tmp_path): + """Provide the Path to a mock image.""" + image = Image.new("RGBA", size=(50, 50), color=(256, 0, 0)) + image_file = tempfile.NamedTemporaryFile(dir=tmp_path) + image.save(image_file, "PNG") + return image_file diff --git a/tests/components/matrix/test_join_rooms.py b/tests/components/matrix/test_join_rooms.py new file mode 100644 index 00000000000..54856b91ac3 --- /dev/null +++ b/tests/components/matrix/test_join_rooms.py @@ -0,0 +1,22 @@ +"""Test MatrixBot._join.""" + +from homeassistant.components.matrix import MatrixBot + +from tests.components.matrix.conftest import TEST_BAD_ROOM, TEST_JOINABLE_ROOMS + + +async def test_join(matrix_bot: MatrixBot, caplog): + """Test joining configured rooms.""" + + # Join configured rooms. + await matrix_bot._join_rooms() + for room_id in TEST_JOINABLE_ROOMS: + assert f"Joined or already in room '{room_id}'" in caplog.messages + + # Joining a disallowed room should not raise an exception. + matrix_bot._listening_rooms = [TEST_BAD_ROOM] + await matrix_bot._join_rooms() + assert ( + f"Could not join room '{TEST_BAD_ROOM}': JoinError: Not allowed to join this room." + in caplog.messages + ) diff --git a/tests/components/matrix/test_login.py b/tests/components/matrix/test_login.py new file mode 100644 index 00000000000..8112d98fc8c --- /dev/null +++ b/tests/components/matrix/test_login.py @@ -0,0 +1,118 @@ +"""Test MatrixBot._login.""" + +from pydantic.dataclasses import dataclass +import pytest + +from homeassistant.components.matrix import MatrixBot +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError + +from tests.components.matrix.conftest import ( + TEST_DEVICE_ID, + TEST_MXID, + TEST_PASSWORD, + TEST_TOKEN, +) + + +@dataclass +class LoginTestParameters: + """Dataclass of parameters representing the login parameters and expected result state.""" + + password: str + access_token: dict[str, str] + expected_login_state: bool + expected_caplog_messages: set[str] + expected_expection: type(Exception) | None = None + + +good_password_missing_token = LoginTestParameters( + password=TEST_PASSWORD, + access_token={}, + expected_login_state=True, + expected_caplog_messages={"Logging in using password"}, +) + +good_password_bad_token = LoginTestParameters( + password=TEST_PASSWORD, + access_token={TEST_MXID: "WrongToken"}, + expected_login_state=True, + expected_caplog_messages={ + "Restoring login from stored access token", + "Restoring login from access token failed: M_UNKNOWN_TOKEN, Invalid access token passed.", + "Logging in using password", + }, +) + +bad_password_good_access_token = LoginTestParameters( + password="WrongPassword", + access_token={TEST_MXID: TEST_TOKEN}, + expected_login_state=True, + expected_caplog_messages={ + "Restoring login from stored access token", + f"Successfully restored login from access token: user_id '{TEST_MXID}', device_id '{TEST_DEVICE_ID}'", + }, +) + +bad_password_bad_access_token = LoginTestParameters( + password="WrongPassword", + access_token={TEST_MXID: "WrongToken"}, + expected_login_state=False, + expected_caplog_messages={ + "Restoring login from stored access token", + "Restoring login from access token failed: M_UNKNOWN_TOKEN, Invalid access token passed.", + "Logging in using password", + "Login by password failed: status_code, LoginError", + }, + expected_expection=ConfigEntryAuthFailed, +) + +bad_password_missing_access_token = LoginTestParameters( + password="WrongPassword", + access_token={}, + expected_login_state=False, + expected_caplog_messages={ + "Logging in using password", + "Login by password failed: status_code, LoginError", + }, + expected_expection=ConfigEntryAuthFailed, +) + + +@pytest.mark.parametrize( + "params", + [ + good_password_missing_token, + good_password_bad_token, + bad_password_good_access_token, + bad_password_bad_access_token, + bad_password_missing_access_token, + ], +) +async def test_login( + matrix_bot: MatrixBot, caplog: pytest.LogCaptureFixture, params: LoginTestParameters +): + """Test logging in with the given parameters and expected state.""" + await matrix_bot._client.logout() + matrix_bot._password = params.password + matrix_bot._access_tokens = params.access_token + + if params.expected_expection: + with pytest.raises(params.expected_expection): + await matrix_bot._login() + else: + await matrix_bot._login() + assert matrix_bot._client.logged_in == params.expected_login_state + assert set(caplog.messages).issuperset(params.expected_caplog_messages) + + +async def test_get_auth_tokens(matrix_bot: MatrixBot, mock_load_json): + """Test loading access_tokens from a mocked file.""" + + # Test loading good tokens. + loaded_tokens = await matrix_bot._get_auth_tokens() + assert loaded_tokens == {TEST_MXID: TEST_TOKEN} + + # Test miscellaneous error from hass. + mock_load_json.side_effect = HomeAssistantError() + loaded_tokens = await matrix_bot._get_auth_tokens() + assert loaded_tokens == {} diff --git a/tests/components/matrix/test_matrix_bot.py b/tests/components/matrix/test_matrix_bot.py new file mode 100644 index 00000000000..0b150a629fe --- /dev/null +++ b/tests/components/matrix/test_matrix_bot.py @@ -0,0 +1,88 @@ +"""Configure and test MatrixBot.""" +from nio import MatrixRoom, RoomMessageText + +from homeassistant.components.matrix import ( + DOMAIN as MATRIX_DOMAIN, + SERVICE_SEND_MESSAGE, + MatrixBot, +) +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.core import HomeAssistant + +from .conftest import ( + MOCK_EXPRESSION_COMMANDS, + MOCK_WORD_COMMANDS, + TEST_JOINABLE_ROOMS, + TEST_NOTIFIER_NAME, +) + + +async def test_services(hass: HomeAssistant, matrix_bot: MatrixBot): + """Test hass/MatrixBot state.""" + + services = hass.services.async_services() + + # Verify that the matrix service is registered + assert (matrix_service := services.get(MATRIX_DOMAIN)) + assert SERVICE_SEND_MESSAGE in matrix_service + + # Verify that the matrix notifier is registered + assert (notify_service := services.get(NOTIFY_DOMAIN)) + assert TEST_NOTIFIER_NAME in notify_service + + +async def test_commands(hass, matrix_bot: MatrixBot, command_events): + """Test that the configured commands were parsed correctly.""" + + assert len(command_events) == 0 + + assert matrix_bot._word_commands == MOCK_WORD_COMMANDS + assert matrix_bot._expression_commands == MOCK_EXPRESSION_COMMANDS + + room_id = TEST_JOINABLE_ROOMS[0] + room = MatrixRoom(room_id=room_id, own_user_id=matrix_bot._mx_id) + + # Test single-word command. + word_command_message = RoomMessageText( + body="!WordTrigger arg1 arg2", + formatted_body=None, + format=None, + source={ + "event_id": "fake_event_id", + "sender": "@SomeUser:example.com", + "origin_server_ts": 123456789, + }, + ) + await matrix_bot._handle_room_message(room, word_command_message) + await hass.async_block_till_done() + assert len(command_events) == 1 + event = command_events.pop() + assert event.data == { + "command": "WordTriggerEventName", + "sender": "@SomeUser:example.com", + "room": room_id, + "args": ["arg1", "arg2"], + } + + # Test expression command. + room = MatrixRoom(room_id=room_id, own_user_id=matrix_bot._mx_id) + expression_command_message = RoomMessageText( + body="My name is FakeName", + formatted_body=None, + format=None, + source={ + "event_id": "fake_event_id", + "sender": "@SomeUser:example.com", + "origin_server_ts": 123456789, + }, + ) + await matrix_bot._handle_room_message(room, expression_command_message) + await hass.async_block_till_done() + assert len(command_events) == 1 + event = command_events.pop() + assert event.data == { + "command": "ExpressionTriggerEventName", + "sender": "@SomeUser:example.com", + "room": room_id, + "args": {"name": "FakeName"}, + } diff --git a/tests/components/matrix/test_send_message.py b/tests/components/matrix/test_send_message.py new file mode 100644 index 00000000000..34964f2b091 --- /dev/null +++ b/tests/components/matrix/test_send_message.py @@ -0,0 +1,71 @@ +"""Test the send_message service.""" + +from homeassistant.components.matrix import ( + ATTR_FORMAT, + ATTR_IMAGES, + DOMAIN as MATRIX_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 HomeAssistant + +from tests.components.matrix.conftest import TEST_BAD_ROOM, TEST_JOINABLE_ROOMS + + +async def test_send_message( + hass: HomeAssistant, matrix_bot: MatrixBot, image_path, matrix_events, caplog +): + """Test the send_message service.""" + assert len(matrix_events) == 0 + await matrix_bot._login() + + # Send a message without an attached image. + data = {ATTR_MESSAGE: "Test message", ATTR_TARGET: TEST_JOINABLE_ROOMS} + await hass.services.async_call( + MATRIX_DOMAIN, SERVICE_SEND_MESSAGE, data, blocking=True + ) + + for room_id in TEST_JOINABLE_ROOMS: + assert f"Message delivered to room '{room_id}'" in caplog.messages + + # Send an HTML message without an attached image. + data = { + ATTR_MESSAGE: "Test message", + ATTR_TARGET: TEST_JOINABLE_ROOMS, + ATTR_DATA: {ATTR_FORMAT: FORMAT_HTML}, + } + await hass.services.async_call( + MATRIX_DOMAIN, SERVICE_SEND_MESSAGE, data, blocking=True + ) + + for room_id in TEST_JOINABLE_ROOMS: + assert f"Message delivered to room '{room_id}'" in caplog.messages + + # Send a message with an attached image. + data[ATTR_DATA] = {ATTR_IMAGES: [image_path.name]} + await hass.services.async_call( + MATRIX_DOMAIN, SERVICE_SEND_MESSAGE, data, blocking=True + ) + + for room_id in TEST_JOINABLE_ROOMS: + assert f"Message delivered to room '{room_id}'" in caplog.messages + + +async def test_unsendable_message( + hass: HomeAssistant, matrix_bot: MatrixBot, matrix_events, caplog +): + """Test the send_message service with an invalid room.""" + assert len(matrix_events) == 0 + await matrix_bot._login() + + data = {ATTR_MESSAGE: "Test message", ATTR_TARGET: TEST_BAD_ROOM} + + await hass.services.async_call( + MATRIX_DOMAIN, SERVICE_SEND_MESSAGE, data, blocking=True + ) + + assert ( + f"Unable to deliver message to room '{TEST_BAD_ROOM}': ErrorResponse: Cannot send a message in this room." + in caplog.messages + )