From 01e4e9f18b106be9b7dbed4bd78b20128869d561 Mon Sep 17 00:00:00 2001 From: Patrick Neff Date: Tue, 4 Aug 2020 12:03:56 +0200 Subject: [PATCH] Add control script --- .gitignore | 3 + Pipfile | 1 - Pipfile.lock | 99 +-------------- matrix_bot/bot.py | 283 ++++++++++++++++++++++++++++--------------- matrix_bot/botctl.py | 35 ++++++ matrix_bot/config.py | 15 +++ setup.py | 7 +- 7 files changed, 241 insertions(+), 202 deletions(-) create mode 100644 matrix_bot/botctl.py create mode 100644 matrix_bot/config.py diff --git a/.gitignore b/.gitignore index 6c62e1bf..9d2517a6 100644 --- a/.gitignore +++ b/.gitignore @@ -140,6 +140,9 @@ cython_debug/ # Project specific credentials.json store/* +next_batch +fifo +bot # VSCode .vscode/* diff --git a/Pipfile b/Pipfile index 18f3dce0..f35b8eb5 100644 --- a/Pipfile +++ b/Pipfile @@ -7,7 +7,6 @@ verify_ssl = true flake8 = "*" flake8-quotes = "*" flake8-annotations = "*" -black = "*" yapf = "*" [packages] diff --git a/Pipfile.lock b/Pipfile.lock index e0ffae16..ee8ded63 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "b572da4f2fb6963d3f61a7a2234f625fd8c09274e429df366367fb4b359510a0" + "sha256": "a17f7aa5b096658663f9e578d693b9f69f01711f3776f8ede29ac1ff34175380" }, "pipfile-spec": 6, "requires": { @@ -331,37 +331,6 @@ } }, "develop": { - "appdirs": { - "hashes": [ - "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", - "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" - ], - "version": "==1.4.4" - }, - "attrs": { - "hashes": [ - "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", - "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==19.3.0" - }, - "black": { - "hashes": [ - "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b", - "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539" - ], - "index": "pypi", - "version": "==19.10b0" - }, - "click": { - "hashes": [ - "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", - "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==7.1.2" - }, "flake8": { "hashes": [ "sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c", @@ -392,13 +361,6 @@ ], "version": "==0.6.1" }, - "pathspec": { - "hashes": [ - "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0", - "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061" - ], - "version": "==0.8.0" - }, "pycodestyle": { "hashes": [ "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", @@ -415,65 +377,6 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.2.0" }, - "regex": { - "hashes": [ - "sha256:0dc64ee3f33cd7899f79a8d788abfbec168410be356ed9bd30bbd3f0a23a7204", - "sha256:1269fef3167bb52631ad4fa7dd27bf635d5a0790b8e6222065d42e91bede4162", - "sha256:14a53646369157baa0499513f96091eb70382eb50b2c82393d17d7ec81b7b85f", - "sha256:3a3af27a8d23143c49a3420efe5b3f8cf1a48c6fc8bc6856b03f638abc1833bb", - "sha256:46bac5ca10fb748d6c55843a931855e2727a7a22584f302dd9bb1506e69f83f6", - "sha256:4c037fd14c5f4e308b8370b447b469ca10e69427966527edcab07f52d88388f7", - "sha256:51178c738d559a2d1071ce0b0f56e57eb315bcf8f7d4cf127674b533e3101f88", - "sha256:5ea81ea3dbd6767873c611687141ec7b06ed8bab43f68fad5b7be184a920dc99", - "sha256:6961548bba529cac7c07af2fd4d527c5b91bb8fe18995fed6044ac22b3d14644", - "sha256:75aaa27aa521a182824d89e5ab0a1d16ca207318a6b65042b046053cfc8ed07a", - "sha256:7a2dd66d2d4df34fa82c9dc85657c5e019b87932019947faece7983f2089a840", - "sha256:8a51f2c6d1f884e98846a0a9021ff6861bdb98457879f412fdc2b42d14494067", - "sha256:9c568495e35599625f7b999774e29e8d6b01a6fb684d77dee1f56d41b11b40cd", - "sha256:9eddaafb3c48e0900690c1727fba226c4804b8e6127ea409689c3bb492d06de4", - "sha256:bbb332d45b32df41200380fff14712cb6093b61bd142272a10b16778c418e98e", - "sha256:bc3d98f621898b4a9bc7fecc00513eec8f40b5b83913d74ccb445f037d58cd89", - "sha256:c11d6033115dc4887c456565303f540c44197f4fc1a2bfb192224a301534888e", - "sha256:c50a724d136ec10d920661f1442e4a8b010a4fe5aebd65e0c2241ea41dbe93dc", - "sha256:d0a5095d52b90ff38592bbdc2644f17c6d495762edf47d876049cfd2968fbccf", - "sha256:d6cff2276e502b86a25fd10c2a96973fdb45c7a977dca2138d661417f3728341", - "sha256:e46d13f38cfcbb79bfdb2964b0fe12561fe633caf964a77a5f8d4e45fe5d2ef7" - ], - "version": "==2020.7.14" - }, - "toml": { - "hashes": [ - "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", - "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88" - ], - "version": "==0.10.1" - }, - "typed-ast": { - "hashes": [ - "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", - "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", - "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", - "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", - "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", - "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", - "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", - "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", - "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", - "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", - "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", - "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", - "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", - "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", - "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", - "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", - "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", - "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", - "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", - "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", - "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" - ], - "version": "==1.4.1" - }, "yapf": { "hashes": [ "sha256:3000abee4c28daebad55da6c85f3cd07b8062ce48e2e9943c8da1b9667d48427", diff --git a/matrix_bot/bot.py b/matrix_bot/bot.py index 5da293a5..b3a5f455 100644 --- a/matrix_bot/bot.py +++ b/matrix_bot/bot.py @@ -1,33 +1,18 @@ -from nio import ( - AsyncClient, - AsyncClientConfig, - LoginResponse, - KeyVerificationEvent, - KeyVerificationStart, - KeyVerificationCancel, - KeyVerificationKey, - KeyVerificationMac, - ToDeviceError, - LocalProtocolError, -) +from nio import (AsyncClient, AsyncClientConfig, LoginResponse, + KeyVerificationEvent, KeyVerificationStart, + KeyVerificationCancel, KeyVerificationKey, KeyVerificationMac, + ToDeviceError, LocalProtocolError, SyncResponse, + RoomMessageText) import click import traceback import sys import os import json import asyncio -import signal +import markdown -# The directory containing the store and credentials file -CONFIG_DIRECTORY = '.' -# file to store credentials in case you want to run program multiple times -# login credentials JSON file -CONFIG_FILE = os.path.join(CONFIG_DIRECTORY, 'credentials.json') -# directory to store persistent data for end-to-end encryption -STORE_PATH = os.path.join(CONFIG_DIRECTORY, 'store') # local directory - -# Default Homeserver URL -HOMESERVER_URL = 'https://matrix.gaja-group.com' +from .config import (CONFIG_FILE, STORE_PATH, NEXT_BATCH_PATH, HOMESERVER_URL, + FIFO_PATH) def write_details_to_disk(resp: LoginResponse, credentials: dict) -> None: @@ -61,16 +46,12 @@ def write_details_to_disk(resp: LoginResponse, credentials: dict) -> None: ) -class Bot: +class Bot(): def __init__(self) -> None: self._loop = asyncio.get_event_loop() - signals = (signal.SIGHUP, signal.SIGTERM, signal.SIGINT) - for sig in signals: - self._loop.add_signal_handler( - sig, lambda sig=sig: asyncio.create_task(self.shutdown(sig))) @classmethod - async def login(cls: any) -> object: + async def login(cls: any) -> any: bot = cls() await bot._login() return bot @@ -91,24 +72,41 @@ class Bot: def config(self) -> dict: return self._config - def send_text_message_to_room(self, room_id: str, message: str) -> None: - async def send_message(room_id: str, message: str) -> None: - """Login and wait for and perform emoji verify.""" - # Set up event callbacks - content = {'msgtype': 'm.text', 'body': message} - await self._login() - if room_id is None: - room_id = self.config['room_id'] - await self.client.sync(timeout=30000, full_state=True) - await self.client.room_send( - room_id=room_id, - message_type='m.room.message', - content=content, - ignore_unverified_devices=True, - ) - await self.client.close() + async def send_message_to_room( + self, + room_id: str, + content: dict, + message_type: str = 'm.room.message') -> None: + """Login and wait for and perform emoji verify.""" + # Set up event callbacks + await self._login() + if room_id is None: + room_id = self.config['room_id'] + await self.client.sync(timeout=30000, full_state=True) + await self.client.room_send( + room_id=room_id, + message_type=message_type, + content=content, + ignore_unverified_devices=True, + ) + await self.client.set_presence('offline') + await self.client.close() - self.loop.run_until_complete(send_message(room_id, message)) + async def send_text_to_room(self, room_id: str, message: str) -> None: + content = { + 'msgtype': 'm.text', + 'format': 'org.matrix.custom.html', + 'formatted_body': markdown.markdown(message), + } + await self.send_message_to_room(room_id, content) + + async def send_notice_to_room(self, room_id: str, message: str) -> None: + content = { + 'msgtype': 'm.notice', + 'format': 'org.matrix.custom.html', + 'formatted_body': markdown.markdown(message), + } + await self.send_message_to_room(room_id, content) async def _login(self) -> AsyncClient: """Login to the matrix homeserver defined in the config file. @@ -131,7 +129,7 @@ class Bot: if not os.path.exists(STORE_PATH): os.makedirs(STORE_PATH) - credentials = self.ask_credentials() + credentials = self._ask_credentials() # Initialize the matrix client client = AsyncClient( @@ -165,7 +163,7 @@ class Bot: 'user_id': credentials['user_id'], 'homeserver': credentials['homeserver'], 'room_id': credentials['room_id'], - 'device_name': resp['device_name'], + 'device_name': credentials['device_name'], 'device_id': resp.device_id, 'access_token': resp.access_token, } @@ -199,6 +197,7 @@ class Bot: # click.secho('Logged in using stored credentials.', fg='green') self._client = client + await self.client.set_presence('online') return client @@ -214,57 +213,100 @@ class Bot: await asyncio.gather(*tasks, return_exceptions=False) self.loop.stop() - def verify(self) -> None: - async def verify() -> None: - """Login and wait for and perform emoji verify.""" - # Set up event callbacks - client = await self._login() - client.add_to_device_callback(self.to_device_callback, - (KeyVerificationEvent, )) - # Sync encryption keys with the server - # Required for participating in encrypted rooms - if self.client.should_upload_keys: - await self.client.keys_upload() - click.secho('\nStarting verification process...', bold=True) - click.secho( - '\nThis program is ready and waiting for the other ' - 'party to initiate an emoji verification with us by ' - 'selecting "Verify by Emoji" in their Matrix ' - 'client.', - fg='green', - ) - await self.client.sync_forever(timeout=30000, full_state=True) + async def _verify(self) -> None: + """Login and wait for and perform emoji verify.""" + # Set up event callbacks + client = await self._login() + client.add_to_device_callback(self._to_device_callback, + (KeyVerificationEvent, )) + # Sync encryption keys with the server + # Required for participating in encrypted rooms + if self.client.should_upload_keys: + await self.client.keys_upload() + click.secho('\nStarting verification process...', bold=True) + click.secho( + '\nThis program is ready and waiting for the other ' + 'party to initiate an emoji verification with us by ' + 'selecting "Verify by Emoji" in their Matrix ' + 'client.', + fg='green', + ) + await self.client.sync_forever(timeout=30000, full_state=True) - self.loop.run_until_complete(verify()) + def verify(self) -> None: + self._run_async(self._verify()) + + def run(self) -> None: + self._run_async(self._run()) + + async def _run(self) -> None: + fifo_name = FIFO_PATH + + if os.path.exists(fifo_name): + os.remove(fifo_name) + + os.mkfifo(fifo_name) + pipe_fd = os.open(fifo_name, (os.O_RDONLY | os.O_NONBLOCK)) + + client = await self._login() + + # we read the previously-written token... + next_batch_name = NEXT_BATCH_PATH + if os.path.exists(next_batch_name): + with open(next_batch_name, 'r') as next_batch_token: + # ... and well async_client to use it + self.client.next_batch = next_batch_token.read() + + client.add_response_callback(self._sync_callback, SyncResponse) + client.add_event_callback(self._room_text_message_callback, + RoomMessageText) + await asyncio.gather( + self.client.sync_forever(timeout=30000, full_state=True), + self._fifo_reader(pipe_fd), + ) + + def _run_async(self, future: asyncio.Future) -> None: + try: + self.loop.run_until_complete(future) + except Exception: + print(traceback.format_exc()) + sys.exit(1) + except asyncio.exceptions.CancelledError: + sys.exit(0) + except KeyboardInterrupt: + sys.exit(0) def _ask_credentials(self) -> dict: """Ask the user for credentials """ - homeserver = HOMESERVER_URL - homeserver = click.prompt( - click.style('Enter your homeserver URL', bold=True), - default=homeserver, - ) + try: + homeserver = HOMESERVER_URL + homeserver = click.prompt( + click.style('Enter your homeserver URL', bold=True), + default=homeserver, + ) - if not homeserver.startswith('https://'): - homeserver = 'https://' + homeserver + if not homeserver.startswith('https://'): + homeserver = 'https://' + homeserver - user_id = '@user:gaja-group.com' - user_id = click.prompt(click.style('Enter your full user ID', - bold=True), - default=user_id) + user_id = '@user:gaja-group.com' + user_id = click.prompt(click.style('Enter your full user ID', + bold=True), + default=user_id) - device_name = 'matrix-bot' - device_name = click.prompt( - click.style('Choose a name for this device', bold=True), - default=device_name, - ) + device_name = 'matrix-bot' + device_name = click.prompt( + click.style('Choose a name for this device', bold=True), + default=device_name, + ) - room_id = '!yourRoomId:gaja-group.com' - room_id = click.prompt( - click.style('Enter a default room ID to send to', bold=True), - default=room_id, - ) + room_id = '!yourRoomId:gaja-group.com' + room_id = click.prompt( + click.style('Enter a default room ID to send to', bold=True), + default=room_id, + ) + except click.exceptions.Abort: + sys.exit(0) return { 'homeserver': homeserver, @@ -273,7 +315,7 @@ class Bot: 'room_id': room_id, } - async def to_device_callback(self, event): # noqa + async def _to_device_callback(self, event): # noqa """Handle events sent to device.""" try: client = self.client @@ -455,21 +497,60 @@ class Bot: # f'sas.timed_out = {sas.timed_out}\n' # f'sas.verified = {sas.verified}\n' # f'sas.verified_devices = {sas.verified_devices}\n') - click.secho('Emoji verification was successful!', - fg='green') - await self.shutdown([], self._loop) + click.secho( + 'Emoji verification was successful! Please use Ctrl+C ' + 'to exit.', + fg='green') else: print(f'Received unexpected event type {type(event)}. ' f'Event is {event}. Event will be ignored.') except BaseException: print(traceback.format_exc()) + def _sync_callback(self, event: any) -> None: + with open(NEXT_BATCH_PATH, 'w') as next_batch_token: + next_batch_token.write(event.next_batch) -@click.group() + async def _room_text_message_callback(self, room: any, + message: any) -> None: + if room.room_id == self.config['room_id']: + if message.body.startswith('!help'): + await self.send_notice_to_room( + room.room_id, """##### Usage + +**!help** - displays this help message + """) + + async def _fifo_reader(self, pipe_fd: str) -> None: + with os.fdopen(pipe_fd) as file: + while True: + data = file.read() + if len(data) > 0: + try: + data = data.split('\0') + for d in data: + if (len(d) > 0): + cmd = json.loads(d) + room_id = self.config['room_id'] + if cmd['type'] == 'message': + if 'room_id' in cmd.keys(): + room_id = cmd['room_id'] + await self.send_notice_to_room( + room_id, cmd['content']) + except json.JSONDecodeError as e: + print('JSON decode error:', e) + pass + await asyncio.sleep(1) + + +@click.group(invoke_without_command=True) @click.option('-v', '--verbose', count=True) -def cli(verbose: int) -> None: +@click.pass_context +def cli(ctx: click.Context, verbose: int) -> None: # click.secho('Matrix Bot\n', bold='true') - pass + if ctx.invoked_subcommand is None: + bot = Bot() + bot.run() @cli.command('verify') @@ -483,7 +564,13 @@ def verify_command() -> None: @click.option('-r', '--room', help='the room to send to') def send_command(message: str, room: str) -> None: bot = Bot() - bot.send_text_message_to_room(room, message) + bot._run_async(bot.send_text_to_room(room, message)) + + +@cli.command('run') +def run() -> None: + bot = Bot() + bot.run() if __name__ == '__main__': diff --git a/matrix_bot/botctl.py b/matrix_bot/botctl.py new file mode 100644 index 00000000..e8d782f8 --- /dev/null +++ b/matrix_bot/botctl.py @@ -0,0 +1,35 @@ +import os +import sys +import click +import json + +from .config import FIFO_PATH + + +def send_message(message: str) -> None: + fifo_name = FIFO_PATH + + if not os.path.exists(fifo_name): + sys.exit(1) + + # Starting two readers and one writer, but only one reader + # will be reading at the same time. + content = {'type': 'message', 'content': message} + with open(fifo_name, 'w') as file: + file.write(json.dumps(content)) + file.write('\0') + + +@click.group() +def cli() -> None: + pass + + +@cli.command() +@click.argument('message', nargs=-1) +def send(message: list) -> None: + send_message(' '.join(message)) + + +if __name__ == '__main__': + cli() diff --git a/matrix_bot/config.py b/matrix_bot/config.py new file mode 100644 index 00000000..2e565e96 --- /dev/null +++ b/matrix_bot/config.py @@ -0,0 +1,15 @@ +import os + +# The directory containing the store and credentials file +CONFIG_DIRECTORY = '.' +# file to store credentials in case you want to run program multiple times +# login credentials JSON file +CONFIG_FILE = os.path.join(CONFIG_DIRECTORY, 'credentials.json') +# directory to store persistent data for end-to-end encryption +STORE_PATH = os.path.join(CONFIG_DIRECTORY, 'store') # local directory +FIFO_PATH = os.path.join(CONFIG_DIRECTORY, 'fifo') # local directory +NEXT_BATCH_PATH = os.path.join(CONFIG_DIRECTORY, + 'next_batch') # local directory + +# Default Homeserver URL +HOMESERVER_URL = 'https://matrix.gaja-group.com' diff --git a/setup.py b/setup.py index 9c5ee2fb..d067e4a5 100644 --- a/setup.py +++ b/setup.py @@ -4,13 +4,10 @@ setup( name='matrix-bot', version='0.1', py_modules=['matrix_bot'], - install_requires=[ - 'Click', - 'matrix-nio[e2e]', - 'markdown' - ], + install_requires=['Click', 'matrix-nio[e2e]', 'markdown'], entry_points=""" [console_scripts] matrix-bot=matrix_bot.bot:cli + matrix-botctl=matrix_bot.botctl:cli """, )