From 06168d4ebd7b4fcff626cc21c9be3b514a625fd5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jun 2022 11:29:13 +0000 Subject: [PATCH 01/42] Bump shell-quote from 1.7.2 to 1.7.3 in /platypush/backend/http/webapp Bumps [shell-quote](https://github.com/substack/node-shell-quote) from 1.7.2 to 1.7.3. - [Release notes](https://github.com/substack/node-shell-quote/releases) - [Changelog](https://github.com/substack/node-shell-quote/blob/master/CHANGELOG.md) - [Commits](https://github.com/substack/node-shell-quote/compare/v1.7.2...1.7.3) --- updated-dependencies: - dependency-name: shell-quote dependency-type: indirect ... Signed-off-by: dependabot[bot] --- platypush/backend/http/webapp/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/platypush/backend/http/webapp/package-lock.json b/platypush/backend/http/webapp/package-lock.json index c27e627e..58b503ef 100644 --- a/platypush/backend/http/webapp/package-lock.json +++ b/platypush/backend/http/webapp/package-lock.json @@ -9710,9 +9710,9 @@ } }, "node_modules/shell-quote": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.2.tgz", - "integrity": "sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==", + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.3.tgz", + "integrity": "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==", "dev": true }, "node_modules/signal-exit": { @@ -18912,9 +18912,9 @@ "dev": true }, "shell-quote": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.2.tgz", - "integrity": "sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==", + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.3.tgz", + "integrity": "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==", "dev": true }, "signal-exit": { From 719bd4fddfc93da0a2e051c70cd2c9910d89d1b1 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 14 Jul 2022 01:50:46 +0200 Subject: [PATCH 02/42] [#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 00000000..d1ac3f8d --- /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 00000000..6986dff1 --- /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 00000000..5449b930 --- /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 03/42] [#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 6986dff1..42f9fc7d 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 04/42] 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 e9acfec4..481bd275 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 c7927a3d2f59600c65a3795d0bc01a28766c409a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 Jul 2022 00:35:29 +0000 Subject: [PATCH 05/42] Bump terser from 5.12.1 to 5.14.2 in /platypush/backend/http/webapp Bumps [terser](https://github.com/terser/terser) from 5.12.1 to 5.14.2. - [Release notes](https://github.com/terser/terser/releases) - [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md) - [Commits](https://github.com/terser/terser/commits) --- updated-dependencies: - dependency-name: terser dependency-type: indirect ... Signed-off-by: dependabot[bot] --- .../backend/http/webapp/package-lock.json | 124 +++++++++++++++--- 1 file changed, 103 insertions(+), 21 deletions(-) diff --git a/platypush/backend/http/webapp/package-lock.json b/platypush/backend/http/webapp/package-lock.json index c27e627e..87c9039c 100644 --- a/platypush/backend/http/webapp/package-lock.json +++ b/platypush/backend/http/webapp/package-lock.json @@ -1759,6 +1759,58 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", + "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", + "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz", + "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.14", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz", + "integrity": "sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@node-ipc/js-queue": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@node-ipc/js-queue/-/js-queue-2.0.3.tgz", @@ -10223,13 +10275,13 @@ } }, "node_modules/terser": { - "version": "5.12.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.12.1.tgz", - "integrity": "sha512-NXbs+7nisos5E+yXwAD+y7zrcTkMqb0dEJxIGtSKPdCBzopf7ni4odPul2aechpV7EXNvOudYOX2bb5tln1jbQ==", + "version": "5.14.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.14.2.tgz", + "integrity": "sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA==", "dependencies": { + "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", - "source-map": "~0.7.2", "source-map-support": "~0.5.20" }, "bin": { @@ -10308,14 +10360,6 @@ "node": ">=0.4.0" } }, - "node_modules/terser/node_modules/source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", - "engines": { - "node": ">= 8" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -12938,6 +12982,49 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "@jridgewell/gen-mapping": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", + "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", + "requires": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", + "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==" + }, + "@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==" + }, + "@jridgewell/source-map": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz", + "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==", + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@jridgewell/sourcemap-codec": { + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" + }, + "@jridgewell/trace-mapping": { + "version": "0.3.14", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz", + "integrity": "sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ==", + "requires": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "@node-ipc/js-queue": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@node-ipc/js-queue/-/js-queue-2.0.3.tgz", @@ -19314,13 +19401,13 @@ "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==" }, "terser": { - "version": "5.12.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.12.1.tgz", - "integrity": "sha512-NXbs+7nisos5E+yXwAD+y7zrcTkMqb0dEJxIGtSKPdCBzopf7ni4odPul2aechpV7EXNvOudYOX2bb5tln1jbQ==", + "version": "5.14.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.14.2.tgz", + "integrity": "sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA==", "requires": { + "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", - "source-map": "~0.7.2", "source-map-support": "~0.5.20" }, "dependencies": { @@ -19328,11 +19415,6 @@ "version": "8.7.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==" - }, - "source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==" } } }, From 32be4df11c0e62524cc638eccc33520b793f771e Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sat, 23 Jul 2022 17:32:14 +0200 Subject: [PATCH 06/42] 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 ca280562..71456ddd 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 07/42] 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 8338620e..2d75697f 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 06427572..21ad1389 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 08/42] 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 40d189d4..46f52452 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 7c87238fec4cc18a0cf8cd97c9d073fa748a4414 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 4 Aug 2022 01:04:00 +0200 Subject: [PATCH 09/42] match_condition should return immediately (no score-based fuzzy search) if an event condition is an exact match --- platypush/message/event/__init__.py | 87 +++++++++++++++++++---------- 1 file changed, 56 insertions(+), 31 deletions(-) diff --git a/platypush/message/event/__init__.py b/platypush/message/event/__init__.py index dc69214a..038f53e2 100644 --- a/platypush/message/event/__init__.py +++ b/platypush/message/event/__init__.py @@ -2,6 +2,7 @@ import copy import hashlib import json import re +import sys import time import uuid @@ -13,15 +14,23 @@ from platypush.utils import get_event_class_by_type class Event(Message): - """ Event message class """ + """Event message class""" # If this class property is set to false then the logging of these events # will be disabled. Logging is usually disabled for events with a very # high frequency that would otherwise pollute the logs e.g. camera capture # events # pylint: disable=redefined-builtin - def __init__(self, target=None, origin=None, id=None, timestamp=None, - disable_logging=False, disable_web_clients_notification=False, **kwargs): + def __init__( + self, + target=None, + origin=None, + id=None, + timestamp=None, + disable_logging=False, + disable_web_clients_notification=False, + **kwargs + ): """ Params: target -- Target node [String] @@ -34,22 +43,27 @@ class Event(Message): self.id = id if id else self._generate_id() self.target = target if target else Config.get('device_id') self.origin = origin if origin else Config.get('device_id') - self.type = '{}.{}'.format(self.__class__.__module__, - self.__class__.__name__) + self.type = '{}.{}'.format(self.__class__.__module__, self.__class__.__name__) self.args = kwargs self.disable_logging = disable_logging self.disable_web_clients_notification = disable_web_clients_notification for arg, value in self.args.items(): if arg not in [ - 'id', 'args', 'origin', 'target', 'type', 'timestamp', 'disable_logging' + 'id', + 'args', + 'origin', + 'target', + 'type', + 'timestamp', + 'disable_logging', ] and not arg.startswith('_'): self.__setattr__(arg, value) @classmethod def build(cls, msg): - """ Builds an event message from a JSON UTF-8 string/bytearray, a - dictionary, or another Event """ + """Builds an event message from a JSON UTF-8 string/bytearray, a + dictionary, or another Event""" msg = super().parse(msg) event_type = msg['args'].pop('type') @@ -64,8 +78,10 @@ class Event(Message): @staticmethod def _generate_id(): - """ Generate a unique event ID """ - return hashlib.md5(str(uuid.uuid1()).encode()).hexdigest() # lgtm [py/weak-sensitive-data-hashing] + """Generate a unique event ID""" + return hashlib.md5( + str(uuid.uuid1()).encode() + ).hexdigest() # lgtm [py/weak-sensitive-data-hashing] def matches_condition(self, condition): """ @@ -120,7 +136,13 @@ class Event(Message): """ result = EventMatchResult(is_match=False) - event_tokens = re.split(r'\s+', self.args[argname].strip().lower()) + if self.args.get(argname) == condition_value: + # In case of an exact match, return immediately + result.is_match = True + result.score = sys.maxsize + return result + + event_tokens = re.split(r'\s+', self.args.get(argname, '').strip().lower()) condition_tokens = re.split(r'\s+', condition_value.strip().lower()) while event_tokens and condition_tokens: @@ -148,9 +170,11 @@ class Event(Message): else: result.parsed_args[argname] += ' ' + event_token - if (len(condition_tokens) == 1 and len(event_tokens) == 1) \ - or (len(event_tokens) > 1 and len(condition_tokens) > 1 - and event_tokens[1] == condition_tokens[1]): + if (len(condition_tokens) == 1 and len(event_tokens) == 1) or ( + len(event_tokens) > 1 + and len(condition_tokens) > 1 + and event_tokens[1] == condition_tokens[1] + ): # Stop appending tokens to this argument, as the next # condition will be satisfied as well condition_tokens.pop(0) @@ -173,30 +197,30 @@ class Event(Message): args = copy.deepcopy(self.args) flatten(args) - return json.dumps({ - 'type': 'event', - 'target': self.target, - 'origin': self.origin if hasattr(self, 'origin') else None, - 'id': self.id if hasattr(self, 'id') else None, - '_timestamp': self.timestamp, - 'args': { - 'type': self.type, - **args + return json.dumps( + { + 'type': 'event', + 'target': self.target, + 'origin': self.origin if hasattr(self, 'origin') else None, + 'id': self.id if hasattr(self, 'id') else None, + '_timestamp': self.timestamp, + 'args': {'type': self.type, **args}, }, - }, cls=self.Encoder) + cls=self.Encoder, + ) -class EventMatchResult(object): - """ When comparing an event against an event condition, you want to - return this object. It contains the match status (True or False), - any parsed arguments, and a match_score that identifies how "strong" - the match is - in case of multiple event matches, the ones with the - highest score will win """ +class EventMatchResult: + """When comparing an event against an event condition, you want to + return this object. It contains the match status (True or False), + any parsed arguments, and a match_score that identifies how "strong" + the match is - in case of multiple event matches, the ones with the + highest score will win""" def __init__(self, is_match, score=0, parsed_args=None): self.is_match = is_match self.score = score - self.parsed_args = {} if not parsed_args else parsed_args + self.parsed_args = parsed_args or {} def flatten(args): @@ -213,4 +237,5 @@ def flatten(args): elif isinstance(arg, (dict, list)): flatten(args[i]) + # 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 10/42] [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 a3f641d2..00097679 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 d1ac3f8d..bc3e6fdb 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 42f9fc7d..ad22f439 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 5449b930..d564aaaf 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 00000000..3cda4ebb --- /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 a5e379d7..5961bc60 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 58afc1090c9686dc81a7691e3e96f51f2ae11e68 Mon Sep 17 00:00:00 2001 From: snyk-bot Date: Thu, 4 Aug 2022 20:31:28 +0000 Subject: [PATCH 11/42] fix: upgrade core-js from 3.21.1 to 3.23.4 Snyk has created this PR to upgrade core-js from 3.21.1 to 3.23.4. See this package in npm: https://www.npmjs.com/package/core-js See this project in Snyk: https://app.snyk.io/org/blacklight/project/96bfd125-5816-4d9e-83c6-94d1569ab0f1?utm_source=github&utm_medium=referral&page=upgrade-pr --- platypush/backend/http/webapp/package-lock.json | 14 +++++++------- platypush/backend/http/webapp/package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/platypush/backend/http/webapp/package-lock.json b/platypush/backend/http/webapp/package-lock.json index c27e627e..6fc42354 100644 --- a/platypush/backend/http/webapp/package-lock.json +++ b/platypush/backend/http/webapp/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@fortawesome/fontawesome-free": "^5.15.4", "axios": "^0.21.4", - "core-js": "^3.21.1", + "core-js": "^3.23.4", "lato-font": "^3.0.0", "mitt": "^2.1.0", "sass": "^1.49.9", @@ -4205,9 +4205,9 @@ } }, "node_modules/core-js": { - "version": "3.21.1", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.21.1.tgz", - "integrity": "sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig==", + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.23.4.tgz", + "integrity": "sha512-vjsKqRc1RyAJC3Ye2kYqgfdThb3zYnx9CrqoCcjMOENMtQPC7ZViBvlDxwYU/2z2NI/IPuiXw5mT4hWhddqjzQ==", "hasInstallScript": true, "funding": { "type": "opencollective", @@ -14868,9 +14868,9 @@ } }, "core-js": { - "version": "3.21.1", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.21.1.tgz", - "integrity": "sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig==" + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.23.4.tgz", + "integrity": "sha512-vjsKqRc1RyAJC3Ye2kYqgfdThb3zYnx9CrqoCcjMOENMtQPC7ZViBvlDxwYU/2z2NI/IPuiXw5mT4hWhddqjzQ==" }, "core-js-compat": { "version": "3.21.1", diff --git a/platypush/backend/http/webapp/package.json b/platypush/backend/http/webapp/package.json index 531f041d..ec9687ad 100644 --- a/platypush/backend/http/webapp/package.json +++ b/platypush/backend/http/webapp/package.json @@ -10,7 +10,7 @@ "dependencies": { "@fortawesome/fontawesome-free": "^5.15.4", "axios": "^0.21.4", - "core-js": "^3.21.1", + "core-js": "^3.23.4", "lato-font": "^3.0.0", "mitt": "^2.1.0", "sass": "^1.49.9", From bd21779a1738628bea7839558467f3e4f3bb878e Mon Sep 17 00:00:00 2001 From: snyk-bot Date: Thu, 4 Aug 2022 20:31:33 +0000 Subject: [PATCH 12/42] fix: upgrade vue-router from 4.0.14 to 4.1.2 Snyk has created this PR to upgrade vue-router from 4.0.14 to 4.1.2. See this package in npm: https://www.npmjs.com/package/vue-router See this project in Snyk: https://app.snyk.io/org/blacklight/project/96bfd125-5816-4d9e-83c6-94d1569ab0f1?utm_source=github&utm_medium=referral&page=upgrade-pr --- .../backend/http/webapp/package-lock.json | 30 +++++++++---------- platypush/backend/http/webapp/package.json | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/platypush/backend/http/webapp/package-lock.json b/platypush/backend/http/webapp/package-lock.json index c27e627e..a8e901fe 100644 --- a/platypush/backend/http/webapp/package-lock.json +++ b/platypush/backend/http/webapp/package-lock.json @@ -16,7 +16,7 @@ "sass": "^1.49.9", "sass-loader": "^10.2.1", "vue": "^3.2.13", - "vue-router": "^4.0.14", + "vue-router": "^4.1.2", "vue-skycons": "^4.2.0", "w3css": "^2.7.0" }, @@ -2768,9 +2768,9 @@ "dev": true }, "node_modules/@vue/devtools-api": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.1.3.tgz", - "integrity": "sha512-79InfO2xHv+WHIrH1bHXQUiQD/wMls9qBk6WVwGCbdwP7/3zINtvqPNMtmSHXsIKjvUAHc8L0ouOj6ZQQRmcXg==" + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.2.1.tgz", + "integrity": "sha512-OEgAMeQXvCoJ+1x8WyQuVZzFo0wcyCmUR3baRVLmKBo1LmYZWMlRiXlux5jd0fqVJu6PfDbOrZItVqUEzLobeQ==" }, "node_modules/@vue/reactivity": { "version": "3.2.31", @@ -10825,11 +10825,11 @@ } }, "node_modules/vue-router": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.0.14.tgz", - "integrity": "sha512-wAO6zF9zxA3u+7AkMPqw9LjoUCjSxfFvINQj3E/DceTt6uEz1XZLraDhdg2EYmvVwTBSGlLYsUw8bDmx0754Mw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.1.2.tgz", + "integrity": "sha512-5BP1qXFncVRwgV/XnqzsKApdMjQPqWIpoUBdL1ynz8HyLxIX/UDAx7Ql2BjmA5CXT/p61JfZvkpiFWFpaqcfag==", "dependencies": { - "@vue/devtools-api": "^6.0.0" + "@vue/devtools-api": "^6.1.4" }, "funding": { "url": "https://github.com/sponsors/posva" @@ -13770,9 +13770,9 @@ } }, "@vue/devtools-api": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.1.3.tgz", - "integrity": "sha512-79InfO2xHv+WHIrH1bHXQUiQD/wMls9qBk6WVwGCbdwP7/3zINtvqPNMtmSHXsIKjvUAHc8L0ouOj6ZQQRmcXg==" + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.2.1.tgz", + "integrity": "sha512-OEgAMeQXvCoJ+1x8WyQuVZzFo0wcyCmUR3baRVLmKBo1LmYZWMlRiXlux5jd0fqVJu6PfDbOrZItVqUEzLobeQ==" }, "@vue/reactivity": { "version": "3.2.31", @@ -19746,11 +19746,11 @@ } }, "vue-router": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.0.14.tgz", - "integrity": "sha512-wAO6zF9zxA3u+7AkMPqw9LjoUCjSxfFvINQj3E/DceTt6uEz1XZLraDhdg2EYmvVwTBSGlLYsUw8bDmx0754Mw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.1.2.tgz", + "integrity": "sha512-5BP1qXFncVRwgV/XnqzsKApdMjQPqWIpoUBdL1ynz8HyLxIX/UDAx7Ql2BjmA5CXT/p61JfZvkpiFWFpaqcfag==", "requires": { - "@vue/devtools-api": "^6.0.0" + "@vue/devtools-api": "^6.1.4" } }, "vue-skycons": { diff --git a/platypush/backend/http/webapp/package.json b/platypush/backend/http/webapp/package.json index 531f041d..7578ec91 100644 --- a/platypush/backend/http/webapp/package.json +++ b/platypush/backend/http/webapp/package.json @@ -16,7 +16,7 @@ "sass": "^1.49.9", "sass-loader": "^10.2.1", "vue": "^3.2.13", - "vue-router": "^4.0.14", + "vue-router": "^4.1.2", "vue-skycons": "^4.2.0", "w3css": "^2.7.0" }, From be4dd48d767671062473ea432515f14c1b8e0235 Mon Sep 17 00:00:00 2001 From: snyk-bot Date: Thu, 4 Aug 2022 20:31:45 +0000 Subject: [PATCH 13/42] fix: upgrade sass from 1.49.9 to 1.53.0 Snyk has created this PR to upgrade sass from 1.49.9 to 1.53.0. See this package in npm: https://www.npmjs.com/package/sass See this project in Snyk: https://app.snyk.io/org/blacklight/project/96bfd125-5816-4d9e-83c6-94d1569ab0f1?utm_source=github&utm_medium=referral&page=upgrade-pr --- platypush/backend/http/webapp/package-lock.json | 14 +++++++------- platypush/backend/http/webapp/package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/platypush/backend/http/webapp/package-lock.json b/platypush/backend/http/webapp/package-lock.json index c27e627e..47be0d71 100644 --- a/platypush/backend/http/webapp/package-lock.json +++ b/platypush/backend/http/webapp/package-lock.json @@ -13,7 +13,7 @@ "core-js": "^3.21.1", "lato-font": "^3.0.0", "mitt": "^2.1.0", - "sass": "^1.49.9", + "sass": "^1.53.0", "sass-loader": "^10.2.1", "vue": "^3.2.13", "vue-router": "^4.0.14", @@ -9402,9 +9402,9 @@ "dev": true }, "node_modules/sass": { - "version": "1.49.9", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.49.9.tgz", - "integrity": "sha512-YlYWkkHP9fbwaFRZQRXgDi3mXZShslVmmo+FVK3kHLUELHHEYrCmL1x6IUjC7wLS6VuJSAFXRQS/DxdsC4xL1A==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.53.0.tgz", + "integrity": "sha512-zb/oMirbKhUgRQ0/GFz8TSAwRq2IlR29vOUJZOx0l8sV+CkHUfHa4u5nqrG+1VceZp7Jfj59SVW9ogdhTvJDcQ==", "dependencies": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -18676,9 +18676,9 @@ "dev": true }, "sass": { - "version": "1.49.9", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.49.9.tgz", - "integrity": "sha512-YlYWkkHP9fbwaFRZQRXgDi3mXZShslVmmo+FVK3kHLUELHHEYrCmL1x6IUjC7wLS6VuJSAFXRQS/DxdsC4xL1A==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.53.0.tgz", + "integrity": "sha512-zb/oMirbKhUgRQ0/GFz8TSAwRq2IlR29vOUJZOx0l8sV+CkHUfHa4u5nqrG+1VceZp7Jfj59SVW9ogdhTvJDcQ==", "requires": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", diff --git a/platypush/backend/http/webapp/package.json b/platypush/backend/http/webapp/package.json index 531f041d..068e5331 100644 --- a/platypush/backend/http/webapp/package.json +++ b/platypush/backend/http/webapp/package.json @@ -13,7 +13,7 @@ "core-js": "^3.21.1", "lato-font": "^3.0.0", "mitt": "^2.1.0", - "sass": "^1.49.9", + "sass": "^1.53.0", "sass-loader": "^10.2.1", "vue": "^3.2.13", "vue-router": "^4.0.14", From 3d5fc9a10b2626e5b0ede0680b08f64a63cc05b8 Mon Sep 17 00:00:00 2001 From: snyk-bot Date: Thu, 4 Aug 2022 20:31:51 +0000 Subject: [PATCH 14/42] fix: upgrade sass-loader from 10.2.1 to 10.3.1 Snyk has created this PR to upgrade sass-loader from 10.2.1 to 10.3.1. See this package in npm: https://www.npmjs.com/package/sass-loader See this project in Snyk: https://app.snyk.io/org/blacklight/project/96bfd125-5816-4d9e-83c6-94d1569ab0f1?utm_source=github&utm_medium=referral&page=upgrade-pr --- platypush/backend/http/webapp/package-lock.json | 16 ++++++++-------- platypush/backend/http/webapp/package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/platypush/backend/http/webapp/package-lock.json b/platypush/backend/http/webapp/package-lock.json index c27e627e..10e6caa1 100644 --- a/platypush/backend/http/webapp/package-lock.json +++ b/platypush/backend/http/webapp/package-lock.json @@ -14,7 +14,7 @@ "lato-font": "^3.0.0", "mitt": "^2.1.0", "sass": "^1.49.9", - "sass-loader": "^10.2.1", + "sass-loader": "^10.3.1", "vue": "^3.2.13", "vue-router": "^4.0.14", "vue-skycons": "^4.2.0", @@ -9418,9 +9418,9 @@ } }, "node_modules/sass-loader": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-10.2.1.tgz", - "integrity": "sha512-RRvWl+3K2LSMezIsd008ErK4rk6CulIMSwrcc2aZvjymUgKo/vjXGp1rSWmfTUX7bblEOz8tst4wBwWtCGBqKA==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-10.3.1.tgz", + "integrity": "sha512-y2aBdtYkbqorVavkC3fcJIUDGIegzDWPn3/LAFhsf3G+MzPKTJx37sROf5pXtUeggSVbNbmfj8TgRaSLMelXRA==", "dependencies": { "klona": "^2.0.4", "loader-utils": "^2.0.0", @@ -9437,7 +9437,7 @@ }, "peerDependencies": { "fibers": ">= 3.1.0", - "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "sass": "^1.3.0", "webpack": "^4.36.0 || ^5.0.0" }, @@ -18686,9 +18686,9 @@ } }, "sass-loader": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-10.2.1.tgz", - "integrity": "sha512-RRvWl+3K2LSMezIsd008ErK4rk6CulIMSwrcc2aZvjymUgKo/vjXGp1rSWmfTUX7bblEOz8tst4wBwWtCGBqKA==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-10.3.1.tgz", + "integrity": "sha512-y2aBdtYkbqorVavkC3fcJIUDGIegzDWPn3/LAFhsf3G+MzPKTJx37sROf5pXtUeggSVbNbmfj8TgRaSLMelXRA==", "requires": { "klona": "^2.0.4", "loader-utils": "^2.0.0", diff --git a/platypush/backend/http/webapp/package.json b/platypush/backend/http/webapp/package.json index 531f041d..b886e579 100644 --- a/platypush/backend/http/webapp/package.json +++ b/platypush/backend/http/webapp/package.json @@ -14,7 +14,7 @@ "lato-font": "^3.0.0", "mitt": "^2.1.0", "sass": "^1.49.9", - "sass-loader": "^10.2.1", + "sass-loader": "^10.3.1", "vue": "^3.2.13", "vue-router": "^4.0.14", "vue-skycons": "^4.2.0", From 7ab02e705d0afca711f49f27042aa4e9fbe7a8dd Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Fri, 5 Aug 2022 19:00:48 +0200 Subject: [PATCH 15/42] 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 ad22f439..28934779 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 16/42] 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 28934779..4a03feba 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 4e1e6da67eb0f6280b51adbdd2f996a822fb1402 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Fri, 12 Aug 2022 14:16:01 +0200 Subject: [PATCH 17/42] Added recv action on websocket plugin --- platypush/message/event/websocket.py | 16 ++ platypush/plugins/websocket/__init__.py | 208 +++++++++++++++++----- platypush/plugins/websocket/manifest.yaml | 4 +- 3 files changed, 182 insertions(+), 46 deletions(-) create mode 100644 platypush/message/event/websocket.py diff --git a/platypush/message/event/websocket.py b/platypush/message/event/websocket.py new file mode 100644 index 00000000..4656c190 --- /dev/null +++ b/platypush/message/event/websocket.py @@ -0,0 +1,16 @@ +from typing import Any + +from platypush.message.event import Event + + +class WebsocketMessageEvent(Event): + """ + Event triggered when a message is receive on a subscribed websocket URL. + """ + + def __init__(self, *args, url: str, message: Any, **kwargs): + """ + :param url: Websocket URL. + :param message: The received message. + """ + super().__init__(*args, url=url, message=message, **kwargs) diff --git a/platypush/plugins/websocket/__init__.py b/platypush/plugins/websocket/__init__.py index 1cfa7c0e..86716394 100644 --- a/platypush/plugins/websocket/__init__.py +++ b/platypush/plugins/websocket/__init__.py @@ -1,13 +1,12 @@ +import asyncio import json +import time -try: - from websockets.exceptions import ConnectionClosed - from websockets import connect as websocket_connect -except ImportError: - from websockets import ConnectionClosed, connect as websocket_connect +from websockets import connect as websocket_connect +from websockets.exceptions import ConnectionClosed -from platypush.context import get_or_create_event_loop -from platypush.message import Message +from platypush.context import get_or_create_event_loop, get_bus +from platypush.message.event.websocket import WebsocketMessageEvent from platypush.plugins import Plugin, action from platypush.utils import get_ssl_client_context @@ -15,61 +14,180 @@ from platypush.utils import get_ssl_client_context class WebsocketPlugin(Plugin): """ Plugin to send messages over a websocket connection. + + Triggers: + + * :class:`platypush.message.event.websocket.WebsocketMessageEvent` when + a message is received on a subscribed websocket. + """ - def __init__(self, **kwargs): - super().__init__(**kwargs) - @action - def send(self, url, msg, ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None): + def send( + self, + url: str, + msg, + ssl_cert=None, + ssl_key=None, + ssl_cafile=None, + ssl_capath=None, + wait_response=False, + ): """ Sends a message to a websocket. :param url: Websocket URL, e.g. ws://localhost:8765 or wss://localhost:8765 - :type url: str - :param msg: Message to be sent. It can be a list, a dict, or a Message object - - :param ssl_cert: Path to the SSL certificate to be used, if the SSL connection requires client authentication - as well (default: None) :type ssl_cert: str - - :param ssl_key: Path to the SSL key to be used, if the SSL connection requires client authentication as well - (default: None) :type ssl_key: str - - :param ssl_cafile: Path to the certificate authority file if required by the SSL configuration (default: None) - :type ssl_cafile: str - - :param ssl_capath: Path to the certificate authority directory if required by the SSL configuration - (default: None) - :type ssl_capath: str + :param ssl_cert: Path to the SSL certificate to be used, if the SSL + connection requires client authentication as well (default: None) + :param ssl_key: Path to the SSL key to be used, if the SSL connection + requires client authentication as well (default: None) + :param ssl_cafile: Path to the certificate authority file if required + by the SSL configuration (default: None) + :param ssl_capath: Path to the certificate authority directory if + required by the SSL configuration (default: None) + :param wait_response: Set to True if you expect a response to the + delivered message. + :return: The received response if ``wait_response`` is set to True, + otherwise nothing. """ async def send(): - websocket_args = {} - if ssl_cert: - websocket_args['ssl'] = get_ssl_client_context(ssl_cert=ssl_cert, - ssl_key=ssl_key, - ssl_cafile=ssl_cafile, - ssl_capath=ssl_capath) + websocket_args = { + 'ssl': self._get_ssl_context( + url, + ssl_cert=ssl_cert, + ssl_key=ssl_key, + ssl_cafile=ssl_cafile, + ssl_capath=ssl_capath, + ) + } - async with websocket_connect(url, **websocket_args) as websocket: + async with websocket_connect(url, **websocket_args) as ws: try: - await websocket.send(str(msg)) + await ws.send(str(msg)) except ConnectionClosed as err: - self.logger.warning('Error on websocket {}: {}'. - format(url, err)) + self.logger.warning('Error on websocket %s: %s', url, err) - try: - msg = json.dumps(msg) - except Exception as e: - self.logger.debug(e) + if wait_response: + messages = await self._ws_recv(ws, num_messages=1) + if messages: + return self._parse_msg(messages[0]) - try: - msg = Message.build(json.loads(msg)) - except Exception as e: - self.logger.debug(e) + msg = self._parse_msg(msg) + loop = get_or_create_event_loop() + return loop.run_until_complete(send()) + + @action + def recv( + self, + url: str, + ssl_cert=None, + ssl_key=None, + ssl_cafile=None, + ssl_capath=None, + num_messages=0, + timeout=0, + ): + """ + Receive one or more messages from a websocket. + + A :class:`platypush.message.event.websocket.WebsocketMessageEvent` + event will be triggered whenever a new message is received. + + :param url: Websocket URL, e.g. ws://localhost:8765 or wss://localhost:8765 + :param ssl_cert: Path to the SSL certificate to be used, if the SSL + connection requires client authentication as well (default: None) + :param ssl_key: Path to the SSL key to be used, if the SSL connection + requires client authentication as well (default: None) + :param ssl_cafile: Path to the certificate authority file if required + by the SSL configuration (default: None) + :param ssl_capath: Path to the certificate authority directory if + required by the SSL configuration (default: None) + :param num_messages: Exit after receiving this number of messages. + Default: 0, receive forever. + :param timeout: Message receive timeout in seconds. Default: 0 - no timeout. + :return: A list with the messages that have been received, unless + ``num_messages`` is set to 0 or ``None``. + """ + + async def recv(): + websocket_args = { + 'ssl': self._get_ssl_context( + url, + ssl_cert=ssl_cert, + ssl_key=ssl_key, + ssl_cafile=ssl_cafile, + ssl_capath=ssl_capath, + ) + } + + async with websocket_connect(url, **websocket_args) as ws: + return await self._ws_recv( + ws, timeout=timeout, num_messages=num_messages + ) loop = get_or_create_event_loop() - loop.run_until_complete(send()) + return loop.run_until_complete(recv()) + + async def _ws_recv(self, ws, timeout=0, num_messages=0): + messages = [] + 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, + path=ws.path, + ) + + while (not num_messages) or (len(messages) < num_messages): + msg = None + err = None + remaining_timeout = time_end - time.time() if time_end else None + + try: + msg = await asyncio.wait_for(ws.recv(), remaining_timeout) + except (ConnectionClosed, asyncio.exceptions.TimeoutError) as e: + err = e + self.logger.warning('Error on websocket %s: %s', url, e) + + if isinstance(err, ConnectionClosed) or ( + time_end and time.time() > time_end + ): + break + + if msg is None: + continue + + msg = self._parse_msg(msg) + messages.append(msg) + get_bus().post(WebsocketMessageEvent(url=url, message=msg)) + + return messages + + @staticmethod + def _parse_msg(msg): + try: + msg = json.dumps(msg) + except Exception: + pass + + return msg + + @staticmethod + def _get_ssl_context( + url: str, ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None + ): + if url.startswith('wss://'): + return get_ssl_client_context( + ssl_cert=ssl_cert, + ssl_key=ssl_key, + ssl_cafile=ssl_cafile, + ssl_capath=ssl_capath, + ) + + return None + # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/websocket/manifest.yaml b/platypush/plugins/websocket/manifest.yaml index 8c95c60e..973fde3f 100644 --- a/platypush/plugins/websocket/manifest.yaml +++ b/platypush/plugins/websocket/manifest.yaml @@ -1,5 +1,7 @@ manifest: - events: {} + events: + platypush.message.event.websocket.WebsocketMessageEvent: when a message is + received on a subscribed websocket. install: pip: [] package: platypush.plugins.websocket From 2e7f3d88681ed2beb54bb500902a44ac459f5ed0 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Fri, 12 Aug 2022 15:22:04 +0200 Subject: [PATCH 18/42] 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 86716394..8999b124 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 4043878afd0803f1553c21238f18baf62e9623ce Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 14 Aug 2022 00:45:29 +0200 Subject: [PATCH 19/42] Refactored concurrency model in ntfy plugin --- platypush/plugins/ntfy/__init__.py | 48 ++++++++++++++---------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/platypush/plugins/ntfy/__init__.py b/platypush/plugins/ntfy/__init__.py index 06427572..8619df7a 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,32 +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._subscriptions: - self._connect() + if self.should_stop() or (self._ws_proc and self._ws_proc.is_alive()): + self.logger.debug('Already connected') + return - while not self._should_stop.is_set(): - self._should_stop.wait(timeout=1) + 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: - 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 3b1ab78268a19b853f41c34a2b8580cee69bc157 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 14 Aug 2022 22:30:49 +0200 Subject: [PATCH 20/42] 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 8338620e..9e3edab8 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 @@ -110,4 +112,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 e17e65a7039e81734f20f9f9cf20cda817a95821 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 14 Aug 2022 22:34:25 +0200 Subject: [PATCH 21/42] 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 8619df7a..1d3faffb 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 4e3c6a5c16550ca19bf4449bd085b491832a6d76 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 15 Aug 2022 00:14:52 +0200 Subject: [PATCH 22/42] 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 8999b124..5744c075 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 9e2b4a00438977ce4832084ccff6451f7feede3d Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Fri, 12 Aug 2022 15:22:04 +0200 Subject: [PATCH 23/42] 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 86716394..8999b124 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 24/42] 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 21ad1389..8619df7a 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 25/42] 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 2d75697f..f35f06a4 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 26/42] 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 8619df7a..1d3faffb 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 27/42] 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 8999b124..5744c075 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 28/42] 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 4a03feba..933e2ce8 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 3cda4ebb..aa7279f5 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 29/42] 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 933e2ce8..91295762 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 30/42] 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 91295762..0ad35ef3 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 31/42] 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 46f52452..7b895fdf 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 32/42] 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 bc3e6fdb..f6e7a3ac 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 0ad35ef3..a673ae20 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 d564aaaf..3f0b5e5b 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 aa7279f5..b28a6cbf 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 33/42] 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 f6e7a3ac..71032e39 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 a673ae20..2df1eba0 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 3f0b5e5b..1b8b2fc9 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 b28a6cbf..ae9fe679 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 34/42] 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 00097679..9bbdb852 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 2df1eba0..9483cecd 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 35/42] 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 9483cecd..aeb77fa4 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 36/42] 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 aeb77fa4..76dba633 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 ae9fe679..1aebba52 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 37/42] 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 71456ddd..ce157319 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 38/42] 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 76dba633..ee8a252d 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 1aebba52..cb9ecc23 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 39/42] 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 71032e39..04ed4976 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 ee8a252d..d31bce4d 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 1b8b2fc9..b3482052 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 40/42] 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 5ca337d9..13d3ed79 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 00000000..eaad6da6 --- /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 00000000..7ec41a28 --- /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 00000000..1572b1f9 --- /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 b848e593..41be8a47 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 From b11a0e8bbb3c50e94f83012556db9cfeff7fef92 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 28 Aug 2022 15:27:45 +0200 Subject: [PATCH 41/42] =?UTF-8?q?Bump=20version:=200.23.3=20=E2=86=92=200.?= =?UTF-8?q?23.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 +++++++++- platypush/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 747d01ed..e6ec694c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,15 @@ All notable changes to this project will be documented in this file. Given the high speed of development in the first phase, changes are being reported only starting from v0.20.2. -## [Unreleased] +## [0.23.4] - 2022-08-28 + +### Added + +- Added `matrix` integration + ([issue](https://git.platypush.tech/platypush/platypush/issues/2), + [PR](https://git.platypush.tech/platypush/platypush/pulls/217)). + +### Changed - Removed `clipboard` backend. Enabling the `clipboard` plugin will also enable clipboard monitoring, with no need for an additional backend. diff --git a/platypush/__init__.py b/platypush/__init__.py index 7de379c2..d255a6cd 100644 --- a/platypush/__init__.py +++ b/platypush/__init__.py @@ -23,7 +23,7 @@ from .message.response import Response from .utils import set_thread_name, get_enabled_plugins __author__ = 'Fabio Manganiello ' -__version__ = '0.23.3' +__version__ = '0.23.4' logger = logging.getLogger('platypush') diff --git a/setup.cfg b/setup.cfg index ad67bf2f..7a618699 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.23.3 +current_version = 0.23.4 commit = True tag = True diff --git a/setup.py b/setup.py index 5961bc60..79054a5f 100755 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ backend = pkg_files('platypush/backend') setup( name="platypush", - version="0.23.3", + version="0.23.4", author="Fabio Manganiello", author_email="info@fabiomanganiello.com", description="Platypush service", From 540a7d469e4ee9aec634a5e9b827bc71e7dfc1da Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 29 Aug 2022 00:55:46 +0200 Subject: [PATCH 42/42] - Fixed documentation errors and warnings - Split Matrix integration into `plugin` and `client` files. --- docs/source/conf.py | 6 +- docs/source/platypush/plugins/dbus.rst | 2 +- docs/source/platypush/plugins/matrix.rst | 2 +- platypush/plugins/matrix/__init__.py | 827 +--------------------- platypush/plugins/matrix/client.py | 856 +++++++++++++++++++++++ platypush/schemas/matrix.py | 2 +- 6 files changed, 871 insertions(+), 824 deletions(-) create mode 100644 platypush/plugins/matrix/client.py diff --git a/docs/source/conf.py b/docs/source/conf.py index 9bbdb852..d27aa7cb 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -71,7 +71,7 @@ master_doc = 'index' # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = 'en' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -195,7 +195,8 @@ intersphinx_mapping = {'https://docs.python.org/': None} todo_include_todos = True autodoc_default_options = { - 'inherited-members': True, + 'members': True, + 'show-inheritance': True, } autodoc_mock_imports = [ @@ -294,6 +295,7 @@ autodoc_mock_imports = [ 'nio', 'aiofiles', 'aiofiles.os', + 'async_lru', ] sys.path.insert(0, os.path.abspath('../..')) diff --git a/docs/source/platypush/plugins/dbus.rst b/docs/source/platypush/plugins/dbus.rst index 8eb7aecf..86be5704 100644 --- a/docs/source/platypush/plugins/dbus.rst +++ b/docs/source/platypush/plugins/dbus.rst @@ -2,4 +2,4 @@ ========================== .. automodule:: platypush.plugins.dbus - :members: + :exclude-members: DBusService, BusType diff --git a/docs/source/platypush/plugins/matrix.rst b/docs/source/platypush/plugins/matrix.rst index 1572b1f9..fc01524c 100644 --- a/docs/source/platypush/plugins/matrix.rst +++ b/docs/source/platypush/plugins/matrix.rst @@ -2,4 +2,4 @@ ========== .. automodule:: platypush.plugins.matrix - :members: + :members: MatrixPlugin diff --git a/platypush/plugins/matrix/__init__.py b/platypush/plugins/matrix/__init__.py index d31bce4d..205da044 100644 --- a/platypush/plugins/matrix/__init__.py +++ b/platypush/plugins/matrix/__init__.py @@ -1,100 +1,26 @@ import asyncio -import datetime -import json import logging import os import pathlib import re -import threading from dataclasses import dataclass -from typing import Collection, Coroutine, Dict, Sequence +from typing import Collection, Coroutine, Sequence from urllib.parse import urlparse -from async_lru import alru_cache from nio import ( Api, - AsyncClient, - AsyncClientConfig, - CallAnswerEvent, - CallHangupEvent, - CallInviteEvent, ErrorResponse, - Event, - InviteEvent, - KeyVerificationStart, - KeyVerificationAccept, - KeyVerificationMac, - KeyVerificationKey, - KeyVerificationCancel, - LocalProtocolError, - LoginResponse, MatrixRoom, - MegolmEvent, - ProfileGetResponse, - RoomCreateEvent, - RoomEncryptedAudio, - RoomEncryptedFile, - RoomEncryptedImage, - RoomEncryptedMedia, - RoomEncryptedVideo, - RoomGetEventError, - RoomGetStateResponse, - RoomMemberEvent, RoomMessage, - RoomMessageAudio, - RoomMessageFile, - RoomMessageFormatted, - RoomMessageText, - RoomMessageImage, - RoomMessageMedia, - RoomMessageVideo, - RoomTopicEvent, - RoomUpgradeEvent, - StickerEvent, - SyncResponse, - ToDeviceError, - UnknownEncryptedEvent, - UnknownEvent, ) -import aiofiles -import aiofiles.os 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.events.ephemeral import ReceiptEvent, TypingNoticeEvent -from nio.events.presence import PresenceEvent from nio.exceptions import OlmUnverifiedDeviceError -from nio.responses import DownloadResponse, RoomMessagesResponse from platypush.config import Config -from platypush.context import get_bus -from platypush.message.event.matrix import ( - MatrixCallAnswerEvent, - MatrixCallHangupEvent, - MatrixCallInviteEvent, - MatrixEncryptedMessageEvent, - MatrixMessageAudioEvent, - MatrixMessageEvent, - MatrixMessageFileEvent, - MatrixMessageImageEvent, - MatrixMessageVideoEvent, - MatrixReactionEvent, - MatrixRoomCreatedEvent, - MatrixRoomInviteEvent, - MatrixRoomJoinEvent, - MatrixRoomLeaveEvent, - MatrixRoomSeenReceiptEvent, - MatrixRoomTopicChangedEvent, - MatrixRoomTypingStartEvent, - MatrixRoomTypingStopEvent, - MatrixSyncEvent, - MatrixUserPresenceEvent, -) from platypush.plugins import AsyncRunnablePlugin, action from platypush.schemas.matrix import ( @@ -111,6 +37,8 @@ from platypush.schemas.matrix import ( from platypush.utils import get_mime_type +from .client import MatrixClient + logger = logging.getLogger(__name__) @@ -130,746 +58,6 @@ class Credentials: } -class MatrixClient(AsyncClient): - def __init__( - self, - *args, - credentials_file: str, - 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)) - - if not store_path: - store_path = os.path.join(Config.get('workdir'), 'matrix', 'store') # type: ignore - - 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, - 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 - 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() - self._last_batches_by_room = {} - self._typing_users_by_room = {} - - 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 - - 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, - ) - - 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) - - if self.should_upload_keys: - self.logger.info('Uploading encryption keys') - await self.keys_upload() - - 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._sync_devices_trust() - self._first_sync_performed.set() - - get_bus().post(MatrixSyncEvent(server_url=self.homeserver)) - 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] = {} - 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, 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 - 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_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 - 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 - 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 - - 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: - """ - 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 - - @client_session - 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 | None, event: Event | None = None - ) -> dict: - sender_id = getattr(event, 'sender', 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, - } - if room - else {} - ), - '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_message( - self, - room: MatrixRoom, - event: RoomMessageText | RoomMessageMedia | RoomEncryptedMedia | StickerEvent, - ): - if self._first_sync_performed.is_set(): - 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 - if event.membership == 'join': - evt_type = MatrixRoomJoinEvent - elif event.membership == 'leave': - evt_type = MatrixRoomLeaveEvent - - if evt_type and self._first_sync_performed.is_set(): - get_bus().post( - evt_type( - **(await self._event_base_args(room, event)), - ) - ) - - async def _on_room_topic_changed(self, room: MatrixRoom, event: RoomTopicEvent): - 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): - 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): - 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): - 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( - MatrixRoomCreatedEvent( - **(await self._event_base_args(room, event)), - ) - ) - - 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): - self.logger.info(f'Received a key verification request from {event.sender}') - - 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 - - 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}' - - 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._get_sas(event) - if not sas: - return - - self.logger.info( - 'Received emoji verification from device %s: %s', - event.sender, - sas.get_emoji(), - ) - - 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._get_sas(event) - if not sas: - return - - 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( - 'The room %s has been moved to %s', room.room_id, event.replacement_room - ) - - 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 - ): - 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 - - 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') - 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 - ) - - 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): """ Matrix chat integration. @@ -903,7 +91,7 @@ class MatrixPlugin(AsyncRunnablePlugin): - 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. + - 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. @@ -960,7 +148,7 @@ class MatrixPlugin(AsyncRunnablePlugin): def __init__( self, - server_url: str = 'https://matrix.to', + server_url: str = 'https://matrix-client.matrix.org', user_id: str | None = None, password: str | None = None, access_token: str | None = None, @@ -985,7 +173,8 @@ class MatrixPlugin(AsyncRunnablePlugin): 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 server_url: Default Matrix instance base URL (default: + ``https://matrix-client.matrix.org``). :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``. @@ -1366,7 +555,7 @@ class MatrixPlugin(AsyncRunnablePlugin): 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'). + room ID (in the format ``!aBcDeFgHiJkMnO:matrix.example.org``). :param alias: The room alias. :return: The room ID, as a string. diff --git a/platypush/plugins/matrix/client.py b/platypush/plugins/matrix/client.py new file mode 100644 index 00000000..1cf957e7 --- /dev/null +++ b/platypush/plugins/matrix/client.py @@ -0,0 +1,856 @@ +import asyncio +import datetime +import json +import logging +import os +import pathlib +import threading + +from dataclasses import dataclass +from typing import Collection, Dict, Optional, Union +from urllib.parse import urlparse + +from async_lru import alru_cache +from nio import ( + AsyncClient, + AsyncClientConfig, + CallAnswerEvent, + CallHangupEvent, + CallInviteEvent, + Event, + InviteEvent, + KeyVerificationStart, + KeyVerificationAccept, + KeyVerificationMac, + KeyVerificationKey, + KeyVerificationCancel, + LocalProtocolError, + LoginResponse, + MatrixRoom, + MegolmEvent, + ProfileGetResponse, + RoomCreateEvent, + RoomEncryptedAudio, + RoomEncryptedFile, + RoomEncryptedImage, + RoomEncryptedMedia, + RoomEncryptedVideo, + RoomGetEventError, + RoomGetStateResponse, + RoomMemberEvent, + RoomMessageAudio, + RoomMessageFile, + RoomMessageFormatted, + RoomMessageText, + RoomMessageImage, + RoomMessageMedia, + RoomMessageVideo, + RoomTopicEvent, + RoomUpgradeEvent, + StickerEvent, + SyncResponse, + ToDeviceError, + UnknownEncryptedEvent, + UnknownEvent, +) + +import aiofiles +import aiofiles.os + +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.responses import DownloadResponse, RoomMessagesResponse + +from platypush.config import Config +from platypush.context import get_bus +from platypush.message.event.matrix import ( + MatrixCallAnswerEvent, + MatrixCallHangupEvent, + MatrixCallInviteEvent, + MatrixEncryptedMessageEvent, + MatrixMessageAudioEvent, + MatrixMessageEvent, + MatrixMessageFileEvent, + MatrixMessageImageEvent, + MatrixMessageVideoEvent, + MatrixReactionEvent, + MatrixRoomCreatedEvent, + MatrixRoomInviteEvent, + MatrixRoomJoinEvent, + MatrixRoomLeaveEvent, + MatrixRoomSeenReceiptEvent, + MatrixRoomTopicChangedEvent, + MatrixRoomTypingStartEvent, + MatrixRoomTypingStopEvent, + MatrixSyncEvent, + MatrixUserPresenceEvent, +) + +from platypush.utils import get_mime_type + +logger = logging.getLogger(__name__) + + +@dataclass +class Credentials: + server_url: str + user_id: str + access_token: str + device_id: str | None + + def to_dict(self) -> dict: + return { + 'server_url': self.server_url, + 'user_id': self.user_id, + 'access_token': self.access_token, + 'device_id': self.device_id, + } + + +class MatrixClient(AsyncClient): + def __init__( + self, + *args, + credentials_file: str, + store_path: str | None = None, + config: Optional[AsyncClientConfig] = 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)) + + if not store_path: + store_path = os.path.join(Config.get('workdir'), 'matrix', 'store') # type: ignore + + 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, + 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 + 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() + self._last_batches_by_room = {} + self._typing_users_by_room = {} + + 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 + + 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, + ) + + 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) + + if self.should_upload_keys: + self.logger.info('Uploading encryption keys') + await self.keys_upload() + + 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._sync_devices_trust() + self._first_sync_performed.set() + + get_bus().post(MatrixSyncEvent(server_url=self.homeserver)) + 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] = {} + 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) -> Optional[OlmDevice]: + 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, 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 + 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_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 + 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 + 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 + + 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: + """ + 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 + + @client_session + 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: Optional[MatrixRoom], event: Optional[Event] = None + ) -> dict: + sender_id = getattr(event, 'sender', 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, + } + if room + else {} + ), + '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_message( + self, + room: MatrixRoom, + event: Union[ + RoomMessageText, RoomMessageMedia, RoomEncryptedMedia, StickerEvent + ], + ): + if self._first_sync_performed.is_set(): + 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 + if event.membership == 'join': + evt_type = MatrixRoomJoinEvent + elif event.membership == 'leave': + evt_type = MatrixRoomLeaveEvent + + if evt_type and self._first_sync_performed.is_set(): + get_bus().post( + evt_type( + **(await self._event_base_args(room, event)), + ) + ) + + async def _on_room_topic_changed(self, room: MatrixRoom, event: RoomTopicEvent): + 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): + 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): + 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): + 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( + MatrixRoomCreatedEvent( + **(await self._event_base_args(room, event)), + ) + ) + + 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): + self.logger.info(f'Received a key verification request from {event.sender}') + + 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 + + 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}' + + 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._get_sas(event) + if not sas: + return + + self.logger.info( + 'Received emoji verification from device %s: %s', + event.sender, + sas.get_emoji(), + ) + + 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._get_sas(event) + if not sas: + return + + 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( + 'The room %s has been moved to %s', room.room_id, event.replacement_room + ) + + 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: Union[UnknownEncryptedEvent, MegolmEvent] + ): + 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 + + 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') + 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 + ) + + async def upload_file( + self, + file: str, + name: Optional[str] = None, + content_type: Optional[str] = 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, + ) + + +# vim:sw=4:ts=4:et: diff --git a/platypush/schemas/matrix.py b/platypush/schemas/matrix.py index cb9ecc23..1f4c2ba7 100644 --- a/platypush/schemas/matrix.py +++ b/platypush/schemas/matrix.py @@ -340,7 +340,7 @@ class MatrixMessageSchema(Schema): class MatrixMessagesResponseSchema(Schema): messages = fields.Nested( - MatrixMessageSchema(), + MatrixMessageSchema, many=True, required=True, attribute='chunk',