From 693ae1c78e7a1352b2559c7f27ef9e70b370a787 Mon Sep 17 00:00:00 2001 From: miruka Date: Fri, 8 Nov 2019 08:30:11 -0400 Subject: [PATCH] Put all nio callbacks in a new separate class --- src/python/matrix_client.py | 335 +---------------------------------- src/python/nio_callbacks.py | 339 ++++++++++++++++++++++++++++++++++++ src/python/utils.py | 12 +- 3 files changed, 354 insertions(+), 332 deletions(-) create mode 100644 src/python/nio_callbacks.py diff --git a/src/python/matrix_client.py b/src/python/matrix_client.py index a7db2fbe..40d30e4e 100644 --- a/src/python/matrix_client.py +++ b/src/python/matrix_client.py @@ -1,9 +1,7 @@ import asyncio import functools import html -import inspect import io -import json import logging as log import platform import traceback @@ -12,7 +10,6 @@ from dataclasses import dataclass from datetime import datetime from functools import partial from pathlib import Path -from types import ModuleType from typing import ( Any, BinaryIO, DefaultDict, Dict, Optional, Set, Tuple, Type, Union, ) @@ -105,7 +102,8 @@ class MatrixClient(nio.AsyncClient): cache_dir = Path(self.backend.app.appdirs.user_cache_dir) self.media_cache = MediaCache(self, cache_dir) - self.connect_callbacks() + from .nio_callbacks import NioCallbacks + self.nio_callbacks = NioCallbacks(self) def __repr__(self) -> str: @@ -114,29 +112,6 @@ class MatrixClient(nio.AsyncClient): ) - @staticmethod - def _classes_defined_in(module: ModuleType) -> Dict[str, Type]: - return { - m[0]: m[1] for m in inspect.getmembers(module, inspect.isclass) - if not m[0].startswith("_") and - m[1].__module__.startswith(module.__name__) - } - - - def connect_callbacks(self) -> None: - for name, class_ in self._classes_defined_in(nio.responses).items(): - with suppress(AttributeError): - self.add_response_callback(getattr(self, f"on{name}"), class_) - - for name, class_ in self._classes_defined_in(nio.events).items(): - with suppress(AttributeError): - self.add_event_callback(getattr(self, f"on{name}"), class_) - - self.add_ephemeral_callback( - self.onTypingNoticeEvent, nio.events.TypingNoticeEvent, - ) - - @property def default_device_name(self) -> str: os_ = f" on {platform.system()}".rstrip() @@ -151,8 +126,8 @@ class MatrixClient(nio.AsyncClient): if isinstance(response, nio.LoginError): raise RuntimeError(response) - else: - asyncio.ensure_future(self.start()) + + asyncio.ensure_future(self.start()) async def resume(self, user_id: str, token: str, device_id: str) -> None: @@ -833,305 +808,3 @@ class MatrixClient(nio.AsyncClient): if item.sender_id == self.user_id: self.models[Event, self.user_id, room.room_id].sync_now() - - - # Callbacks for nio responses - - async def onSyncResponse(self, resp: nio.SyncResponse) -> None: - for room_id, info in resp.rooms.join.items(): - if room_id not in self.past_tokens: - self.past_tokens[room_id] = info.timeline.prev_batch - - # TODO: way of knowing if a nio.MatrixRoom is left - for room_id, info in resp.rooms.leave.items(): - # TODO: handle in nio, these are rooms that were left before - # starting the client. - if room_id not in self.all_rooms: - log.warning("Left room not in MatrixClient.rooms: %r", room_id) - continue - - # TODO: handle left events in nio async client - for ev in info.timeline.events: - if isinstance(ev, nio.RoomMemberEvent): - await self.onRoomMemberEvent(self.all_rooms[room_id], ev) - - await self.register_nio_room(self.all_rooms[room_id], left=True) - - if not self.first_sync_done.is_set(): - asyncio.ensure_future(self.load_rooms_without_visible_events()) - - self.first_sync_done.set() - self.first_sync_date = datetime.now() - self.models[Account][self.user_id].first_sync_done = True - - - async def onErrorResponse(self, resp: nio.ErrorResponse) -> None: - # TODO: show something in the client, must be seen on login screen too - try: - log.warning("%s - %s", resp, json.dumps(vars(resp), indent=4)) - except Exception: - log.warning(repr(resp)) - - - # Callbacks for nio room events - # Content: %1 is the sender, %2 the target (ev.state_key). - - async def onRoomMessageText(self, room, ev) -> None: - co = HTML_FILTER.filter( - ev.formatted_body - if ev.format == "org.matrix.custom.html" else - utils.plain2html(ev.body), - ) - await self.register_nio_event(room, ev, content=co) - - - async def onRoomMessageEmote(self, room, ev) -> None: - co = HTML_FILTER.filter_inline( - ev.formatted_body - if ev.format == "org.matrix.custom.html" else - utils.plain2html(ev.body), - ) - await self.register_nio_event(room, ev, content=co) - - - async def onRoomMessageUnknown(self, room, ev) -> None: - co = "%1 sent a message this client doesn't understand." - await self.register_nio_event(room, ev, content=co) - - - async def onRoomMessageMedia(self, room, ev) -> None: - info = ev.source["content"].get("info", {}) - media_crypt_dict = ev.source["content"].get("file", {}) - thumb_info = info.get("thumbnail_info", {}) - thumb_crypt_dict = info.get("thumbnail_file", {}) - - await self.register_nio_event( - room, - ev, - content = "", - inline_content = ev.body, - - media_url = ev.url, - media_title = ev.body, - media_width = info.get("w") or 0, - media_height = info.get("h") or 0, - media_duration = info.get("duration") or 0, - media_size = info.get("size") or 0, - media_mime = info.get("mimetype") or 0, - media_crypt_dict = media_crypt_dict, - - thumbnail_url = - info.get("thumbnail_url") or thumb_crypt_dict.get("url") or "", - - thumbnail_width = thumb_info.get("w") or 0, - thumbnail_height = thumb_info.get("h") or 0, - thumbnail_crypt_dict = thumb_crypt_dict, - ) - - - async def onRoomEncryptedMedia(self, room, ev) -> None: - await self.onRoomMessageMedia(room, ev) - - - async def onRoomCreateEvent(self, room, ev) -> None: - co = "%1 allowed users on other matrix servers to join this room." \ - if ev.federate else \ - "%1 blocked users on other matrix servers from joining this room." - await self.register_nio_event(room, ev, content=co) - - - async def onRoomGuestAccessEvent(self, room, ev) -> None: - allowed = "allowed" if ev.guest_access else "forbad" - co = f"%1 {allowed} guests to join the room." - await self.register_nio_event(room, ev, content=co) - - - async def onRoomJoinRulesEvent(self, room, ev) -> None: - access = "public" if ev.join_rule == "public" else "invite-only" - co = f"%1 made the room {access}." - await self.register_nio_event(room, ev, content=co) - - - async def onRoomHistoryVisibilityEvent(self, room, ev) -> None: - if ev.history_visibility == "shared": - to = "all room members" - elif ev.history_visibility == "world_readable": - to = "any member or outsider" - elif ev.history_visibility == "joined": - to = "all room members, since the time they joined" - elif ev.history_visibility == "invited": - to = "all room members, since the time they were invited" - else: - to = "???" - log.warning("Invalid visibility - %s", - json.dumps(vars(ev), indent=4)) - - co = f"%1 made future room history visible to {to}." - await self.register_nio_event(room, ev, content=co) - - - async def onPowerLevelsEvent(self, room, ev) -> None: - co = "%1 changed the room's permissions." # TODO: improve - await self.register_nio_event(room, ev, content=co) - - - async def process_room_member_event( - self, room, ev, - ) -> Optional[Tuple[TypeSpecifier, str]]: - - if ev.prev_content == ev.content: - return None - - prev = ev.prev_content - now = ev.content - membership = ev.membership - prev_membership = ev.prev_membership - ev_date = datetime.fromtimestamp(ev.server_timestamp / 1000) - - member_change = TypeSpecifier.membership_change - - # Membership changes - if not prev or membership != prev_membership: - reason = f" Reason: {now['reason']}" if now.get("reason") else "" - - if membership == "join": - return ( - member_change, - "%1 accepted their invitation." - if prev and prev_membership == "invite" else - "%1 joined the room.", - ) - - if membership == "invite": - return (member_change, "%1 invited %2 to the room.") - - if membership == "leave": - if ev.state_key == ev.sender: - return ( - member_change, - f"%1 declined their invitation.{reason}" - if prev and prev_membership == "invite" else - f"%1 left the room.{reason}", - ) - - return ( - member_change, - - f"%1 withdrew %2's invitation.{reason}" - if prev and prev_membership == "invite" else - - f"%1 unbanned %2 from the room.{reason}" - if prev and prev_membership == "ban" else - - f"%1 kicked out %2 from the room.{reason}", - ) - - if membership == "ban": - return (member_change, f"%1 banned %2 from the room.{reason}") - - # Profile changes - changed = [] - - if prev and now["avatar_url"] != prev["avatar_url"]: - changed.append("profile picture") # TODO: s - - if prev and now["displayname"] != prev["displayname"]: - changed.append('display name from "{}" to "{}"'.format( - prev["displayname"] or ev.state_key, - now["displayname"] or ev.state_key, - )) - - if changed: - # Update our account profile if the event is newer than last update - if ev.state_key == self.user_id: - account = self.models[Account][self.user_id] - updated = account.profile_updated - - if not updated or updated < ev_date: - account.profile_updated = ev_date - account.display_name = now["displayname"] or "" - account.avatar_url = now["avatar_url"] or "" - - # Hide profile events from the timeline - XXX - self.skipped_events[room.room_id] += 1 - return None - - return ( - TypeSpecifier.profile_change, - "%1 changed their {}.".format(" and ".join(changed)), - ) - - log.warning("Unknown member event: %s", json.dumps(vars(ev), indent=4)) - return None - - - async def onRoomMemberEvent(self, room, ev) -> None: - type_and_content = await self.process_room_member_event(room, ev) - - if type_and_content is None: - # This is run from register_nio_event otherwise - await self.register_nio_room(room) - else: - type_specifier, content = type_and_content - await self.register_nio_event( - room, ev, content=content, type_specifier=type_specifier, - ) - - - async def onRoomAliasEvent(self, room, ev) -> None: - co = f"%1 set the room's main address to {ev.canonical_alias}." - await self.register_nio_event(room, ev, content=co) - - - async def onRoomNameEvent(self, room, ev) -> None: - co = f"%1 changed the room's name to \"{ev.name}\"." - await self.register_nio_event(room, ev, content=co) - - - async def onRoomTopicEvent(self, room, ev) -> None: - topic = HTML_FILTER.filter_inline(ev.topic) - co = f"%1 changed the room's topic to \"{topic}\"." - await self.register_nio_event(room, ev, content=co) - - - async def onRoomEncryptionEvent(self, room, ev) -> None: - co = "%1 turned on encryption for this room." - await self.register_nio_event(room, ev, content=co) - - - async def onMegolmEvent(self, room, ev) -> None: - co = "%1 sent an undecryptable message." - await self.register_nio_event(room, ev, content=co) - - - async def onBadEvent(self, room, ev) -> None: - co = "%1 sent a malformed event." - await self.register_nio_event(room, ev, content=co) - - - async def onUnknownBadEvent(self, room, ev) -> None: - co = "%1 sent an event this client doesn't understand." - await self.register_nio_event(room, ev, content=co) - - - # Callbacks for nio invite events - - async def onInviteEvent(self, room, ev) -> None: - await self.register_nio_room(room) - - - # Callbacks for nio ephemeral events - - async def onTypingNoticeEvent(self, room, ev) -> None: - # Prevent recent past typing notices from being shown for a split - # second on client startup: - if not self.first_sync_done.is_set(): - return - - if room.room_id not in self.models[Room, self.user_id]: - return - - self.models[Room, self.user_id][room.room_id].typing_members = sorted( - room.user_name(user_id) for user_id in ev.users - if user_id not in self.backend.clients - ) diff --git a/src/python/nio_callbacks.py b/src/python/nio_callbacks.py new file mode 100644 index 00000000..53ae3ca9 --- /dev/null +++ b/src/python/nio_callbacks.py @@ -0,0 +1,339 @@ +import asyncio +import json +import logging as log +from contextlib import suppress +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional, Tuple + +import nio + +from . import utils +from .html_filter import HTML_FILTER +from .matrix_client import MatrixClient +from .models.items import Account, Room, TypeSpecifier + + +@dataclass +class NioCallbacks: + client: MatrixClient = field() + + + def __post_init__(self) -> None: + c = self.client + + for name, class_ in utils.classes_defined_in(nio.responses).items(): + with suppress(AttributeError): + c.add_response_callback(getattr(self, f"on{name}"), class_) + + for name, class_ in utils.classes_defined_in(nio.events).items(): + with suppress(AttributeError): + c.add_event_callback(getattr(self, f"on{name}"), class_) + + c.add_ephemeral_callback( + self.onTypingNoticeEvent, nio.events.TypingNoticeEvent, + ) + + + async def onSyncResponse(self, resp: nio.SyncResponse) -> None: + c = self.client + + for room_id, info in resp.rooms.join.items(): + if room_id not in c.past_tokens: + c.past_tokens[room_id] = info.timeline.prev_batch + + # TODO: way of knowing if a nio.MatrixRoom is left + for room_id, info in resp.rooms.leave.items(): + # TODO: handle in nio, these are rooms that were left before + # starting the client. + if room_id not in c.all_rooms: + log.warning("Left room not in MatrixClient.rooms: %r", room_id) + continue + + # TODO: handle left events in nio async client + for ev in info.timeline.events: + if isinstance(ev, nio.RoomMemberEvent): + await self.onRoomMemberEvent(c.all_rooms[room_id], ev) + + await c.register_nio_room(c.all_rooms[room_id], left=True) + + if not c.first_sync_done.is_set(): + asyncio.ensure_future(c.load_rooms_without_visible_events()) + + c.first_sync_done.set() + c.first_sync_date = datetime.now() + c.models[Account][c.user_id].first_sync_done = True + + + async def onErrorResponse(self, resp: nio.ErrorResponse) -> None: + # TODO: show something in the client, must be seen on login screen too + try: + log.warning("%s - %s", resp, json.dumps(vars(resp), indent=4)) + except Exception: + log.warning(repr(resp)) + + + # Callbacks for nio room events + # Content: %1 is the sender, %2 the target (ev.state_key). + + async def onRoomMessageText(self, room, ev) -> None: + co = HTML_FILTER.filter( + ev.formatted_body + if ev.format == "org.matrix.custom.html" else + utils.plain2html(ev.body), + ) + await self.client.register_nio_event(room, ev, content=co) + + + async def onRoomMessageEmote(self, room, ev) -> None: + co = HTML_FILTER.filter_inline( + ev.formatted_body + if ev.format == "org.matrix.custom.html" else + utils.plain2html(ev.body), + ) + await self.client.register_nio_event(room, ev, content=co) + + + async def onRoomMessageUnknown(self, room, ev) -> None: + co = "%1 sent a message this client doesn't understand." + await self.client.register_nio_event(room, ev, content=co) + + + async def onRoomMessageMedia(self, room, ev) -> None: + info = ev.source["content"].get("info", {}) + media_crypt_dict = ev.source["content"].get("file", {}) + thumb_info = info.get("thumbnail_info", {}) + thumb_crypt_dict = info.get("thumbnail_file", {}) + + await self.client.register_nio_event( + room, + ev, + content = "", + inline_content = ev.body, + + media_url = ev.url, + media_title = ev.body, + media_width = info.get("w") or 0, + media_height = info.get("h") or 0, + media_duration = info.get("duration") or 0, + media_size = info.get("size") or 0, + media_mime = info.get("mimetype") or 0, + media_crypt_dict = media_crypt_dict, + + thumbnail_url = + info.get("thumbnail_url") or thumb_crypt_dict.get("url") or "", + + thumbnail_width = thumb_info.get("w") or 0, + thumbnail_height = thumb_info.get("h") or 0, + thumbnail_crypt_dict = thumb_crypt_dict, + ) + + + async def onRoomEncryptedMedia(self, room, ev) -> None: + await self.onRoomMessageMedia(room, ev) + + + async def onRoomCreateEvent(self, room, ev) -> None: + co = "%1 allowed users on other matrix servers to join this room." \ + if ev.federate else \ + "%1 blocked users on other matrix servers from joining this room." + await self.client.register_nio_event(room, ev, content=co) + + + async def onRoomGuestAccessEvent(self, room, ev) -> None: + allowed = "allowed" if ev.guest_access else "forbad" + co = f"%1 {allowed} guests to join the room." + await self.client.register_nio_event(room, ev, content=co) + + + async def onRoomJoinRulesEvent(self, room, ev) -> None: + access = "public" if ev.join_rule == "public" else "invite-only" + co = f"%1 made the room {access}." + await self.client.register_nio_event(room, ev, content=co) + + + async def onRoomHistoryVisibilityEvent(self, room, ev) -> None: + if ev.history_visibility == "shared": + to = "all room members" + elif ev.history_visibility == "world_readable": + to = "any member or outsider" + elif ev.history_visibility == "joined": + to = "all room members, since the time they joined" + elif ev.history_visibility == "invited": + to = "all room members, since the time they were invited" + else: + to = "???" + log.warning("Invalid visibility - %s", + json.dumps(vars(ev), indent=4)) + + co = f"%1 made future room history visible to {to}." + await self.client.register_nio_event(room, ev, content=co) + + + async def onPowerLevelsEvent(self, room, ev) -> None: + co = "%1 changed the room's permissions." # TODO: improve + await self.client.register_nio_event(room, ev, content=co) + + + async def process_room_member_event( + self, room, ev, + ) -> Optional[Tuple[TypeSpecifier, str]]: + + if ev.prev_content == ev.content: + return None + + prev = ev.prev_content + now = ev.content + membership = ev.membership + prev_membership = ev.prev_membership + ev_date = datetime.fromtimestamp(ev.server_timestamp / 1000) + + member_change = TypeSpecifier.membership_change + + # Membership changes + if not prev or membership != prev_membership: + reason = f" Reason: {now['reason']}" if now.get("reason") else "" + + if membership == "join": + return ( + member_change, + "%1 accepted their invitation." + if prev and prev_membership == "invite" else + "%1 joined the room.", + ) + + if membership == "invite": + return (member_change, "%1 invited %2 to the room.") + + if membership == "leave": + if ev.state_key == ev.sender: + return ( + member_change, + f"%1 declined their invitation.{reason}" + if prev and prev_membership == "invite" else + f"%1 left the room.{reason}", + ) + + return ( + member_change, + + f"%1 withdrew %2's invitation.{reason}" + if prev and prev_membership == "invite" else + + f"%1 unbanned %2 from the room.{reason}" + if prev and prev_membership == "ban" else + + f"%1 kicked out %2 from the room.{reason}", + ) + + if membership == "ban": + return (member_change, f"%1 banned %2 from the room.{reason}") + + # Profile changes + changed = [] + + if prev and now["avatar_url"] != prev["avatar_url"]: + changed.append("profile picture") # TODO: s + + if prev and now["displayname"] != prev["displayname"]: + changed.append('display name from "{}" to "{}"'.format( + prev["displayname"] or ev.state_key, + now["displayname"] or ev.state_key, + )) + + if changed: + # Update our account profile if the event is newer than last update + if ev.state_key == self.client.user_id: + account = self.client.models[Account][self.client.user_id] + updated = account.profile_updated + + if not updated or updated < ev_date: + account.profile_updated = ev_date + account.display_name = now["displayname"] or "" + account.avatar_url = now["avatar_url"] or "" + + # Hide profile events from the timeline - XXX + self.client.skipped_events[room.room_id] += 1 + return None + + return ( + TypeSpecifier.profile_change, + "%1 changed their {}.".format(" and ".join(changed)), + ) + + log.warning("Unknown member event: %s", json.dumps(vars(ev), indent=4)) + return None + + + async def onRoomMemberEvent(self, room, ev) -> None: + type_and_content = await self.process_room_member_event(room, ev) + + if type_and_content is None: + # This is run from register_nio_event otherwise + await self.client.register_nio_room(room) + else: + type_specifier, content = type_and_content + await self.client.register_nio_event( + room, ev, content=content, type_specifier=type_specifier, + ) + + + async def onRoomAliasEvent(self, room, ev) -> None: + co = f"%1 set the room's main address to {ev.canonical_alias}." + await self.client.register_nio_event(room, ev, content=co) + + + async def onRoomNameEvent(self, room, ev) -> None: + co = f"%1 changed the room's name to \"{ev.name}\"." + await self.client.register_nio_event(room, ev, content=co) + + + async def onRoomTopicEvent(self, room, ev) -> None: + topic = HTML_FILTER.filter_inline(ev.topic) + co = f"%1 changed the room's topic to \"{topic}\"." + await self.client.register_nio_event(room, ev, content=co) + + + async def onRoomEncryptionEvent(self, room, ev) -> None: + co = "%1 turned on encryption for this room." + await self.client.register_nio_event(room, ev, content=co) + + + async def onMegolmEvent(self, room, ev) -> None: + co = "%1 sent an undecryptable message." + await self.client.register_nio_event(room, ev, content=co) + + + async def onBadEvent(self, room, ev) -> None: + co = "%1 sent a malformed event." + await self.client.register_nio_event(room, ev, content=co) + + + async def onUnknownBadEvent(self, room, ev) -> None: + co = "%1 sent an event this client doesn't understand." + await self.client.register_nio_event(room, ev, content=co) + + + # Callbacks for nio invite events + + async def onInviteEvent(self, room, ev) -> None: + await self.client.register_nio_room(room) + + + # Callbacks for nio ephemeral events + + async def onTypingNoticeEvent(self, room, ev) -> None: + # Prevent recent past typing notices from being shown for a split + # second on client startup: + if not self.client.first_sync_done.is_set(): + return + + if room.room_id not in self.client.models[Room, self.client.user_id]: + return + + room_item = self.client.models[Room, self.client.user_id][room.room_id] + + room_item.typing_members = sorted( + room.user_name(user_id) for user_id in ev.users + if user_id not in self.client.backend.clients + ) diff --git a/src/python/utils.py b/src/python/utils.py index 7ad8dfe2..8c7041d8 100644 --- a/src/python/utils.py +++ b/src/python/utils.py @@ -1,10 +1,12 @@ import collections import html +import inspect import xml.etree.cElementTree as xml_etree # FIXME: bandit warning from enum import Enum from enum import auto as autostr from pathlib import Path -from typing import IO, Any, Tuple, Union +from types import ModuleType +from typing import IO, Any, Dict, Tuple, Type, Union import filetype @@ -73,3 +75,11 @@ def serialize_value_for_qml(value: Any) -> Any: return f"file://{value!s}" return value + + +def classes_defined_in(module: ModuleType) -> Dict[str, Type]: + return { + m[0]: m[1] for m in inspect.getmembers(module, inspect.isclass) + if not m[0].startswith("_") and + m[1].__module__.startswith(module.__name__) + }