Change matrix component to use matrix-nio instead of matrix_client (#72797)

This commit is contained in:
Paarth Shah 2023-09-02 06:02:55 -07:00 committed by GitHub
parent f48e8623da
commit 4d3b978398
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 879 additions and 243 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
"""Tests for the Matrix component."""

View File

@ -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<name>.*)",
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>.*)"),
"name": "ExpressionTriggerEventName",
"rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"],
}
],
"#RoomAliasString:example.com": [
{
"expression": re.compile("My name is (?P<name>.*)"),
"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

View File

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

View File

@ -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 == {}

View File

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

View File

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