mirror of
https://github.com/home-assistant/core.git
synced 2025-04-27 18:57:57 +00:00
Change matrix component to use matrix-nio instead of matrix_client (#72797)
This commit is contained in:
parent
f48e8623da
commit
4d3b978398
@ -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
|
||||
|
@ -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.*
|
||||
|
@ -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
|
||||
|
@ -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,12 +121,11 @@ 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(
|
||||
matrix_bot = MatrixBot(
|
||||
hass,
|
||||
os.path.join(hass.config.path(), SESSION_FILE),
|
||||
config[CONF_HOMESERVER],
|
||||
@ -105,15 +135,12 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
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
|
||||
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]
|
||||
|
||||
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
|
||||
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,
|
||||
)
|
||||
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:
|
||||
if isinstance(response, LoginError):
|
||||
_LOGGER.warning(
|
||||
"Login by password failed: %s, %s",
|
||||
response.status_code,
|
||||
response.message,
|
||||
)
|
||||
|
||||
if not self._client.logged_in:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Login failed, both token and username/password are invalid"
|
||||
)
|
||||
|
||||
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(
|
||||
"Login failed, both token and username/password invalid: %d, %s",
|
||||
ex.code,
|
||||
ex.content,
|
||||
)
|
||||
# Re-raise the error so _setup can catch it
|
||||
raise
|
||||
|
||||
return client
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
_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)
|
||||
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",
|
||||
"Unable to deliver message to room '%s': %s",
|
||||
target_room,
|
||||
ex.code,
|
||||
ex.content,
|
||||
response,
|
||||
)
|
||||
|
||||
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)
|
||||
_LOGGER.debug("Message delivered to room '%s'", target_room)
|
||||
|
||||
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],
|
||||
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
|
||||
)
|
||||
if not _is_allowed_path:
|
||||
_LOGGER.error("Path not allowed: %s", image_path)
|
||||
return
|
||||
|
||||
# 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)
|
||||
|
||||
_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),
|
||||
)
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -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)
|
||||
|
10
mypy.ini
10
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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
1
tests/components/matrix/__init__.py
Normal file
1
tests/components/matrix/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the Matrix component."""
|
248
tests/components/matrix/conftest.py
Normal file
248
tests/components/matrix/conftest.py
Normal 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
|
22
tests/components/matrix/test_join_rooms.py
Normal file
22
tests/components/matrix/test_join_rooms.py
Normal 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
|
||||
)
|
118
tests/components/matrix/test_login.py
Normal file
118
tests/components/matrix/test_login.py
Normal 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 == {}
|
88
tests/components/matrix/test_matrix_bot.py
Normal file
88
tests/components/matrix/test_matrix_bot.py
Normal 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"},
|
||||
}
|
71
tests/components/matrix/test_send_message.py
Normal file
71
tests/components/matrix/test_send_message.py
Normal 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
|
||||
)
|
Loading…
x
Reference in New Issue
Block a user