From 719bd4fddfc93da0a2e051c70cd2c9910d89d1b1 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 14 Jul 2022 01:50:46 +0200 Subject: [PATCH 01/27] [#217 WIP] Initial plugin implementation. - Added initial synchronization and users cache. - Added loop to poll for new events (TODO: use websocket after the first sync) - Added login, sync and join actions --- platypush/message/event/matrix.py | 103 ++++++ platypush/plugins/matrix/__init__.py | 444 +++++++++++++++++++++++++ platypush/plugins/matrix/manifest.yaml | 10 + 3 files changed, 557 insertions(+) create mode 100644 platypush/message/event/matrix.py create mode 100644 platypush/plugins/matrix/__init__.py create mode 100644 platypush/plugins/matrix/manifest.yaml diff --git a/platypush/message/event/matrix.py b/platypush/message/event/matrix.py new file mode 100644 index 0000000000..d1ac3f8d74 --- /dev/null +++ b/platypush/message/event/matrix.py @@ -0,0 +1,103 @@ +from abc import ABC +from datetime import datetime +from typing import Dict, Any + +from platypush.message.event import Event + + +class MatrixEvent(Event, ABC): + """ + Base matrix event. + """ + + def __init__( + self, + *args, + server_url: str, + sender_id: str | None = None, + sender_display_name: str | None = None, + sender_avatar_url: str | None = None, + room_id: str | None = None, + room_name: str | None = None, + room_topic: str | None = None, + server_timestamp: datetime | None = None, + **kwargs + ): + """ + :param server_url: Base server URL. + :param sender_id: The event's sender ID. + :param sender_display_name: The event's sender display name. + :param sender_avatar_url: The event's sender avatar URL. + :param room_id: Event room ID. + :param room_name: The name of the room associated to the event. + :param room_topic: The topic of the room associated to the event. + :param server_timestamp: The server timestamp of the event. + """ + evt_args: Dict[str, Any] = { + 'server_url': server_url, + } + + if sender_id: + evt_args['sender_id'] = sender_id + if sender_display_name: + evt_args['sender_display_name'] = sender_display_name + if sender_avatar_url: + evt_args['sender_avatar_url'] = sender_avatar_url + if room_id: + evt_args['room_id'] = room_id + if room_name: + evt_args['room_name'] = room_name + if room_topic: + evt_args['room_topic'] = room_topic + if server_timestamp: + evt_args['server_timestamp'] = server_timestamp + + super().__init__(*args, **evt_args, **kwargs) + + +class MatrixMessageEvent(MatrixEvent): + """ + Event triggered when a message is received on a subscribed room. + """ + + def __init__(self, *args, body: str, **kwargs): + """ + :param body: The body of the message. + """ + super().__init__(*args, body=body, **kwargs) + + +class MatrixRoomJoinEvent(MatrixEvent): + """ + Event triggered when a user joins a room. + """ + + +class MatrixRoomLeaveEvent(MatrixEvent): + """ + Event triggered when a user leaves a room. + """ + + +class MatrixRoomInviteEvent(MatrixEvent): + """ + Event triggered when a user is invited to a room. + """ + + +class MatrixRoomInviteMeEvent(MatrixEvent): + """ + Event triggered when the currently logged in user is invited to a room. + """ + + +class MatrixRoomTopicChangeEvent(MatrixEvent): + """ + Event triggered when the topic/title of a room changes. + """ + + def __init__(self, *args, topic: str, **kwargs): + """ + :param topic: New room topic. + """ + super().__init__(*args, topic=topic, **kwargs) diff --git a/platypush/plugins/matrix/__init__.py b/platypush/plugins/matrix/__init__.py new file mode 100644 index 0000000000..6986dff162 --- /dev/null +++ b/platypush/plugins/matrix/__init__.py @@ -0,0 +1,444 @@ +import datetime +import json +import multiprocessing +import os +import pathlib +import requests +from abc import ABC, abstractmethod +from urllib.parse import urljoin +from typing import Optional, Collection, Dict, Tuple, Any + +from platypush.config import Config +from platypush.context import get_bus +from platypush.message.event.matrix import ( + MatrixEvent, + MatrixRoomTopicChangeEvent, + MatrixMessageEvent, + MatrixRoomJoinEvent, + MatrixRoomLeaveEvent, + MatrixRoomInviteEvent, + MatrixRoomInviteMeEvent, +) + +from platypush.plugins import RunnablePlugin, action + + +class RetrieveWorker(ABC): + def __init__(self, server_url: str, access_token: str): + self._server_url = server_url + self._access_token = access_token + + @abstractmethod + def _url(self, id: int | str) -> str: + raise NotImplementedError() + + @abstractmethod + def _process_response(self, rs: dict) -> dict: + raise NotImplementedError() + + def __call__(self, id: str) -> Tuple[str, dict]: + url = urljoin(self._server_url, self._url(id)) + rs = requests.get( + url, + headers={ + 'Authorization': f'Bearer {self._access_token}', + }, + ) + + rs.raise_for_status() + return (id, self._process_response(rs.json())) + + +class UserRetrieveWorker(RetrieveWorker): + def _url(self, id: str) -> str: + return f'/_matrix/client/r0/profile/{id}' + + def _process_response(self, rs: dict) -> dict: + return { + 'display_name': rs.get('displayname'), + 'avatar_url': rs.get('avatar_url'), + } + + +class RoomRetrieveWorker: + def _url(self, id: str) -> str: + return f'/_matrix/client/r0/room/{id}' + + def _process_response(self, rs: dict) -> dict: + return { + 'display_name': rs.get('displayname'), + 'avatar_url': rs.get('avatar_url'), + } + + +class MatrixPlugin(RunnablePlugin): + """ + Matrix chat integration. + + Triggers: + + * :class:`platypush.message.event.matrix.MatrixMessageEvent`: when a message is received. + * :class:`platypush.message.event.matrix.MatrixRoomJoinEvent`: when a user joins a room. + * :class:`platypush.message.event.matrix.MatrixRoomLeaveEvent`: when a user leaves a room. + * :class:`platypush.message.event.matrix.MatrixRoomInviteEvent`: when a user (other than the + currently logged one) is invited to a room. + * :class:`platypush.message.event.matrix.MatrixRoomMeInviteEvent`: when the currently logged in + user is invited to a room. + * :class:`platypush.message.event.matrix.MatrixRoomTopicChangeEvent`: when the topic/title of a room changes. + + """ + + def __init__( + self, + server_url: str = 'https://matrix.to', + username: str | None = None, + password: str | None = None, + access_token: str | None = None, + autojoin_on_invite: bool = False, + **kwargs, + ): + """ + Authentication requires either username/password or an access token. + + If you don't want to provide cleartext credentials in the configuration, you can + retrieve an access token offline through the following request:: + + curl -XPOST '{"type":"m.login.password", "user":"username", "password":"password"}' \ + "https://matrix.example.com/_matrix/client/r0/login" + + This may be required if the user or the instance enforce 2FA. + + :param server_url: Default Matrix instance base URL (default: ``https://matrix.to``). + :param username: Default username. Provide either username/password _or_ an access token. + :param password: Default password. Provide either username/password _or_ an access token. + :param access_token: Default access token. Provide either username/password _or_ an access token. + :param autojoin_on_invite: Whether the account should automatically join rooms + upon invite. If false (default value), then you may want to implement your own + logic in an event hook when a :class:`platypush.message.event.matrix.MatrixRoomInviteMeEvent` + event is received, and call the :meth:`.join` method if required. + """ + super().__init__(**kwargs) + self._server_url = server_url + self._user_id = None + self._autojoin_on_invite = autojoin_on_invite + self._workdir = os.path.join(Config.get('workdir'), 'matrix') # type: ignore + pathlib.Path(self._workdir).mkdir(parents=True, exist_ok=True) + + self._sessions_file = os.path.join(self._workdir, 'sessions.json') + self._credentials_file = os.path.join(self._workdir, 'credentials.json') + self._users_cache_file = os.path.join(self._workdir, 'users.json') + self._rooms_cache_file = os.path.join(self._workdir, 'rooms.json') + self._users_cache = {} + self._rooms_cache = {} + self._set_credentials(username, password, access_token, overwrite=True) + + def _set_credentials( + self, + username: str | None = None, + password: str | None = None, + access_token: str | None = None, + overwrite: bool = False, + ): + if username or overwrite: + self._username = username + if password or overwrite: + self._password = password + if access_token or overwrite: + self._access_token = access_token + + def _execute(self, url: str, method: str = 'get', **kwargs): + if self._access_token: + kwargs['headers'] = { + 'Authorization': f'Bearer {self._access_token}', + **kwargs.get('headers', {}), + } + + url = urljoin(self._server_url, f'/_matrix/client/{url.lstrip("/")}') + req_method = getattr(requests, method.lower()) + rs = req_method(url, **kwargs) + rs.raise_for_status() + rs = rs.json() + assert not rs.get('error'), rs.get('error') + return rs + + def _save_credentials(self, credentials: dict): + with open(self._credentials_file, 'w') as f: + json.dump(credentials, f) + + def _refresh_user_id(self): + devices = self._execute('/v3/devices').get('devices', []) + assert devices, 'The user is not logged into any devices' + self._user_id = devices[0]['user_id'] + + @action + def login( + self, + server_url: str | None = None, + username: str | None = None, + password: str | None = None, + access_token: str | None = None, + ): + """ + Login to an instance if username/password/access_token were not specified in the plugin + configuration. Otherwise, change the currently logged user or instance. + + :param server_url: New Matrix instance base URL. + :param username: New username. + :param password: New user password. + :param access_token: New access token. + """ + self._server_url = server_url or self._server_url + self._set_credentials(username, password, access_token, overwrite=False) + + if self._access_token: + self._refresh_user_id() + elif self._username and self._password: + rs = self._execute( + '/r0/login', + method='post', + json={ + 'type': 'm.login.password', + 'user': self._username, + 'password': self._password, + 'initial_device_display_name': 'Platypush Matrix integration', + }, + ) + + assert rs.get('access_token'), 'No access token provided by the server' + self._access_token = rs['access_token'] + self._user_id = rs['user_id'] + self._save_credentials(rs) + elif os.path.isfile(self._credentials_file): + with open(self._credentials_file, 'r') as f: + self._access_token = json.load(f)['access_token'] + self._refresh_user_id() + else: + raise AssertionError( + 'No username, password and access token provided nor stored' + ) + + self.logger.info( + f'Successfully logged in to {self._server_url} as {self._user_id}' + ) + + @staticmethod + def _timestamp_to_datetime(t: int | float) -> datetime.datetime: + return datetime.datetime.fromtimestamp(t / 1000) + + def _parse_event( + self, room_id: str, event: dict, users: dict + ) -> Optional[MatrixEvent]: + evt_type = event.get('type') + evt_class = None + args: Dict[str, Any] = { + 'server_url': self._server_url, + 'room_id': room_id, + } + + if event.get('sender') and isinstance(event.get('sender'), str): + cached_user = users.get(event['sender'], {}) + args['sender_id'] = event['sender'] + args['sender_display_name'] = cached_user.get('display_name') + args['sender_avatar_url'] = cached_user.get('avatar_url') + + if event.get('origin_server_ts'): + args['server_timestamp'] = self._timestamp_to_datetime( + event['origin_server_ts'] + ) + + if evt_type == 'm.room.topic': + evt_class = MatrixRoomTopicChangeEvent + args['topic'] = event.get('content', {}).get('topic') # type: ignore + elif evt_type == 'm.room.message': + evt_class = MatrixMessageEvent + args['body'] = event.get('content', {}).get('body') # type: ignore + elif evt_type == 'm.room.member': + membership = event.get('content', {}).get('membership') + if membership == 'join': + evt_class = MatrixRoomJoinEvent + elif membership == 'invite': + evt_class = MatrixRoomInviteEvent + elif membership == 'leave': + evt_class = MatrixRoomLeaveEvent + + if evt_class: + return evt_class(**args) + + def _parse_invite_event( + self, room_id: str, events: Collection[dict] + ) -> MatrixRoomInviteMeEvent: + evt_args: Dict[str, Any] = { + 'server_url': self._server_url, + 'room_id': room_id, + } + + for event in events: + evt_type = event.get('type') + if evt_type == 'm.room.name': + evt_args['room_name'] = event.get('content', {}).get('name') + elif evt_type == 'm.room.topic': + evt_args['room_topic'] = event.get('content', {}).get('topic') + if event.get('origin_server_ts'): + evt_args['server_timestamp'] = self._timestamp_to_datetime( + event['origin_server_ts'] + ) + + if evt_args.get('room_name'): + self._rooms_cache[room_id] = { + 'room_id': room_id, + 'room_name': evt_args['room_name'], + 'room_topic': evt_args.get('room_topic'), + } + + self._rewrite_rooms_cache() + + return MatrixRoomInviteMeEvent(**evt_args) + + def _retrieve_users_info(self, users: Collection[str]) -> Dict[str, dict]: + users_info = {user: {} for user in users} + retrieve = UserRetrieveWorker(self._server_url, self._access_token or '') + with multiprocessing.Pool(4) as pool: + pool_res = pool.map(retrieve, users_info.keys()) + + return { + user_id: { + 'user_id': user_id, + **info, + } + for user_id, info in pool_res + } + + def _extract_senders(self, rooms) -> Dict[str, dict]: + cache_has_changes = False + senders = set() + + for room in rooms: + room_events = room.get('timeline', {}).get('events', []) + for evt in room_events: + if evt.get('type') == 'm.room.member': + cache_has_changes = True + self._users_cache[evt['sender']] = { + 'user_id': evt['sender'], + 'display_name': evt.get('content', {}).get('displayname'), + 'avatar_url': evt.get('content', {}).get('avatar_url'), + } + + senders.update({evt['sender'] for evt in room_events if evt.get('sender')}) + + missing_senders = {user for user in senders if user not in self._users_cache} + + if missing_senders: + cache_has_changes = True + self._users_cache.update(self._retrieve_users_info(missing_senders)) + + senders_map = { + user: self._users_cache.get(user, {'user_id': user}) for user in senders + } + + if cache_has_changes: + self._rewrite_users_cache() + + return senders_map + + def _process_events(self, events: dict) -> Collection[MatrixEvent]: + rooms = events.get('rooms', {}) + joined_rooms = rooms.get('join', {}) + invited_rooms = rooms.get('invite', {}) + parsed_events = [] + senders = self._extract_senders(joined_rooms.values()) + + # Create events + for room_id, room in joined_rooms.items(): + room_events = room.get('timeline', {}).get('events', []) + parsed_room_events = [ + self._parse_event(room_id=room_id, event=event, users=senders) + for event in room_events + ] + + parsed_events.extend([evt for evt in parsed_room_events if evt]) + + for room_id, room in invited_rooms.items(): + room_events = room.get('invite_state', {}).get('events', []) + parsed_room_event = self._parse_invite_event( + room_id=room_id, events=room_events + ) + parsed_events.append(parsed_room_event) + + if self._autojoin_on_invite: + self.join(room_id) + + parsed_events.sort(key=lambda e: e.server_timestamp) + return parsed_events + + def _reload_users_cache(self): + if os.path.isfile(self._users_cache_file): + with open(self._users_cache_file, 'r') as f: + self._users_cache.update(json.load(f)) + + def _rewrite_users_cache(self): + with open(self._users_cache_file, 'w') as f: + json.dump(self._users_cache, f) + + def _reload_rooms_cache(self): + if os.path.isfile(self._rooms_cache_file): + with open(self._rooms_cache_file, 'r') as f: + self._rooms_cache.update(json.load(f)) + + def _rewrite_rooms_cache(self): + with open(self._rooms_cache_file, 'w') as f: + json.dump(self._rooms_cache, f) + + @action + def sync(self): + """ + Sync the state for the currently logged session. + """ + next_batch = None + sessions = {} + if os.path.isfile(self._sessions_file): + with open(self._sessions_file, 'r') as f: + sessions = json.load(f) + next_batch = sessions.get(self._user_id, {}).get('next_batch') + + if not next_batch: + self.logger.info('Synchronizing Matrix events') + + rs = self._execute('/r0/sync', params={'since': next_batch}) + events = self._process_events(rs) + if events and next_batch: + for event in events: + get_bus().post(event) + + if not sessions.get(self._user_id): + sessions[self._user_id] = {} + + sessions[self._user_id]['next_batch'] = rs.get('next_batch') + with open(self._sessions_file, 'w') as f: + json.dump(sessions, f) + + if not next_batch: + self.logger.info('Matrix events synchronized') + + @action + def join(self, room_id: str): + """ + Join a room by ID. + + :param room_id: Room ID or alias. + """ + self._execute(f'/v3/join/{room_id}', method='post') + self.logger.info('Successfully joined room %s', room_id) + + def main(self): + self.login() + self._reload_users_cache() + self._reload_rooms_cache() + + while not self._should_stop.is_set(): + try: + self.sync() + finally: + self._should_stop.wait(timeout=10) + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/matrix/manifest.yaml b/platypush/plugins/matrix/manifest.yaml new file mode 100644 index 0000000000..5449b9302d --- /dev/null +++ b/platypush/plugins/matrix/manifest.yaml @@ -0,0 +1,10 @@ +manifest: + events: + platypush.message.event.matrix.MatrixMessageEvent: when a message is received. + platypush.message.event.matrix.MatrixRoomJoinEvent: when a user joins a room. + platypush.message.event.matrix.MatrixRoomLeaveEvent: when a user leaves a room. + platypush.message.event.matrix.MatrixRoomInviteEvent: when a user (other than the currently logged one) is invited to a room. + platypush.message.event.matrix.MatrixRoomMeInviteEvent: when the currently logged in user is invited to a room. + platypush.message.event.matrix.MatrixRoomTopicChangeEvent: when the topic/title of a room changes. + package: platypush.plugins.matrix + type: plugin From cc29136db7caa55bc6bb6c6f0c1766bc1a7e028d Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Fri, 15 Jul 2022 00:37:21 +0200 Subject: [PATCH 02/27] [#2] Support for caching rooms info and exposing them in the events --- platypush/plugins/matrix/__init__.py | 66 +++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 11 deletions(-) diff --git a/platypush/plugins/matrix/__init__.py b/platypush/plugins/matrix/__init__.py index 6986dff162..42f9fc7dc4 100644 --- a/platypush/plugins/matrix/__init__.py +++ b/platypush/plugins/matrix/__init__.py @@ -33,7 +33,7 @@ class RetrieveWorker(ABC): raise NotImplementedError() @abstractmethod - def _process_response(self, rs: dict) -> dict: + def _process_response(self, rs: dict | Collection[dict]) -> dict: raise NotImplementedError() def __call__(self, id: str) -> Tuple[str, dict]: @@ -60,15 +60,20 @@ class UserRetrieveWorker(RetrieveWorker): } -class RoomRetrieveWorker: +class RoomRetrieveWorker(RetrieveWorker): def _url(self, id: str) -> str: - return f'/_matrix/client/r0/room/{id}' + return f'/_matrix/client/v3/rooms/{id}/state' - def _process_response(self, rs: dict) -> dict: - return { - 'display_name': rs.get('displayname'), - 'avatar_url': rs.get('avatar_url'), - } + def _process_response(self, rs: Collection[dict]) -> dict: + info = {} + for event in rs: + event_type = event.get('type') + if event_type == 'm.room.name': + info['name'] = event.get('content', {}).get('name') + elif event_type == 'm.room.topic': + info['name'] = event.get('content', {}).get('topic') + + return info class MatrixPlugin(RunnablePlugin): @@ -128,8 +133,8 @@ class MatrixPlugin(RunnablePlugin): self._credentials_file = os.path.join(self._workdir, 'credentials.json') self._users_cache_file = os.path.join(self._workdir, 'users.json') self._rooms_cache_file = os.path.join(self._workdir, 'rooms.json') - self._users_cache = {} - self._rooms_cache = {} + self._users_cache: Dict[str, dict] = {} + self._rooms_cache: Dict[str, dict] = {} self._set_credentials(username, password, access_token, overwrite=True) def _set_credentials( @@ -230,9 +235,12 @@ class MatrixPlugin(RunnablePlugin): ) -> Optional[MatrixEvent]: evt_type = event.get('type') evt_class = None + room_info = self._rooms_cache.get(room_id, {}) args: Dict[str, Any] = { 'server_url': self._server_url, 'room_id': room_id, + 'room_name': room_info.get('name'), + 'room_topic': room_info.get('topic'), } if event.get('sender') and isinstance(event.get('sender'), str): @@ -249,6 +257,7 @@ class MatrixPlugin(RunnablePlugin): if evt_type == 'm.room.topic': evt_class = MatrixRoomTopicChangeEvent args['topic'] = event.get('content', {}).get('topic') # type: ignore + # TODO Handle encrypted rooms events (`m.room.encrypted`) elif evt_type == 'm.room.message': evt_class = MatrixMessageEvent args['body'] = event.get('content', {}).get('body') # type: ignore @@ -308,6 +317,20 @@ class MatrixPlugin(RunnablePlugin): for user_id, info in pool_res } + def _retrieve_rooms_info(self, rooms: Collection[str]) -> Dict[str, dict]: + rooms_info = {room: {} for room in rooms} + retrieve = RoomRetrieveWorker(self._server_url, self._access_token or '') + with multiprocessing.Pool(4) as pool: + pool_res = pool.map(retrieve, rooms_info.keys()) + + return { + room_id: { + 'room_id': room_id, + **info, + } + for room_id, info in pool_res + } + def _extract_senders(self, rooms) -> Dict[str, dict]: cache_has_changes = False senders = set() @@ -340,14 +363,34 @@ class MatrixPlugin(RunnablePlugin): return senders_map + def _extract_rooms(self, rooms: Collection[str]) -> Dict[str, dict]: + missing_rooms_info = { + room_id for room_id in rooms if not self._rooms_cache.get(room_id) + } + + if missing_rooms_info: + self._rooms_cache.update(self._retrieve_rooms_info(missing_rooms_info)) + self._rewrite_rooms_cache() + + return { + room_id: self._rooms_cache.get( + room_id, + { + 'room_id': room_id, + }, + ) + for room_id in rooms + } + def _process_events(self, events: dict) -> Collection[MatrixEvent]: rooms = events.get('rooms', {}) joined_rooms = rooms.get('join', {}) invited_rooms = rooms.get('invite', {}) parsed_events = [] senders = self._extract_senders(joined_rooms.values()) + self._extract_rooms(joined_rooms.keys()) - # Create events + # Create joined rooms events for room_id, room in joined_rooms.items(): room_events = room.get('timeline', {}).get('events', []) parsed_room_events = [ @@ -357,6 +400,7 @@ class MatrixPlugin(RunnablePlugin): parsed_events.extend([evt for evt in parsed_room_events if evt]) + # Create invite events for room_id, room in invited_rooms.items(): room_events = room.get('invite_state', {}).get('events', []) parsed_room_event = self._parse_invite_event( From 3edb8352b433a332fbbd0622dc052edb787ad0a9 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sat, 16 Jul 2022 02:09:22 +0200 Subject: [PATCH 03/27] Support sections with empty bodies in the YAML configuration files. --- platypush/config/__init__.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/platypush/config/__init__.py b/platypush/config/__init__.py index e9acfec451..481bd27518 100644 --- a/platypush/config/__init__.py +++ b/platypush/config/__init__.py @@ -213,11 +213,10 @@ class Config: config['scripts_dir'] = os.path.abspath( os.path.expanduser(file_config[section]) ) - elif ( - 'disabled' not in file_config[section] - or file_config[section]['disabled'] is False - ): - config[section] = file_config[section] + else: + section_config = file_config.get(section, {}) or {} + if not section_config.get('disabled'): + config[section] = section_config return config From 32be4df11c0e62524cc638eccc33520b793f771e Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sat, 23 Jul 2022 17:32:14 +0200 Subject: [PATCH 04/27] More robust way to retrieve an object's attribute on schemas --- platypush/schemas/__init__.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/platypush/schemas/__init__.py b/platypush/schemas/__init__.py index ca2805627c..71456ddd91 100644 --- a/platypush/schemas/__init__.py +++ b/platypush/schemas/__init__.py @@ -6,7 +6,7 @@ from dateutil.tz import tzutc from marshmallow import fields -class StrippedString(fields.Function): # lgtm [py/missing-call-to-init] +class StrippedString(fields.Function): # lgtm [py/missing-call-to-init] def __init__(self, *args, **kwargs): kwargs['serialize'] = self._strip kwargs['deserialize'] = self._strip @@ -21,7 +21,15 @@ class StrippedString(fields.Function): # lgtm [py/missing-call-to-init] return value.strip() -class DateTime(fields.Function): # lgtm [py/missing-call-to-init] +class Function(fields.Function): # lgtm [py/missing-call-to-init] + def _get_attr(self, obj, attr: str): + if hasattr(obj, attr): + return getattr(obj, attr) + elif hasattr(obj, 'get'): + return obj.get(attr) + + +class DateTime(Function): # lgtm [py/missing-call-to-init] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.metadata = { @@ -30,7 +38,7 @@ class DateTime(fields.Function): # lgtm [py/missing-call-to-init] } def _serialize(self, value, attr, obj, **kwargs) -> Optional[str]: - value = normalize_datetime(obj.get(attr)) + value = normalize_datetime(self._get_attr(obj, attr)) if value: return value.isoformat() @@ -38,7 +46,7 @@ class DateTime(fields.Function): # lgtm [py/missing-call-to-init] return normalize_datetime(value) -class Date(fields.Function): # lgtm [py/missing-call-to-init] +class Date(Function): # lgtm [py/missing-call-to-init] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.metadata = { @@ -47,7 +55,7 @@ class Date(fields.Function): # lgtm [py/missing-call-to-init] } def _serialize(self, value, attr, obj, **kwargs) -> Optional[str]: - value = normalize_datetime(obj.get(attr)) + value = normalize_datetime(self._get_attr(obj, attr)) if value: return date(value.year, value.month, value.day).isoformat() @@ -56,10 +64,12 @@ class Date(fields.Function): # lgtm [py/missing-call-to-init] return date.fromtimestamp(dt.timestamp()) -def normalize_datetime(dt: Union[str, date, datetime]) -> Optional[Union[date, datetime]]: +def normalize_datetime( + dt: Optional[Union[str, date, datetime]] +) -> Optional[Union[date, datetime]]: if not dt: return - if isinstance(dt, datetime) or isinstance(dt, date): + if isinstance(dt, (datetime, date)): return dt try: From c32142c8b5059af78a099654d0c2904f04924369 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sat, 23 Jul 2022 17:33:23 +0200 Subject: [PATCH 05/27] Added wait_stop() method to RunnablePlugin --- platypush/plugins/__init__.py | 25 ++++++++++++++++--------- platypush/plugins/ntfy/__init__.py | 4 +--- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/platypush/plugins/__init__.py b/platypush/plugins/__init__.py index 8338620ecf..2d75697f5f 100644 --- a/platypush/plugins/__init__.py +++ b/platypush/plugins/__init__.py @@ -19,12 +19,12 @@ def action(f): result = f(*args, **kwargs) if result and isinstance(result, Response): - result.errors = result.errors \ - if isinstance(result.errors, list) else [result.errors] + result.errors = ( + result.errors if isinstance(result.errors, list) else [result.errors] + ) response = result elif isinstance(result, tuple) and len(result) == 2: - response.errors = result[1] \ - if isinstance(result[1], list) else [result[1]] + response.errors = result[1] if isinstance(result[1], list) else [result[1]] if len(response.errors) == 1 and response.errors[0] is None: response.errors = [] @@ -39,12 +39,14 @@ def action(f): return _execute_action -class Plugin(EventGenerator, ExtensionWithManifest): # lgtm [py/missing-call-to-init] - """ Base plugin class """ +class Plugin(EventGenerator, ExtensionWithManifest): # lgtm [py/missing-call-to-init] + """Base plugin class""" def __init__(self, **kwargs): super().__init__() - self.logger = logging.getLogger('platypush:plugin:' + get_plugin_name_by_class(self.__class__)) + self.logger = logging.getLogger( + 'platypush:plugin:' + get_plugin_name_by_class(self.__class__) + ) if 'logging' in kwargs: self.logger.setLevel(getattr(logging, kwargs['logging'].upper())) @@ -53,8 +55,9 @@ class Plugin(EventGenerator, ExtensionWithManifest): # lgtm [py/missing-call-t ) def run(self, method, *args, **kwargs): - assert method in self.registered_actions, '{} is not a registered action on {}'.\ - format(method, self.__class__.__name__) + assert ( + method in self.registered_actions + ), '{} is not a registered action on {}'.format(method, self.__class__.__name__) return getattr(self, method)(*args, **kwargs) @@ -62,6 +65,7 @@ class RunnablePlugin(Plugin): """ Class for runnable plugins - i.e. plugins that have a start/stop method and can be started. """ + def __init__(self, poll_interval: Optional[float] = None, **kwargs): """ :param poll_interval: How often the :meth:`.loop` function should be execute (default: None, no pause/interval). @@ -78,6 +82,9 @@ class RunnablePlugin(Plugin): def should_stop(self): return self._should_stop.is_set() + def wait_stop(self, timeout=None): + return self._should_stop.wait(timeout=timeout) + def start(self): set_thread_name(self.__class__.__name__) self._thread = threading.Thread(target=self._runner) diff --git a/platypush/plugins/ntfy/__init__.py b/platypush/plugins/ntfy/__init__.py index 064275723f..21ad1389c1 100644 --- a/platypush/plugins/ntfy/__init__.py +++ b/platypush/plugins/ntfy/__init__.py @@ -121,9 +121,7 @@ class NtfyPlugin(RunnablePlugin): def main(self): if self._subscriptions: self._connect() - - while not self._should_stop.is_set(): - self._should_stop.wait(timeout=1) + self.wait_stop() def stop(self): if self._ws_proc: From 55671f4aff4ff3a7dc67d4e29d519416999c7702 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 25 Jul 2022 00:41:08 +0200 Subject: [PATCH 06/27] If a request on a RunnablePlugin throws an exception then we should also restart the plugin upon reload Plus some Black/LINT chores --- platypush/message/request/__init__.py | 141 ++++++++++++++++++-------- 1 file changed, 97 insertions(+), 44 deletions(-) diff --git a/platypush/message/request/__init__.py b/platypush/message/request/__init__.py index 40d189d4d0..46f52452be 100644 --- a/platypush/message/request/__init__.py +++ b/platypush/message/request/__init__.py @@ -12,17 +12,30 @@ from platypush.config import Config from platypush.context import get_plugin from platypush.message import Message from platypush.message.response import Response -from platypush.utils import get_hash, get_module_and_method_from_action, get_redis_queue_name_by_message, \ - is_functional_procedure +from platypush.utils import ( + get_hash, + get_module_and_method_from_action, + get_redis_queue_name_by_message, + is_functional_procedure, +) logger = logging.getLogger('platypush') class Request(Message): - """ Request message class """ + """Request message class""" - def __init__(self, target, action, origin=None, id=None, backend=None, - args=None, token=None, timestamp=None): + def __init__( + self, + target, + action, + origin=None, + id=None, + backend=None, + args=None, + token=None, + timestamp=None, + ): """ Params: target -- Target node [Str] @@ -48,9 +61,13 @@ class Request(Message): @classmethod def build(cls, msg): msg = super().parse(msg) - args = {'target': msg.get('target', Config.get('device_id')), 'action': msg['action'], - 'args': msg.get('args', {}), 'id': msg['id'] if 'id' in msg else cls._generate_id(), - 'timestamp': msg['_timestamp'] if '_timestamp' in msg else time.time()} + args = { + 'target': msg.get('target', Config.get('device_id')), + 'action': msg['action'], + 'args': msg.get('args', {}), + 'id': msg['id'] if 'id' in msg else cls._generate_id(), + 'timestamp': msg['_timestamp'] if '_timestamp' in msg else time.time(), + } if 'origin' in msg: args['origin'] = msg['origin'] @@ -61,7 +78,7 @@ class Request(Message): @staticmethod def _generate_id(): _id = '' - for i in range(0, 16): + for _ in range(0, 16): _id += '%.2x' % random.randint(0, 255) return _id @@ -84,9 +101,14 @@ class Request(Message): return proc_config(*args, **kwargs) - proc = Procedure.build(name=proc_name, requests=proc_config['actions'], - _async=proc_config['_async'], args=self.args, - backend=self.backend, id=self.id) + proc = Procedure.build( + name=proc_name, + requests=proc_config['actions'], + _async=proc_config['_async'], + args=self.args, + backend=self.backend, + id=self.id, + ) return proc.execute(*args, **kwargs) @@ -112,7 +134,7 @@ class Request(Message): if isinstance(value, str): value = self.expand_value_from_context(value, **context) - elif isinstance(value, dict) or isinstance(value, list): + elif isinstance(value, (dict, list)): self._expand_context(event_args=value, **context) event_args[key] = value @@ -132,7 +154,11 @@ class Request(Message): try: exec('{}="{}"'.format(k, re.sub(r'(^|[^\\])"', '\1\\"', v))) except Exception as e: - logger.debug('Could not set context variable {}={}: {}'.format(k, v, str(e))) + logger.debug( + 'Could not set context variable {}={}: {}'.format( + k, v, str(e) + ) + ) logger.debug('Context: {}'.format(context)) parsed_value = '' @@ -152,7 +178,7 @@ class Request(Message): if callable(context_value): context_value = context_value() - if isinstance(context_value, range) or isinstance(context_value, tuple): + if isinstance(context_value, (range, tuple)): context_value = [*context_value] if isinstance(context_value, datetime.date): context_value = context_value.isoformat() @@ -162,7 +188,7 @@ class Request(Message): parsed_value += prefix + ( json.dumps(context_value) - if isinstance(context_value, list) or isinstance(context_value, dict) + if isinstance(context_value, (list, dict)) else str(context_value) ) else: @@ -205,6 +231,9 @@ class Request(Message): """ def _thread_func(_n_tries, errors=None): + from platypush.context import get_bus + from platypush.plugins import RunnablePlugin + response = None try: @@ -221,11 +250,15 @@ class Request(Message): return response else: action = self.expand_value_from_context(self.action, **context) - (module_name, method_name) = get_module_and_method_from_action(action) + (module_name, method_name) = get_module_and_method_from_action( + action + ) plugin = get_plugin(module_name) except Exception as e: logger.exception(e) - msg = 'Uncaught pre-processing exception from action [{}]: {}'.format(self.action, str(e)) + msg = 'Uncaught pre-processing exception from action [{}]: {}'.format( + self.action, str(e) + ) logger.warning(msg) response = Response(output=None, errors=[msg]) self._send_response(response) @@ -243,24 +276,37 @@ class Request(Message): response = plugin.run(method_name, args) if not response: - logger.warning('Received null response from action {}'.format(action)) + logger.warning( + 'Received null response from action {}'.format(action) + ) else: if response.is_error(): - logger.warning(('Response processed with errors from ' + - 'action {}: {}').format( - action, str(response))) + logger.warning( + ( + 'Response processed with errors from ' + 'action {}: {}' + ).format(action, str(response)) + ) elif not response.disable_logging: - logger.info('Processed response from action {}: {}'. - format(action, str(response))) + logger.info( + 'Processed response from action {}: {}'.format( + action, str(response) + ) + ) except (AssertionError, TimeoutError) as e: plugin.logger.exception(e) - logger.warning('{} from action [{}]: {}'.format(type(e), action, str(e))) + logger.warning( + '{} from action [{}]: {}'.format(type(e), action, str(e)) + ) response = Response(output=None, errors=[str(e)]) except Exception as e: # Retry mechanism plugin.logger.exception(e) - logger.warning(('Uncaught exception while processing response ' + - 'from action [{}]: {}').format(action, str(e))) + logger.warning( + ( + 'Uncaught exception while processing response ' + + 'from action [{}]: {}' + ).format(action, str(e)) + ) errors = errors or [] if str(e) not in errors: @@ -269,17 +315,21 @@ class Request(Message): response = Response(output=None, errors=errors) if _n_tries - 1 > 0: logger.info('Reloading plugin {} and retrying'.format(module_name)) - get_plugin(module_name, reload=True) - response = _thread_func(_n_tries=_n_tries-1, errors=errors) + plugin = get_plugin(module_name, reload=True) + if isinstance(plugin, RunnablePlugin): + plugin.bus = get_bus() + plugin.start() + + response = _thread_func(_n_tries=_n_tries - 1, errors=errors) finally: self._send_response(response) - return response - token_hash = Config.get('token_hash') + return response - if token_hash: - if self.token is None or get_hash(self.token) != token_hash: - raise PermissionError() + stored_token_hash = Config.get('token_hash') + token = getattr(self, 'token', '') + if stored_token_hash and get_hash(token) != stored_token_hash: + raise PermissionError() if _async: Thread(target=_thread_func, args=(n_tries,)).start() @@ -292,15 +342,18 @@ class Request(Message): the message into a UTF-8 JSON string """ - return json.dumps({ - 'type': 'request', - 'target': self.target, - 'action': self.action, - 'args': self.args, - 'origin': self.origin if hasattr(self, 'origin') else None, - 'id': self.id if hasattr(self, 'id') else None, - 'token': self.token if hasattr(self, 'token') else None, - '_timestamp': self.timestamp, - }) + return json.dumps( + { + 'type': 'request', + 'target': self.target, + 'action': self.action, + 'args': self.args, + 'origin': self.origin if hasattr(self, 'origin') else None, + 'id': self.id if hasattr(self, 'id') else None, + 'token': self.token if hasattr(self, 'token') else None, + '_timestamp': self.timestamp, + } + ) + # vim:sw=4:ts=4:et: From cbe2e7bbfe91b1aaf5beb8f91bb2d08c8ea51a25 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 4 Aug 2022 03:08:54 +0200 Subject: [PATCH 07/27] [WIP] --- docs/source/conf.py | 211 ++--- platypush/message/event/matrix.py | 96 ++- platypush/plugins/matrix/__init__.py | 1025 ++++++++++++++---------- platypush/plugins/matrix/manifest.yaml | 21 +- platypush/schemas/matrix.py | 109 +++ setup.py | 2 + 6 files changed, 942 insertions(+), 522 deletions(-) create mode 100644 platypush/schemas/matrix.py diff --git a/docs/source/conf.py b/docs/source/conf.py index a3f641d2a5..0009767951 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -138,15 +138,12 @@ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. # # 'preamble': '', - # Latex figure (float) alignment # # 'figure_align': 'htbp', @@ -156,8 +153,7 @@ latex_elements = { # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'platypush.tex', 'platypush Documentation', - 'BlackLight', 'manual'), + (master_doc, 'platypush.tex', 'platypush Documentation', 'BlackLight', 'manual'), ] @@ -165,10 +161,7 @@ latex_documents = [ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'platypush', 'platypush Documentation', - [author], 1) -] +man_pages = [(master_doc, 'platypush', 'platypush Documentation', [author], 1)] # -- Options for Texinfo output ---------------------------------------------- @@ -177,9 +170,15 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'platypush', 'platypush Documentation', - author, 'platypush', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + 'platypush', + 'platypush Documentation', + author, + 'platypush', + 'One line description of project.', + 'Miscellaneous', + ), ] @@ -199,99 +198,101 @@ autodoc_default_options = { 'inherited-members': True, } -autodoc_mock_imports = ['googlesamples.assistant.grpc.audio_helpers', - 'google.assistant.embedded', - 'google.assistant.library', - 'google.assistant.library.event', - 'google.assistant.library.file_helpers', - 'google.oauth2.credentials', - 'oauth2client', - 'apiclient', - 'tenacity', - 'smartcard', - 'Leap', - 'oauth2client', - 'rtmidi', - 'bluetooth', - 'gevent.wsgi', - 'Adafruit_IO', - 'pyperclip', - 'pydbus', - 'inputs', - 'inotify', - 'omxplayer', - 'plexapi', - 'cwiid', - 'sounddevice', - 'soundfile', - 'numpy', - 'cv2', - 'nfc', - 'ndef', - 'bcrypt', - 'google', - 'feedparser', - 'kafka', - 'googlesamples', - 'icalendar', - 'httplib2', - 'mpd', - 'serial', - 'pyHS100', - 'grpc', - 'envirophat', - 'gps', - 'picamera', - 'pmw3901', - 'PIL', - 'croniter', - 'pyaudio', - 'avs', - 'PyOBEX', - 'todoist', - 'trello', - 'telegram', - 'telegram.ext', - 'pyfirmata2', - 'cups', - 'graphyte', - 'cpuinfo', - 'psutil', - 'openzwave', - 'deepspeech', - 'wave', - 'pvporcupine ', - 'pvcheetah', - 'pyotp', - 'linode_api4', - 'pyzbar', - 'tensorflow', - 'keras', - 'pandas', - 'samsungtvws', - 'paramiko', - 'luma', - 'zeroconf', - 'dbus', - 'gi', - 'gi.repository', - 'twilio', - 'Adafruit_Python_DHT', - 'RPi.GPIO', - 'RPLCD', - 'imapclient', - 'pysmartthings', - 'aiohttp', - 'watchdog', - 'pyngrok', - 'irc', - 'irc.bot', - 'irc.strings', - 'irc.client', - 'irc.connection', - 'irc.events', - 'defusedxml', - ] +autodoc_mock_imports = [ + 'googlesamples.assistant.grpc.audio_helpers', + 'google.assistant.embedded', + 'google.assistant.library', + 'google.assistant.library.event', + 'google.assistant.library.file_helpers', + 'google.oauth2.credentials', + 'oauth2client', + 'apiclient', + 'tenacity', + 'smartcard', + 'Leap', + 'oauth2client', + 'rtmidi', + 'bluetooth', + 'gevent.wsgi', + 'Adafruit_IO', + 'pyperclip', + 'pydbus', + 'inputs', + 'inotify', + 'omxplayer', + 'plexapi', + 'cwiid', + 'sounddevice', + 'soundfile', + 'numpy', + 'cv2', + 'nfc', + 'ndef', + 'bcrypt', + 'google', + 'feedparser', + 'kafka', + 'googlesamples', + 'icalendar', + 'httplib2', + 'mpd', + 'serial', + 'pyHS100', + 'grpc', + 'envirophat', + 'gps', + 'picamera', + 'pmw3901', + 'PIL', + 'croniter', + 'pyaudio', + 'avs', + 'PyOBEX', + 'todoist', + 'trello', + 'telegram', + 'telegram.ext', + 'pyfirmata2', + 'cups', + 'graphyte', + 'cpuinfo', + 'psutil', + 'openzwave', + 'deepspeech', + 'wave', + 'pvporcupine ', + 'pvcheetah', + 'pyotp', + 'linode_api4', + 'pyzbar', + 'tensorflow', + 'keras', + 'pandas', + 'samsungtvws', + 'paramiko', + 'luma', + 'zeroconf', + 'dbus', + 'gi', + 'gi.repository', + 'twilio', + 'Adafruit_Python_DHT', + 'RPi.GPIO', + 'RPLCD', + 'imapclient', + 'pysmartthings', + 'aiohttp', + 'watchdog', + 'pyngrok', + 'irc', + 'irc.bot', + 'irc.strings', + 'irc.client', + 'irc.connection', + 'irc.events', + 'defusedxml', + 'nio', +] sys.path.insert(0, os.path.abspath('../..')) diff --git a/platypush/message/event/matrix.py b/platypush/message/event/matrix.py index d1ac3f8d74..bc3e6fdb06 100644 --- a/platypush/message/event/matrix.py +++ b/platypush/message/event/matrix.py @@ -67,6 +67,92 @@ class MatrixMessageEvent(MatrixEvent): super().__init__(*args, body=body, **kwargs) +class MatrixMediaMessageEvent(MatrixMessageEvent): + """ + Event triggered when a media message is received on a subscribed room. + """ + + def __init__(self, *args, url: str, **kwargs): + """ + :param url: The URL of the media file. + """ + super().__init__(*args, url=url, **kwargs) + + +class MatrixStickerEvent(MatrixMediaMessageEvent): + """ + Event triggered when a sticker is sent to a room. + """ + + +class MatrixReactionEvent(MatrixEvent): + """ + Event triggered when a user submits a reaction to an event. + """ + + def __init__(self, *args, in_response_to_event_id: str, **kwargs): + """ + :param in_response_to_event_id: The ID of the URL related to the reaction. + """ + super().__init__( + *args, in_response_to_event_id=in_response_to_event_id, **kwargs + ) + + +class MatrixEncryptedMessageEvent(MatrixMessageEvent): + """ + Event triggered when a message is received but the client doesn't + have the E2E keys to decrypt it, or encryption has not been enabled. + """ + + +class MatrixCallEvent(MatrixEvent): + """ + Base class for Matrix call events. + """ + + def __init__( + self, *args, call_id: str, version: int, sdp: str | None = None, **kwargs + ): + """ + :param call_id: The unique ID of the call. + :param version: An increasing integer representing the version of the call. + :param sdp: SDP text of the session description. + """ + super().__init__(*args, call_id=call_id, version=version, sdp=sdp, **kwargs) + + +class MatrixCallInviteEvent(MatrixCallEvent): + """ + Event triggered when the user is invited to a call. + """ + + def __init__(self, *args, invite_validity: float | None = None, **kwargs): + """ + :param invite_validity: For how long the invite will be valid, in seconds. + :param sdp: SDP text of the session description. + """ + super().__init__(*args, invite_validity=invite_validity, **kwargs) + + +class MatrixCallAnswerEvent(MatrixCallEvent): + """ + Event triggered by the callee when they wish to answer the call. + """ + + +class MatrixCallHangupEvent(MatrixCallEvent): + """ + Event triggered when a participant in the call exists. + """ + + +class MatrixRoomCreatedEvent(MatrixEvent): + """ + Event triggered when a room is created. + """ + + class MatrixRoomJoinEvent(MatrixEvent): """ Event triggered when a user joins a room. @@ -81,17 +167,11 @@ class MatrixRoomLeaveEvent(MatrixEvent): class MatrixRoomInviteEvent(MatrixEvent): """ - Event triggered when a user is invited to a room. + Event triggered when the user is invited to a room. """ -class MatrixRoomInviteMeEvent(MatrixEvent): - """ - Event triggered when the currently logged in user is invited to a room. - """ - - -class MatrixRoomTopicChangeEvent(MatrixEvent): +class MatrixRoomTopicChangedEvent(MatrixEvent): """ Event triggered when the topic/title of a room changes. """ diff --git a/platypush/plugins/matrix/__init__.py b/platypush/plugins/matrix/__init__.py index 42f9fc7dc4..ad22f43987 100644 --- a/platypush/plugins/matrix/__init__.py +++ b/platypush/plugins/matrix/__init__.py @@ -1,488 +1,701 @@ import datetime import json +import logging import multiprocessing import os import pathlib -import requests -from abc import ABC, abstractmethod -from urllib.parse import urljoin -from typing import Optional, Collection, Dict, Tuple, Any +import re -from platypush.config import Config -from platypush.context import get_bus -from platypush.message.event.matrix import ( - MatrixEvent, - MatrixRoomTopicChangeEvent, - MatrixMessageEvent, - MatrixRoomJoinEvent, - MatrixRoomLeaveEvent, - MatrixRoomInviteEvent, - MatrixRoomInviteMeEvent, +from aiohttp import ClientConnectionError, ServerDisconnectedError +from dataclasses import dataclass +from functools import wraps +from typing import Callable + +from async_lru import alru_cache +from nio import ( + AsyncClient, + AsyncClientConfig, + CallAnswerEvent, + CallHangupEvent, + CallInviteEvent, + DevicesError, + Event, + InviteNameEvent, + JoinedRoomsError, + KeyVerificationStart, + LoginResponse, + MatrixRoom, + MegolmEvent, + ProfileGetResponse, + RoomCreateEvent, + RoomGetEventError, + RoomGetStateError, + RoomGetStateResponse, + RoomMemberEvent, + RoomMessageText, + RoomMessageMedia, + RoomTopicEvent, + RoomUpgradeEvent, + StickerEvent, + UnknownEncryptedEvent, + UnknownEvent, ) +from nio.client.async_client import client_session + +from platypush.config import Config +from platypush.context import get_bus, get_or_create_event_loop +from platypush.message.event.matrix import ( + MatrixCallAnswerEvent, + MatrixCallHangupEvent, + MatrixCallInviteEvent, + MatrixEncryptedMessageEvent, + MatrixMediaMessageEvent, + MatrixMessageEvent, + MatrixReactionEvent, + MatrixRoomCreatedEvent, + MatrixRoomInviteEvent, + MatrixRoomJoinEvent, + MatrixRoomLeaveEvent, + MatrixRoomTopicChangedEvent, + MatrixStickerEvent, +) + +from platypush.message.response import Response from platypush.plugins import RunnablePlugin, action +from platypush.schemas.matrix import ( + MatrixDeviceSchema, + MatrixProfileSchema, + MatrixRoomSchema, +) + +from platypush.utils import set_thread_name + +logger = logging.getLogger(__name__) -class RetrieveWorker(ABC): - def __init__(self, server_url: str, access_token: str): - self._server_url = server_url - self._access_token = access_token +@dataclass +class Credentials: + server_url: str + user_id: str + access_token: str + device_id: str | None - @abstractmethod - def _url(self, id: int | str) -> str: - raise NotImplementedError() - - @abstractmethod - def _process_response(self, rs: dict | Collection[dict]) -> dict: - raise NotImplementedError() - - def __call__(self, id: str) -> Tuple[str, dict]: - url = urljoin(self._server_url, self._url(id)) - rs = requests.get( - url, - headers={ - 'Authorization': f'Bearer {self._access_token}', - }, - ) - - rs.raise_for_status() - return (id, self._process_response(rs.json())) - - -class UserRetrieveWorker(RetrieveWorker): - def _url(self, id: str) -> str: - return f'/_matrix/client/r0/profile/{id}' - - def _process_response(self, rs: dict) -> dict: + def to_dict(self) -> dict: return { - 'display_name': rs.get('displayname'), - 'avatar_url': rs.get('avatar_url'), + 'server_url': self.server_url, + 'user_id': self.user_id, + 'access_token': self.access_token, + 'device_id': self.device_id, } -class RoomRetrieveWorker(RetrieveWorker): - def _url(self, id: str) -> str: - return f'/_matrix/client/v3/rooms/{id}/state' +def _action_wrapper(f: Callable) -> Callable: + @wraps(f) + def _wrapper(*args, **kwargs): + try: + return f(*args, **kwargs) + except Exception as e: + logger.exception(e) + return Response(errors=[str(e)]) - def _process_response(self, rs: Collection[dict]) -> dict: - info = {} - for event in rs: - event_type = event.get('type') - if event_type == 'm.room.name': - info['name'] = event.get('content', {}).get('name') - elif event_type == 'm.room.topic': - info['name'] = event.get('content', {}).get('topic') + return _wrapper - return info + +class MatrixClient(AsyncClient): + def __init__( + self, + *args, + credentials_file: str, + store_path: str | None = None, + config: AsyncClientConfig | None = None, + autojoin_on_invite=False, + **kwargs, + ): + credentials_file = os.path.abspath(os.path.expanduser(credentials_file)) + if not store_path: + store_path = os.path.join(Config.get('workdir'), 'matrix', 'store') # type: ignore + if store_path: + store_path = os.path.abspath(os.path.expanduser(store_path)) + pathlib.Path(store_path).mkdir(exist_ok=True, parents=True) + if not config: + config = AsyncClientConfig( + max_limit_exceeded=0, + max_timeouts=0, + store_sync_tokens=True, + encryption_enabled=True, + ) + + super().__init__(*args, config=config, store_path=store_path, **kwargs) + self.logger = logging.getLogger(self.__class__.__name__) + self._credentials_file = credentials_file + self._autojoin_on_invite = autojoin_on_invite + + async def _autojoin_room_callback(self, room: MatrixRoom, *_): + await self.join(room.room_id) # type: ignore + + def _load_from_file(self): + if not os.path.isfile(self._credentials_file): + return + + try: + with open(self._credentials_file, 'r') as f: + credentials = json.load(f) + except json.JSONDecodeError: + self.logger.warning( + 'Could not read credentials_file %s - overwriting it', + self._credentials_file, + ) + return + + assert credentials.get('user_id'), 'Missing user_id' + assert credentials.get('access_token'), 'Missing access_token' + + self.access_token = credentials['access_token'] + self.user_id = credentials['user_id'] + self.homeserver = credentials.get('server_url', self.homeserver) + if credentials.get('device_id'): + self.device_id = credentials['device_id'] + + self.load_store() + + async def login( + self, + password: str | None = None, + device_name: str | None = None, + token: str | None = None, + ) -> LoginResponse: + self._load_from_file() + login_res = None + + if self.access_token: + self.load_store() + self.logger.info( + 'Logged in to %s as %s using the stored access token', + self.homeserver, + self.user_id, + ) + + if self.should_upload_keys: + self.logger.info('Uploading encryption keys') + await self.keys_upload() + + login_res = LoginResponse( + user_id=self.user_id, + device_id=self.device_id, + access_token=self.access_token, + ) + else: + assert self.user, 'No credentials file found and no user provided' + login_args = {'device_name': device_name} + if token: + login_args['token'] = token + else: + assert ( + password + ), 'No credentials file found and no password nor access token provided' + login_args['password'] = password + + login_res = await super().login(**login_args) + assert isinstance(login_res, LoginResponse), f'Failed to login: {login_res}' + self.logger.info(login_res) + + credentials = Credentials( + server_url=self.homeserver, + user_id=login_res.user_id, + access_token=login_res.access_token, + device_id=login_res.device_id, + ) + + with open(self._credentials_file, 'w') as f: + json.dump(credentials.to_dict(), f) + os.chmod(self._credentials_file, 0o600) + + self.logger.info('Synchronizing rooms') + sync_token = self.loaded_sync_token + self.loaded_sync_token = '' + await self.sync(sync_filter={'room': {'timeline': {'limit': 1}}}) + + self.loaded_sync_token = sync_token + self.logger.info('Rooms synchronized') + self._add_callbacks() + return login_res + + def _add_callbacks(self): + self.add_event_callback(self._event_catch_all, Event) + self.add_event_callback(self._on_invite, InviteNameEvent) # type: ignore + self.add_event_callback(self._on_room_message, RoomMessageText) # type: ignore + self.add_event_callback(self._on_media_message, RoomMessageMedia) # type: ignore + self.add_event_callback(self._on_room_member, RoomMemberEvent) # type: ignore + self.add_event_callback(self._on_room_topic_changed, RoomTopicEvent) # type: ignore + self.add_event_callback(self._on_call_invite, CallInviteEvent) # type: ignore + self.add_event_callback(self._on_call_answer, CallAnswerEvent) # type: ignore + self.add_event_callback(self._on_call_hangup, CallHangupEvent) # type: ignore + self.add_event_callback(self._on_sticker_message, StickerEvent) # type: ignore + self.add_event_callback(self._on_unknown_event, UnknownEvent) # type: ignore + self.add_event_callback(self._on_unknown_encrypted_event, UnknownEncryptedEvent) # type: ignore + self.add_event_callback(self._on_unknown_encrypted_event, MegolmEvent) # type: ignore + self.add_to_device_callback(self._on_key_verification_start, KeyVerificationStart) # type: ignore + + if self._autojoin_on_invite: + self.add_event_callback(self._autojoin_room_callback, InviteNameEvent) # type: ignore + + @alru_cache(maxsize=500) + @client_session + async def get_profile(self, user_id: str | None = None) -> ProfileGetResponse: + """ + Cached version of get_profile. + """ + ret = await super().get_profile(user_id) + assert isinstance( + ret, ProfileGetResponse + ), f'Could not retrieve profile for user {user_id}: {ret.message}' + return ret + + @alru_cache(maxsize=500) + @client_session + async def room_get_state(self, room_id: str) -> RoomGetStateResponse: + """ + Cached version of room_get_state. + """ + ret = await super().room_get_state(room_id) + assert isinstance( + ret, RoomGetStateResponse + ), f'Could not retrieve profile for room {room_id}: {ret.message}' + return ret + + async def _event_base_args( + self, room: MatrixRoom, event: Event | None = None + ) -> dict: + sender_id = event.sender if event else None + sender = ( + await self.get_profile(sender_id) if sender_id else None # type: ignore + ) + + return { + 'server_url': self.homeserver, + 'sender_id': sender_id, + 'sender_display_name': sender.displayname if sender else None, + 'sender_avatar_url': sender.avatar_url if sender else None, + 'room_id': room.room_id, + 'room_name': room.name, + 'room_topic': room.topic, + 'server_timestamp': ( + datetime.datetime.fromtimestamp(event.server_timestamp / 1000) + if event and getattr(event, 'server_timestamp', None) + else None + ), + } + + async def _event_catch_all(self, room: MatrixRoom, event: Event): + self.logger.debug('Received event on room %s: %r', room.room_id, event) + + async def _on_invite(self, room: MatrixRoom, event: RoomMessageText): + get_bus().post( + MatrixRoomInviteEvent( + **(await self._event_base_args(room, event)), + ) + ) + + async def _on_room_message(self, room: MatrixRoom, event: RoomMessageText): + get_bus().post( + MatrixMessageEvent( + **(await self._event_base_args(room, event)), + body=event.body, + ) + ) + + async def _on_room_member(self, room: MatrixRoom, event: RoomMemberEvent): + evt_type = None + if event.membership == 'join': + evt_type = MatrixRoomJoinEvent + elif event.membership == 'leave': + evt_type = MatrixRoomLeaveEvent + + if evt_type: + get_bus().post( + evt_type( + **(await self._event_base_args(room, event)), + ) + ) + + async def _on_room_topic_changed(self, room: MatrixRoom, event: RoomTopicEvent): + get_bus().post( + MatrixRoomTopicChangedEvent( + **(await self._event_base_args(room, event)), + topic=event.topic, + ) + ) + + async def _on_call_invite(self, room: MatrixRoom, event: CallInviteEvent): + get_bus().post( + MatrixCallInviteEvent( + call_id=event.call_id, + version=event.version, + invite_validity=event.lifetime / 1000.0, + sdp=event.offer.get('sdp'), + **(await self._event_base_args(room, event)), + ) + ) + + async def _on_call_answer(self, room: MatrixRoom, event: CallAnswerEvent): + get_bus().post( + MatrixCallAnswerEvent( + call_id=event.call_id, + version=event.version, + sdp=event.answer.get('sdp'), + **(await self._event_base_args(room, event)), + ) + ) + + async def _on_call_hangup(self, room: MatrixRoom, event: CallHangupEvent): + get_bus().post( + MatrixCallHangupEvent( + call_id=event.call_id, + version=event.version, + **(await self._event_base_args(room, event)), + ) + ) + + async def _on_room_created(self, room: MatrixRoom, event: RoomCreateEvent): + get_bus().post( + MatrixRoomCreatedEvent( + **(await self._event_base_args(room, event)), + ) + ) + + async def _on_media_message(self, room: MatrixRoom, event: RoomMessageMedia): + get_bus().post( + MatrixMediaMessageEvent( + url=event.url, + **(await self._event_base_args(room, event)), + ) + ) + + async def _on_sticker_message(self, room: MatrixRoom, event: StickerEvent): + get_bus().post( + MatrixStickerEvent( + url=event.url, + **(await self._event_base_args(room, event)), + ) + ) + + def _on_key_verification_start(self, event: KeyVerificationStart): + assert self.olm, 'OLM state machine not initialized' + print('************ HERE') + print(event) + self.olm.handle_key_verification(event) + + async def _on_room_upgrade(self, room: MatrixRoom, event: RoomUpgradeEvent): + self.logger.info( + 'The room %s has been upgraded to %s', room.room_id, event.replacement_room + ) + + await self.room_leave(room.room_id) + await self.join(event.replacement_room) + + async def _on_unknown_encrypted_event( + self, room: MatrixRoom, event: UnknownEncryptedEvent | MegolmEvent + ): + body = getattr(event, 'ciphertext', '') + get_bus().post( + MatrixEncryptedMessageEvent( + body=body, + **(await self._event_base_args(room, event)), + ) + ) + + async def _on_unknown_event(self, room: MatrixRoom, event: UnknownEvent): + evt = None + + if event.type == 'm.reaction': + # Get the ID of the event this was a reaction to + relation_dict = event.source.get('content', {}).get('m.relates_to', {}) + reacted_to = relation_dict.get('event_id') + if reacted_to and relation_dict.get('rel_type') == 'm.annotation': + event_response = await self.room_get_event(room.room_id, reacted_to) + + if isinstance(event_response, RoomGetEventError): + self.logger.warning( + 'Error getting event that was reacted to (%s)', reacted_to + ) + else: + evt = MatrixReactionEvent( + in_response_to_event_id=event_response.event.event_id, + **(await self._event_base_args(room, event)), + ) + + if evt: + get_bus().post(evt) + else: + self.logger.info( + 'Received an unknown event on room %s: %r', room.room_id, event + ) class MatrixPlugin(RunnablePlugin): """ Matrix chat integration. + Requires: + + * **matrix-nio** (``pip install 'matrix-nio[e2e]'``) + * **libolm** (on Debian ```apt-get install libolm-devel``, on Arch + ``pacman -S libolm``) + * **async_lru** (``pip install async_lru``) + + Note that ``libolm`` and the ``[e2e]`` module are only required if you want E2E encryption + support. + Triggers: * :class:`platypush.message.event.matrix.MatrixMessageEvent`: when a message is received. + * :class:`platypush.message.event.matrix.MatrixMediaMessageEvent`: when a media message is received. + * :class:`platypush.message.event.matrix.MatrixRoomCreatedEvent`: when a room is created. * :class:`platypush.message.event.matrix.MatrixRoomJoinEvent`: when a user joins a room. * :class:`platypush.message.event.matrix.MatrixRoomLeaveEvent`: when a user leaves a room. - * :class:`platypush.message.event.matrix.MatrixRoomInviteEvent`: when a user (other than the - currently logged one) is invited to a room. - * :class:`platypush.message.event.matrix.MatrixRoomMeInviteEvent`: when the currently logged in - user is invited to a room. - * :class:`platypush.message.event.matrix.MatrixRoomTopicChangeEvent`: when the topic/title of a room changes. + * :class:`platypush.message.event.matrix.MatrixRoomInviteEvent`: when the user is invited to a room. + * :class:`platypush.message.event.matrix.MatrixRoomTopicChangedEvent`: when the topic/title of a room changes. + * :class:`platypush.message.event.matrix.MatrixCallInviteEvent`: when the user is invited to a call. + * :class:`platypush.message.event.matrix.MatrixCallAnswerEvent`: when a called user wishes to pick the call. + * :class:`platypush.message.event.matrix.MatrixCallHangupEvent`: when a called user exits the call. + * :class:`platypush.message.event.matrix.MatrixStickerEvent`: when a sticker is sent to a room. + * :class:`platypush.message.event.matrix.MatrixEncryptedMessageEvent`: when a message is received but the + client doesn't have the E2E keys to decrypt it, or encryption has not been enabled. """ def __init__( self, server_url: str = 'https://matrix.to', - username: str | None = None, + user_id: str | None = None, password: str | None = None, access_token: str | None = None, + device_name: str | None = 'platypush', + device_id: str | None = None, autojoin_on_invite: bool = False, **kwargs, ): """ - Authentication requires either username/password or an access token. + Authentication requires user_id/password on the first login. + Afterwards, session credentials are stored under + ``<$PLATYPUSH_WORKDIR>/matrix/credentials.json`` (default: + ``~/.local/share/platypush/matrix/credentials.json``), and you can + remove the cleartext credentials from your configuration file. - If you don't want to provide cleartext credentials in the configuration, you can - retrieve an access token offline through the following request:: - - curl -XPOST '{"type":"m.login.password", "user":"username", "password":"password"}' \ - "https://matrix.example.com/_matrix/client/r0/login" - - This may be required if the user or the instance enforce 2FA. + Otherwise, if you already have an ``access_token``, you can set the + associated field instead of using ``password``. This may be required if + the user has 2FA enabled. :param server_url: Default Matrix instance base URL (default: ``https://matrix.to``). - :param username: Default username. Provide either username/password _or_ an access token. - :param password: Default password. Provide either username/password _or_ an access token. - :param access_token: Default access token. Provide either username/password _or_ an access token. + :param user_id: user_id, in the format ``@user:example.org``, or just the username if the + account is hosted on the same server configured in the ``server_url``. + :param password: User password. + :param access_token: User access token. + :param device_name: The name of this device/connection (default: ``platypush``). + :param device_id: Use an existing ``device_id`` for the sessions. :param autojoin_on_invite: Whether the account should automatically join rooms upon invite. If false (default value), then you may want to implement your own - logic in an event hook when a :class:`platypush.message.event.matrix.MatrixRoomInviteMeEvent` + logic in an event hook when a :class:`platypush.message.event.matrix.MatrixRoomInviteEvent` event is received, and call the :meth:`.join` method if required. """ super().__init__(**kwargs) + if not (server_url.startswith('http://') or server_url.startswith('https://')): + server_url = f'https://{server_url}' self._server_url = server_url - self._user_id = None + server_name = self._server_url.split('/')[2].split(':')[0] + + if user_id and not re.match(user_id, '^@[a-zA-Z0-9.-_]+:.+'): + user_id = f'@{user_id}:{server_name}' + + self._matrix_proc: multiprocessing.Process | None = None + self._user_id = user_id + self._password = password + self._access_token = access_token + self._device_name = device_name + self._device_id = device_id self._autojoin_on_invite = autojoin_on_invite + self._event_loop = get_or_create_event_loop() self._workdir = os.path.join(Config.get('workdir'), 'matrix') # type: ignore + self._credentials_file = os.path.join(self._workdir, 'credentials.json') + self._client = self._get_client() pathlib.Path(self._workdir).mkdir(parents=True, exist_ok=True) - self._sessions_file = os.path.join(self._workdir, 'sessions.json') - self._credentials_file = os.path.join(self._workdir, 'credentials.json') - self._users_cache_file = os.path.join(self._workdir, 'users.json') - self._rooms_cache_file = os.path.join(self._workdir, 'rooms.json') - self._users_cache: Dict[str, dict] = {} - self._rooms_cache: Dict[str, dict] = {} - self._set_credentials(username, password, access_token, overwrite=True) - - def _set_credentials( - self, - username: str | None = None, - password: str | None = None, - access_token: str | None = None, - overwrite: bool = False, - ): - if username or overwrite: - self._username = username - if password or overwrite: - self._password = password - if access_token or overwrite: - self._access_token = access_token - - def _execute(self, url: str, method: str = 'get', **kwargs): - if self._access_token: - kwargs['headers'] = { - 'Authorization': f'Bearer {self._access_token}', - **kwargs.get('headers', {}), - } - - url = urljoin(self._server_url, f'/_matrix/client/{url.lstrip("/")}') - req_method = getattr(requests, method.lower()) - rs = req_method(url, **kwargs) - rs.raise_for_status() - rs = rs.json() - assert not rs.get('error'), rs.get('error') - return rs - - def _save_credentials(self, credentials: dict): - with open(self._credentials_file, 'w') as f: - json.dump(credentials, f) - - def _refresh_user_id(self): - devices = self._execute('/v3/devices').get('devices', []) - assert devices, 'The user is not logged into any devices' - self._user_id = devices[0]['user_id'] - - @action - def login( - self, - server_url: str | None = None, - username: str | None = None, - password: str | None = None, - access_token: str | None = None, - ): - """ - Login to an instance if username/password/access_token were not specified in the plugin - configuration. Otherwise, change the currently logged user or instance. - - :param server_url: New Matrix instance base URL. - :param username: New username. - :param password: New user password. - :param access_token: New access token. - """ - self._server_url = server_url or self._server_url - self._set_credentials(username, password, access_token, overwrite=False) - - if self._access_token: - self._refresh_user_id() - elif self._username and self._password: - rs = self._execute( - '/r0/login', - method='post', - json={ - 'type': 'm.login.password', - 'user': self._username, - 'password': self._password, - 'initial_device_display_name': 'Platypush Matrix integration', - }, - ) - - assert rs.get('access_token'), 'No access token provided by the server' - self._access_token = rs['access_token'] - self._user_id = rs['user_id'] - self._save_credentials(rs) - elif os.path.isfile(self._credentials_file): - with open(self._credentials_file, 'r') as f: - self._access_token = json.load(f)['access_token'] - self._refresh_user_id() - else: - raise AssertionError( - 'No username, password and access token provided nor stored' - ) - - self.logger.info( - f'Successfully logged in to {self._server_url} as {self._user_id}' + def _get_client(self) -> AsyncClient: + return MatrixClient( + homeserver=self._server_url, + user=self._user_id, + credentials_file=self._credentials_file, + autojoin_on_invite=self._autojoin_on_invite, + device_id=self._device_id, ) - @staticmethod - def _timestamp_to_datetime(t: int | float) -> datetime.datetime: - return datetime.datetime.fromtimestamp(t / 1000) + def _login(self) -> AsyncClient: + if not self._client: + self._client = self._get_client() - def _parse_event( - self, room_id: str, event: dict, users: dict - ) -> Optional[MatrixEvent]: - evt_type = event.get('type') - evt_class = None - room_info = self._rooms_cache.get(room_id, {}) - args: Dict[str, Any] = { - 'server_url': self._server_url, - 'room_id': room_id, - 'room_name': room_info.get('name'), - 'room_topic': room_info.get('topic'), - } - - if event.get('sender') and isinstance(event.get('sender'), str): - cached_user = users.get(event['sender'], {}) - args['sender_id'] = event['sender'] - args['sender_display_name'] = cached_user.get('display_name') - args['sender_avatar_url'] = cached_user.get('avatar_url') - - if event.get('origin_server_ts'): - args['server_timestamp'] = self._timestamp_to_datetime( - event['origin_server_ts'] + self._event_loop.run_until_complete( + self._client.login( + password=self._password, + device_name=self._device_name, + token=self._access_token, ) + ) - if evt_type == 'm.room.topic': - evt_class = MatrixRoomTopicChangeEvent - args['topic'] = event.get('content', {}).get('topic') # type: ignore - # TODO Handle encrypted rooms events (`m.room.encrypted`) - elif evt_type == 'm.room.message': - evt_class = MatrixMessageEvent - args['body'] = event.get('content', {}).get('body') # type: ignore - elif evt_type == 'm.room.member': - membership = event.get('content', {}).get('membership') - if membership == 'join': - evt_class = MatrixRoomJoinEvent - elif membership == 'invite': - evt_class = MatrixRoomInviteEvent - elif membership == 'leave': - evt_class = MatrixRoomLeaveEvent + return self._client - if evt_class: - return evt_class(**args) + def _connect(self): + if self.should_stop() or (self._matrix_proc and self._matrix_proc.is_alive()): + self.logger.debug('Already connected') + return - def _parse_invite_event( - self, room_id: str, events: Collection[dict] - ) -> MatrixRoomInviteMeEvent: - evt_args: Dict[str, Any] = { - 'server_url': self._server_url, - 'room_id': room_id, - } + self._login() + self._matrix_proc = multiprocessing.Process(target=self._run_client) + self._matrix_proc.start() - for event in events: - evt_type = event.get('type') - if evt_type == 'm.room.name': - evt_args['room_name'] = event.get('content', {}).get('name') - elif evt_type == 'm.room.topic': - evt_args['room_topic'] = event.get('content', {}).get('topic') - if event.get('origin_server_ts'): - evt_args['server_timestamp'] = self._timestamp_to_datetime( - event['origin_server_ts'] + async def _run_async_client(self): + await self._client.sync_forever(timeout=0, full_state=True) + + def _run_client(self): + set_thread_name('matrix-client') + + while True: + try: + self._event_loop.run_until_complete(self._run_async_client()) + except (ClientConnectionError, ServerDisconnectedError): + self.logger.warning( + 'Cannot connect to the Matrix server. Retrying in 15s' ) + self._should_stop.wait(15) + except KeyboardInterrupt: + pass + finally: + if self._client: + self._event_loop.run_until_complete(self._client.close()) + self._matrix_proc = None + self._connect() - if evt_args.get('room_name'): - self._rooms_cache[room_id] = { - 'room_id': room_id, - 'room_name': evt_args['room_name'], - 'room_topic': evt_args.get('room_topic'), - } + @action + @_action_wrapper + def send_message( + self, + room_id: str, + message_type: str = 'text', + body: str | None = None, + tx_id: str | None = None, + ignore_unverified_devices: bool = False, + ): + """ + Send a message to a room. - self._rewrite_rooms_cache() - - return MatrixRoomInviteMeEvent(**evt_args) - - def _retrieve_users_info(self, users: Collection[str]) -> Dict[str, dict]: - users_info = {user: {} for user in users} - retrieve = UserRetrieveWorker(self._server_url, self._access_token or '') - with multiprocessing.Pool(4) as pool: - pool_res = pool.map(retrieve, users_info.keys()) - - return { - user_id: { - 'user_id': user_id, - **info, - } - for user_id, info in pool_res - } - - def _retrieve_rooms_info(self, rooms: Collection[str]) -> Dict[str, dict]: - rooms_info = {room: {} for room in rooms} - retrieve = RoomRetrieveWorker(self._server_url, self._access_token or '') - with multiprocessing.Pool(4) as pool: - pool_res = pool.map(retrieve, rooms_info.keys()) - - return { - room_id: { - 'room_id': room_id, - **info, - } - for room_id, info in pool_res - } - - def _extract_senders(self, rooms) -> Dict[str, dict]: - cache_has_changes = False - senders = set() - - for room in rooms: - room_events = room.get('timeline', {}).get('events', []) - for evt in room_events: - if evt.get('type') == 'm.room.member': - cache_has_changes = True - self._users_cache[evt['sender']] = { - 'user_id': evt['sender'], - 'display_name': evt.get('content', {}).get('displayname'), - 'avatar_url': evt.get('content', {}).get('avatar_url'), - } - - senders.update({evt['sender'] for evt in room_events if evt.get('sender')}) - - missing_senders = {user for user in senders if user not in self._users_cache} - - if missing_senders: - cache_has_changes = True - self._users_cache.update(self._retrieve_users_info(missing_senders)) - - senders_map = { - user: self._users_cache.get(user, {'user_id': user}) for user in senders - } - - if cache_has_changes: - self._rewrite_users_cache() - - return senders_map - - def _extract_rooms(self, rooms: Collection[str]) -> Dict[str, dict]: - missing_rooms_info = { - room_id for room_id in rooms if not self._rooms_cache.get(room_id) - } - - if missing_rooms_info: - self._rooms_cache.update(self._retrieve_rooms_info(missing_rooms_info)) - self._rewrite_rooms_cache() - - return { - room_id: self._rooms_cache.get( - room_id, - { - 'room_id': room_id, + :param room_id: Room ID. + :param body: Message body. + :param message_type: Message type. Supported: `text`, `audio`, `video`, + `image`. Default: `text`. + :param tx_id: Unique transaction ID to associate to this message. + :param ignore_unverified_devices: If true, unverified devices will be + ignored (default: False). + """ + message_type = 'm.' + message_type + return self._event_loop.run_until_complete( + self._client.room_send( + message_type=message_type, + room_id=room_id, + tx_id=tx_id, + ignore_unverified_devices=ignore_unverified_devices, + content={ + 'body': body, }, ) - for room_id in rooms - } - - def _process_events(self, events: dict) -> Collection[MatrixEvent]: - rooms = events.get('rooms', {}) - joined_rooms = rooms.get('join', {}) - invited_rooms = rooms.get('invite', {}) - parsed_events = [] - senders = self._extract_senders(joined_rooms.values()) - self._extract_rooms(joined_rooms.keys()) - - # Create joined rooms events - for room_id, room in joined_rooms.items(): - room_events = room.get('timeline', {}).get('events', []) - parsed_room_events = [ - self._parse_event(room_id=room_id, event=event, users=senders) - for event in room_events - ] - - parsed_events.extend([evt for evt in parsed_room_events if evt]) - - # Create invite events - for room_id, room in invited_rooms.items(): - room_events = room.get('invite_state', {}).get('events', []) - parsed_room_event = self._parse_invite_event( - room_id=room_id, events=room_events - ) - parsed_events.append(parsed_room_event) - - if self._autojoin_on_invite: - self.join(room_id) - - parsed_events.sort(key=lambda e: e.server_timestamp) - return parsed_events - - def _reload_users_cache(self): - if os.path.isfile(self._users_cache_file): - with open(self._users_cache_file, 'r') as f: - self._users_cache.update(json.load(f)) - - def _rewrite_users_cache(self): - with open(self._users_cache_file, 'w') as f: - json.dump(self._users_cache, f) - - def _reload_rooms_cache(self): - if os.path.isfile(self._rooms_cache_file): - with open(self._rooms_cache_file, 'r') as f: - self._rooms_cache.update(json.load(f)) - - def _rewrite_rooms_cache(self): - with open(self._rooms_cache_file, 'w') as f: - json.dump(self._rooms_cache, f) + ) @action - def sync(self): + @_action_wrapper + def get_profile(self, user_id: str): """ - Sync the state for the currently logged session. + Retrieve the details about a user. + + :param user_id: User ID. + :return: .. schema:: matrix.MatrixProfileSchema """ - next_batch = None - sessions = {} - if os.path.isfile(self._sessions_file): - with open(self._sessions_file, 'r') as f: - sessions = json.load(f) - next_batch = sessions.get(self._user_id, {}).get('next_batch') - - if not next_batch: - self.logger.info('Synchronizing Matrix events') - - rs = self._execute('/r0/sync', params={'since': next_batch}) - events = self._process_events(rs) - if events and next_batch: - for event in events: - get_bus().post(event) - - if not sessions.get(self._user_id): - sessions[self._user_id] = {} - - sessions[self._user_id]['next_batch'] = rs.get('next_batch') - with open(self._sessions_file, 'w') as f: - json.dump(sessions, f) - - if not next_batch: - self.logger.info('Matrix events synchronized') + profile = self._event_loop.run_until_complete(self._client.get_profile(user_id)) # type: ignore + profile.user_id = user_id # type: ignore + return MatrixProfileSchema().dump(profile) @action - def join(self, room_id: str): + @_action_wrapper + def get_room(self, room_id: str): """ - Join a room by ID. + Retrieve the details about a room. - :param room_id: Room ID or alias. + :param room_id: room ID. + :return: .. schema:: matrix.MatrixRoomSchema """ - self._execute(f'/v3/join/{room_id}', method='post') - self.logger.info('Successfully joined room %s', room_id) + response = self._event_loop.run_until_complete( + self._client.room_get_state(room_id) + ) + assert not isinstance(response, RoomGetStateError), response.message + room_args = {'room_id': room_id, 'own_user_id': None, 'encrypted': False} + room_params = {} + + for evt in response.events: + if evt.get('type') == 'm.room.create': + room_args['own_user_id'] = evt.get('content', {}).get('creator') + elif evt.get('type') == 'm.room.encryption': + room_args['encrypted'] = False + elif evt.get('type') == 'm.room.name': + room_params['name'] = evt.get('content', {}).get('name') + elif evt.get('type') == 'm.room.topic': + room_params['topic'] = evt.get('content', {}).get('topic') + + room = MatrixRoom(**room_args) + for k, v in room_params.items(): + setattr(room, k, v) + return MatrixRoomSchema().dump(room) + + @action + @_action_wrapper + def get_devices(self): + """ + Get the list of devices associated to the current user. + + :return: .. schema:: matrix.MatrixDeviceSchema(many=True) + """ + response = self._event_loop.run_until_complete(self._client.devices()) + assert not isinstance(response, DevicesError), response.message + return MatrixDeviceSchema().dump(response.devices, many=True) + + @action + @_action_wrapper + def get_joined_rooms(self): + """ + Retrieve the rooms that the user has joined. + """ + response = self._event_loop.run_until_complete(self._client.joined_rooms()) + assert not isinstance(response, JoinedRoomsError), response.message + + return [self.get_room(room_id).output for room_id in response.rooms] + + @action + @_action_wrapper + def upload_keys(self): + """ + Synchronize the E2EE keys with the homeserver. + """ + self._event_loop.run_until_complete(self._client.keys_upload()) def main(self): - self.login() - self._reload_users_cache() - self._reload_rooms_cache() + self._connect() + self.wait_stop() - while not self._should_stop.is_set(): - try: - self.sync() - finally: - self._should_stop.wait(timeout=10) + def stop(self): + if self._matrix_proc: + self._matrix_proc.terminate() + self._matrix_proc.join(timeout=10) + self._matrix_proc.kill() + self._matrix_proc = None + + super().stop() # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/matrix/manifest.yaml b/platypush/plugins/matrix/manifest.yaml index 5449b9302d..d564aaaf1f 100644 --- a/platypush/plugins/matrix/manifest.yaml +++ b/platypush/plugins/matrix/manifest.yaml @@ -1,10 +1,25 @@ manifest: events: platypush.message.event.matrix.MatrixMessageEvent: when a message is received. + platypush.message.event.matrix.MatrixMediaMessageEvent: when a media message is received. + platypush.message.event.matrix.MatrixRoomCreatedEvent: when a room is created. platypush.message.event.matrix.MatrixRoomJoinEvent: when a user joins a room. platypush.message.event.matrix.MatrixRoomLeaveEvent: when a user leaves a room. - platypush.message.event.matrix.MatrixRoomInviteEvent: when a user (other than the currently logged one) is invited to a room. - platypush.message.event.matrix.MatrixRoomMeInviteEvent: when the currently logged in user is invited to a room. - platypush.message.event.matrix.MatrixRoomTopicChangeEvent: when the topic/title of a room changes. + platypush.message.event.matrix.MatrixRoomInviteEvent: when the user is invited to a room. + platypush.message.event.matrix.MatrixRoomTopicChangedEvent: when the topic/title of a room changes. + platypush.message.event.matrix.MatrixCallInviteEvent: when the user is invited to a call. + platypush.message.event.matrix.MatrixCallAnswerEvent: when a called user wishes to pick the call. + platypush.message.event.matrix.MatrixCallHangupEvent: when a called user exits the call. + platypush.message.event.matrix.MatrixStickerEvent: when a sticker is sent to a room. + platypush.message.event.matrix.MatrixEncryptedMessageEvent: | + when a message is received but the client doesn't + have the E2E keys to decrypt it, or encryption has not been enabled. + apt: + - libolm-devel + pacman: + - libolm + pip: + - matrix-nio[e2e] + - async_lru package: platypush.plugins.matrix type: plugin diff --git a/platypush/schemas/matrix.py b/platypush/schemas/matrix.py new file mode 100644 index 0000000000..3cda4ebbf3 --- /dev/null +++ b/platypush/schemas/matrix.py @@ -0,0 +1,109 @@ +from marshmallow import fields +from marshmallow.schema import Schema + +from platypush.schemas import DateTime + + +class MatrixProfileSchema(Schema): + user_id = fields.String( + required=True, + metadata={ + 'description': 'User ID', + 'example': '@myuser:matrix.example.org', + }, + ) + + display_name = fields.String( + attribute='displayname', + metadata={ + 'description': 'User display name', + 'example': 'Foo Bar', + }, + ) + + avatar_url = fields.URL( + metadata={ + 'description': 'User avatar URL', + 'example': 'mxc://matrix.platypush.tech/AbCdEfG0123456789', + } + ) + + +class MatrixRoomSchema(Schema): + room_id = fields.String( + required=True, + metadata={ + 'description': 'Room ID', + 'example': '!aBcDeFgHiJkMnO:matrix.example.org', + }, + ) + + name = fields.String( + metadata={ + 'description': 'Room name', + 'example': 'My Room', + } + ) + + display_name = fields.String( + metadata={ + 'description': 'Room display name', + 'example': 'My Room', + } + ) + + topic = fields.String( + metadata={ + 'description': 'Room topic', + 'example': 'My Room Topic', + } + ) + + avatar_url = fields.URL( + attribute='room_avatar_url', + metadata={ + 'description': 'Room avatar URL', + 'example': 'mxc://matrix.platypush.tech/AbCdEfG0123456789', + }, + ) + + owner_id = fields.String( + attribute='own_user_id', + metadata={ + 'description': 'Owner user ID', + 'example': '@myuser:matrix.example.org', + }, + ) + + encrypted = fields.Bool() + + +class MatrixDeviceSchema(Schema): + device_id = fields.String( + required=True, + attribute='id', + metadata={ + 'description': 'ABCDEFG', + }, + ) + + display_name = fields.String( + metadata={ + 'description': 'Device display name', + 'example': 'My Device', + } + ) + + last_seen_ip = fields.String( + metadata={ + 'description': 'Last IP associated to this device', + 'example': '1.2.3.4', + } + ) + + last_seen_date = DateTime( + metadata={ + 'description': 'The last time that the device was reported online', + 'example': '2022-07-23T17:20:01.254223', + } + ) diff --git a/setup.py b/setup.py index a5e379d734..5961bc60f2 100755 --- a/setup.py +++ b/setup.py @@ -268,5 +268,7 @@ setup( 'ngrok': ['pyngrok'], # Support for IRC integration 'irc': ['irc'], + # Support for the Matrix integration + 'matrix': ['matrix-nio'], }, ) From 7ab02e705d0afca711f49f27042aa4e9fbe7a8dd Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Fri, 5 Aug 2022 19:00:48 +0200 Subject: [PATCH 08/27] Removed redundant _action_wrapper decorator --- platypush/plugins/matrix/__init__.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/platypush/plugins/matrix/__init__.py b/platypush/plugins/matrix/__init__.py index ad22f43987..28934779ae 100644 --- a/platypush/plugins/matrix/__init__.py +++ b/platypush/plugins/matrix/__init__.py @@ -8,8 +8,6 @@ import re from aiohttp import ClientConnectionError, ServerDisconnectedError from dataclasses import dataclass -from functools import wraps -from typing import Callable from async_lru import alru_cache from nio import ( @@ -61,7 +59,6 @@ from platypush.message.event.matrix import ( MatrixStickerEvent, ) -from platypush.message.response import Response from platypush.plugins import RunnablePlugin, action from platypush.schemas.matrix import ( MatrixDeviceSchema, @@ -90,18 +87,6 @@ class Credentials: } -def _action_wrapper(f: Callable) -> Callable: - @wraps(f) - def _wrapper(*args, **kwargs): - try: - return f(*args, **kwargs) - except Exception as e: - logger.exception(e) - return Response(errors=[str(e)]) - - return _wrapper - - class MatrixClient(AsyncClient): def __init__( self, @@ -576,7 +561,6 @@ class MatrixPlugin(RunnablePlugin): self._connect() @action - @_action_wrapper def send_message( self, room_id: str, @@ -610,7 +594,6 @@ class MatrixPlugin(RunnablePlugin): ) @action - @_action_wrapper def get_profile(self, user_id: str): """ Retrieve the details about a user. @@ -623,7 +606,6 @@ class MatrixPlugin(RunnablePlugin): return MatrixProfileSchema().dump(profile) @action - @_action_wrapper def get_room(self, room_id: str): """ Retrieve the details about a room. @@ -654,7 +636,6 @@ class MatrixPlugin(RunnablePlugin): return MatrixRoomSchema().dump(room) @action - @_action_wrapper def get_devices(self): """ Get the list of devices associated to the current user. @@ -666,7 +647,6 @@ class MatrixPlugin(RunnablePlugin): return MatrixDeviceSchema().dump(response.devices, many=True) @action - @_action_wrapper def get_joined_rooms(self): """ Retrieve the rooms that the user has joined. @@ -677,7 +657,6 @@ class MatrixPlugin(RunnablePlugin): return [self.get_room(room_id).output for room_id in response.rooms] @action - @_action_wrapper def upload_keys(self): """ Synchronize the E2EE keys with the homeserver. From 354f3906f9ff15e9ec95b94b23b2bd53dd0b44cf Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Fri, 12 Aug 2022 00:11:15 +0200 Subject: [PATCH 09/27] Changed autojoin_on_invite default value --- platypush/plugins/matrix/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/platypush/plugins/matrix/__init__.py b/platypush/plugins/matrix/__init__.py index 28934779ae..4a03feba5b 100644 --- a/platypush/plugins/matrix/__init__.py +++ b/platypush/plugins/matrix/__init__.py @@ -94,7 +94,7 @@ class MatrixClient(AsyncClient): credentials_file: str, store_path: str | None = None, config: AsyncClientConfig | None = None, - autojoin_on_invite=False, + autojoin_on_invite=True, **kwargs, ): credentials_file = os.path.abspath(os.path.expanduser(credentials_file)) @@ -458,7 +458,7 @@ class MatrixPlugin(RunnablePlugin): access_token: str | None = None, device_name: str | None = 'platypush', device_id: str | None = None, - autojoin_on_invite: bool = False, + autojoin_on_invite: bool = True, **kwargs, ): """ @@ -480,7 +480,7 @@ class MatrixPlugin(RunnablePlugin): :param device_name: The name of this device/connection (default: ``platypush``). :param device_id: Use an existing ``device_id`` for the sessions. :param autojoin_on_invite: Whether the account should automatically join rooms - upon invite. If false (default value), then you may want to implement your own + upon invite. If false, then you may want to implement your own logic in an event hook when a :class:`platypush.message.event.matrix.MatrixRoomInviteEvent` event is received, and call the :meth:`.join` method if required. """ From 9e2b4a00438977ce4832084ccff6451f7feede3d Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Fri, 12 Aug 2022 15:22:04 +0200 Subject: [PATCH 10/27] Removed references to deprecated websockets attributes --- platypush/plugins/websocket/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/platypush/plugins/websocket/__init__.py b/platypush/plugins/websocket/__init__.py index 86716394a5..8999b12477 100644 --- a/platypush/plugins/websocket/__init__.py +++ b/platypush/plugins/websocket/__init__.py @@ -135,9 +135,9 @@ class WebsocketPlugin(Plugin): time_start = time.time() time_end = time_start + timeout if timeout else 0 url = 'ws{secure}://{host}:{port}{path}'.format( - secure='s' if ws.secure else '', - host=ws.host, - port=ws.port, + secure='s' if ws._secure else '', + host=ws.remote_address[0], + port=ws.remote_address[1], path=ws.path, ) From f4672ce5c331d1ade8cc31b82100b768cc2cf285 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 14 Aug 2022 00:45:29 +0200 Subject: [PATCH 11/27] Refactored concurrency model in ntfy plugin --- platypush/plugins/ntfy/__init__.py | 44 +++++++++++++++--------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/platypush/plugins/ntfy/__init__.py b/platypush/plugins/ntfy/__init__.py index 21ad1389c1..8619df7a1b 100644 --- a/platypush/plugins/ntfy/__init__.py +++ b/platypush/plugins/ntfy/__init__.py @@ -7,6 +7,7 @@ from typing import Optional, Collection, Mapping import requests import websockets +import websockets.exceptions from platypush.context import get_bus from platypush.message.event.ntfy import NotificationEvent @@ -48,23 +49,14 @@ class NtfyPlugin(RunnablePlugin): ] ) - self._event_loop: Optional[asyncio.AbstractEventLoop] = None self._subscriptions = subscriptions or [] self._ws_proc = None - def _connect(self): - if self.should_stop() or (self._ws_proc and self._ws_proc.is_alive()): - self.logger.debug('Already connected') - return - - self._ws_proc = multiprocessing.Process(target=self._ws_process) - self._ws_proc.start() - async def _get_ws_handler(self, url): reconnect_wait_secs = 1 reconnect_wait_secs_max = 60 - while True: + while not self.should_stop(): self.logger.debug(f'Connecting to {url}') try: @@ -104,30 +96,38 @@ class NtfyPlugin(RunnablePlugin): reconnect_wait_secs * 2, reconnect_wait_secs_max ) - async def _ws_processor(self, urls): - await asyncio.wait([self._get_ws_handler(url) for url in urls]) - def _ws_process(self): - self._event_loop = get_or_create_event_loop() + loop = get_or_create_event_loop() try: - self._event_loop.run_until_complete( - self._ws_processor( - {f'{self._ws_url}/{sub}/ws' for sub in self._subscriptions} + loop.run_until_complete( + asyncio.wait( + { + self._get_ws_handler(f'{self._ws_url}/{sub}/ws') + for sub in self._subscriptions + } ) ) except KeyboardInterrupt: pass def main(self): + if self.should_stop() or (self._ws_proc and self._ws_proc.is_alive()): + self.logger.debug('Already connected') + return + if self._subscriptions: - self._connect() + self._ws_proc = multiprocessing.Process(target=self._ws_process) + self._ws_proc.start() + self.wait_stop() def stop(self): - if self._ws_proc: - self._ws_proc.kill() - self._ws_proc.join() - self._ws_proc = None + if self._ws_proc and self._ws_proc.is_alive(): + self._ws_proc.terminate() + try: + self._ws_proc.join(timeout=3) + except TimeoutError: + self._ws_proc.kill() super().stop() From dba03d3e33aea8b7faf8385ad5fc8e619484f535 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 14 Aug 2022 22:30:49 +0200 Subject: [PATCH 12/27] Added AsyncRunnablePlugin class. This class handles runnable plugins that have their own asyncio event loop, without the pain usually caused by the management of multiple threads + asyncio loops. --- platypush/plugins/__init__.py | 72 +++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/platypush/plugins/__init__.py b/platypush/plugins/__init__.py index 2d75697f5f..f35f06a4e6 100644 --- a/platypush/plugins/__init__.py +++ b/platypush/plugins/__init__.py @@ -1,7 +1,9 @@ +import asyncio import logging import threading import time +from abc import ABC, abstractmethod from functools import wraps from typing import Optional @@ -117,4 +119,74 @@ class RunnablePlugin(Plugin): self._thread = None +class AsyncRunnablePlugin(RunnablePlugin, ABC): + """ + Class for runnable plugins with an asynchronous event loop attached. + """ + + def __init__(self, *args, _stop_timeout: Optional[float] = 30.0, **kwargs): + super().__init__(*args, **kwargs) + + self._stop_timeout = _stop_timeout + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._loop_runner: Optional[threading.Thread] = None + self._task: Optional[asyncio.Task] = None + + @property + def _should_start_runner(self): + return True + + @abstractmethod + async def listen(self): + pass + + async def _listen(self): + try: + await self.listen() + except KeyboardInterrupt: + pass + except RuntimeError as e: + if not ( + str(e).startswith('Event loop stopped before ') + or str(e).startswith('no running event loop') + ): + raise e + + def _start_listener(self): + set_thread_name(self.__class__.__name__ + ':listener') + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + + self._task = self._loop.create_task(self._listen()) + self._task.set_name(self.__class__.__name__ + '.listen') + self._loop.run_forever() + + def main(self): + if self.should_stop() or (self._loop_runner and self._loop_runner.is_alive()): + self.logger.info('The main loop is already being run/stopped') + return + + if self._should_start_runner: + self._loop_runner = threading.Thread(target=self._start_listener) + self._loop_runner.start() + + self.wait_stop() + + def stop(self): + if self._task and self._loop and not self._task.done(): + self._loop.call_soon_threadsafe(self._task.cancel) + + if self._loop and self._loop.is_running(): + self._loop.call_soon_threadsafe(self._loop.stop) + self._loop = None + + if self._loop_runner and self._loop_runner.is_alive(): + try: + self._loop_runner.join(timeout=self._stop_timeout) + finally: + self._loop_runner = None + + super().stop() + + # vim:sw=4:ts=4:et: From 770a14daae4e3e7f58c449a4d60162644e4345a0 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 14 Aug 2022 22:34:25 +0200 Subject: [PATCH 13/27] ntfy plugin migrated to AsyncRunnablePlugin. This commit removes a lot of the loop management boilerplate. --- platypush/plugins/ntfy/__init__.py | 55 ++++++++---------------------- 1 file changed, 14 insertions(+), 41 deletions(-) diff --git a/platypush/plugins/ntfy/__init__.py b/platypush/plugins/ntfy/__init__.py index 8619df7a1b..1d3faffbc5 100644 --- a/platypush/plugins/ntfy/__init__.py +++ b/platypush/plugins/ntfy/__init__.py @@ -1,8 +1,6 @@ import asyncio import json -import multiprocessing import os -import time from typing import Optional, Collection, Mapping import requests @@ -11,11 +9,10 @@ import websockets.exceptions from platypush.context import get_bus from platypush.message.event.ntfy import NotificationEvent -from platypush.plugins import RunnablePlugin, action -from platypush.context import get_or_create_event_loop +from platypush.plugins import AsyncRunnablePlugin, action -class NtfyPlugin(RunnablePlugin): +class NtfyPlugin(AsyncRunnablePlugin): """ Ntfy integration. @@ -50,7 +47,6 @@ class NtfyPlugin(RunnablePlugin): ) self._subscriptions = subscriptions or [] - self._ws_proc = None async def _get_ws_handler(self, url): reconnect_wait_secs = 1 @@ -60,7 +56,7 @@ class NtfyPlugin(RunnablePlugin): self.logger.debug(f'Connecting to {url}') try: - async with websockets.connect(url) as ws: + async with websockets.connect(url) as ws: # type: ignore reconnect_wait_secs = 1 self.logger.info(f'Connected to {url}') async for msg in ws: @@ -91,45 +87,22 @@ class NtfyPlugin(RunnablePlugin): ) except websockets.exceptions.WebSocketException as e: self.logger.error('Websocket error: %s', e) - time.sleep(reconnect_wait_secs) + await asyncio.sleep(reconnect_wait_secs) reconnect_wait_secs = min( reconnect_wait_secs * 2, reconnect_wait_secs_max ) - def _ws_process(self): - loop = get_or_create_event_loop() - try: - loop.run_until_complete( - asyncio.wait( - { - self._get_ws_handler(f'{self._ws_url}/{sub}/ws') - for sub in self._subscriptions - } - ) - ) - except KeyboardInterrupt: - pass + async def listen(self): + return await asyncio.wait( + [ + self._get_ws_handler(f'{self._ws_url}/{sub}/ws') + for sub in set(self._subscriptions) + ] + ) - def main(self): - if self.should_stop() or (self._ws_proc and self._ws_proc.is_alive()): - self.logger.debug('Already connected') - return - - if self._subscriptions: - self._ws_proc = multiprocessing.Process(target=self._ws_process) - self._ws_proc.start() - - self.wait_stop() - - def stop(self): - if self._ws_proc and self._ws_proc.is_alive(): - self._ws_proc.terminate() - try: - self._ws_proc.join(timeout=3) - except TimeoutError: - self._ws_proc.kill() - - super().stop() + @property + def _should_start_runner(self): + return bool(self._subscriptions) @action def send_message( From 2797ffbe53c39527011fddef9a18db545ff842ce Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 15 Aug 2022 00:14:52 +0200 Subject: [PATCH 14/27] The websocket plugin now extends AsyncRunnablePlugin too --- platypush/plugins/websocket/__init__.py | 58 ++++++++++++++++++------- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/platypush/plugins/websocket/__init__.py b/platypush/plugins/websocket/__init__.py index 8999b12477..5744c075d8 100644 --- a/platypush/plugins/websocket/__init__.py +++ b/platypush/plugins/websocket/__init__.py @@ -2,18 +2,20 @@ import asyncio import json import time -from websockets import connect as websocket_connect +from typing import Optional, Collection + +from websockets import connect as websocket_connect # type: ignore from websockets.exceptions import ConnectionClosed -from platypush.context import get_or_create_event_loop, get_bus +from platypush.context import get_bus from platypush.message.event.websocket import WebsocketMessageEvent -from platypush.plugins import Plugin, action +from platypush.plugins import AsyncRunnablePlugin, action from platypush.utils import get_ssl_client_context -class WebsocketPlugin(Plugin): +class WebsocketPlugin(AsyncRunnablePlugin): """ - Plugin to send messages over a websocket connection. + Plugin to send and receive messages over websocket connections. Triggers: @@ -22,6 +24,22 @@ class WebsocketPlugin(Plugin): """ + def __init__(self, subscriptions: Optional[Collection[str]] = None, **kwargs): + """ + :param subscriptions: List of websocket URLs that should be subscribed + at startup, prefixed by ``ws://`` or ``wss://``. + """ + super().__init__(**kwargs) + self._subscriptions = subscriptions or [] + + @property + def loop(self): + if not self._loop: + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + + return self._loop + @action def send( self, @@ -52,6 +70,8 @@ class WebsocketPlugin(Plugin): otherwise nothing. """ + msg = self._parse_msg(msg) + async def send(): websocket_args = { 'ssl': self._get_ssl_context( @@ -70,13 +90,11 @@ class WebsocketPlugin(Plugin): self.logger.warning('Error on websocket %s: %s', url, err) if wait_response: - messages = await self._ws_recv(ws, num_messages=1) + messages = await self._recv(ws, num_messages=1) if messages: return self._parse_msg(messages[0]) - msg = self._parse_msg(msg) - loop = get_or_create_event_loop() - return loop.run_until_complete(send()) + return asyncio.run_coroutine_threadsafe(send(), self.loop).result() @action def recv( @@ -123,14 +141,11 @@ class WebsocketPlugin(Plugin): } async with websocket_connect(url, **websocket_args) as ws: - return await self._ws_recv( - ws, timeout=timeout, num_messages=num_messages - ) + return await self._recv(ws, timeout=timeout, num_messages=num_messages) - loop = get_or_create_event_loop() - return loop.run_until_complete(recv()) + return self.loop.call_soon_threadsafe(recv) - async def _ws_recv(self, ws, timeout=0, num_messages=0): + async def _recv(self, ws, timeout=0, num_messages=0): messages = [] time_start = time.time() time_end = time_start + timeout if timeout else 0 @@ -166,6 +181,10 @@ class WebsocketPlugin(Plugin): return messages + @property + def _should_start_runner(self): + return bool(self._subscriptions) + @staticmethod def _parse_msg(msg): try: @@ -175,11 +194,18 @@ class WebsocketPlugin(Plugin): return msg + async def listen(self): + async def _recv(url): + async with websocket_connect(url) as ws: + return await self._recv(ws) + + await asyncio.wait([_recv(url) for url in set(self._subscriptions)]) + @staticmethod def _get_ssl_context( url: str, ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None ): - if url.startswith('wss://'): + if url.startswith('wss://') or url.startswith('https://'): return get_ssl_client_context( ssl_cert=ssl_cert, ssl_key=ssl_key, From c04bc8d2bcd49db06cb529be029a9e1d5c3522eb Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 15 Aug 2022 02:10:26 +0200 Subject: [PATCH 15/27] The matrix plugin joins the AsyncRunnablePlugin family too --- platypush/plugins/matrix/__init__.py | 244 ++++++++++++++------------- platypush/schemas/matrix.py | 10 ++ 2 files changed, 141 insertions(+), 113 deletions(-) diff --git a/platypush/plugins/matrix/__init__.py b/platypush/plugins/matrix/__init__.py index 4a03feba5b..933e2ce8bd 100644 --- a/platypush/plugins/matrix/__init__.py +++ b/platypush/plugins/matrix/__init__.py @@ -1,13 +1,13 @@ +import asyncio import datetime import json import logging -import multiprocessing import os import pathlib import re -from aiohttp import ClientConnectionError, ServerDisconnectedError from dataclasses import dataclass +from typing import Coroutine from async_lru import alru_cache from nio import ( @@ -21,6 +21,11 @@ from nio import ( InviteNameEvent, JoinedRoomsError, KeyVerificationStart, + KeyVerificationEvent, + KeyVerificationAccept, + KeyVerificationMac, + KeyVerificationKey, + KeyVerificationCancel, LoginResponse, MatrixRoom, MegolmEvent, @@ -29,12 +34,14 @@ from nio import ( RoomGetEventError, RoomGetStateError, RoomGetStateResponse, + RoomKeyRequest, RoomMemberEvent, RoomMessageText, RoomMessageMedia, RoomTopicEvent, RoomUpgradeEvent, StickerEvent, + ToDeviceEvent, UnknownEncryptedEvent, UnknownEvent, ) @@ -42,7 +49,7 @@ from nio import ( from nio.client.async_client import client_session from platypush.config import Config -from platypush.context import get_bus, get_or_create_event_loop +from platypush.context import get_bus from platypush.message.event.matrix import ( MatrixCallAnswerEvent, MatrixCallHangupEvent, @@ -59,15 +66,14 @@ from platypush.message.event.matrix import ( MatrixStickerEvent, ) -from platypush.plugins import RunnablePlugin, action +from platypush.plugins import AsyncRunnablePlugin, action from platypush.schemas.matrix import ( MatrixDeviceSchema, + MatrixEventIdSchema, MatrixProfileSchema, MatrixRoomSchema, ) -from platypush.utils import set_thread_name - logger = logging.getLogger(__name__) @@ -98,6 +104,7 @@ class MatrixClient(AsyncClient): **kwargs, ): credentials_file = os.path.abspath(os.path.expanduser(credentials_file)) + if not store_path: store_path = os.path.join(Config.get('workdir'), 'matrix', 'store') # type: ignore if store_path: @@ -115,6 +122,7 @@ class MatrixClient(AsyncClient): self.logger = logging.getLogger(self.__class__.__name__) self._credentials_file = credentials_file self._autojoin_on_invite = autojoin_on_invite + self._first_sync_performed = asyncio.Event() async def _autojoin_room_callback(self, room: MatrixRoom, *_): await self.join(room.room_id) # type: ignore @@ -197,13 +205,15 @@ class MatrixClient(AsyncClient): os.chmod(self._credentials_file, 0o600) self.logger.info('Synchronizing rooms') + self._first_sync_performed.clear() sync_token = self.loaded_sync_token self.loaded_sync_token = '' + self._add_callbacks() await self.sync(sync_filter={'room': {'timeline': {'limit': 1}}}) self.loaded_sync_token = sync_token + self._first_sync_performed.set() self.logger.info('Rooms synchronized') - self._add_callbacks() return login_res def _add_callbacks(self): @@ -221,6 +231,14 @@ class MatrixClient(AsyncClient): self.add_event_callback(self._on_unknown_encrypted_event, UnknownEncryptedEvent) # type: ignore self.add_event_callback(self._on_unknown_encrypted_event, MegolmEvent) # type: ignore self.add_to_device_callback(self._on_key_verification_start, KeyVerificationStart) # type: ignore + self.add_to_device_callback(self._on_to_device_event, RoomKeyRequest) # type: ignore + self.add_to_device_callback(self._on_to_device_event, ToDeviceEvent) + self.add_to_device_callback(self._on_to_device_event, KeyVerificationStart) + self.add_to_device_callback(self._on_to_device_event, KeyVerificationKey) + self.add_to_device_callback(self._on_to_device_event, KeyVerificationMac) + self.add_to_device_callback(self._on_to_device_event, KeyVerificationAccept) + self.add_to_device_callback(self._on_to_device_event, KeyVerificationCancel) + self.add_to_device_callback(self._on_to_device_event, KeyVerificationEvent) if self._autojoin_on_invite: self.add_event_callback(self._autojoin_room_callback, InviteNameEvent) # type: ignore @@ -283,12 +301,13 @@ class MatrixClient(AsyncClient): ) async def _on_room_message(self, room: MatrixRoom, event: RoomMessageText): - get_bus().post( - MatrixMessageEvent( - **(await self._event_base_args(room, event)), - body=event.body, + if self._first_sync_performed.is_set(): + get_bus().post( + MatrixMessageEvent( + **(await self._event_base_args(room, event)), + body=event.body, + ) ) - ) async def _on_room_member(self, room: MatrixRoom, event: RoomMemberEvent): evt_type = None @@ -297,7 +316,7 @@ class MatrixClient(AsyncClient): elif event.membership == 'leave': evt_type = MatrixRoomLeaveEvent - if evt_type: + if evt_type and self._first_sync_performed.is_set(): get_bus().post( evt_type( **(await self._event_base_args(room, event)), @@ -305,42 +324,46 @@ class MatrixClient(AsyncClient): ) async def _on_room_topic_changed(self, room: MatrixRoom, event: RoomTopicEvent): - get_bus().post( - MatrixRoomTopicChangedEvent( - **(await self._event_base_args(room, event)), - topic=event.topic, + if self._first_sync_performed.is_set(): + get_bus().post( + MatrixRoomTopicChangedEvent( + **(await self._event_base_args(room, event)), + topic=event.topic, + ) ) - ) async def _on_call_invite(self, room: MatrixRoom, event: CallInviteEvent): - get_bus().post( - MatrixCallInviteEvent( - call_id=event.call_id, - version=event.version, - invite_validity=event.lifetime / 1000.0, - sdp=event.offer.get('sdp'), - **(await self._event_base_args(room, event)), + if self._first_sync_performed.is_set(): + get_bus().post( + MatrixCallInviteEvent( + call_id=event.call_id, + version=event.version, + invite_validity=event.lifetime / 1000.0, + sdp=event.offer.get('sdp'), + **(await self._event_base_args(room, event)), + ) ) - ) async def _on_call_answer(self, room: MatrixRoom, event: CallAnswerEvent): - get_bus().post( - MatrixCallAnswerEvent( - call_id=event.call_id, - version=event.version, - sdp=event.answer.get('sdp'), - **(await self._event_base_args(room, event)), + if self._first_sync_performed.is_set(): + get_bus().post( + MatrixCallAnswerEvent( + call_id=event.call_id, + version=event.version, + sdp=event.answer.get('sdp'), + **(await self._event_base_args(room, event)), + ) ) - ) async def _on_call_hangup(self, room: MatrixRoom, event: CallHangupEvent): - get_bus().post( - MatrixCallHangupEvent( - call_id=event.call_id, - version=event.version, - **(await self._event_base_args(room, event)), + if self._first_sync_performed.is_set(): + get_bus().post( + MatrixCallHangupEvent( + call_id=event.call_id, + version=event.version, + **(await self._event_base_args(room, event)), + ) ) - ) async def _on_room_created(self, room: MatrixRoom, event: RoomCreateEvent): get_bus().post( @@ -350,30 +373,33 @@ class MatrixClient(AsyncClient): ) async def _on_media_message(self, room: MatrixRoom, event: RoomMessageMedia): - get_bus().post( - MatrixMediaMessageEvent( - url=event.url, - **(await self._event_base_args(room, event)), + if self._first_sync_performed.is_set(): + get_bus().post( + MatrixMediaMessageEvent( + url=event.url, + **(await self._event_base_args(room, event)), + ) ) - ) async def _on_sticker_message(self, room: MatrixRoom, event: StickerEvent): - get_bus().post( - MatrixStickerEvent( - url=event.url, - **(await self._event_base_args(room, event)), + if self._first_sync_performed.is_set(): + get_bus().post( + MatrixStickerEvent( + url=event.url, + **(await self._event_base_args(room, event)), + ) ) - ) def _on_key_verification_start(self, event: KeyVerificationStart): assert self.olm, 'OLM state machine not initialized' - print('************ HERE') - print(event) self.olm.handle_key_verification(event) + def _on_to_device_event(self, event: ToDeviceEvent): + pass # TODO + async def _on_room_upgrade(self, room: MatrixRoom, event: RoomUpgradeEvent): self.logger.info( - 'The room %s has been upgraded to %s', room.room_id, event.replacement_room + 'The room %s has been moved to %s', room.room_id, event.replacement_room ) await self.room_leave(room.room_id) @@ -393,7 +419,7 @@ class MatrixClient(AsyncClient): async def _on_unknown_event(self, room: MatrixRoom, event: UnknownEvent): evt = None - if event.type == 'm.reaction': + if event.type == 'm.reaction' and self._first_sync_performed.is_set(): # Get the ID of the event this was a reaction to relation_dict = event.source.get('content', {}).get('m.relates_to', {}) reacted_to = relation_dict.get('event_id') @@ -418,7 +444,7 @@ class MatrixClient(AsyncClient): ) -class MatrixPlugin(RunnablePlugin): +class MatrixPlugin(AsyncRunnablePlugin): """ Matrix chat integration. @@ -493,16 +519,16 @@ class MatrixPlugin(RunnablePlugin): if user_id and not re.match(user_id, '^@[a-zA-Z0-9.-_]+:.+'): user_id = f'@{user_id}:{server_name}' - self._matrix_proc: multiprocessing.Process | None = None + # self._matrix_proc: multiprocessing.Process | None = None self._user_id = user_id self._password = password self._access_token = access_token self._device_name = device_name self._device_id = device_id self._autojoin_on_invite = autojoin_on_invite - self._event_loop = get_or_create_event_loop() self._workdir = os.path.join(Config.get('workdir'), 'matrix') # type: ignore self._credentials_file = os.path.join(self._workdir, 'credentials.json') + self._processed_responses = {} self._client = self._get_client() pathlib.Path(self._workdir).mkdir(parents=True, exist_ok=True) @@ -515,50 +541,42 @@ class MatrixPlugin(RunnablePlugin): device_id=self._device_id, ) - def _login(self) -> AsyncClient: + async def _login(self) -> AsyncClient: if not self._client: self._client = self._get_client() - self._event_loop.run_until_complete( - self._client.login( - password=self._password, - device_name=self._device_name, - token=self._access_token, - ) + await self._client.login( + password=self._password, + device_name=self._device_name, + token=self._access_token, ) return self._client - def _connect(self): - if self.should_stop() or (self._matrix_proc and self._matrix_proc.is_alive()): - self.logger.debug('Already connected') - return + async def listen(self): + while not self.should_stop(): + await self._login() + assert self._client - self._login() - self._matrix_proc = multiprocessing.Process(target=self._run_client) - self._matrix_proc.start() - - async def _run_async_client(self): - await self._client.sync_forever(timeout=0, full_state=True) - - def _run_client(self): - set_thread_name('matrix-client') - - while True: try: - self._event_loop.run_until_complete(self._run_async_client()) - except (ClientConnectionError, ServerDisconnectedError): - self.logger.warning( - 'Cannot connect to the Matrix server. Retrying in 15s' - ) - self._should_stop.wait(15) + await self._client.sync_forever(timeout=30000, full_state=True) except KeyboardInterrupt: pass finally: - if self._client: - self._event_loop.run_until_complete(self._client.close()) - self._matrix_proc = None - self._connect() + try: + await self._client.close() + finally: + self._client = None + + def _loop_execute(self, coro: Coroutine): + assert self._loop, 'The loop is not running' + ret = asyncio.run_coroutine_threadsafe(coro, self._loop).result() + + if hasattr(ret, 'transport_response'): + response = ret.transport_response + assert response.ok, f'{coro} failed with status {response.status}' + + return ret @action def send_message( @@ -579,11 +597,14 @@ class MatrixPlugin(RunnablePlugin): :param tx_id: Unique transaction ID to associate to this message. :param ignore_unverified_devices: If true, unverified devices will be ignored (default: False). + :return: .. schema:: matrix.MatrixEventIdSchema """ - message_type = 'm.' + message_type - return self._event_loop.run_until_complete( + assert self._client, 'Client not connected' + assert self._loop, 'The loop is not running' + + ret = self._loop_execute( self._client.room_send( - message_type=message_type, + message_type='m.' + message_type, room_id=room_id, tx_id=tx_id, ignore_unverified_devices=ignore_unverified_devices, @@ -593,6 +614,12 @@ class MatrixPlugin(RunnablePlugin): ) ) + ret = asyncio.run_coroutine_threadsafe( + ret.transport_response.json(), self._loop + ).result() + + return MatrixEventIdSchema().dump(ret) + @action def get_profile(self, user_id: str): """ @@ -601,8 +628,9 @@ class MatrixPlugin(RunnablePlugin): :param user_id: User ID. :return: .. schema:: matrix.MatrixProfileSchema """ - profile = self._event_loop.run_until_complete(self._client.get_profile(user_id)) # type: ignore - profile.user_id = user_id # type: ignore + assert self._client, 'Client not connected' + profile = self._loop_execute(self._client.get_profile(user_id)) + profile.user_id = user_id return MatrixProfileSchema().dump(profile) @action @@ -613,10 +641,10 @@ class MatrixPlugin(RunnablePlugin): :param room_id: room ID. :return: .. schema:: matrix.MatrixRoomSchema """ - response = self._event_loop.run_until_complete( - self._client.room_get_state(room_id) - ) + assert self._client, 'Client not connected' + response = self._loop_execute(self._client.room_get_state(room_id)) assert not isinstance(response, RoomGetStateError), response.message + room_args = {'room_id': room_id, 'own_user_id': None, 'encrypted': False} room_params = {} @@ -642,7 +670,8 @@ class MatrixPlugin(RunnablePlugin): :return: .. schema:: matrix.MatrixDeviceSchema(many=True) """ - response = self._event_loop.run_until_complete(self._client.devices()) + assert self._client, 'Client not connected' + response = self._loop_execute(self._client.devices()) assert not isinstance(response, DevicesError), response.message return MatrixDeviceSchema().dump(response.devices, many=True) @@ -651,30 +680,19 @@ class MatrixPlugin(RunnablePlugin): """ Retrieve the rooms that the user has joined. """ - response = self._event_loop.run_until_complete(self._client.joined_rooms()) + assert self._client, 'Client not connected' + response = self._loop_execute(self._client.joined_rooms()) assert not isinstance(response, JoinedRoomsError), response.message - return [self.get_room(room_id).output for room_id in response.rooms] + return [self.get_room(room_id).output for room_id in response.rooms] # type: ignore @action def upload_keys(self): """ Synchronize the E2EE keys with the homeserver. """ - self._event_loop.run_until_complete(self._client.keys_upload()) - - def main(self): - self._connect() - self.wait_stop() - - def stop(self): - if self._matrix_proc: - self._matrix_proc.terminate() - self._matrix_proc.join(timeout=10) - self._matrix_proc.kill() - self._matrix_proc = None - - super().stop() + assert self._client, 'Client not connected' + self._loop_execute(self._client.keys_upload()) # vim:sw=4:ts=4:et: diff --git a/platypush/schemas/matrix.py b/platypush/schemas/matrix.py index 3cda4ebbf3..aa7279f5aa 100644 --- a/platypush/schemas/matrix.py +++ b/platypush/schemas/matrix.py @@ -4,6 +4,16 @@ from marshmallow.schema import Schema from platypush.schemas import DateTime +class MatrixEventIdSchema(Schema): + event_id = fields.String( + required=True, + metadata={ + 'description': 'Event ID', + 'example': '$24KT_aQz6sSKaZH8oTCibRTl62qywDgQXMpz5epXsW5', + }, + ) + + class MatrixProfileSchema(Schema): user_id = fields.String( required=True, From 05908e1a77e7a8def6fdbac4ce0312110368ba42 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Wed, 17 Aug 2022 10:28:31 +0200 Subject: [PATCH 16/27] Fixing key verification process --- platypush/plugins/matrix/__init__.py | 102 +++++++++++++++++++++------ 1 file changed, 82 insertions(+), 20 deletions(-) diff --git a/platypush/plugins/matrix/__init__.py b/platypush/plugins/matrix/__init__.py index 933e2ce8bd..9129576236 100644 --- a/platypush/plugins/matrix/__init__.py +++ b/platypush/plugins/matrix/__init__.py @@ -21,11 +21,11 @@ from nio import ( InviteNameEvent, JoinedRoomsError, KeyVerificationStart, - KeyVerificationEvent, KeyVerificationAccept, KeyVerificationMac, KeyVerificationKey, KeyVerificationCancel, + LocalProtocolError, LoginResponse, MatrixRoom, MegolmEvent, @@ -34,19 +34,19 @@ from nio import ( RoomGetEventError, RoomGetStateError, RoomGetStateResponse, - RoomKeyRequest, RoomMemberEvent, RoomMessageText, RoomMessageMedia, RoomTopicEvent, RoomUpgradeEvent, StickerEvent, - ToDeviceEvent, + ToDeviceError, UnknownEncryptedEvent, UnknownEvent, ) from nio.client.async_client import client_session +from nio.exceptions import OlmUnverifiedDeviceError from platypush.config import Config from platypush.context import get_bus @@ -169,10 +169,6 @@ class MatrixClient(AsyncClient): self.user_id, ) - if self.should_upload_keys: - self.logger.info('Uploading encryption keys') - await self.keys_upload() - login_res = LoginResponse( user_id=self.user_id, device_id=self.device_id, @@ -204,12 +200,16 @@ class MatrixClient(AsyncClient): json.dump(credentials.to_dict(), f) os.chmod(self._credentials_file, 0o600) + if self.should_upload_keys: + self.logger.info('Uploading encryption keys') + await self.keys_upload() + self.logger.info('Synchronizing rooms') self._first_sync_performed.clear() sync_token = self.loaded_sync_token self.loaded_sync_token = '' - self._add_callbacks() await self.sync(sync_filter={'room': {'timeline': {'limit': 1}}}) + self._add_callbacks() self.loaded_sync_token = sync_token self._first_sync_performed.set() @@ -231,14 +231,10 @@ class MatrixClient(AsyncClient): self.add_event_callback(self._on_unknown_encrypted_event, UnknownEncryptedEvent) # type: ignore self.add_event_callback(self._on_unknown_encrypted_event, MegolmEvent) # type: ignore self.add_to_device_callback(self._on_key_verification_start, KeyVerificationStart) # type: ignore - self.add_to_device_callback(self._on_to_device_event, RoomKeyRequest) # type: ignore - self.add_to_device_callback(self._on_to_device_event, ToDeviceEvent) - self.add_to_device_callback(self._on_to_device_event, KeyVerificationStart) - self.add_to_device_callback(self._on_to_device_event, KeyVerificationKey) - self.add_to_device_callback(self._on_to_device_event, KeyVerificationMac) - self.add_to_device_callback(self._on_to_device_event, KeyVerificationAccept) - self.add_to_device_callback(self._on_to_device_event, KeyVerificationCancel) - self.add_to_device_callback(self._on_to_device_event, KeyVerificationEvent) + self.add_to_device_callback(self._on_key_verification_cancel, KeyVerificationCancel) # type: ignore + self.add_to_device_callback(self._on_key_verification_key, KeyVerificationKey) # type: ignore + self.add_to_device_callback(self._on_key_verification_mac, KeyVerificationMac) # type: ignore + self.add_to_device_callback(self._on_key_verification_accept, KeyVerificationAccept) # type: ignore if self._autojoin_on_invite: self.add_event_callback(self._autojoin_room_callback, InviteNameEvent) # type: ignore @@ -390,12 +386,75 @@ class MatrixClient(AsyncClient): ) ) - def _on_key_verification_start(self, event: KeyVerificationStart): + async def _on_key_verification_start(self, event: KeyVerificationStart): assert self.olm, 'OLM state machine not initialized' self.olm.handle_key_verification(event) + self.logger.info(f'Received a key verification request from {event.sender}') - def _on_to_device_event(self, event: ToDeviceEvent): - pass # TODO + if 'emoji' not in event.short_authentication_string: + self.logger.warning( + 'Only emoji verification is supported, but the verifying device ' + 'provided the following authentication methods: %r', + event.short_authentication_string, + ) + return + + rs = await self.accept_key_verification(event.transaction_id) + assert not isinstance( + rs, ToDeviceError + ), f'accept_key_verification failed: {rs}' + + sas = self.key_verifications[event.transaction_id] + rs = await self.to_device(sas.share_key()) + assert not isinstance(rs, ToDeviceError), f'Shared key exchange failed: {rs}' + + async def _on_key_verification_accept(self, event: KeyVerificationAccept): + self.logger.info('Key verification from device %s accepted', event.sender) + + async def _on_key_verification_cancel(self, event: KeyVerificationCancel): + self.logger.info( + 'The device %s cancelled a key verification request. ' 'Reason: %s', + event.sender, + event.reason, + ) + + async def _on_key_verification_key(self, event: KeyVerificationKey): + sas = self.key_verifications[event.transaction_id] + self.logger.info( + 'Received emoji verification from device %s: %s', + event.sender, + sas.get_emoji(), + ) + + # TODO Support user interaction instead of blindly confirming? + # await asyncio.sleep(5) + print('***** SENDING AUTH STRING') + rs = await self.confirm_short_auth_string(event.transaction_id) + assert not isinstance( + rs, ToDeviceError + ), f'confirm_short_auth_string failed: {rs}' + + async def _on_key_verification_mac(self, event: KeyVerificationMac): + self.logger.info('Received MAC verification request from %s', event.sender) + sas = self.key_verifications[event.transaction_id] + + try: + mac = sas.get_mac() + except LocalProtocolError as e: + self.logger.warning( + 'Verification from %s cancelled or unexpected protocol error. ' + 'Reason: %s', + e, + event.sender, + ) + return + + rs = await self.to_device(mac) + assert not isinstance( + rs, ToDeviceError + ), f'Sending of the verification MAC to {event.sender} failed: {rs}' + + self.logger.info('This device has been successfully verified!') async def _on_room_upgrade(self, room: MatrixRoom, event: RoomUpgradeEvent): self.logger.info( @@ -570,7 +629,10 @@ class MatrixPlugin(AsyncRunnablePlugin): def _loop_execute(self, coro: Coroutine): assert self._loop, 'The loop is not running' - ret = asyncio.run_coroutine_threadsafe(coro, self._loop).result() + try: + ret = asyncio.run_coroutine_threadsafe(coro, self._loop).result() + except OlmUnverifiedDeviceError as e: + raise AssertionError(str(e)) if hasattr(ret, 'transport_response'): response = ret.transport_response From c89c71292858da796b81ff68fd2cdc99962391fd Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Wed, 24 Aug 2022 01:49:43 +0200 Subject: [PATCH 17/27] Fixed device trust process --- platypush/plugins/matrix/__init__.py | 56 +++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/platypush/plugins/matrix/__init__.py b/platypush/plugins/matrix/__init__.py index 9129576236..0ad35ef32e 100644 --- a/platypush/plugins/matrix/__init__.py +++ b/platypush/plugins/matrix/__init__.py @@ -386,9 +386,16 @@ class MatrixClient(AsyncClient): ) ) + def _get_sas(self, event): + sas = self.key_verifications.get(event.transaction_id) + if not sas: + self.logger.debug( + 'Received a key verification event with no associated transaction ID' + ) + + return sas + async def _on_key_verification_start(self, event: KeyVerificationStart): - assert self.olm, 'OLM state machine not initialized' - self.olm.handle_key_verification(event) self.logger.info(f'Received a key verification request from {event.sender}') if 'emoji' not in event.short_authentication_string: @@ -399,12 +406,15 @@ class MatrixClient(AsyncClient): ) return - rs = await self.accept_key_verification(event.transaction_id) + sas = self._get_sas(event) + if not sas: + return + + rs = await self.accept_key_verification(sas.transaction_id) assert not isinstance( rs, ToDeviceError ), f'accept_key_verification failed: {rs}' - sas = self.key_verifications[event.transaction_id] rs = await self.to_device(sas.share_key()) assert not isinstance(rs, ToDeviceError), f'Shared key exchange failed: {rs}' @@ -419,24 +429,26 @@ class MatrixClient(AsyncClient): ) async def _on_key_verification_key(self, event: KeyVerificationKey): - sas = self.key_verifications[event.transaction_id] + sas = self._get_sas(event) + if not sas: + return + self.logger.info( 'Received emoji verification from device %s: %s', event.sender, sas.get_emoji(), ) - # TODO Support user interaction instead of blindly confirming? - # await asyncio.sleep(5) - print('***** SENDING AUTH STRING') - rs = await self.confirm_short_auth_string(event.transaction_id) + rs = await self.confirm_short_auth_string(sas.transaction_id) assert not isinstance( rs, ToDeviceError ), f'confirm_short_auth_string failed: {rs}' async def _on_key_verification_mac(self, event: KeyVerificationMac): self.logger.info('Received MAC verification request from %s', event.sender) - sas = self.key_verifications[event.transaction_id] + sas = self._get_sas(event) + if not sas: + return try: mac = sas.get_mac() @@ -517,6 +529,30 @@ class MatrixPlugin(AsyncRunnablePlugin): Note that ``libolm`` and the ``[e2e]`` module are only required if you want E2E encryption support. + Unless you configure the extension to use the token of an existing trusted + device, it is recommended that you mark the virtual device used by this + integration as trusted through a device that is already trusted. You may + encounter errors when sending or receiving messages on encrypted rooms if + your user has some untrusted devices. The easiest way to mark the device as + trusted is the following: + + - Configure the integration with your credentials and start Platypush. + - Use the same credentials to log in through a Matrix app or web client + (Element, Hydrogen, etc.) that has already been trusted. + - You should see a notification that prompts you to review the + untrusted devices logged in to your account. Dismiss it for now - + that verification path is currently broken on the underlying library + used by this integration. + - Instead, select a room that you have already joined, select the list + of users in the room and select yourself. + - In the _Security_ section, you should see that at least one device is + marked as unverified, and you can start the verification process by + clicking on it. + - Select "_Verify through emoji_". A list of emojis should be prompted. + Optionally, verify the logs of the application to check that you see + the same list. Then confirm that you see the same emojis, and your + device will be automatically marked as trusted. + Triggers: * :class:`platypush.message.event.matrix.MatrixMessageEvent`: when a message is received. From 550f026e13bf363844981aad5d45a7e42c21b2da Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 25 Aug 2022 00:30:53 +0200 Subject: [PATCH 18/27] Cleaner logging for assertion errors in plugin actions --- platypush/message/request/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/platypush/message/request/__init__.py b/platypush/message/request/__init__.py index 46f52452be..7b895fdf33 100644 --- a/platypush/message/request/__init__.py +++ b/platypush/message/request/__init__.py @@ -293,9 +293,8 @@ class Request(Message): ) ) except (AssertionError, TimeoutError) as e: - plugin.logger.exception(e) logger.warning( - '{} from action [{}]: {}'.format(type(e), action, str(e)) + '%s from action [%s]: %s', e.__class__.__name__, action, str(e) ) response = Response(output=None, errors=[str(e)]) except Exception as e: From e4eb4cd7dcd0604ac31a934f6e8ab98e315a20fd Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 25 Aug 2022 00:34:01 +0200 Subject: [PATCH 19/27] More granular control over trusted devices, and added global synchronization event --- platypush/message/event/matrix.py | 10 +- platypush/plugins/matrix/__init__.py | 278 ++++++++++++++++++++----- platypush/plugins/matrix/manifest.yaml | 2 + platypush/schemas/matrix.py | 40 ++++ 4 files changed, 274 insertions(+), 56 deletions(-) diff --git a/platypush/message/event/matrix.py b/platypush/message/event/matrix.py index bc3e6fdb06..f6e7a3ac0c 100644 --- a/platypush/message/event/matrix.py +++ b/platypush/message/event/matrix.py @@ -1,11 +1,10 @@ -from abc import ABC from datetime import datetime from typing import Dict, Any from platypush.message.event import Event -class MatrixEvent(Event, ABC): +class MatrixEvent(Event): """ Base matrix event. """ @@ -55,6 +54,13 @@ class MatrixEvent(Event, ABC): super().__init__(*args, **evt_args, **kwargs) +class MatrixSyncEvent(MatrixEvent): + """ + Event triggered when the startup synchronization has been completed and the + plugin is ready to use. + """ + + class MatrixMessageEvent(MatrixEvent): """ Event triggered when a message is received on a subscribed room. diff --git a/platypush/plugins/matrix/__init__.py b/platypush/plugins/matrix/__init__.py index 0ad35ef32e..a673ae204f 100644 --- a/platypush/plugins/matrix/__init__.py +++ b/platypush/plugins/matrix/__init__.py @@ -7,7 +7,7 @@ import pathlib import re from dataclasses import dataclass -from typing import Coroutine +from typing import Collection, Coroutine, Dict from async_lru import alru_cache from nio import ( @@ -46,6 +46,7 @@ from nio import ( ) from nio.client.async_client import client_session +from nio.crypto.device import OlmDevice from nio.exceptions import OlmUnverifiedDeviceError from platypush.config import Config @@ -64,12 +65,14 @@ from platypush.message.event.matrix import ( MatrixRoomLeaveEvent, MatrixRoomTopicChangedEvent, MatrixStickerEvent, + MatrixSyncEvent, ) from platypush.plugins import AsyncRunnablePlugin, action from platypush.schemas.matrix import ( MatrixDeviceSchema, MatrixEventIdSchema, + MatrixMyDeviceSchema, MatrixProfileSchema, MatrixRoomSchema, ) @@ -101,6 +104,10 @@ class MatrixClient(AsyncClient): store_path: str | None = None, config: AsyncClientConfig | None = None, autojoin_on_invite=True, + autotrust_devices=False, + autotrust_devices_whitelist: Collection[str] | None = None, + autotrust_rooms_whitelist: Collection[str] | None = None, + autotrust_users_whitelist: Collection[str] | None = None, **kwargs, ): credentials_file = os.path.abspath(os.path.expanduser(credentials_file)) @@ -122,6 +129,10 @@ class MatrixClient(AsyncClient): self.logger = logging.getLogger(self.__class__.__name__) self._credentials_file = credentials_file self._autojoin_on_invite = autojoin_on_invite + self._autotrust_devices = autotrust_devices + self._autotrust_devices_whitelist = autotrust_devices_whitelist + self._autotrust_rooms_whitelist = autotrust_rooms_whitelist or set() + self._autotrust_users_whitelist = autotrust_users_whitelist or set() self._first_sync_performed = asyncio.Event() async def _autojoin_room_callback(self, room: MatrixRoom, *_): @@ -204,18 +215,104 @@ class MatrixClient(AsyncClient): self.logger.info('Uploading encryption keys') await self.keys_upload() - self.logger.info('Synchronizing rooms') + self.logger.info('Synchronizing state') self._first_sync_performed.clear() sync_token = self.loaded_sync_token self.loaded_sync_token = '' await self.sync(sync_filter={'room': {'timeline': {'limit': 1}}}) - self._add_callbacks() - self.loaded_sync_token = sync_token + + self._add_callbacks() + self._sync_devices_trust() self._first_sync_performed.set() - self.logger.info('Rooms synchronized') + + get_bus().post(MatrixSyncEvent(server_url=self.homeserver)) + self.logger.info('State synchronized') return login_res + def _sync_devices_trust(self): + all_devices = self.get_devices() + devices_to_trust: Dict[str, OlmDevice] = {} + untrusted_devices = { + device_id: device + for device_id, device in all_devices.items() + if not device.verified + } + + if self._autotrust_devices: + devices_to_trust.update(untrusted_devices) + else: + if self._autotrust_devices_whitelist: + devices_to_trust.update( + { + device_id: device + for device_id, device in all_devices.items() + if device_id in self._autotrust_devices_whitelist + and device_id in untrusted_devices + } + ) + if self._autotrust_rooms_whitelist: + devices_to_trust.update( + { + device_id: device + for room_id, devices in self.get_devices_by_room().items() + for device_id, device in devices.items() # type: ignore + if room_id in self._autotrust_rooms_whitelist + and device_id in untrusted_devices + } + ) + if self._autotrust_users_whitelist: + devices_to_trust.update( + { + device_id: device + for user_id, devices in self.get_devices_by_user().items() + for device_id, device in devices.items() # type: ignore + if user_id in self._autotrust_users_whitelist + and device_id in untrusted_devices + } + ) + + for device in devices_to_trust.values(): + self.verify_device(device) + self.logger.info( + 'Device %s by user %s added to the whitelist', device.id, device.user_id + ) + + def get_devices_by_user( + self, user_id: str | None = None + ) -> Dict[str, Dict[str, OlmDevice]] | Dict[str, OlmDevice]: + devices = {user: devices for user, devices in self.device_store.items()} + + if user_id: + devices = devices.get(user_id, {}) + return devices + + def get_devices(self) -> Dict[str, OlmDevice]: + return { + device_id: device + for _, devices in self.device_store.items() + for device_id, device in devices.items() + } + + def get_device(self, device_id: str) -> OlmDevice | None: + return self.get_devices().get(device_id) + + def get_devices_by_room( + self, room_id: str | None = None + ) -> Dict[str, Dict[str, OlmDevice]] | Dict[str, OlmDevice]: + devices = { + room_id: { + device_id: device + for _, devices in self.room_devices(room_id).items() + for device_id, device in devices.items() + } + for room_id in self.rooms.keys() + } + + if room_id: + devices = devices.get(room_id, {}) + return devices + def _add_callbacks(self): self.add_event_callback(self._event_catch_all, Event) self.add_event_callback(self._on_invite, InviteNameEvent) # type: ignore @@ -526,8 +623,8 @@ class MatrixPlugin(AsyncRunnablePlugin): ``pacman -S libolm``) * **async_lru** (``pip install async_lru``) - Note that ``libolm`` and the ``[e2e]`` module are only required if you want E2E encryption - support. + Note that ``libolm`` and the ``[e2e]`` module are only required if you want + E2E encryption support. Unless you configure the extension to use the token of an existing trusted device, it is recommended that you mark the virtual device used by this @@ -555,19 +652,34 @@ class MatrixPlugin(AsyncRunnablePlugin): Triggers: - * :class:`platypush.message.event.matrix.MatrixMessageEvent`: when a message is received. - * :class:`platypush.message.event.matrix.MatrixMediaMessageEvent`: when a media message is received. - * :class:`platypush.message.event.matrix.MatrixRoomCreatedEvent`: when a room is created. - * :class:`platypush.message.event.matrix.MatrixRoomJoinEvent`: when a user joins a room. - * :class:`platypush.message.event.matrix.MatrixRoomLeaveEvent`: when a user leaves a room. - * :class:`platypush.message.event.matrix.MatrixRoomInviteEvent`: when the user is invited to a room. - * :class:`platypush.message.event.matrix.MatrixRoomTopicChangedEvent`: when the topic/title of a room changes. - * :class:`platypush.message.event.matrix.MatrixCallInviteEvent`: when the user is invited to a call. - * :class:`platypush.message.event.matrix.MatrixCallAnswerEvent`: when a called user wishes to pick the call. - * :class:`platypush.message.event.matrix.MatrixCallHangupEvent`: when a called user exits the call. - * :class:`platypush.message.event.matrix.MatrixStickerEvent`: when a sticker is sent to a room. - * :class:`platypush.message.event.matrix.MatrixEncryptedMessageEvent`: when a message is received but the - client doesn't have the E2E keys to decrypt it, or encryption has not been enabled. + * :class:`platypush.message.event.matrix.MatrixMessageEvent`: when a + message is received. + * :class:`platypush.message.event.matrix.MatrixMediaMessageEvent`: when + a media message is received. + * :class:`platypush.message.event.matrix.MatrixSyncEvent`: when the + startup synchronization has been completed and the plugin is ready to + use. + * :class:`platypush.message.event.matrix.MatrixRoomCreatedEvent`: when + a room is created. + * :class:`platypush.message.event.matrix.MatrixRoomJoinEvent`: when a + user joins a room. + * :class:`platypush.message.event.matrix.MatrixRoomLeaveEvent`: when a + user leaves a room. + * :class:`platypush.message.event.matrix.MatrixRoomInviteEvent`: when + the user is invited to a room. + * :class:`platypush.message.event.matrix.MatrixRoomTopicChangedEvent`: + when the topic/title of a room changes. + * :class:`platypush.message.event.matrix.MatrixCallInviteEvent`: when + the user is invited to a call. + * :class:`platypush.message.event.matrix.MatrixCallAnswerEvent`: when a + called user wishes to pick the call. + * :class:`platypush.message.event.matrix.MatrixCallHangupEvent`: when a + called user exits the call. + * :class:`platypush.message.event.matrix.MatrixStickerEvent`: when a + sticker is sent to a room. + * :class:`platypush.message.event.matrix.MatrixEncryptedMessageEvent`: + when a message is received but the client doesn't have the E2E keys + to decrypt it, or encryption has not been enabled. """ @@ -580,6 +692,10 @@ class MatrixPlugin(AsyncRunnablePlugin): device_name: str | None = 'platypush', device_id: str | None = None, autojoin_on_invite: bool = True, + autotrust_devices: bool = False, + autotrust_devices_whitelist: Collection[str] | None = None, + autotrust_users_whitelist: Collection[str] | None = None, + autotrust_rooms_whitelist: Collection[str] | None = None, **kwargs, ): """ @@ -594,16 +710,31 @@ class MatrixPlugin(AsyncRunnablePlugin): the user has 2FA enabled. :param server_url: Default Matrix instance base URL (default: ``https://matrix.to``). - :param user_id: user_id, in the format ``@user:example.org``, or just the username if the - account is hosted on the same server configured in the ``server_url``. + :param user_id: user_id, in the format ``@user:example.org``, or just + the username if the account is hosted on the same server configured in + the ``server_url``. :param password: User password. :param access_token: User access token. :param device_name: The name of this device/connection (default: ``platypush``). :param device_id: Use an existing ``device_id`` for the sessions. - :param autojoin_on_invite: Whether the account should automatically join rooms - upon invite. If false, then you may want to implement your own - logic in an event hook when a :class:`platypush.message.event.matrix.MatrixRoomInviteEvent` - event is received, and call the :meth:`.join` method if required. + :param autojoin_on_invite: Whether the account should automatically + join rooms upon invite. If false, then you may want to implement your + own logic in an event hook when a + :class:`platypush.message.event.matrix.MatrixRoomInviteEvent` event is + received, and call the :meth:`.join` method if required. + :param autotrust_devices: If set to True, the plugin will automatically + trust the devices on encrypted rooms. Set this property to True + only if you only plan to use a bot on trusted rooms. Note that if + no automatic trust mechanism is set you may need to explicitly + create your logic for trusting users - either with a hook when + :class:`platypush.message.event.matrix.MatrixSyncEvent` is + received, or when a room is joined, or before sending a message. + :param autotrust_devices_whitelist: Automatically trust devices with IDs + IDs provided in this list. + :param autotrust_users_whitelist: Automatically trust devices from the + user IDs provided in this list. + :param autotrust_rooms_whitelist: Automatically trust devices on the + room IDs provided in this list. """ super().__init__(**kwargs) if not (server_url.startswith('http://') or server_url.startswith('https://')): @@ -614,57 +745,67 @@ class MatrixPlugin(AsyncRunnablePlugin): if user_id and not re.match(user_id, '^@[a-zA-Z0-9.-_]+:.+'): user_id = f'@{user_id}:{server_name}' - # self._matrix_proc: multiprocessing.Process | None = None self._user_id = user_id self._password = password self._access_token = access_token self._device_name = device_name self._device_id = device_id self._autojoin_on_invite = autojoin_on_invite + self._autotrust_devices = autotrust_devices + self._autotrust_devices_whitelist = set(autotrust_devices_whitelist or []) + self._autotrust_users_whitelist = set(autotrust_users_whitelist or []) + self._autotrust_rooms_whitelist = set(autotrust_rooms_whitelist or []) self._workdir = os.path.join(Config.get('workdir'), 'matrix') # type: ignore self._credentials_file = os.path.join(self._workdir, 'credentials.json') self._processed_responses = {} self._client = self._get_client() pathlib.Path(self._workdir).mkdir(parents=True, exist_ok=True) - def _get_client(self) -> AsyncClient: + def _get_client(self) -> MatrixClient: return MatrixClient( homeserver=self._server_url, user=self._user_id, credentials_file=self._credentials_file, autojoin_on_invite=self._autojoin_on_invite, + autotrust_devices=self._autotrust_devices, + autotrust_devices_whitelist=self._autotrust_devices_whitelist, + autotrust_rooms_whitelist=self._autotrust_rooms_whitelist, + autotrust_users_whitelist=self._autotrust_users_whitelist, device_id=self._device_id, ) - async def _login(self) -> AsyncClient: + @property + def client(self) -> MatrixClient: if not self._client: self._client = self._get_client() + return self._client - await self._client.login( + async def _login(self) -> MatrixClient: + await self.client.login( password=self._password, device_name=self._device_name, token=self._access_token, ) - return self._client + return self.client async def listen(self): while not self.should_stop(): await self._login() - assert self._client try: - await self._client.sync_forever(timeout=30000, full_state=True) + await self.client.sync_forever(timeout=30000, full_state=True) except KeyboardInterrupt: pass finally: try: - await self._client.close() + await self.client.close() finally: self._client = None def _loop_execute(self, coro: Coroutine): assert self._loop, 'The loop is not running' + try: ret = asyncio.run_coroutine_threadsafe(coro, self._loop).result() except OlmUnverifiedDeviceError as e: @@ -694,24 +835,25 @@ class MatrixPlugin(AsyncRunnablePlugin): `image`. Default: `text`. :param tx_id: Unique transaction ID to associate to this message. :param ignore_unverified_devices: If true, unverified devices will be - ignored (default: False). + ignored. Otherwise, if the room is encrypted and it contains + devices that haven't been marked as trusted, the message + delivery may fail (default: False). :return: .. schema:: matrix.MatrixEventIdSchema """ - assert self._client, 'Client not connected' - assert self._loop, 'The loop is not running' - ret = self._loop_execute( - self._client.room_send( - message_type='m.' + message_type, + self.client.room_send( + message_type='m.room.message', room_id=room_id, tx_id=tx_id, ignore_unverified_devices=ignore_unverified_devices, content={ + 'msgtype': 'm.' + message_type, 'body': body, }, ) ) + assert self._loop ret = asyncio.run_coroutine_threadsafe( ret.transport_response.json(), self._loop ).result() @@ -726,8 +868,7 @@ class MatrixPlugin(AsyncRunnablePlugin): :param user_id: User ID. :return: .. schema:: matrix.MatrixProfileSchema """ - assert self._client, 'Client not connected' - profile = self._loop_execute(self._client.get_profile(user_id)) + profile = self._loop_execute(self.client.get_profile(user_id)) # type: ignore profile.user_id = user_id return MatrixProfileSchema().dump(profile) @@ -739,8 +880,7 @@ class MatrixPlugin(AsyncRunnablePlugin): :param room_id: room ID. :return: .. schema:: matrix.MatrixRoomSchema """ - assert self._client, 'Client not connected' - response = self._loop_execute(self._client.room_get_state(room_id)) + response = self._loop_execute(self.client.room_get_state(room_id)) # type: ignore assert not isinstance(response, RoomGetStateError), response.message room_args = {'room_id': room_id, 'own_user_id': None, 'encrypted': False} @@ -762,26 +902,32 @@ class MatrixPlugin(AsyncRunnablePlugin): return MatrixRoomSchema().dump(room) @action - def get_devices(self): + def get_my_devices(self): """ Get the list of devices associated to the current user. - :return: .. schema:: matrix.MatrixDeviceSchema(many=True) + :return: .. schema:: matrix.MatrixMyDeviceSchema(many=True) """ - assert self._client, 'Client not connected' - response = self._loop_execute(self._client.devices()) + response = self._loop_execute(self.client.devices()) assert not isinstance(response, DevicesError), response.message - return MatrixDeviceSchema().dump(response.devices, many=True) + return MatrixMyDeviceSchema().dump(response.devices, many=True) + + @action + def get_device(self, device_id: str): + """ + Get the info about a device given its ID. + + :return: .. schema:: matrix.MatrixDeviceSchema + """ + return MatrixDeviceSchema().dump(self._get_device(device_id)) @action def get_joined_rooms(self): """ Retrieve the rooms that the user has joined. """ - assert self._client, 'Client not connected' - response = self._loop_execute(self._client.joined_rooms()) + response = self._loop_execute(self.client.joined_rooms()) assert not isinstance(response, JoinedRoomsError), response.message - return [self.get_room(room_id).output for room_id in response.rooms] # type: ignore @action @@ -789,8 +935,32 @@ class MatrixPlugin(AsyncRunnablePlugin): """ Synchronize the E2EE keys with the homeserver. """ - assert self._client, 'Client not connected' - self._loop_execute(self._client.keys_upload()) + self._loop_execute(self.client.keys_upload()) + + def _get_device(self, device_id: str): + device = self.client.get_device(device_id) + assert device, f'No such device_id: {device_id}' + return device + + @action + def trust_device(self, device_id: str): + """ + Mark a device as trusted. + + :param device_id: Device ID. + """ + device = self._get_device(device_id) + self.client.verify_device(device) + + @action + def untrust_device(self, device_id: str): + """ + Mark a device as untrusted. + + :param device_id: Device ID. + """ + device = self._get_device(device_id) + self.client.unverify_device(device) # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/matrix/manifest.yaml b/platypush/plugins/matrix/manifest.yaml index d564aaaf1f..3f0b5e5b2d 100644 --- a/platypush/plugins/matrix/manifest.yaml +++ b/platypush/plugins/matrix/manifest.yaml @@ -2,6 +2,8 @@ manifest: events: platypush.message.event.matrix.MatrixMessageEvent: when a message is received. platypush.message.event.matrix.MatrixMediaMessageEvent: when a media message is received. + platypush.message.event.matrix.MatrixSyncEvent: when the startup + synchronization has been completed and the plugin is ready to use. platypush.message.event.matrix.MatrixRoomCreatedEvent: when a room is created. platypush.message.event.matrix.MatrixRoomJoinEvent: when a user joins a room. platypush.message.event.matrix.MatrixRoomLeaveEvent: when a user leaves a room. diff --git a/platypush/schemas/matrix.py b/platypush/schemas/matrix.py index aa7279f5aa..b28a6cbf29 100644 --- a/platypush/schemas/matrix.py +++ b/platypush/schemas/matrix.py @@ -97,6 +97,46 @@ class MatrixDeviceSchema(Schema): }, ) + user_id = fields.String( + required=True, + metadata={ + 'description': 'User ID associated to the device', + 'example': '@myuser:matrix.example.org', + }, + ) + + display_name = fields.String( + metadata={ + 'description': 'Display name of the device', + 'example': 'Element Android', + }, + ) + + blacklisted = fields.Boolean() + deleted = fields.Boolean(default=False) + ignored = fields.Boolean() + verified = fields.Boolean() + + keys = fields.Dict( + metadata={ + 'description': 'Encryption keys supported by the device', + 'example': { + 'curve25519': 'BtlB0vaQmtYFsvOYkmxyzw9qP5yGjuAyRh4gXh3q', + 'ed25519': 'atohIK2FeVlYoY8xxpZ1bhDbveD+HA2DswNFqUxP', + }, + }, + ) + + +class MatrixMyDeviceSchema(Schema): + device_id = fields.String( + required=True, + attribute='id', + metadata={ + 'description': 'ABCDEFG', + }, + ) + display_name = fields.String( metadata={ 'description': 'Device display name', From 48ec6ef68b1df988c69d6d81ba2aa35dde7d227b Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Fri, 26 Aug 2022 23:48:29 +0200 Subject: [PATCH 20/27] Implemented proper support for encrypted media and added download method --- platypush/message/event/matrix.py | 54 ++++- platypush/plugins/matrix/__init__.py | 299 ++++++++++++++++++++----- platypush/plugins/matrix/manifest.yaml | 10 +- platypush/schemas/matrix.py | 34 ++- 4 files changed, 330 insertions(+), 67 deletions(-) diff --git a/platypush/message/event/matrix.py b/platypush/message/event/matrix.py index f6e7a3ac0c..71032e3945 100644 --- a/platypush/message/event/matrix.py +++ b/platypush/message/event/matrix.py @@ -66,28 +66,58 @@ class MatrixMessageEvent(MatrixEvent): Event triggered when a message is received on a subscribed room. """ - def __init__(self, *args, body: str, **kwargs): + def __init__( + self, + *args, + body: str = '', + url: str | None = None, + thumbnail_url: str | None = None, + mimetype: str | None = None, + formatted_body: str | None = None, + format: str | None = None, + **kwargs + ): """ :param body: The body of the message. + :param url: The URL of the media file, if the message includes media. + :param thumbnail_url: The URL of the thumbnail, if the message includes media. + :param mimetype: The MIME type of the media file, if the message includes media. + :param formatted_body: The formatted body, if ``format`` is specified. + :param format: The format of the message (e.g. ``html`` or ``markdown``). """ - super().__init__(*args, body=body, **kwargs) + super().__init__( + *args, + body=body, + url=url, + thumbnail_url=thumbnail_url, + mimetype=mimetype, + formatted_body=formatted_body, + format=format, + **kwargs + ) -class MatrixMediaMessageEvent(MatrixMessageEvent): +class MatrixMessageImageEvent(MatrixEvent): """ - Event triggered when a media message is received on a subscribed room. + Event triggered when a message containing an image is received. """ - def __init__(self, *args, url: str, **kwargs): - """ - :param url: The URL of the media file. - """ - super().__init__(*args, url=url, **kwargs) - -class MatrixStickerEvent(MatrixMediaMessageEvent): +class MatrixMessageFileEvent(MatrixEvent): """ - Event triggered when a sticker is sent to a room. + Event triggered when a message containing a generic file is received. + """ + + +class MatrixMessageAudioEvent(MatrixEvent): + """ + Event triggered when a message containing an audio file is received. + """ + + +class MatrixMessageVideoEvent(MatrixEvent): + """ + Event triggered when a message containing a video file is received. """ diff --git a/platypush/plugins/matrix/__init__.py b/platypush/plugins/matrix/__init__.py index a673ae204f..2df1eba087 100644 --- a/platypush/plugins/matrix/__init__.py +++ b/platypush/plugins/matrix/__init__.py @@ -5,21 +5,23 @@ import logging import os import pathlib import re +import threading from dataclasses import dataclass from typing import Collection, Coroutine, Dict +from urllib.parse import urlparse from async_lru import alru_cache from nio import ( + Api, AsyncClient, AsyncClientConfig, CallAnswerEvent, CallHangupEvent, CallInviteEvent, - DevicesError, + ErrorResponse, Event, InviteNameEvent, - JoinedRoomsError, KeyVerificationStart, KeyVerificationAccept, KeyVerificationMac, @@ -31,12 +33,21 @@ from nio import ( MegolmEvent, ProfileGetResponse, RoomCreateEvent, + RoomEncryptedAudio, + RoomEncryptedFile, + RoomEncryptedImage, + RoomEncryptedMedia, + RoomEncryptedVideo, RoomGetEventError, - RoomGetStateError, RoomGetStateResponse, RoomMemberEvent, + RoomMessageAudio, + RoomMessageFile, + RoomMessageFormatted, RoomMessageText, + RoomMessageImage, RoomMessageMedia, + RoomMessageVideo, RoomTopicEvent, RoomUpgradeEvent, StickerEvent, @@ -46,8 +57,10 @@ from nio import ( ) from nio.client.async_client import client_session +from nio.crypto import decrypt_attachment from nio.crypto.device import OlmDevice from nio.exceptions import OlmUnverifiedDeviceError +from nio.responses import DownloadResponse from platypush.config import Config from platypush.context import get_bus @@ -56,21 +69,24 @@ from platypush.message.event.matrix import ( MatrixCallHangupEvent, MatrixCallInviteEvent, MatrixEncryptedMessageEvent, - MatrixMediaMessageEvent, + MatrixMessageAudioEvent, MatrixMessageEvent, + MatrixMessageFileEvent, + MatrixMessageImageEvent, + MatrixMessageVideoEvent, MatrixReactionEvent, MatrixRoomCreatedEvent, MatrixRoomInviteEvent, MatrixRoomJoinEvent, MatrixRoomLeaveEvent, MatrixRoomTopicChangedEvent, - MatrixStickerEvent, MatrixSyncEvent, ) from platypush.plugins import AsyncRunnablePlugin, action from platypush.schemas.matrix import ( MatrixDeviceSchema, + MatrixDownloadedFileSchema, MatrixEventIdSchema, MatrixMyDeviceSchema, MatrixProfileSchema, @@ -114,9 +130,11 @@ class MatrixClient(AsyncClient): if not store_path: store_path = os.path.join(Config.get('workdir'), 'matrix', 'store') # type: ignore - if store_path: - store_path = os.path.abspath(os.path.expanduser(store_path)) - pathlib.Path(store_path).mkdir(exist_ok=True, parents=True) + + assert store_path + store_path = os.path.abspath(os.path.expanduser(store_path)) + pathlib.Path(store_path).mkdir(exist_ok=True, parents=True) + if not config: config = AsyncClientConfig( max_limit_exceeded=0, @@ -135,6 +153,27 @@ class MatrixClient(AsyncClient): self._autotrust_users_whitelist = autotrust_users_whitelist or set() self._first_sync_performed = asyncio.Event() + self._encrypted_attachments_keystore_path = os.path.join( + store_path, 'attachment_keys.json' + ) + self._encrypted_attachments_keystore = {} + self._sync_store_timer: threading.Timer | None = None + keystore = {} + + try: + with open(self._encrypted_attachments_keystore_path, 'r') as f: + keystore = json.load(f) + except (ValueError, OSError): + with open(self._encrypted_attachments_keystore_path, 'w') as f: + f.write(json.dumps({})) + + pathlib.Path(self._encrypted_attachments_keystore_path).touch( + mode=0o600, exist_ok=True + ) + self._encrypted_attachments_keystore = { + tuple(key.split('|')): data for key, data in keystore.items() + } + async def _autojoin_room_callback(self, room: MatrixRoom, *_): await self.join(room.room_id) # type: ignore @@ -217,12 +256,12 @@ class MatrixClient(AsyncClient): self.logger.info('Synchronizing state') self._first_sync_performed.clear() + self._add_callbacks() sync_token = self.loaded_sync_token self.loaded_sync_token = '' await self.sync(sync_filter={'room': {'timeline': {'limit': 1}}}) self.loaded_sync_token = sync_token - self._add_callbacks() self._sync_devices_trust() self._first_sync_performed.set() @@ -316,14 +355,15 @@ class MatrixClient(AsyncClient): def _add_callbacks(self): self.add_event_callback(self._event_catch_all, Event) self.add_event_callback(self._on_invite, InviteNameEvent) # type: ignore - self.add_event_callback(self._on_room_message, RoomMessageText) # type: ignore - self.add_event_callback(self._on_media_message, RoomMessageMedia) # type: ignore + self.add_event_callback(self._on_message, RoomMessageText) # type: ignore + self.add_event_callback(self._on_message, RoomMessageMedia) # type: ignore + self.add_event_callback(self._on_message, RoomEncryptedMedia) # type: ignore + self.add_event_callback(self._on_message, StickerEvent) # type: ignore self.add_event_callback(self._on_room_member, RoomMemberEvent) # type: ignore self.add_event_callback(self._on_room_topic_changed, RoomTopicEvent) # type: ignore self.add_event_callback(self._on_call_invite, CallInviteEvent) # type: ignore self.add_event_callback(self._on_call_answer, CallAnswerEvent) # type: ignore self.add_event_callback(self._on_call_hangup, CallHangupEvent) # type: ignore - self.add_event_callback(self._on_sticker_message, StickerEvent) # type: ignore self.add_event_callback(self._on_unknown_event, UnknownEvent) # type: ignore self.add_event_callback(self._on_unknown_encrypted_event, UnknownEncryptedEvent) # type: ignore self.add_event_callback(self._on_unknown_encrypted_event, MegolmEvent) # type: ignore @@ -336,6 +376,24 @@ class MatrixClient(AsyncClient): if self._autojoin_on_invite: self.add_event_callback(self._autojoin_room_callback, InviteNameEvent) # type: ignore + def _sync_store(self): + self.logger.info('Synchronizing keystore') + serialized_keystore = json.dumps( + { + f'{server}|{media_id}': data + for ( + server, + media_id, + ), data in self._encrypted_attachments_keystore.items() + } + ) + + try: + with open(self._encrypted_attachments_keystore_path, 'w') as f: + f.write(serialized_keystore) + finally: + self._sync_store_timer = None + @alru_cache(maxsize=500) @client_session async def get_profile(self, user_id: str | None = None) -> ProfileGetResponse: @@ -360,6 +418,39 @@ class MatrixClient(AsyncClient): ), f'Could not retrieve profile for room {room_id}: {ret.message}' return ret + async def download( + self, + server_name: str, + media_id: str, + filename: str | None = None, + allow_remote: bool = True, + ): + response = await super().download( + server_name, media_id, filename, allow_remote=allow_remote + ) + + assert isinstance( + response, DownloadResponse + ), f'Could not download media {media_id}: {response}' + + encryption_data = self._encrypted_attachments_keystore.get( + (server_name, media_id) + ) + if encryption_data: + self.logger.info('Decrypting media %s using the available keys', media_id) + response.filename = encryption_data.get('body', response.filename) + response.content_type = encryption_data.get( + 'mimetype', response.content_type + ) + response.body = decrypt_attachment( + response.body, + key=encryption_data.get('key'), + hash=encryption_data.get('hash'), + iv=encryption_data.get('iv'), + ) + + return response + async def _event_base_args( self, room: MatrixRoom, event: Event | None = None ) -> dict: @@ -393,14 +484,60 @@ class MatrixClient(AsyncClient): ) ) - async def _on_room_message(self, room: MatrixRoom, event: RoomMessageText): + async def _on_message( + self, + room: MatrixRoom, + event: RoomMessageText | RoomMessageMedia | RoomEncryptedMedia | StickerEvent, + ): if self._first_sync_performed.is_set(): - get_bus().post( - MatrixMessageEvent( - **(await self._event_base_args(room, event)), - body=event.body, - ) - ) + evt_type = MatrixMessageEvent + evt_args = { + 'body': event.body, + 'url': getattr(event, 'url', None), + **(await self._event_base_args(room, event)), + } + + if isinstance(event, (RoomMessageMedia, RoomEncryptedMedia, StickerEvent)): + evt_args['url'] = event.url + + if isinstance(event, RoomEncryptedMedia): + evt_args['thumbnail_url'] = event.thumbnail_url + evt_args['mimetype'] = event.mimetype + self._store_encrypted_media_keys(event) + if isinstance(event, RoomMessageFormatted): + evt_args['format'] = event.format + evt_args['formatted_body'] = event.formatted_body + + if isinstance(event, (RoomMessageImage, RoomEncryptedImage)): + evt_type = MatrixMessageImageEvent + elif isinstance(event, (RoomMessageAudio, RoomEncryptedAudio)): + evt_type = MatrixMessageAudioEvent + elif isinstance(event, (RoomMessageVideo, RoomEncryptedVideo)): + evt_type = MatrixMessageVideoEvent + elif isinstance(event, (RoomMessageFile, RoomEncryptedFile)): + evt_type = MatrixMessageFileEvent + + get_bus().post(evt_type(**evt_args)) + + def _store_encrypted_media_keys(self, event: RoomEncryptedMedia): + url = event.url.strip('/') + parsed_url = urlparse(url) + homeserver = parsed_url.netloc.strip('/') + media_key = (homeserver, parsed_url.path.strip('/')) + + self._encrypted_attachments_keystore[media_key] = { + 'url': url, + 'body': event.body, + 'key': event.key['k'], + 'hash': event.hashes['sha256'], + 'iv': event.iv, + 'homeserver': homeserver, + 'mimetype': event.mimetype, + } + + if not self._sync_store_timer: + self._sync_store_timer = threading.Timer(5, self._sync_store) + self._sync_store_timer.start() async def _on_room_member(self, room: MatrixRoom, event: RoomMemberEvent): evt_type = None @@ -465,24 +602,6 @@ class MatrixClient(AsyncClient): ) ) - async def _on_media_message(self, room: MatrixRoom, event: RoomMessageMedia): - if self._first_sync_performed.is_set(): - get_bus().post( - MatrixMediaMessageEvent( - url=event.url, - **(await self._event_base_args(room, event)), - ) - ) - - async def _on_sticker_message(self, room: MatrixRoom, event: StickerEvent): - if self._first_sync_performed.is_set(): - get_bus().post( - MatrixStickerEvent( - url=event.url, - **(await self._event_base_args(room, event)), - ) - ) - def _get_sas(self, event): sas = self.key_verifications.get(event.transaction_id) if not sas: @@ -650,12 +769,23 @@ class MatrixPlugin(AsyncRunnablePlugin): the same list. Then confirm that you see the same emojis, and your device will be automatically marked as trusted. + All the URLs returned by actions and events on this plugin are in the + ``mxc:///`` format. You can either convert them to HTTP + through the :meth:`.mxc_to_http` method, or download them through the + :meth:`.download` method. + Triggers: * :class:`platypush.message.event.matrix.MatrixMessageEvent`: when a message is received. - * :class:`platypush.message.event.matrix.MatrixMediaMessageEvent`: when - a media message is received. + * :class:`platypush.message.event.matrix.MatrixMessageImageEvent`: when a + message containing an image is received. + * :class:`platypush.message.event.matrix.MatrixMessageAudioEvent`: when a + message containing an audio file is received. + * :class:`platypush.message.event.matrix.MatrixMessageVideoEvent`: when a + message containing a video file is received. + * :class:`platypush.message.event.matrix.MatrixMessageFileEvent`: when a + message containing a generic file is received. * :class:`platypush.message.event.matrix.MatrixSyncEvent`: when the startup synchronization has been completed and the plugin is ready to use. @@ -675,8 +805,6 @@ class MatrixPlugin(AsyncRunnablePlugin): called user wishes to pick the call. * :class:`platypush.message.event.matrix.MatrixCallHangupEvent`: when a called user exits the call. - * :class:`platypush.message.event.matrix.MatrixStickerEvent`: when a - sticker is sent to a room. * :class:`platypush.message.event.matrix.MatrixEncryptedMessageEvent`: when a message is received but the client doesn't have the E2E keys to decrypt it, or encryption has not been enabled. @@ -691,6 +819,7 @@ class MatrixPlugin(AsyncRunnablePlugin): access_token: str | None = None, device_name: str | None = 'platypush', device_id: str | None = None, + download_path: str | None = None, autojoin_on_invite: bool = True, autotrust_devices: bool = False, autotrust_devices_whitelist: Collection[str] | None = None, @@ -717,6 +846,8 @@ class MatrixPlugin(AsyncRunnablePlugin): :param access_token: User access token. :param device_name: The name of this device/connection (default: ``platypush``). :param device_id: Use an existing ``device_id`` for the sessions. + :param download_path: The folder where downloaded media will be saved + (default: ``~/Downloads``). :param autojoin_on_invite: Whether the account should automatically join rooms upon invite. If false, then you may want to implement your own logic in an event hook when a @@ -750,6 +881,10 @@ class MatrixPlugin(AsyncRunnablePlugin): self._access_token = access_token self._device_name = device_name self._device_id = device_id + self._download_path = download_path or os.path.join( + os.path.expanduser('~'), 'Downloads' + ) + self._autojoin_on_invite = autojoin_on_invite self._autotrust_devices = autotrust_devices self._autotrust_devices_whitelist = set(autotrust_devices_whitelist or []) @@ -797,6 +932,8 @@ class MatrixPlugin(AsyncRunnablePlugin): await self.client.sync_forever(timeout=30000, full_state=True) except KeyboardInterrupt: pass + except Exception as e: + self.logger.exception(e) finally: try: await self.client.close() @@ -811,6 +948,7 @@ class MatrixPlugin(AsyncRunnablePlugin): except OlmUnverifiedDeviceError as e: raise AssertionError(str(e)) + assert not isinstance(ret, ErrorResponse), ret.message if hasattr(ret, 'transport_response'): response = ret.transport_response assert response.ok, f'{coro} failed with status {response.status}' @@ -853,11 +991,7 @@ class MatrixPlugin(AsyncRunnablePlugin): ) ) - assert self._loop - ret = asyncio.run_coroutine_threadsafe( - ret.transport_response.json(), self._loop - ).result() - + ret = self._loop_execute(ret.transport_response.json()) return MatrixEventIdSchema().dump(ret) @action @@ -881,8 +1015,6 @@ class MatrixPlugin(AsyncRunnablePlugin): :return: .. schema:: matrix.MatrixRoomSchema """ response = self._loop_execute(self.client.room_get_state(room_id)) # type: ignore - assert not isinstance(response, RoomGetStateError), response.message - room_args = {'room_id': room_id, 'own_user_id': None, 'encrypted': False} room_params = {} @@ -909,7 +1041,6 @@ class MatrixPlugin(AsyncRunnablePlugin): :return: .. schema:: matrix.MatrixMyDeviceSchema(many=True) """ response = self._loop_execute(self.client.devices()) - assert not isinstance(response, DevicesError), response.message return MatrixMyDeviceSchema().dump(response.devices, many=True) @action @@ -927,7 +1058,6 @@ class MatrixPlugin(AsyncRunnablePlugin): Retrieve the rooms that the user has joined. """ response = self._loop_execute(self.client.joined_rooms()) - assert not isinstance(response, JoinedRoomsError), response.message return [self.get_room(room_id).output for room_id in response.rooms] # type: ignore @action @@ -937,7 +1067,7 @@ class MatrixPlugin(AsyncRunnablePlugin): """ self._loop_execute(self.client.keys_upload()) - def _get_device(self, device_id: str): + def _get_device(self, device_id: str) -> OlmDevice: device = self.client.get_device(device_id) assert device, f'No such device_id: {device_id}' return device @@ -962,5 +1092,72 @@ class MatrixPlugin(AsyncRunnablePlugin): device = self._get_device(device_id) self.client.unverify_device(device) + @action + def mxc_to_http(self, url: str, homeserver: str | None = None) -> str: + """ + Convert a Matrix URL (in the format ``mxc://server/media_id``) to an + HTTP URL. + + Note that invoking this function on a URL containing encrypted content + (i.e. a URL containing media sent to an encrypted room) will provide a + URL that points to encrypted content. The best way to deal with + encrypted media is by using :meth:`.download` to download the media + locally. + + :param url: The MXC URL to be converted. + :param homeserver: The hosting homeserver (default: the same as the URL). + :return: The converted HTTP(s) URL. + """ + http_url = Api.mxc_to_http(url, homeserver=homeserver) + assert http_url, f'Could not convert URL {url}' + return http_url + + @action + def download( + self, + url: str, + download_path: str | None = None, + filename: str | None = None, + allow_remote=True, + ): + """ + Download a file given its Matrix URL. + + Note that URLs that point to encrypted resources will be automatically + decrypted only if they were received on a room joined by this account. + + :param url: Matrix URL, in the format + :return: .. schema:: matrix.MatrixDownloadedFileSchema + """ + parsed_url = urlparse(url) + server = parsed_url.netloc.strip('/') + media_id = parsed_url.path.strip('/') + + response = self._loop_execute( + self.client.download( + server, media_id, filename=filename, allow_remote=allow_remote + ) + ) + + if not download_path: + download_path = self._download_path + if not filename: + filename = response.filename or media_id + + outfile = os.path.join(str(download_path), str(filename)) + pathlib.Path(download_path).mkdir(parents=True, exist_ok=True) + + with open(outfile, 'wb') as f: + f.write(response.body) + + return MatrixDownloadedFileSchema().dump( + { + 'url': url, + 'path': outfile, + 'size': len(response.body), + 'content_type': response.content_type, + } + ) + # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/matrix/manifest.yaml b/platypush/plugins/matrix/manifest.yaml index 3f0b5e5b2d..1b8b2fc920 100644 --- a/platypush/plugins/matrix/manifest.yaml +++ b/platypush/plugins/matrix/manifest.yaml @@ -1,7 +1,14 @@ manifest: events: platypush.message.event.matrix.MatrixMessageEvent: when a message is received. - platypush.message.event.matrix.MatrixMediaMessageEvent: when a media message is received. + platypush.message.event.matrix.MatrixMessageImageEvent: when a message + containing an image is received. + platypush.message.event.matrix.MatrixMessageAudioEvent: when a message + containing an audio file is received. + platypush.message.event.matrix.MatrixMessageVideoEvent: when a message + containing a video file is received. + platypush.message.event.matrix.MatrixMessageFileEvent: when a message + containing a generic file is received. platypush.message.event.matrix.MatrixSyncEvent: when the startup synchronization has been completed and the plugin is ready to use. platypush.message.event.matrix.MatrixRoomCreatedEvent: when a room is created. @@ -12,7 +19,6 @@ manifest: platypush.message.event.matrix.MatrixCallInviteEvent: when the user is invited to a call. platypush.message.event.matrix.MatrixCallAnswerEvent: when a called user wishes to pick the call. platypush.message.event.matrix.MatrixCallHangupEvent: when a called user exits the call. - platypush.message.event.matrix.MatrixStickerEvent: when a sticker is sent to a room. platypush.message.event.matrix.MatrixEncryptedMessageEvent: | when a message is received but the client doesn't have the E2E keys to decrypt it, or encryption has not been enabled. diff --git a/platypush/schemas/matrix.py b/platypush/schemas/matrix.py index b28a6cbf29..ae9fe679f4 100644 --- a/platypush/schemas/matrix.py +++ b/platypush/schemas/matrix.py @@ -34,7 +34,7 @@ class MatrixProfileSchema(Schema): avatar_url = fields.URL( metadata={ 'description': 'User avatar URL', - 'example': 'mxc://matrix.platypush.tech/AbCdEfG0123456789', + 'example': 'mxc://matrix.example.org/AbCdEfG0123456789', } ) @@ -73,7 +73,7 @@ class MatrixRoomSchema(Schema): attribute='room_avatar_url', metadata={ 'description': 'Room avatar URL', - 'example': 'mxc://matrix.platypush.tech/AbCdEfG0123456789', + 'example': 'mxc://matrix.example.org/AbCdEfG0123456789', }, ) @@ -157,3 +157,33 @@ class MatrixMyDeviceSchema(Schema): 'example': '2022-07-23T17:20:01.254223', } ) + + +class MatrixDownloadedFileSchema(Schema): + url = fields.String( + metadata={ + 'description': 'Matrix URL of the original resource', + 'example': 'mxc://matrix.example.org/YhQycHvFOvtiDDbEeWWtEhXx', + }, + ) + + path = fields.String( + metadata={ + 'description': 'Local path where the file has been saved', + 'example': '/home/user/Downloads/image.png', + } + ) + + content_type = fields.String( + metadata={ + 'description': 'Content type of the downloaded file', + 'example': 'image/png', + } + ) + + size = fields.Int( + metadata={ + 'description': 'Length in bytes of the output file', + 'example': 1024, + } + ) From 513195b396b634880feaaa4af5dc4e4e3e5e8277 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sat, 27 Aug 2022 15:12:50 +0200 Subject: [PATCH 21/27] Implemented support for file upload --- docs/source/conf.py | 2 + platypush/plugins/matrix/__init__.py | 132 ++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 5 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 0009767951..9bbdb852a7 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -292,6 +292,8 @@ autodoc_mock_imports = [ 'irc.events', 'defusedxml', 'nio', + 'aiofiles', + 'aiofiles.os', ] sys.path.insert(0, os.path.abspath('../..')) diff --git a/platypush/plugins/matrix/__init__.py b/platypush/plugins/matrix/__init__.py index 2df1eba087..9483cecd03 100644 --- a/platypush/plugins/matrix/__init__.py +++ b/platypush/plugins/matrix/__init__.py @@ -56,6 +56,9 @@ from nio import ( UnknownEvent, ) +import aiofiles +import aiofiles.os + from nio.client.async_client import client_session from nio.crypto import decrypt_attachment from nio.crypto.device import OlmDevice @@ -93,6 +96,8 @@ from platypush.schemas.matrix import ( MatrixRoomSchema, ) +from platypush.utils import get_mime_type + logger = logging.getLogger(__name__) @@ -730,6 +735,27 @@ class MatrixClient(AsyncClient): 'Received an unknown event on room %s: %r', room.room_id, event ) + async def upload_file( + self, + file: str, + name: str | None = None, + content_type: str | None = None, + encrypt: bool = False, + ): + file = os.path.expanduser(file) + file_stat = await aiofiles.os.stat(file) + + async with aiofiles.open(file, 'rb') as f: + return await super().upload( + f, # type: ignore + content_type=( + content_type or get_mime_type(file) or 'application/octet-stream' + ), + filename=name or os.path.basename(file), + encrypt=encrypt, + filesize=file_stat.st_size, + ) + class MatrixPlugin(AsyncRunnablePlugin): """ @@ -955,12 +981,65 @@ class MatrixPlugin(AsyncRunnablePlugin): return ret + def _process_local_attachment(self, attachment: str, room_id: str) -> dict: + attachment = os.path.expanduser(attachment) + assert os.path.isfile(attachment), f'{attachment} is not a valid file' + + filename = os.path.basename(attachment) + mime_type = get_mime_type(attachment) or 'application/octet-stream' + message_type = mime_type.split('/')[0] + if message_type not in {'audio', 'video', 'image'}: + message_type = 'text' + + encrypted = self.get_room(room_id).output.get('encrypted', False) # type: ignore + url = self.upload( + attachment, name=filename, content_type=mime_type, encrypt=encrypted + ).output # type: ignore + + return { + 'url': url, + 'msgtype': 'm.' + message_type, + 'body': filename, + 'info': { + 'size': os.stat(attachment).st_size, + 'mimetype': mime_type, + }, + } + + def _process_remote_attachment(self, attachment: str) -> dict: + parsed_url = urlparse(attachment) + server = parsed_url.netloc.strip('/') + media_id = parsed_url.path.strip('/') + + response = self._loop_execute(self.client.download(server, media_id)) + + content_type = response.content_type + message_type = content_type.split('/')[0] + if message_type not in {'audio', 'video', 'image'}: + message_type = 'text' + + return { + 'url': attachment, + 'msgtype': 'm.' + message_type, + 'body': response.filename, + 'info': { + 'size': len(response.body), + 'mimetype': content_type, + }, + } + + def _process_attachment(self, attachment: str, room_id: str): + if attachment.startswith('mxc://'): + return self._process_remote_attachment(attachment) + return self._process_local_attachment(attachment, room_id=room_id) + @action def send_message( self, room_id: str, message_type: str = 'text', body: str | None = None, + attachment: str | None = None, tx_id: str | None = None, ignore_unverified_devices: bool = False, ): @@ -969,6 +1048,12 @@ class MatrixPlugin(AsyncRunnablePlugin): :param room_id: Room ID. :param body: Message body. + :param attachment: Path to a local file to send as an attachment, or + URL of an existing Matrix media ID in the format + ``mxc:///``. If the attachment is a local file, + the file will be automatically uploaded, ``message_type`` will be + automatically inferred from the file and the ``body`` will be + replaced by the filename. :param message_type: Message type. Supported: `text`, `audio`, `video`, `image`. Default: `text`. :param tx_id: Unique transaction ID to associate to this message. @@ -978,16 +1063,21 @@ class MatrixPlugin(AsyncRunnablePlugin): delivery may fail (default: False). :return: .. schema:: matrix.MatrixEventIdSchema """ + content = { + 'msgtype': 'm.' + message_type, + 'body': body, + } + + if attachment: + content.update(self._process_attachment(attachment, room_id=room_id)) + ret = self._loop_execute( self.client.room_send( message_type='m.room.message', room_id=room_id, tx_id=tx_id, ignore_unverified_devices=ignore_unverified_devices, - content={ - 'msgtype': 'm.' + message_type, - 'body': body, - }, + content=content, ) ) @@ -1126,7 +1216,14 @@ class MatrixPlugin(AsyncRunnablePlugin): Note that URLs that point to encrypted resources will be automatically decrypted only if they were received on a room joined by this account. - :param url: Matrix URL, in the format + :param url: Matrix URL, in the format ``mxc:///``. + :param download_path: Override the default ``download_path`` (output + directory for the downloaded file). + :param filename: Name of the output file (default: inferred from the + remote resource). + :param allow_remote: Indicates to the server that it should not attempt + to fetch the media if it is deemed remote. This is to prevent + routing loops where the server contacts itself. :return: .. schema:: matrix.MatrixDownloadedFileSchema """ parsed_url = urlparse(url) @@ -1159,5 +1256,30 @@ class MatrixPlugin(AsyncRunnablePlugin): } ) + @action + def upload( + self, + file: str, + name: str | None = None, + content_type: str | None = None, + encrypt: bool = False, + ) -> str: + """ + Upload a file to the server. + + :param file: Path to the file to upload. + :param name: Filename to be used for the remote file (default: same as + the local file). + :param content_type: Specify a content type for the file (default: + inferred from the file's extension and content). + :param encrypt: Encrypt the file (default: False). + :return: The Matrix URL of the uploaded resource. + """ + rs = self._loop_execute( + self.client.upload_file(file, name, content_type, encrypt) + ) + + return rs[0].content_uri + # vim:sw=4:ts=4:et: From 912168626ce3ada5a3d5d4c82fee5321bc9d29ce Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sat, 27 Aug 2022 21:50:48 +0200 Subject: [PATCH 22/27] Added join_room, leave_room and invite_to_room and extended handling on invitation events --- platypush/plugins/matrix/__init__.py | 34 +++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/platypush/plugins/matrix/__init__.py b/platypush/plugins/matrix/__init__.py index 9483cecd03..aeb77fa4d0 100644 --- a/platypush/plugins/matrix/__init__.py +++ b/platypush/plugins/matrix/__init__.py @@ -21,7 +21,7 @@ from nio import ( CallInviteEvent, ErrorResponse, Event, - InviteNameEvent, + InviteEvent, KeyVerificationStart, KeyVerificationAccept, KeyVerificationMac, @@ -359,7 +359,7 @@ class MatrixClient(AsyncClient): def _add_callbacks(self): self.add_event_callback(self._event_catch_all, Event) - self.add_event_callback(self._on_invite, InviteNameEvent) # type: ignore + self.add_event_callback(self._on_invite, InviteEvent) # type: ignore self.add_event_callback(self._on_message, RoomMessageText) # type: ignore self.add_event_callback(self._on_message, RoomMessageMedia) # type: ignore self.add_event_callback(self._on_message, RoomEncryptedMedia) # type: ignore @@ -379,7 +379,7 @@ class MatrixClient(AsyncClient): self.add_to_device_callback(self._on_key_verification_accept, KeyVerificationAccept) # type: ignore if self._autojoin_on_invite: - self.add_event_callback(self._autojoin_room_callback, InviteNameEvent) # type: ignore + self.add_event_callback(self._autojoin_room_callback, InviteEvent) # type: ignore def _sync_store(self): self.logger.info('Synchronizing keystore') @@ -1281,5 +1281,33 @@ class MatrixPlugin(AsyncRunnablePlugin): return rs[0].content_uri + @action + def invite_to_room(self, room_id: str, user_id: str): + """ + Invite a user to a room. + + :param room_id: Room ID. + :param user_id: User ID. + """ + self._loop_execute(self.client.room_invite(room_id, user_id)) + + @action + def join_room(self, room_id: str): + """ + Join a room. + + :param room_id: Room ID. + """ + self._loop_execute(self.client.join(room_id)) + + @action + def leave_room(self, room_id: str): + """ + Leave a joined room. + + :param room_id: Room ID. + """ + self._loop_execute(self.client.room_leave(room_id)) + # vim:sw=4:ts=4:et: From d890b6cbe8fd46e180283594b13bc9d26465d81b Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sat, 27 Aug 2022 23:26:42 +0200 Subject: [PATCH 23/27] Added create_room action --- platypush/plugins/matrix/__init__.py | 64 +++++++++++++++++++++++++++- platypush/schemas/matrix.py | 10 +++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/platypush/plugins/matrix/__init__.py b/platypush/plugins/matrix/__init__.py index aeb77fa4d0..76dba6336d 100644 --- a/platypush/plugins/matrix/__init__.py +++ b/platypush/plugins/matrix/__init__.py @@ -8,7 +8,7 @@ import re import threading from dataclasses import dataclass -from typing import Collection, Coroutine, Dict +from typing import Collection, Coroutine, Dict, Sequence from urllib.parse import urlparse from async_lru import alru_cache @@ -58,6 +58,7 @@ from nio import ( import aiofiles import aiofiles.os +from nio.api import RoomVisibility from nio.client.async_client import client_session from nio.crypto import decrypt_attachment @@ -93,6 +94,7 @@ from platypush.schemas.matrix import ( MatrixEventIdSchema, MatrixMyDeviceSchema, MatrixProfileSchema, + MatrixRoomIdSchema, MatrixRoomSchema, ) @@ -1112,7 +1114,7 @@ class MatrixPlugin(AsyncRunnablePlugin): if evt.get('type') == 'm.room.create': room_args['own_user_id'] = evt.get('content', {}).get('creator') elif evt.get('type') == 'm.room.encryption': - room_args['encrypted'] = False + room_args['encrypted'] = True elif evt.get('type') == 'm.room.name': room_params['name'] = evt.get('content', {}).get('name') elif evt.get('type') == 'm.room.topic': @@ -1146,6 +1148,8 @@ class MatrixPlugin(AsyncRunnablePlugin): def get_joined_rooms(self): """ Retrieve the rooms that the user has joined. + + :return: .. schema:: matrix.MatrixRoomSchema(many=True) """ response = self._loop_execute(self.client.joined_rooms()) return [self.get_room(room_id).output for room_id in response.rooms] # type: ignore @@ -1281,6 +1285,62 @@ class MatrixPlugin(AsyncRunnablePlugin): return rs[0].content_uri + @action + def create_room( + self, + name: str | None = None, + alias: str | None = None, + topic: str | None = None, + is_public: bool = False, + is_direct: bool = False, + federate: bool = True, + encrypted: bool = False, + invite_users: Sequence[str] = (), + ): + """ + Create a new room on the server. + + :param name: Room name. + :param alias: Custom alias for the canonical name. For example, if set + to ``foo``, the alias for this room will be + ``#foo:matrix.example.org``. + :param topic: Room topic. + :param is_public: Set to True if you want the room to be public and + discoverable (default: False). + :param is_direct: Set to True if this should be considered a direct + room with only one user (default: False). + :param federate: Whether you want to allow users from other servers to + join the room (default: True). + :param encrypted: Whether the room should be encrypted (default: False). + :param invite_users: A list of user IDs to invite to the room. + :return: .. schema:: matrix.MatrixRoomIdSchema + """ + rs = self._loop_execute( + self.client.room_create( + name=name, + alias=alias, + topic=topic, + is_direct=is_direct, + federate=federate, + invite=invite_users, + visibility=( + RoomVisibility.public if is_public else RoomVisibility.private + ), + initial_state=[ + { + 'type': 'm.room.encryption', + 'content': { + 'algorithm': 'm.megolm.v1.aes-sha2', + }, + } + ] + if encrypted + else (), + ) + ) + + return MatrixRoomIdSchema().dump(rs) + @action def invite_to_room(self, room_id: str, user_id: str): """ diff --git a/platypush/schemas/matrix.py b/platypush/schemas/matrix.py index ae9fe679f4..1aebba52d6 100644 --- a/platypush/schemas/matrix.py +++ b/platypush/schemas/matrix.py @@ -14,6 +14,16 @@ class MatrixEventIdSchema(Schema): ) +class MatrixRoomIdSchema(Schema): + room_id = fields.String( + required=True, + metadata={ + 'description': 'Room ID', + 'example': '!aBcDeFgHiJkMnO:matrix.example.org', + }, + ) + + class MatrixProfileSchema(Schema): user_id = fields.String( required=True, From 0e3cabc5f69f609e848e3ccb19e821cb881e99e3 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 28 Aug 2022 11:55:30 +0200 Subject: [PATCH 24/27] Support `attribute` parameter on `Function` schema fields. --- platypush/schemas/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/platypush/schemas/__init__.py b/platypush/schemas/__init__.py index 71456ddd91..ce157319b5 100644 --- a/platypush/schemas/__init__.py +++ b/platypush/schemas/__init__.py @@ -22,7 +22,9 @@ class StrippedString(fields.Function): # lgtm [py/missing-call-to-init] class Function(fields.Function): # lgtm [py/missing-call-to-init] - def _get_attr(self, obj, attr: str): + def _get_attr(self, obj, attr: str, _recursive=True): + if self.attribute and _recursive: + return self._get_attr(obj, self.attribute, False) if hasattr(obj, attr): return getattr(obj, attr) elif hasattr(obj, 'get'): From e479ca7e3e5d5ff1c987d16ef40cca5291892d77 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 28 Aug 2022 12:26:27 +0200 Subject: [PATCH 25/27] Completing the Matrix plugin integration Newly implemented actions: - `get_messages` - `get_room_members` - `update_device` - `delete_devices` - `room_alias_to_id` - `add_room_alias` - `delete_room_alias` - `kick` - `ban` - `unban` - `forget` --- platypush/plugins/matrix/__init__.py | 211 ++++++++++++++++++++++++++- platypush/schemas/matrix.py | 186 +++++++++++++++++++++++ 2 files changed, 392 insertions(+), 5 deletions(-) diff --git a/platypush/plugins/matrix/__init__.py b/platypush/plugins/matrix/__init__.py index 76dba6336d..ee8a252d1d 100644 --- a/platypush/plugins/matrix/__init__.py +++ b/platypush/plugins/matrix/__init__.py @@ -41,6 +41,7 @@ from nio import ( RoomGetEventError, RoomGetStateResponse, RoomMemberEvent, + RoomMessage, RoomMessageAudio, RoomMessageFile, RoomMessageFormatted, @@ -51,6 +52,7 @@ from nio import ( RoomTopicEvent, RoomUpgradeEvent, StickerEvent, + SyncResponse, ToDeviceError, UnknownEncryptedEvent, UnknownEvent, @@ -58,13 +60,14 @@ from nio import ( import aiofiles import aiofiles.os -from nio.api import RoomVisibility +from nio.api import MessageDirection, RoomVisibility from nio.client.async_client import client_session +from nio.client.base_client import logged_in from nio.crypto import decrypt_attachment from nio.crypto.device import OlmDevice from nio.exceptions import OlmUnverifiedDeviceError -from nio.responses import DownloadResponse +from nio.responses import DownloadResponse, RoomMessagesResponse from platypush.config import Config from platypush.context import get_bus @@ -92,6 +95,8 @@ from platypush.schemas.matrix import ( MatrixDeviceSchema, MatrixDownloadedFileSchema, MatrixEventIdSchema, + MatrixMemberSchema, + MatrixMessagesResponseSchema, MatrixMyDeviceSchema, MatrixProfileSchema, MatrixRoomIdSchema, @@ -159,6 +164,7 @@ class MatrixClient(AsyncClient): self._autotrust_rooms_whitelist = autotrust_rooms_whitelist or set() self._autotrust_users_whitelist = autotrust_users_whitelist or set() self._first_sync_performed = asyncio.Event() + self._last_batches_by_room = {} self._encrypted_attachments_keystore_path = os.path.join( store_path, 'attachment_keys.json' @@ -177,6 +183,7 @@ class MatrixClient(AsyncClient): pathlib.Path(self._encrypted_attachments_keystore_path).touch( mode=0o600, exist_ok=True ) + self._encrypted_attachments_keystore = { tuple(key.split('|')): data for key, data in keystore.items() } @@ -276,6 +283,37 @@ class MatrixClient(AsyncClient): self.logger.info('State synchronized') return login_res + @logged_in + async def sync(self, *args, **kwargs) -> SyncResponse: + response = await super().sync(*args, **kwargs) + assert isinstance(response, SyncResponse), str(response) + self._last_batches_by_room.update( + { + room_id: { + 'prev_batch': room.timeline.prev_batch, + 'next_batch': response.next_batch, + } + for room_id, room in response.rooms.join.items() + } + ) + + return response + + @logged_in + async def room_messages( + self, room_id: str, start: str | None = None, *args, **kwargs + ) -> RoomMessagesResponse: + if not start: + start = self._last_batches_by_room.get(room_id, {}).get('prev_batch') + assert start, ( + f'No sync batches were found for room {room_id} and no start' + 'batch has been provided' + ) + + response = await super().room_messages(room_id, start, *args, **kwargs) + assert isinstance(response, RoomMessagesResponse), str(response) + return response + def _sync_devices_trust(self): all_devices = self.get_devices() devices_to_trust: Dict[str, OlmDevice] = {} @@ -425,6 +463,7 @@ class MatrixClient(AsyncClient): ), f'Could not retrieve profile for room {room_id}: {ret.message}' return ret + @client_session async def download( self, server_name: str, @@ -1125,6 +1164,44 @@ class MatrixPlugin(AsyncRunnablePlugin): setattr(room, k, v) return MatrixRoomSchema().dump(room) + @action + def get_messages( + self, + room_id: str, + start: str | None = None, + end: str | None = None, + backwards: bool = True, + limit: int = 10, + ): + """ + Retrieve a list of messages from a room. + + :param room_id: Room ID. + :param start: Start retrieving messages from this batch ID (default: + latest batch returned from a call to ``sync``). + :param end: Retrieving messages until this batch ID. + :param backwards: Set to True if you want to retrieve messages starting + from the most recent, in descending order (default). Otherwise, the + first returned message will be the oldest and messages will be + returned in ascending order. + :param limit: Maximum number of messages to be returned (default: 10). + # :return: .. schema:: matrix.MatrixMessagesResponseSchema + """ + response = self._loop_execute( + self.client.room_messages( + room_id, + start=start, + end=end, + limit=limit, + direction=( + MessageDirection.back if backwards else MessageDirection.front + ), + ) + ) + + response.chunk = [m for m in response.chunk if isinstance(m, RoomMessage)] + return MatrixMessagesResponseSchema().dump(response) + @action def get_my_devices(self): """ @@ -1144,6 +1221,44 @@ class MatrixPlugin(AsyncRunnablePlugin): """ return MatrixDeviceSchema().dump(self._get_device(device_id)) + @action + def update_device(self, device_id: str, display_name: str | None = None): + """ + Update information about a user's device. + + :param display_name: New display name. + :return: .. schema:: matrix.MatrixDeviceSchema + """ + content = {} + if display_name: + content['display_name'] = display_name + + self._loop_execute(self.client.update_device(device_id, content)) + return MatrixDeviceSchema().dump(self._get_device(device_id)) + + @action + def delete_devices( + self, + devices: Sequence[str], + username: str | None = None, + password: str | None = None, + ): + """ + Delete a list of devices from the user's authorized list and invalidate + their access tokens. + + :param devices: List of devices that should be deleted. + :param username: Username, if the server requires authentication upon + device deletion. + :param password: User password, if the server requires authentication + upon device deletion. + """ + auth = {} + if username and password: + auth = {'type': 'm.login.password', 'user': username, 'password': password} + + self._loop_execute(self.client.delete_devices([*devices], auth=auth)) + @action def get_joined_rooms(self): """ @@ -1154,6 +1269,48 @@ class MatrixPlugin(AsyncRunnablePlugin): response = self._loop_execute(self.client.joined_rooms()) return [self.get_room(room_id).output for room_id in response.rooms] # type: ignore + @action + def get_room_members(self, room_id: str): + """ + Retrieve the list of users joined into a room. + + :param room_id: The room ID. + :return: .. schema:: matrix.MatrixMemberSchema(many=True) + """ + response = self._loop_execute(self.client.joined_members(room_id)) + return MatrixMemberSchema().dump(response.members, many=True) + + @action + def room_alias_to_id(self, alias: str) -> str: + """ + Convert a room alias (in the format ``#alias:matrix.example.org``) to a + room ID (in the format ``!aBcDeFgHiJkMnO:matrix.example.org'). + + :param alias: The room alias. + :return: The room ID, as a string. + """ + response = self._loop_execute(self.client.room_resolve_alias(alias)) + return response.room_id + + @action + def add_room_alias(self, room_id: str, alias: str): + """ + Add an alias to a room. + + :param room_id: An existing room ID. + :param alias: The room alias. + """ + self._loop_execute(self.client.room_put_alias(alias, room_id)) + + @action + def delete_room_alias(self, alias: str): + """ + Delete a room alias. + + :param alias: The room alias. + """ + self._loop_execute(self.client.room_delete_alias(alias)) + @action def upload_keys(self): """ @@ -1342,7 +1499,7 @@ class MatrixPlugin(AsyncRunnablePlugin): return MatrixRoomIdSchema().dump(rs) @action - def invite_to_room(self, room_id: str, user_id: str): + def invite(self, room_id: str, user_id: str): """ Invite a user to a room. @@ -1352,7 +1509,39 @@ class MatrixPlugin(AsyncRunnablePlugin): self._loop_execute(self.client.room_invite(room_id, user_id)) @action - def join_room(self, room_id: str): + def kick(self, room_id: str, user_id: str, reason: str | None = None): + """ + Kick a user out of a room. + + :param room_id: Room ID. + :param user_id: User ID. + :param reason: Optional reason. + """ + self._loop_execute(self.client.room_kick(room_id, user_id, reason)) + + @action + def ban(self, room_id: str, user_id: str, reason: str | None = None): + """ + Ban a user from a room. + + :param room_id: Room ID. + :param user_id: User ID. + :param reason: Optional reason. + """ + self._loop_execute(self.client.room_ban(room_id, user_id, reason)) + + @action + def unban(self, room_id: str, user_id: str): + """ + Remove a user ban from a room. + + :param room_id: Room ID. + :param user_id: User ID. + """ + self._loop_execute(self.client.room_unban(room_id, user_id)) + + @action + def join(self, room_id: str): """ Join a room. @@ -1361,7 +1550,7 @@ class MatrixPlugin(AsyncRunnablePlugin): self._loop_execute(self.client.join(room_id)) @action - def leave_room(self, room_id: str): + def leave(self, room_id: str): """ Leave a joined room. @@ -1369,5 +1558,17 @@ class MatrixPlugin(AsyncRunnablePlugin): """ self._loop_execute(self.client.room_leave(room_id)) + @action + def forget(self, room_id: str): + """ + Leave a joined room and forget its data as well as all the messages. + + If all the users leave a room, that room will be marked for deletion by + the homeserver. + + :param room_id: Room ID. + """ + self._loop_execute(self.client.room_forget(room_id)) + # vim:sw=4:ts=4:et: diff --git a/platypush/schemas/matrix.py b/platypush/schemas/matrix.py index 1aebba52d6..cb9ecc2390 100644 --- a/platypush/schemas/matrix.py +++ b/platypush/schemas/matrix.py @@ -4,6 +4,14 @@ from marshmallow.schema import Schema from platypush.schemas import DateTime +class MillisecondsTimestamp(DateTime): + def _get_attr(self, *args, **kwargs): + value = super()._get_attr(*args, **kwargs) + if isinstance(value, int): + value = float(value / 1000) + return value + + class MatrixEventIdSchema(Schema): event_id = fields.String( required=True, @@ -49,6 +57,12 @@ class MatrixProfileSchema(Schema): ) +class MatrixMemberSchema(MatrixProfileSchema): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['display_name'].attribute = 'display_name' + + class MatrixRoomSchema(Schema): room_id = fields.String( required=True, @@ -197,3 +211,175 @@ class MatrixDownloadedFileSchema(Schema): 'example': 1024, } ) + + +class MatrixMessageSchema(Schema): + event_id = fields.String( + required=True, + metadata={ + 'description': 'Event ID associated to this message', + 'example': '$2eOQ5ueafANj91GnPCRkRUOOjM7dI5kFDOlfMNCD2ly', + }, + ) + + room_id = fields.String( + required=True, + metadata={ + 'description': 'The ID of the room containing the message', + 'example': '!aBcDeFgHiJkMnO:matrix.example.org', + }, + ) + + user_id = fields.String( + required=True, + attribute='sender', + metadata={ + 'description': 'ID of the user who sent the message', + 'example': '@myuser:matrix.example.org', + }, + ) + + body = fields.String( + required=True, + metadata={ + 'description': 'Message body', + 'example': 'Hello world!', + }, + ) + + format = fields.String( + metadata={ + 'description': 'Message format', + 'example': 'markdown', + }, + ) + + formatted_body = fields.String( + metadata={ + 'description': 'Formatted body', + 'example': '**Hello world!**', + }, + ) + + url = fields.String( + metadata={ + 'description': 'mxc:// URL if this message contains an attachment', + 'example': 'mxc://matrix.example.org/oarGdlpvcwppARPjzNlmlXkD', + }, + ) + + content_type = fields.String( + attribute='mimetype', + metadata={ + 'description': 'If the message contains an attachment, this field ' + 'will contain its MIME type', + 'example': 'image/jpeg', + }, + ) + + transaction_id = fields.String( + metadata={ + 'description': 'Set if this message a unique transaction_id associated', + 'example': 'mQ8hZR6Dx8I8YDMwONYmBkf7lTgJSMV/ZPqosDNM', + }, + ) + + decrypted = fields.Bool( + metadata={ + 'description': 'True if the message was encrypted and has been ' + 'successfully decrypted', + }, + ) + + verified = fields.Bool( + metadata={ + 'description': 'True if this is an encrypted message coming from a ' + 'verified source' + }, + ) + + hashes = fields.Dict( + metadata={ + 'description': 'If the message has been decrypted, this field ' + 'contains a mapping of its hashes', + 'example': {'sha256': 'yoQLQwcURq6/bJp1xQ/uhn9Z2xeA27KhMhPd/mfT8tR'}, + }, + ) + + iv = fields.String( + metadata={ + 'description': 'If the message has been decrypted, this field ' + 'contains the encryption initial value', + 'example': 'NqJMMdijlLvAAAAAAAAAAA', + }, + ) + + key = fields.Dict( + metadata={ + 'description': 'If the message has been decrypted, this field ' + 'contains the encryption/decryption key', + 'example': { + 'alg': 'A256CTR', + 'ext': True, + 'k': 'u6jjAyNvJoBHE55P5ZfvX49m3oSt9s_L4PSQdprRSJI', + 'key_ops': ['encrypt', 'decrypt'], + 'kty': 'oct', + }, + }, + ) + + timestamp = MillisecondsTimestamp( + required=True, + attribute='server_timestamp', + metadata={ + 'description': 'When the event was registered on the server', + 'example': '2022-07-23T17:20:01.254223', + }, + ) + + +class MatrixMessagesResponseSchema(Schema): + messages = fields.Nested( + MatrixMessageSchema(), + many=True, + required=True, + attribute='chunk', + ) + + start = fields.String( + required=True, + nullable=True, + metadata={ + 'description': 'Pointer to the first message. It can be used as a ' + '``start``/``end`` for another ``get_messages`` query.', + 'example': 's10226_143893_619_3648_5951_5_555_7501_0', + }, + ) + + end = fields.String( + required=True, + nullable=True, + metadata={ + 'description': 'Pointer to the last message. It can be used as a ' + '``start``/``end`` for another ``get_messages`` query.', + 'example': 't2-10202_143892_626_3663_5949_6_558_7501_0', + }, + ) + + start_time = MillisecondsTimestamp( + required=True, + nullable=True, + metadata={ + 'description': 'The oldest timestamp of the returned messages', + 'example': '2022-07-23T16:20:01.254223', + }, + ) + + end_time = MillisecondsTimestamp( + required=True, + nullable=True, + metadata={ + 'description': 'The newest timestamp of the returned messages', + 'example': '2022-07-23T18:20:01.254223', + }, + ) From c417d2f692a9ec503f5256fde723973856940976 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 28 Aug 2022 15:17:11 +0200 Subject: [PATCH 26/27] Implemented last Matrix integration features. - Added presence, typing and seen receipt events. - Added set display_name and avatar methods. --- platypush/message/event/matrix.py | 31 +++++++ platypush/plugins/matrix/__init__.py | 124 ++++++++++++++++++++++--- platypush/plugins/matrix/manifest.yaml | 41 +++++--- 3 files changed, 172 insertions(+), 24 deletions(-) diff --git a/platypush/message/event/matrix.py b/platypush/message/event/matrix.py index 71032e3945..04ed49769a 100644 --- a/platypush/message/event/matrix.py +++ b/platypush/message/event/matrix.py @@ -217,3 +217,34 @@ class MatrixRoomTopicChangedEvent(MatrixEvent): :param topic: New room topic. """ super().__init__(*args, topic=topic, **kwargs) + + +class MatrixRoomTypingStartEvent(MatrixEvent): + """ + Event triggered when a user in a room starts typing. + """ + + +class MatrixRoomTypingStopEvent(MatrixEvent): + """ + Event triggered when a user in a room stops typing. + """ + + +class MatrixRoomSeenReceiptEvent(MatrixEvent): + """ + Event triggered when the last message seen by a user in a room is updated. + """ + + +class MatrixUserPresenceEvent(MatrixEvent): + """ + Event triggered when a user comes online or goes offline. + """ + + def __init__(self, *args, is_active: bool, last_active: datetime | None, **kwargs): + """ + :param is_active: True if the user is currently online. + :param topic: When the user was last active. + """ + super().__init__(*args, is_active=is_active, last_active=last_active, **kwargs) diff --git a/platypush/plugins/matrix/__init__.py b/platypush/plugins/matrix/__init__.py index ee8a252d1d..d31bce4d66 100644 --- a/platypush/plugins/matrix/__init__.py +++ b/platypush/plugins/matrix/__init__.py @@ -66,6 +66,8 @@ from nio.client.async_client import client_session from nio.client.base_client import logged_in from nio.crypto import decrypt_attachment from nio.crypto.device import OlmDevice +from nio.events.ephemeral import ReceiptEvent, TypingNoticeEvent +from nio.events.presence import PresenceEvent from nio.exceptions import OlmUnverifiedDeviceError from nio.responses import DownloadResponse, RoomMessagesResponse @@ -86,8 +88,12 @@ from platypush.message.event.matrix import ( MatrixRoomInviteEvent, MatrixRoomJoinEvent, MatrixRoomLeaveEvent, + MatrixRoomSeenReceiptEvent, MatrixRoomTopicChangedEvent, + MatrixRoomTypingStartEvent, + MatrixRoomTypingStopEvent, MatrixSyncEvent, + MatrixUserPresenceEvent, ) from platypush.plugins import AsyncRunnablePlugin, action @@ -165,6 +171,7 @@ class MatrixClient(AsyncClient): self._autotrust_users_whitelist = autotrust_users_whitelist or set() self._first_sync_performed = asyncio.Event() self._last_batches_by_room = {} + self._typing_users_by_room = {} self._encrypted_attachments_keystore_path = os.path.join( store_path, 'attachment_keys.json' @@ -417,6 +424,9 @@ class MatrixClient(AsyncClient): self.add_to_device_callback(self._on_key_verification_key, KeyVerificationKey) # type: ignore self.add_to_device_callback(self._on_key_verification_mac, KeyVerificationMac) # type: ignore self.add_to_device_callback(self._on_key_verification_accept, KeyVerificationAccept) # type: ignore + self.add_ephemeral_callback(self._on_typing, TypingNoticeEvent) # type: ignore + self.add_ephemeral_callback(self._on_receipt, ReceiptEvent) # type: ignore + self.add_presence_callback(self._on_presence, PresenceEvent) # type: ignore if self._autojoin_on_invite: self.add_event_callback(self._autojoin_room_callback, InviteEvent) # type: ignore @@ -498,9 +508,9 @@ class MatrixClient(AsyncClient): return response async def _event_base_args( - self, room: MatrixRoom, event: Event | None = None + self, room: MatrixRoom | None, event: Event | None = None ) -> dict: - sender_id = event.sender if event else None + sender_id = getattr(event, 'sender', None) sender = ( await self.get_profile(sender_id) if sender_id else None # type: ignore ) @@ -510,9 +520,15 @@ class MatrixClient(AsyncClient): 'sender_id': sender_id, 'sender_display_name': sender.displayname if sender else None, 'sender_avatar_url': sender.avatar_url if sender else None, - 'room_id': room.room_id, - 'room_name': room.name, - 'room_topic': room.topic, + **( + { + 'room_id': room.room_id, + 'room_name': room.name, + 'room_topic': room.topic, + } + if room + else {} + ), 'server_timestamp': ( datetime.datetime.fromtimestamp(event.server_timestamp / 1000) if event and getattr(event, 'server_timestamp', None) @@ -738,16 +754,72 @@ class MatrixClient(AsyncClient): await self.room_leave(room.room_id) await self.join(event.replacement_room) + async def _on_typing(self, room: MatrixRoom, event: TypingNoticeEvent): + users = set(event.users) + typing_users = self._typing_users_by_room.get(room.room_id, set()) + start_typing_users = users.difference(typing_users) + stop_typing_users = typing_users.difference(users) + + for user in start_typing_users: + event.sender = user # type: ignore + get_bus().post( + MatrixRoomTypingStartEvent( + **(await self._event_base_args(room, event)), # type: ignore + sender=user, + ) + ) + + for user in stop_typing_users: + event.sender = user # type: ignore + get_bus().post( + MatrixRoomTypingStopEvent( + **(await self._event_base_args(room, event)), # type: ignore + ) + ) + + self._typing_users_by_room[room.room_id] = users + + async def _on_receipt(self, room: MatrixRoom, event: ReceiptEvent): + if self._first_sync_performed.is_set(): + for receipt in event.receipts: + event.sender = receipt.user_id # type: ignore + get_bus().post( + MatrixRoomSeenReceiptEvent( + **(await self._event_base_args(room, event)), # type: ignore + ) + ) + + async def _on_presence(self, event: PresenceEvent): + if self._first_sync_performed.is_set(): + last_active = ( + ( + datetime.datetime.now() + - datetime.timedelta(seconds=event.last_active_ago / 1000) + ) + if event.last_active_ago + else None + ) + + event.sender = event.user_id # type: ignore + get_bus().post( + MatrixUserPresenceEvent( + **(await self._event_base_args(None, event)), # type: ignore + is_active=event.currently_active or False, + last_active=last_active, + ) + ) + async def _on_unknown_encrypted_event( self, room: MatrixRoom, event: UnknownEncryptedEvent | MegolmEvent ): - body = getattr(event, 'ciphertext', '') - get_bus().post( - MatrixEncryptedMessageEvent( - body=body, - **(await self._event_base_args(room, event)), + if self._first_sync_performed.is_set(): + body = getattr(event, 'ciphertext', '') + get_bus().post( + MatrixEncryptedMessageEvent( + body=body, + **(await self._event_base_args(room, event)), + ) ) - ) async def _on_unknown_event(self, room: MatrixRoom, event: UnknownEvent): evt = None @@ -875,6 +947,14 @@ class MatrixPlugin(AsyncRunnablePlugin): * :class:`platypush.message.event.matrix.MatrixEncryptedMessageEvent`: when a message is received but the client doesn't have the E2E keys to decrypt it, or encryption has not been enabled. + * :class:`platypush.message.event.matrix.MatrixRoomTypingStartEvent`: + when a user in a room starts typing. + * :class:`platypush.message.event.matrix.MatrixRoomTypingStopEvent`: + when a user in a room stops typing. + * :class:`platypush.message.event.matrix.MatrixRoomSeenReceiptEvent`: + when the last message seen by a user in a room is updated. + * :class:`platypush.message.event.matrix.MatrixUserPresenceEvent`: + when a user comes online or goes offline. """ @@ -1001,6 +1081,8 @@ class MatrixPlugin(AsyncRunnablePlugin): pass except Exception as e: self.logger.exception(e) + self.logger.info('Waiting 10 seconds before reconnecting') + await asyncio.sleep(10) finally: try: await self.client.close() @@ -1185,7 +1267,7 @@ class MatrixPlugin(AsyncRunnablePlugin): first returned message will be the oldest and messages will be returned in ascending order. :param limit: Maximum number of messages to be returned (default: 10). - # :return: .. schema:: matrix.MatrixMessagesResponseSchema + :return: .. schema:: matrix.MatrixMessagesResponseSchema """ response = self._loop_execute( self.client.room_messages( @@ -1570,5 +1652,23 @@ class MatrixPlugin(AsyncRunnablePlugin): """ self._loop_execute(self.client.room_forget(room_id)) + @action + def set_display_name(self, display_name: str): + """ + Set/change the display name for the current user. + + :param display_name: New display name. + """ + self._loop_execute(self.client.set_displayname(display_name)) + + @action + def set_avatar(self, url: str): + """ + Set/change the avatar URL for the current user. + + :param url: New avatar URL. It must be a valid ``mxc://`` link. + """ + self._loop_execute(self.client.set_avatar(url)) + # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/matrix/manifest.yaml b/platypush/plugins/matrix/manifest.yaml index 1b8b2fc920..b348205275 100644 --- a/platypush/plugins/matrix/manifest.yaml +++ b/platypush/plugins/matrix/manifest.yaml @@ -1,6 +1,7 @@ manifest: events: - platypush.message.event.matrix.MatrixMessageEvent: when a message is received. + platypush.message.event.matrix.MatrixMessageEvent: when a message is + received. platypush.message.event.matrix.MatrixMessageImageEvent: when a message containing an image is received. platypush.message.event.matrix.MatrixMessageAudioEvent: when a message @@ -11,17 +12,33 @@ manifest: containing a generic file is received. platypush.message.event.matrix.MatrixSyncEvent: when the startup synchronization has been completed and the plugin is ready to use. - platypush.message.event.matrix.MatrixRoomCreatedEvent: when a room is created. - platypush.message.event.matrix.MatrixRoomJoinEvent: when a user joins a room. - platypush.message.event.matrix.MatrixRoomLeaveEvent: when a user leaves a room. - platypush.message.event.matrix.MatrixRoomInviteEvent: when the user is invited to a room. - platypush.message.event.matrix.MatrixRoomTopicChangedEvent: when the topic/title of a room changes. - platypush.message.event.matrix.MatrixCallInviteEvent: when the user is invited to a call. - platypush.message.event.matrix.MatrixCallAnswerEvent: when a called user wishes to pick the call. - platypush.message.event.matrix.MatrixCallHangupEvent: when a called user exits the call. - platypush.message.event.matrix.MatrixEncryptedMessageEvent: | - when a message is received but the client doesn't - have the E2E keys to decrypt it, or encryption has not been enabled. + platypush.message.event.matrix.MatrixRoomCreatedEvent: when a room is + created. + platypush.message.event.matrix.MatrixRoomJoinEvent: when a user joins a + room. + platypush.message.event.matrix.MatrixRoomLeaveEvent: when a user leaves a + room. + platypush.message.event.matrix.MatrixRoomInviteEvent: when the user is + invited to a room. + platypush.message.event.matrix.MatrixRoomTopicChangedEvent: when the + topic/title of a room changes. + platypush.message.event.matrix.MatrixCallInviteEvent: when the user is + invited to a call. + platypush.message.event.matrix.MatrixCallAnswerEvent: when a called user + wishes to pick the call. + platypush.message.event.matrix.MatrixCallHangupEvent: when a called user + exits the call. + platypush.message.event.matrix.MatrixEncryptedMessageEvent: when a message + is received but the client doesn't have the E2E keys to decrypt it, or + encryption has not been enabled. + platypush.message.event.matrix.MatrixRoomTypingStartEvent: when a user in a + room starts typing. + platypush.message.event.matrix.MatrixRoomTypingStopEvent: when a user in a + room stops typing. + platypush.message.event.matrix.MatrixRoomSeenReceiptEvent: when the last + message seen by a user in a room is updated. + platypush.message.event.matrix.MatrixUserPresenceEvent: when a user comes + online or goes offline. apt: - libolm-devel pacman: From 4308024eef73da823960a6b9fa0391a45a7cb0d3 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 28 Aug 2022 15:18:23 +0200 Subject: [PATCH 27/27] Added missing docs --- docs/source/events.rst | 2 ++ docs/source/platypush/events/matrix.rst | 5 +++++ docs/source/platypush/events/websocket.rst | 5 +++++ docs/source/platypush/plugins/matrix.rst | 5 +++++ docs/source/plugins.rst | 1 + 5 files changed, 18 insertions(+) create mode 100644 docs/source/platypush/events/matrix.rst create mode 100644 docs/source/platypush/events/websocket.rst create mode 100644 docs/source/platypush/plugins/matrix.rst diff --git a/docs/source/events.rst b/docs/source/events.rst index 5ca337d9d2..13d3ed79d1 100644 --- a/docs/source/events.rst +++ b/docs/source/events.rst @@ -41,6 +41,7 @@ Events platypush/events/linode.rst platypush/events/log.http.rst platypush/events/mail.rst + platypush/events/matrix.rst platypush/events/media.rst platypush/events/midi.rst platypush/events/mqtt.rst @@ -72,6 +73,7 @@ Events platypush/events/weather.rst platypush/events/web.rst platypush/events/web.widget.rst + platypush/events/websocket.rst platypush/events/wiimote.rst platypush/events/zeroborg.rst platypush/events/zeroconf.rst diff --git a/docs/source/platypush/events/matrix.rst b/docs/source/platypush/events/matrix.rst new file mode 100644 index 0000000000..eaad6da652 --- /dev/null +++ b/docs/source/platypush/events/matrix.rst @@ -0,0 +1,5 @@ +``matrix`` +========== + +.. automodule:: platypush.message.event.matrix + :members: diff --git a/docs/source/platypush/events/websocket.rst b/docs/source/platypush/events/websocket.rst new file mode 100644 index 0000000000..7ec41a2897 --- /dev/null +++ b/docs/source/platypush/events/websocket.rst @@ -0,0 +1,5 @@ +``websocket`` +============= + +.. automodule:: platypush.message.event.websocket + :members: diff --git a/docs/source/platypush/plugins/matrix.rst b/docs/source/platypush/plugins/matrix.rst new file mode 100644 index 0000000000..1572b1f989 --- /dev/null +++ b/docs/source/platypush/plugins/matrix.rst @@ -0,0 +1,5 @@ +``matrix`` +========== + +.. automodule:: platypush.plugins.matrix + :members: diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index b848e5930d..41be8a4710 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -75,6 +75,7 @@ Plugins platypush/plugins/mail.smtp.rst platypush/plugins/mailgun.rst platypush/plugins/mastodon.rst + platypush/plugins/matrix.rst platypush/plugins/media.chromecast.rst platypush/plugins/media.gstreamer.rst platypush/plugins/media.jellyfin.rst