From 2eee29e5e857b24b07948ce0e76ab018c753874c Mon Sep 17 00:00:00 2001 From: Dusty Pianalto Date: Wed, 30 Oct 2019 00:07:29 -0800 Subject: [PATCH] Initial Commit Move files from old Repo --- morpheus/__init__.py | 0 morpheus/bot/__init__.py | 0 morpheus/core/__init__.py | 0 morpheus/core/api.py | 203 ++++++++++++++++++++++++++++++++++++ morpheus/core/client.py | 161 ++++++++++++++++++++++++++++ morpheus/core/content.py | 213 ++++++++++++++++++++++++++++++++++++++ morpheus/core/context.py | 1 + morpheus/core/events.py | 75 ++++++++++++++ morpheus/core/room.py | 94 +++++++++++++++++ morpheus/core/utils.py | 96 +++++++++++++++++ requirements.txt | 1 + tests/__init__.py | 0 12 files changed, 844 insertions(+) create mode 100644 morpheus/__init__.py create mode 100644 morpheus/bot/__init__.py create mode 100644 morpheus/core/__init__.py create mode 100644 morpheus/core/api.py create mode 100644 morpheus/core/client.py create mode 100644 morpheus/core/content.py create mode 100644 morpheus/core/context.py create mode 100644 morpheus/core/events.py create mode 100644 morpheus/core/room.py create mode 100644 morpheus/core/utils.py create mode 100644 requirements.txt create mode 100644 tests/__init__.py diff --git a/morpheus/__init__.py b/morpheus/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/morpheus/bot/__init__.py b/morpheus/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/morpheus/core/__init__.py b/morpheus/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/morpheus/core/api.py b/morpheus/core/api.py new file mode 100644 index 0000000..8344b1f --- /dev/null +++ b/morpheus/core/api.py @@ -0,0 +1,203 @@ +import json +from typing import Union +import uuid +import aiohttp +from aiohttp.client_exceptions import ClientConnectionError +import asyncio +from urllib.parse import quote, urlencode, urlparse +from dataclasses import dataclass + +MATRIX_API = "/_matrix/client/r0" +MATRIX_MEDIA = "/_matrix/media/r0" + + +@dataclass +class APIConfig: + max_retry: int = 10 + max_wait_time: int = 3600 + backoff_factor: float = 0.1 + ssl: bool = None + proxy: str = None + + +class API: + def __init__( + self, + *, + base_url: str, + user_id: str, + password: str = None, + token: str = None, + device_id: str = None, + device_name: str = None, + config: APIConfig = APIConfig(), + ): + self.base_url = base_url + self.user_id = user_id + self.password = password + self.token = token + self.device_id = device_id + self.device_name = device_name + self.access_token = None + self.config = config + self.client_session = aiohttp.ClientSession() + + def build_url( + self, endpoint: str, request_type: str = None, query: dict = None + ) -> str: + path = f'{MATRIX_MEDIA if request_type == "MEDIA" else MATRIX_API}/{endpoint}' + path = self.base_url + quote(path) + if query: + path += f"?{urlencode(query).lower()}" + return path + + def get_wait_time(self, num_timeouts: int) -> float: + if num_timeouts <= 2: + return 0.0 + + return min( + self.config.backoff_factor * (2 ** (num_timeouts - 1)), + self.config.max_wait_time, + ) + + async def close(self): + if self.client_session: + await self.client_session.close() + self.client_session = None + + async def _send( + self, method: str, path: str, data: dict = None, headers: dict = {} + ) -> Union[dict, bytes]: + if not self.client_session: + self.client_session = aiohttp.ClientSession() + + raw_resp = await self.client_session.request( + method, + path, + json=data, + ssl=self.config.ssl, + proxy=self.config.proxy, + headers=headers, + ) + if raw_resp.content_type == "application/json": + return await raw_resp.json() + else: + return await raw_resp.read() + + async def send( + self, method: str, path: str, data: dict = None, content_type: str = None + ) -> dict: + if not self.access_token: + raise RuntimeError("Client is not logged in") + + headers = { + "Authorization": f"Bearer {self.access_token}", + "content_type": content_type or "application/json", + } + + timeouts = 0 + + for _ in range(self.config.max_retry or 1): + try: + resp = await self._send(method, path, data, headers) + + if isinstance(resp, bytes): + break + + if isinstance(resp, dict) and resp.get("retry_after_ms"): + await asyncio.sleep(resp["retry_after_ms"] / 1000) + else: + break + except (asyncio.TimeoutError, ClientConnectionError, TimeoutError): + timeouts += 1 + await asyncio.sleep(self.get_wait_time(timeouts)) + else: + raise RuntimeWarning(f"Max retries reached for {method} - {path} | {data}") + + return resp + + async def login(self): + path = self.build_url("login") + + data = {} + if self.password and self.user_id: + data = { + "type": "m.login.password", + "identifier": {"user": self.user_id, "type": "m.id.user"}, + "password": self.password, + } + elif self.token: + data = {"type": "m.login.token", "token": self.token} + else: + raise RuntimeError("No valid login types configured") + if self.device_id: + data["device_id"] = self.device_id + if self.device_name: + data["device_name"] = self.device_name + + headers = {"content_type": "application/json"} + resp = await self._send("post", path, data=data, headers=headers) + self.access_token = resp.get("access_token") + self.device_id = resp.get("device_id") + if not self.user_id: + self.user_id = resp.get("user_id") + return resp + + async def logout(self): + path = self.build_url("logout") + await self.send("POST", path) + self.access_token = None + + async def logout_all(self): + path = self.build_url("logout/all") + await self.send("POST", path) + self.access_token = None + + async def room_send(self, room_id: str, event_type: str, content: dict): + if room_id.startswith("!") and ":" in room_id: + path = self.build_url(f"rooms/{room_id}/send/{event_type}/{uuid.uuid4()}") + elif room_id.startswith("#") and ":" in room_id: + path = self.build_url(f"directory/room/{room_id}") + resp = await self.send("GET", path) + if resp.get("room_id"): + path = self.build_url( + f'rooms/{resp["room_id"]}/send/{event_type}/{uuid.uuid4()}' + ) + else: + raise RuntimeWarning(resp) + else: + raise RuntimeWarning(f"{room_id} is not a valid room id or alias") + + return await self.send("PUT", path, data=content) + + async def get_joined_rooms(self): + path = self.build_url("joined_rooms") + resp = await self.send("GET", path) + if resp.get("joined_rooms"): + return resp["joined_rooms"] + else: + return [] + + async def get_sync( + self, + query_filter: str = None, + since: str = None, + full_state: bool = False, + set_presence: str = "online", + timeout: int = 10000, + ): + query = { + "full_state": full_state, + "set_presence": set_presence, + "timeout": timeout, + } + if query_filter: + query["filter"] = query_filter + if since: + query["since"] = since + + path = self.build_url("sync", query=query) + print(path) + resp = await self.send("GET", path) + + return resp diff --git a/morpheus/core/client.py b/morpheus/core/client.py new file mode 100644 index 0000000..85849ed --- /dev/null +++ b/morpheus/core/client.py @@ -0,0 +1,161 @@ +import asyncio +from typing import Union, Optional, Dict + +from .api import API, APIConfig +from .room import Room + + +class Client: + def __init__( + self, + prefix: Union[str, list, tuple], + homeserver: str = "https://matrixcoding.chat", + ): + self.prefix = prefix + self.homeserver = homeserver + self.user_id: Optional[str] = None + self.password: Optional[str] = None + self.token: Optional[str] = None + self.rooms: Dict[str, Room] = {} + self.api: Optional[API] = None + self.running: bool = False + self.sync_timeout: int = 1000 + self.sync_since: Optional[str] = None + self.sync_full_state: bool = False + self.sync_set_presence: str = "online" + self.sync_filter: Optional[str] = None + self.sync_delay: Optional[str] = None + self.sync_process_dispatcher = { + "presence": self.process_presence_events, + "rooms": self.process_room_events, + "groups": self.process_group_events, + } + self.event_dispatchers: Dict[str, callable] = {} + self.users = [] + + async def run(self, user_id: str = None, password: str = None, token: str = None): + if not password and not token: + raise RuntimeError("Either the password or a token is required") + self.user_id = user_id + self.password = password + self.token = token + self.api = API( + base_url=self.homeserver, user_id=self.user_id, password=self.password, token=self.token + ) + resp = await self.api.login() + if resp.get("errcode"): + raise RuntimeError(resp) + self.running = True + while self.running: + await self.sync() + if self.sync_delay: + await asyncio.sleep(self.sync_delay) + + async def sync(self): + resp = await self.api.get_sync( + self.sync_filter, + self.sync_since, + self.sync_full_state, + self.sync_set_presence, + self.sync_timeout, + ) + if resp.get("errcode"): + self.running = False + raise RuntimeError(resp) + self.sync_since = resp["next_batch"] + for key, value in resp.items(): + if key == "next_batch": + self.sync_since = value + else: + if key in self.sync_process_dispatcher: + func = self.sync_process_dispatcher[key] + await func(value) + return resp + + async def process_presence_events(self, value: dict): + events = value["events"] + for event_dict in events: + event = self.process_event(event_dict) + # TODO Do something with presence event... + + async def process_room_events(self, value: dict): + await self.process_room_join_events(value["join"]) + await self.process_room_invite_events(value["invite"]) + await self.process_room_leave_events(value["leave"]) + + async def process_room_join_events(self, rooms: dict): + from morpheus.core.events import StateEvent, MessageEvent + for room_id, data in rooms.items(): + if room_id not in self.rooms: + self.rooms[room_id] = Room(room_id, self) + room = self.rooms[room_id] + + # Process state events and update Room state + for event_dict in data["state"]["events"]: + event_dict["room"] = room + event = self.process_event(event_dict) + await room.update_state(event) + handler = self.event_dispatchers.get(event.type) + if handler: + await self.invoke(handler, event) + + # Process timeline + for event_dict in data["timeline"]["events"]: + event_dict["room"] = room + event = self.process_event(event_dict) + if isinstance(event, StateEvent): + await room.update_state(event) + elif isinstance(event, MessageEvent): + if event not in room.message_cache: + room.message_cache.append(event) + handler = self.event_dispatchers.get(event.type) + if handler: + await self.invoke(handler, event) + + # Process ephemeral events + for event in data['ephemeral']['events']: + if event['type'] == 'm.receipt': + room.update_read_receipts(event['content']) + # TODO Update read receipts for users + elif event['type'] == 'm.typing': + # TODO process typing messages + pass + + async def process_room_invite_events(self, rooms: dict): + pass + + async def process_room_leave_events(self, rooms: dict): + pass + + async def process_group_events(self, value: dict): + pass + + def process_event(self, event: dict): + from .events import ( + EventBase, + RoomEvent, + StateEvent, + RedactionEvent, + MessageEvent, + ) + + if event.get("redacted"): + return RedactionEvent.from_dict(self, event) + elif event.get("state_key") is not None: + return StateEvent.from_dict(self, event) + elif event["type"] == "m.presence": + return EventBase.from_dict(self, event) + elif event["type"] == "m.room.message": + return MessageEvent.from_dict(self, event) + else: + return RoomEvent.from_dict(self, event) + + @staticmethod + async def invoke(handler: callable, event): + # handler must be a callable which takes the event as an argument + await handler(event) + + def register_handler(self, event_type, handler: callable): + if not callable(handler): + raise TypeError(f'handler must be a callable not {type(handler)}') + self.event_dispatchers[event_type] = handler diff --git a/morpheus/core/content.py b/morpheus/core/content.py new file mode 100644 index 0000000..0b0ff4e --- /dev/null +++ b/morpheus/core/content.py @@ -0,0 +1,213 @@ +from dataclasses import dataclass, field +from typing import Optional, List, Dict + +from .utils import ( + EncryptedFile, + ImageInfo, + FileInfo, + AudioInfo, + VideoInfo, + LocationInfo, + PreviousRoom, + Invite, + ReactionRelation, + notification_power_levels_default_factory +) + + +@dataclass +class ContentBase: + pass + + +@dataclass +class MessageContentBase(ContentBase): + body: str + msgtype: str + + +@dataclass +class MTextContent(MessageContentBase): + format: Optional[str] = None + formatted_body: Optional[str] = None + msgtype = "m.text" + + +@dataclass +class MEmoteContent(MTextContent): + msgtype = "m.emote" + + +@dataclass +class MNoticeContent(MTextContent): + msgtype = "m.notice" + + +@dataclass +class MImageContent(MessageContentBase): + msgtype = "m.image" + info: ImageInfo + url: Optional[str] = None + file: Optional[EncryptedFile] = None + + +@dataclass +class MFileContent(MessageContentBase): + msgtype = "m.file" + filename: str + info: FileInfo + url: Optional[str] = None + file: Optional[EncryptedFile] = None + + +@dataclass +class MAudioContent(MessageContentBase): + msgtype = "m.audio" + info: AudioInfo + url: Optional[str] = None + file: Optional[EncryptedFile] = None + + +@dataclass +class MLocationContent(MessageContentBase): + msgtype = "m.location" + geo_uri: str + info: LocationInfo + + +@dataclass +class MVideoContent(MessageContentBase): + msgtype = "m.video" + info: VideoInfo + url: Optional[str] = None + file: Optional[EncryptedFile] = None + + +@dataclass +class PresenceContent(ContentBase): + presence: str + last_active_ago: int + currently_active: bool + avatar_url: Optional[str] = None + displayname: Optional[str] = None + status_message: Optional[str] = None + + +@dataclass +class MRoomAliasesContent(ContentBase): + aliases: List[str] + + +@dataclass +class MRoomCanonicalAliasContent(ContentBase): + alias: str + + +@dataclass +class MRoomCreateContent(ContentBase): + creator: str + room_version: Optional[str] = "1" + m_federate: Optional[bool] = True + predecessor: Optional[PreviousRoom] = None + + +@dataclass +class MRoomJoinRulesContent(ContentBase): + join_rule: str + + +@dataclass +class MRoomMemberContent(ContentBase): + membership: str + is_direct: bool = False + third_party_invite: Optional[Invite] = None + avatar_url: Optional[str] = None + displayname: str = None + inviter: str = None + + +@dataclass +class MRoomPowerLevelsContent(ContentBase): + ban: int = 50 + events: Dict[str, int] = field(default_factory=dict) + events_default: int = 0 + invite: int = 50 + kick: int = 50 + redact: int = 50 + state_default: int = 50 + users: Dict[str, int] = field(default_factory=dict) + users_default: int = 0 + notifications: Dict[str, int] = field(default_factory=notification_power_levels_default_factory) + + +@dataclass +class MRoomRedactionContent(ContentBase): + reason: str + + +@dataclass +class MRoomRelatedGroupsContent(ContentBase): + groups: List[str] + + +@dataclass +class MRoomTopicContent(ContentBase): + topic: str + + +@dataclass +class MRoomNameContent(ContentBase): + name: str + + +@dataclass +class MRoomHistoryVisibilityContent(ContentBase): + history_visibility: str + + +@dataclass +class MRoomBotOptionsContent(ContentBase): + options: Dict[str, dict] + + +@dataclass +class MReactionContent(ContentBase): + relation: ReactionRelation + + +@dataclass +class MRoomAvatarContent(ContentBase): + url: str + + +@dataclass +class MRoomGuestAccessContent(ContentBase): + guest_access: str + + +content_dispatcher = { + "m.text": MTextContent, + "m.audio": MAudioContent, + "m.emote": MEmoteContent, + "m.notice": MNoticeContent, + "m.image": MImageContent, + "m.file": MFileContent, + "m.location": MLocationContent, + "m.video": MVideoContent, + "m.presence": PresenceContent, + "m.room.aliases": MRoomAliasesContent, + "m.room.canonical_alias": MRoomCanonicalAliasContent, + "m.room.create": MRoomCreateContent, + "m.room.join_rules": MRoomJoinRulesContent, + "m.room.member": MRoomMemberContent, + "m.room.power_levels": MRoomPowerLevelsContent, + "m.room.redaction": MRoomRedactionContent, + "m.room.related_groups": MRoomRelatedGroupsContent, + "m.room.topic": MRoomTopicContent, + "m.room.name": MRoomNameContent, + "m.room.history_visibility": MRoomHistoryVisibilityContent, + "m.room.bot.options": MRoomBotOptionsContent, + 'm.reaction': MReactionContent, + 'm.room.avatar': MRoomAvatarContent, + 'm.room.guest_access': MRoomGuestAccessContent, +} diff --git a/morpheus/core/context.py b/morpheus/core/context.py new file mode 100644 index 0000000..85636af --- /dev/null +++ b/morpheus/core/context.py @@ -0,0 +1 @@ +# TODO Create Context diff --git a/morpheus/core/events.py b/morpheus/core/events.py new file mode 100644 index 0000000..da29942 --- /dev/null +++ b/morpheus/core/events.py @@ -0,0 +1,75 @@ +from dataclasses import dataclass +from typing import Optional, List + +from .client import Client +from .room import Room +from .content import ContentBase +from .utils import ReactionRelation + + +@dataclass +class EventBase: + client: Client + content: ContentBase + type: str + sender: str + + @classmethod + def from_dict(cls, client: Client, event_dict: dict): + from .content import content_dispatcher + if event_dict['type'] == 'm.room.message': + content_class = content_dispatcher[event_dict['content']['msgtype']] + else: + content_class = content_dispatcher[event_dict['type']] + + if event_dict['type'] == 'm.reaction': + content_dict = {'relation': ReactionRelation(**event_dict['content']['m.relates_to'])} + elif event_dict['type'] == 'm.room.bot.options': + content_dict = {'options': event_dict['content']} + else: + content_dict = event_dict['content'] + del event_dict['content'] + + return cls( + client=client, + content=content_class(**content_dict), + **event_dict + ) + + +@dataclass +class UnsignedData: + age: int + redacted_because: Optional[EventBase] = None + transaction_id: Optional[str] = None + invite_room_state: Optional[List[EventBase]] = None + + +@dataclass +class RoomEvent(EventBase): + event_id: str + origin_server_ts: int + unsigned: UnsignedData + room: Room + + +@dataclass +class StateEvent(RoomEvent): + state_key: str + age: int = None + prev_content: Optional[EventBase] = None + + +@dataclass +class RedactionEvent(RoomEvent): + redacts: EventBase + + +@dataclass +class MessageEvent(RoomEvent): + pass + + +@dataclass +class PresenceEvent(EventBase): + pass diff --git a/morpheus/core/room.py b/morpheus/core/room.py new file mode 100644 index 0000000..59e8879 --- /dev/null +++ b/morpheus/core/room.py @@ -0,0 +1,94 @@ +# TODO Add Room class +from typing import List, Optional, Dict +from datetime import datetime, timedelta +from collections import deque + +from .content import ( + MRoomPowerLevelsContent, + MRoomAliasesContent, + MRoomBotOptionsContent, + MRoomCanonicalAliasContent, + MRoomCreateContent, + MRoomHistoryVisibilityContent, + MRoomJoinRulesContent, + MRoomNameContent, + MRoomRelatedGroupsContent, + MRoomTopicContent, +) +from .utils import PreviousRoom + + +class Room: + def __init__(self, room_id: str, client): + from .client import Client + + self.id = room_id + self.client: Client = client + self.groups: Optional[List[str]] = None + self.topic: str = "" + self.join_rule: Optional[str] = None + self.version: int = 4 + self.creator: Optional[str] = None + self.created_at: Optional[datetime] = None + self.name: Optional[str] = None + self.aliases: Optional[List[str]] = None + self.history_visibility: Optional[str] = None + self.avatar_url: str = "" + self.canonical_alias: Optional[str] = None + self.power_levels: Optional[MRoomPowerLevelsContent] = None + self.bot_options: Optional[Dict[str, dict]] = None + self.federated: bool = True + self.predecessor: Optional[PreviousRoom] = None + self.heroes: Optional[List[str]] = None + self.joined_member_count: Optional[int] = None + self.invited_member_count: Optional[int] = None + self.read_receipts: Dict[str, Dict[str, int]] = {} + self.message_cache = deque(maxlen=1000) + + def update_read_receipts(self, receipts: Dict[str, Dict[str, Dict[str, Dict[str, int]]]]): + for event_id, receipt in receipts.items(): + users = receipt['m.read'] + for user, time in users.items(): + self.read_receipts[user] = {event_id: time['ts']} + + async def update_state(self, state_event=None): + from .events import StateEvent + + if not state_event or state_event.room != self: + path = self.client.api.build_url(f"rooms/{self.id}/state") + state_events = await self.client.api.send("GET", path) + for state_event in state_events: + self._update_state(self.client.process_event(state_event)) + else: + if not isinstance(state_event, StateEvent) or state_event.type == "m.room.member": + return + self._update_state(state_event) + + def _update_state(self, event): + content = event.content + if isinstance(content, MRoomTopicContent): + self.topic = content.topic + elif isinstance(content, MRoomNameContent): + self.name = content.name + elif isinstance(content, MRoomRelatedGroupsContent): + self.groups = content.groups + elif isinstance(content, MRoomJoinRulesContent): + self.join_rule = content.join_rule + elif isinstance(content, MRoomHistoryVisibilityContent): + self.history_visibility = content.history_visibility + elif isinstance(content, MRoomCreateContent): + self.creator = content.creator + self.federated = content.m_federate + self.version = content.room_version + self.predecessor = content.predecessor + elif isinstance(content, MRoomCanonicalAliasContent): + self.canonical_alias = content.alias + elif isinstance(content, MRoomAliasesContent): + self.aliases = content.aliases + elif isinstance(content, MRoomBotOptionsContent): + self.bot_options = content.options + elif isinstance(content, MRoomPowerLevelsContent): + self.power_levels = content + + def __eq__(self, other): + return other.__class__ == self.__class__ and other.id == self.id diff --git a/morpheus/core/utils.py b/morpheus/core/utils.py new file mode 100644 index 0000000..b632a05 --- /dev/null +++ b/morpheus/core/utils.py @@ -0,0 +1,96 @@ +from dataclasses import dataclass +from typing import Optional, List, Dict + + +@dataclass +class JSONWebKey: + key_opts: List[str] + k: str + ext: bool = True + alg: str = "A256CTR" + kty: str = "oct" + + +@dataclass +class EncryptedFile: + url: str + key: JSONWebKey + iv: str + hashes: Dict[str, str] + v: str = "v2" + + +@dataclass +class ImageInfoBase: + h: int + w: int + mimetype: str + size: int + + +@dataclass +class ImageInfo(ImageInfoBase): + thumbnail_info: ImageInfoBase + thumbnail_url: Optional[str] = None + thumbnail_file: Optional[EncryptedFile] = None + + +@dataclass +class FileInfo: + mimetype: str + size: int + thumbnail_info: ImageInfoBase + thumbnail_url: Optional[str] = None + thumbnail_file: Optional[EncryptedFile] = None + + +@dataclass +class AudioInfo: + duration: int + mimetype: str + size: int + + +@dataclass +class LocationInfo: + thumbnail_info: ImageInfoBase + thumbnail_url: Optional[str] = None + thumbnail_file: Optional[EncryptedFile] = None + + +@dataclass +class VideoInfo(ImageInfoBase): + duration: int + thumbnail_info: ImageInfoBase + thumbnail_url: Optional[str] = None + thumbnail_file: Optional[EncryptedFile] = None + + +@dataclass +class PreviousRoom: + room_id: str + event_id: str + + +@dataclass +class Signed: + mxid: str + signatures: Dict[str, Dict[str, str]] + token: str + + +@dataclass +class Invite: + display_name: str + signed: Signed + + +@dataclass +class ReactionRelation: + rel_type: str + event_id: str + key: str + + +def notification_power_levels_default_factory(): + return {'room': 50} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ce23571 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +aiohttp \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29