From 03fd7066425f9449a8344a650e5a61ac2774f57a Mon Sep 17 00:00:00 2001 From: Dusty Pianalto Date: Sat, 19 Oct 2019 10:10:52 -0800 Subject: [PATCH] Initial Commit --- .gitignore | 91 ++++++++++ .idea/.gitignore | 2 + .idea/geeksbot-matrix.iml | 11 ++ .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 7 + .idea/modules.xml | 8 + .idea/watcherTasks.xml | 25 +++ geeksbot.py | 43 +++++ lib/__init__.py | 0 lib/http.py | 167 ++++++++++++++++++ 10 files changed, 360 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/geeksbot-matrix.iml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/watcherTasks.xml create mode 100644 geeksbot.py create mode 100644 lib/__init__.py create mode 100644 lib/http.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7632a21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,91 @@ +config.json + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# mypy +.mypy_cache/ + +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +.idea/vcs.xml + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/dictionaries +.idea/**/shelf + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# CMake +cmake-build-debug/ +cmake-build-release/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..5c98b42 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,2 @@ +# Default ignored files +/workspace.xml \ No newline at end of file diff --git a/.idea/geeksbot-matrix.iml b/.idea/geeksbot-matrix.iml new file mode 100644 index 0000000..4746ad2 --- /dev/null +++ b/.idea/geeksbot-matrix.iml @@ -0,0 +1,11 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..d825a43 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..6f3c1e6 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/watcherTasks.xml b/.idea/watcherTasks.xml new file mode 100644 index 0000000..00e48b4 --- /dev/null +++ b/.idea/watcherTasks.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/geeksbot.py b/geeksbot.py new file mode 100644 index 0000000..cbabbb6 --- /dev/null +++ b/geeksbot.py @@ -0,0 +1,43 @@ +import json +import asyncio +from nio import (AsyncClient, RoomMessageText) +import logging + +log_format = '%(asctime)s ||| %(name)s | %(levelname)s | %(message)s' +logging.basicConfig(format=log_format, level=logging.INFO) +logger = logging.getLogger('geeksbot') +logger.setLevel(logging.INFO) + + +class Geeksbot(AsyncClient): + def __init__(self, base_url, username, password): + self.base_url = base_url + self.username = username + self.password = password + super(Geeksbot, self).__init__(self.base_url, self.username) + self.add_event_callback(self.on_message, RoomMessageText) + + async def login(self): + await super(Geeksbot, self).login(self.password) + + async def on_message(self, room, event): + logger.info(f'Message recieved for room {room.display_name} | {room.user_name(event.sender)}: {event.body}') + if event.body.startswith('$say '): + msg = { + "body": event.body.split(' ', 1)[1], + "msgtype": 'm.text' + } + print(msg) + resp = await self.room_send(room.room_id, 'm.room.message', msg) + print(resp) + + +async def main(): + with open('config.json') as f: + config = json.load(f) + client = Geeksbot(**config) + + await client.login() + await client.sync_forever(timeout=1000) + +asyncio.get_event_loop().run_until_complete(main()) \ No newline at end of file diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/http.py b/lib/http.py new file mode 100644 index 0000000..b86ab74 --- /dev/null +++ b/lib/http.py @@ -0,0 +1,167 @@ +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 HTTPConfig: + max_retry: int = 10 + max_wait_time: int = 3600 + backoff_factor: float = 0.1 + ssl: bool = None + proxy: str = None + + +class HTTP: + def __init__( + self, + *, + base_url: str, + username: str, + password: str = None, + token: str = None, + device_id: str = None, + device_name: str = None, + config: HTTPConfig = HTTPConfig(), + ): + self.base_url = base_url + self.username = username + 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)}" + 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 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: + data = { + "type": "m.login.password", + "identifier": {"user": self.username, "type": "m.id.user"}, + "password": self.password, + } + elif self.token: + data = {"type": "m.login.token", "token": self.token} + 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") + 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)