diff --git a/.coveragerc b/.coveragerc index cf7a5a2cd9c3a60cbea56295ce8c5284b2bb4c01..d2192ca2e46ebad471dbfb33aa9f592675ee47c2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -166,6 +166,9 @@ omit = homeassistant/components/mailgun.py homeassistant/components/*/mailgun.py + homeassistant/components/matrix.py + homeassistant/components/*/matrix.py + homeassistant/components/maxcube.py homeassistant/components/*/maxcube.py @@ -519,7 +522,6 @@ omit = homeassistant/components/notify/lannouncer.py homeassistant/components/notify/llamalab_automate.py homeassistant/components/notify/mastodon.py - homeassistant/components/notify/matrix.py homeassistant/components/notify/message_bird.py homeassistant/components/notify/mycroft.py homeassistant/components/notify/nfandroidtv.py diff --git a/CODEOWNERS b/CODEOWNERS index a62ed67db664545d8f20002b8399a32ba40d51fd..33966d1badbd8ace556bcce45af7eec6f9becbb3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -94,6 +94,8 @@ homeassistant/components/*/hive.py @Rendili @KJonline homeassistant/components/homekit/* @cdce8p homeassistant/components/knx.py @Julius2342 homeassistant/components/*/knx.py @Julius2342 +homeassistant/components/matrix.py @tinloaf +homeassistant/components/*/matrix.py @tinloaf homeassistant/components/qwikswitch.py @kellerza homeassistant/components/*/qwikswitch.py @kellerza homeassistant/components/*/rfxtrx.py @danielhiversen diff --git a/homeassistant/components/matrix.py b/homeassistant/components/matrix.py new file mode 100644 index 0000000000000000000000000000000000000000..569b012b4846254d82fec86709b67efb58d7286e --- /dev/null +++ b/homeassistant/components/matrix.py @@ -0,0 +1,351 @@ +""" +The matrix bot component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/matrix/ +""" +import logging +import os +from functools import partial + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.notify import (ATTR_TARGET, ATTR_MESSAGE) +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, + CONF_VERIFY_SSL, CONF_NAME, + EVENT_HOMEASSISTANT_STOP, + EVENT_HOMEASSISTANT_START) +from homeassistant.util.json import load_json, save_json +from homeassistant.exceptions import HomeAssistantError + +REQUIREMENTS = ['matrix-client==0.2.0'] + +_LOGGER = logging.getLogger(__name__) + +SESSION_FILE = '.matrix.conf' + +CONF_HOMESERVER = 'homeserver' +CONF_ROOMS = 'rooms' +CONF_COMMANDS = 'commands' +CONF_WORD = 'word' +CONF_EXPRESSION = 'expression' + +EVENT_MATRIX_COMMAND = 'matrix_command' + +DOMAIN = 'matrix' + +COMMAND_SCHEMA = vol.All( + # Basic Schema + 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]), + }), + # Make sure it's either a word or an expression command + cv.has_at_least_one_key(CONF_WORD, CONF_EXPRESSION) +) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOMESERVER): cv.url, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, + vol.Required(CONF_USERNAME): cv.matches_regex("@[^:]*:.*"), + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_ROOMS, default=[]): vol.All(cv.ensure_list, + [cv.string]), + vol.Optional(CONF_COMMANDS, default=[]): [COMMAND_SCHEMA] + }) +}, extra=vol.ALLOW_EXTRA) + +SERVICE_SEND_MESSAGE = 'send_message' + +SERVICE_SCHEMA_SEND_MESSAGE = vol.Schema({ + vol.Required(ATTR_MESSAGE): cv.string, + vol.Required(ATTR_TARGET): vol.All(cv.ensure_list, [cv.string]), +}) + + +def setup(hass, config): + """Set up the Matrix bot component.""" + from matrix_client.client import MatrixRequestError + + 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 + + hass.services.register( + DOMAIN, SERVICE_SEND_MESSAGE, bot.handle_send_message, + schema=SERVICE_SCHEMA_SEND_MESSAGE) + + return True + + +class MatrixBot(object): + """The Matrix Bot.""" + + def __init__(self, hass, config_file, homeserver, verify_ssl, + username, password, listening_rooms, commands): + """Set up the client.""" + self.hass = hass + + self._session_filepath = config_file + self._auth_tokens = self._get_auth_tokens() + + self._homeserver = homeserver + self._verify_tls = verify_ssl + self._mx_id = username + self._password = password + + self._listening_rooms = listening_rooms + + # Logging in is deferred b/c it does I/O + self._setup_done = False + + # 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() + + # word commands are stored dict-of-dict: First dict indexes by room ID + # / alias, second dict indexes by the word + self._word_commands = {} + + # regular expression commands are stored as a list of commands per + # room, i.e., a dict-of-list + self._expression_commands = {} + + for command in commands: + if not command.get(CONF_ROOMS): + command[CONF_ROOMS] = listening_rooms + + 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 + else: + for room_id in command[CONF_ROOMS]: + if room_id not in self._expression_commands: + self._expression_commands[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): + """Handle a message sent to a room.""" + if event['content']['msgtype'] != 'm.text': + return + + if event['sender'] == self._mx_id: + return + + _LOGGER.debug("Handling message: %s", event['content']['body']) + + if event['content']['body'][0] == "!": + # Could trigger a single-word command. + pieces = event['content']['body'].split(' ') + cmd = pieces[0][1:] + + command = self._word_commands.get(room_id, {}).get(cmd) + if command: + event_data = { + 'command': command[CONF_NAME], + 'sender': event['sender'], + 'room': room_id, + 'args': pieces[1:] + } + self.hass.bus.fire(EVENT_MATRIX_COMMAND, event_data) + + # 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']) + if not match: + continue + event_data = { + 'command': command[CONF_NAME], + 'sender': event['sender'], + 'room': room_id, + 'args': match.groupdict() + } + self.hass.bus.fire(EVENT_MATRIX_COMMAND, event_data) + + def _join_or_get_room(self, room_id_or_alias): + """Join a room or get it, if we are already in the room. + + 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: + _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): + """Join the rooms that we listen for commands in.""" + from matrix_client.client import MatrixRequestError + + 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") + + except MatrixRequestError as ex: + _LOGGER.error("Could not join room %s: %s", room_id, ex) + + def _get_auth_tokens(self): + """ + Read sorted authentication tokens from disk. + + Returns the auth_tokens dictionary. + """ + try: + auth_tokens = load_json(self._session_filepath) + + return auth_tokens + except HomeAssistantError as ex: + _LOGGER.warning( + "Loading authentication tokens from file '%s' failed: %s", + self._session_filepath, str(ex)) + return {} + + def _store_auth_token(self, token): + """Store authentication token to session and persistent storage.""" + self._auth_tokens[self._mx_id] = token + + save_json(self._session_filepath, self._auth_tokens) + + def _login(self): + """Login to the matrix homeserver and return the client instance.""" + from matrix_client.client import MatrixRequestError + + # Attempt to generate a valid client using either of the two possible + # login methods: + client = None + + # 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.") + + except MatrixRequestError as ex: + _LOGGER.warning( + "Login by token failed, falling back to password. " + "login_by_token raised: (%d) %s", + ex.code, ex.content) + + # 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.") + + except MatrixRequestError as ex: + _LOGGER.error( + "Login failed, both token and username/password invalid " + "login_by_password raised: (%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.""" + from matrix_client.client import MatrixClient + + 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.""" + from matrix_client.client import MatrixClient + + _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_message(self, message, target_rooms): + """Send the message to the matrix server.""" + from matrix_client.client import MatrixRequestError + + for target_room in target_rooms: + try: + room = self._join_or_get_room(target_room) + _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) + + def handle_send_message(self, service): + """Handle the send_message service.""" + if not self._setup_done: + _LOGGER.warning("Could not send message: setup is not done!") + return + + self._send_message(service.data[ATTR_MESSAGE], + service.data[ATTR_TARGET]) diff --git a/homeassistant/components/notify/matrix.py b/homeassistant/components/notify/matrix.py index 03bc53e204c72caa4af2c5f7f38420c0051f8b8f..fc29ad91dc9156a004916fdb9101697ae2fe952f 100644 --- a/homeassistant/components/notify/matrix.py +++ b/homeassistant/components/notify/matrix.py @@ -5,181 +5,46 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.matrix/ """ import logging -import os -from urllib.parse import urlparse import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import (ATTR_TARGET, PLATFORM_SCHEMA, - BaseNotificationService) -from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_VERIFY_SSL -from homeassistant.util.json import load_json, save_json - -REQUIREMENTS = ['matrix-client==0.0.6'] + BaseNotificationService, + ATTR_MESSAGE) _LOGGER = logging.getLogger(__name__) -SESSION_FILE = 'matrix.conf' - -CONF_HOMESERVER = 'homeserver' CONF_DEFAULT_ROOM = 'default_room' +DOMAIN = 'matrix' +DEPENDENCIES = [DOMAIN] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOMESERVER): cv.url, - vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_DEFAULT_ROOM): cv.string, }) def get_service(hass, config, discovery_info=None): """Get the Matrix notification service.""" - from matrix_client.client import MatrixRequestError - - try: - return MatrixNotificationService( - os.path.join(hass.config.path(), SESSION_FILE), - config.get(CONF_HOMESERVER), - config.get(CONF_DEFAULT_ROOM), - config.get(CONF_VERIFY_SSL), - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD)) - - except MatrixRequestError: - return None + return MatrixNotificationService(config.get(CONF_DEFAULT_ROOM)) class MatrixNotificationService(BaseNotificationService): """Send Notifications to a Matrix Room.""" - def __init__(self, config_file, homeserver, default_room, verify_ssl, - username, password): - """Set up the client.""" - self.session_filepath = config_file - self.auth_tokens = self.get_auth_tokens() - - self.homeserver = homeserver - self.default_room = default_room - self.verify_tls = verify_ssl - self.username = username - self.password = password - - self.mx_id = "{user}@{homeserver}".format( - user=username, homeserver=urlparse(homeserver).netloc) - - # Login, this will raise a MatrixRequestError if login is unsuccessful - self.client = self.login() - - def get_auth_tokens(self): - """ - Read sorted authentication tokens from disk. - - Returns the auth_tokens dictionary. - """ - if not os.path.exists(self.session_filepath): - return {} - - try: - data = load_json(self.session_filepath) - - auth_tokens = {} - for mx_id, token in data.items(): - auth_tokens[mx_id] = token - - return auth_tokens - - except (OSError, IOError, PermissionError) as ex: - _LOGGER.warning( - "Loading authentication tokens from file '%s' failed: %s", - self.session_filepath, str(ex)) - return {} - - def store_auth_token(self, token): - """Store authentication token to session and persistent storage.""" - self.auth_tokens[self.mx_id] = token - - save_json(self.session_filepath, self.auth_tokens) - - def login(self): - """Login to the matrix homeserver and return the client instance.""" - from matrix_client.client import MatrixRequestError + def __init__(self, default_room): + """Set up the notification service.""" + self._default_room = default_room - # Attempt to generate a valid client using either of the two possible - # login methods: - client = None - - # 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.") - - except MatrixRequestError as ex: - _LOGGER.warning( - "Login by token failed, falling back to password. " - "login_by_token raised: (%d) %s", - ex.code, ex.content) - - # 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.") - - except MatrixRequestError as ex: - _LOGGER.error( - "Login failed, both token and username/password invalid " - "login_by_password raised: (%d) %s", - ex.code, ex.content) - - # re-raise the error so the constructor can catch it. - raise - - return client - - def login_by_token(self): - """Login using authentication token and return the client.""" - from matrix_client.client import MatrixClient - - return MatrixClient( - base_url=self.homeserver, - token=self.auth_tokens[self.mx_id], - user_id=self.username, - valid_cert_check=self.verify_tls) - - def login_by_password(self): - """Login using password authentication and return the client.""" - from matrix_client.client import MatrixClient - - _client = MatrixClient( - base_url=self.homeserver, - valid_cert_check=self.verify_tls) - - _client.login_with_password(self.username, self.password) - - self.store_auth_token(_client.token) - - return _client - - def send_message(self, message, **kwargs): + def send_message(self, message="", **kwargs): """Send the message to the matrix server.""" - from matrix_client.client import MatrixRequestError - - target_rooms = kwargs.get(ATTR_TARGET) or [self.default_room] - - rooms = self.client.get_rooms() - for target_room in target_rooms: - try: - if target_room in rooms: - room = rooms[target_room] - else: - room = self.client.join_room(target_room) + target_rooms = kwargs.get(ATTR_TARGET) or [self._default_room] - _LOGGER.debug(room.send_text(message)) + service_data = { + ATTR_TARGET: target_rooms, + ATTR_MESSAGE: message + } - except MatrixRequestError as ex: - _LOGGER.error( - "Unable to deliver message to room '%s': (%d): %s", - target_room, ex.code, ex.content) + return self.hass.services.call( + DOMAIN, 'send_message', service_data=service_data) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 8177999cc9490fcc9472368daf4b72130f55d77f..0bd490940a925c4ee86246ad1fb3bce2208ef836 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -96,6 +96,36 @@ def isdevice(value): raise vol.Invalid('No device at {} found'.format(value)) +def matches_regex(regex): + """Validate that the value is a string that matches a regex.""" + regex = re.compile(regex) + + def validator(value: Any) -> str: + """Validate that value matches the given regex.""" + if not isinstance(value, str): + raise vol.Invalid('not a string value: {}'.format(value)) + + if not regex.match(value): + raise vol.Invalid('value {} does not match regular expression {}' + .format(regex.pattern, value)) + + return value + return validator + + +def is_regex(value): + """Validate that a string is a valid regular expression.""" + try: + r = re.compile(value) + return r + except TypeError: + raise vol.Invalid("value {} is of the wrong type for a regular " + "expression".format(value)) + except re.error: + raise vol.Invalid("value {} is not a valid regular expression".format( + value)) + + def isfile(value: Any) -> str: """Validate that the value is an existing file.""" if value is None: diff --git a/requirements_all.txt b/requirements_all.txt index 88dd3d60904f345507c5cb66e1b9261db87419ea..74f9ff8e1959839b351abd04f75d6996a4ec84e9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -511,8 +511,8 @@ luftdaten==0.1.3 # homeassistant.components.sensor.lyft lyft_rides==0.2 -# homeassistant.components.notify.matrix -matrix-client==0.0.6 +# homeassistant.components.matrix +matrix-client==0.2.0 # homeassistant.components.maxcube maxcube-api==0.1.0 diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index aff0acf9e3a0187ce62e65782a391187969fd926..28efcb3e8685793c1502e18aa795b6af5af5ea29 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -565,3 +565,31 @@ def test_socket_timeout(): # pylint: disable=invalid-name assert _GLOBAL_DEFAULT_TIMEOUT == schema(None) assert schema(1) == 1.0 + + +def test_matches_regex(): + """Test matches_regex validator.""" + schema = vol.Schema(cv.matches_regex('.*uiae.*')) + + with pytest.raises(vol.Invalid): + schema(1.0) + + with pytest.raises(vol.Invalid): + schema(" nrtd ") + + test_str = "This is a test including uiae." + assert(schema(test_str) == test_str) + + +def test_is_regex(): + """Test the is_regex validator.""" + schema = vol.Schema(cv.is_regex) + + with pytest.raises(vol.Invalid): + schema("(") + + with pytest.raises(vol.Invalid): + schema({"a dict": "is not a regex"}) + + valid_re = ".*" + schema(valid_re)