From cfe2cf16db8390bb32b9efc41245bc3d79805165 Mon Sep 17 00:00:00 2001 From: Patrick Neff Date: Wed, 5 Aug 2020 23:13:23 +0200 Subject: [PATCH] Rewrite bot --- .editorconfig | 5 +- .gitignore | 2 + Pipfile | 2 + Pipfile.lock | 18 +- README.md | 11 +- matrix_bot/bot.py | 442 ++++++++++++++++----------------------- matrix_bot/botctl.py | 35 ---- matrix_bot/cli.py | 117 +++++++++++ matrix_bot/config.py | 41 ++-- matrix_bot/exceptions.py | 2 + matrix_bot/message.py | 89 ++++++++ matrix_bot/utils.py | 30 +++ setup.py | 5 +- 13 files changed, 482 insertions(+), 317 deletions(-) delete mode 100644 matrix_bot/botctl.py create mode 100644 matrix_bot/cli.py create mode 100644 matrix_bot/exceptions.py create mode 100644 matrix_bot/message.py create mode 100644 matrix_bot/utils.py diff --git a/.editorconfig b/.editorconfig index 73c55167..31cd5bc4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,4 +5,7 @@ indent_style = space indent_size = 4 charset = utf-8 trim_trailing_whitespace = false -insert_final_newline = false \ No newline at end of file +insert_final_newline = false + +[*.py] +insert_final_newline = true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9d2517a6..4a8c64ee 100644 --- a/.gitignore +++ b/.gitignore @@ -154,3 +154,5 @@ bot # Local History for Visual Studio Code .history/ + +bot_old.py diff --git a/Pipfile b/Pipfile index f35b8eb5..c95d1003 100644 --- a/Pipfile +++ b/Pipfile @@ -8,9 +8,11 @@ flake8 = "*" flake8-quotes = "*" flake8-annotations = "*" yapf = "*" +isort = "*" [packages] matrix-bot = {editable = true, path = "."} +appdirs = "*" [requires] python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock index ee8ded63..2412998c 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "a17f7aa5b096658663f9e578d693b9f69f01711f3776f8ede29ac1ff34175380" + "sha256": "4e2921a6197811de728000532c94147d2fdcf4a20d76d1d7e6da6d39eebd4d91" }, "pipfile-spec": 6, "requires": { @@ -41,6 +41,14 @@ "markers": "python_full_version >= '3.5.3'", "version": "==3.6.2" }, + "appdirs": { + "hashes": [ + "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", + "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" + ], + "index": "pypi", + "version": "==1.4.4" + }, "async-timeout": { "hashes": [ "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", @@ -354,6 +362,14 @@ "index": "pypi", "version": "==3.2.0" }, + "isort": { + "hashes": [ + "sha256:723de131d9ae9d2561844f0ee525ce33a396a11bcda57174f036ed5ab3d6a122", + "sha256:cdca22530d093ed16983ba52c41560fa0219d1b958e44fd2ae2995dcc7b785be" + ], + "index": "pypi", + "version": "==5.3.0" + }, "mccabe": { "hashes": [ "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", diff --git a/README.md b/README.md index 439733eb..b4fb3cdb 100644 --- a/README.md +++ b/README.md @@ -30,15 +30,10 @@ After that you can begin sending messages cd MATRIX_BOT_DIR source venv/bin/activate - matrix-bot message "Message Content" # To send to the default room - matrix-bot message -r '!yourRoomId' "Message Content" # To send to a specific room + matrix-bot send '!yourRoomId' "Message Content" -Or start the bot in daemon mode. In this mode the configured room will be monitored for incoming messages and you can send messages from external scripts with `matrix-botctl` +Or start the bot in daemon mode. In this mode the bot will listen for defined events (not implemented yet) cd MATRIX_BOT_DIR source venv/bin/activate - matrix-bot - - # Then you can send messages with - - matrix-botctl message "Message Content" + matrix-bot run diff --git a/matrix_bot/bot.py b/matrix_bot/bot.py index b3a5f455..65c7f675 100644 --- a/matrix_bot/bot.py +++ b/matrix_bot/bot.py @@ -1,124 +1,46 @@ -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 markdown +import json +import logging +import os +import signal +import sys +import traceback -from .config import (CONFIG_FILE, STORE_PATH, NEXT_BATCH_PATH, HOMESERVER_URL, - FIFO_PATH) +import click +from nio import (AsyncClient, AsyncClientConfig, InviteEvent, + KeyVerificationCancel, KeyVerificationEvent, + KeyVerificationKey, KeyVerificationMac, KeyVerificationStart, + LocalProtocolError, LoginResponse, MatrixRoom, RoomMessage, + RoomMessageText, SyncResponse, ToDeviceError) + +from .config import Config +from .utils import setup_logger + +# from .message import TextMessage -def write_details_to_disk(resp: LoginResponse, credentials: dict) -> None: - """Write the required login details to disk. - - It will allow following logins to be made without password. - - Arguments: - --------- - resp : LoginResponse - successful client login response - credentials : dict - The credentials used to sign in - - """ - # open the config file in write-mode - with open(CONFIG_FILE, 'w') as f: - # write the login details to disk - json.dump( - { - 'homeserver': credentials[ - 'homeserver'], # e.g. "https://matrix.example.org" - 'device_name': - credentials['device_name'], # e.g. 'matrix-bot' - 'room_id': - credentials['room_id'], # e.g. '!yourRoomId:example.org' - 'user_id': resp.user_id, # e.g. '@user:example.org' - 'device_id': - resp.device_id, # device ID, 10 uppercase letters - 'access_token': resp.access_token, # cryptogr. access token - }, - f, - ) - - -class Bot(): +class Bot(object): def __init__(self) -> None: - self._loop = asyncio.get_event_loop() + self.__logger = setup_logger(__name__) + self.logger.debug('Initializing Bot') + self.__loop = asyncio.get_running_loop() + self.__first_sync = True + for s in (signal.SIGINT, signal.SIGTERM): + self.__loop.add_signal_handler(s, self.__signal_handler) - @classmethod - async def login(cls: any) -> any: - bot = cls() - await bot._login() - return bot - - @property - def client(self) -> AsyncClient: - return self._client - - @property - def loop(self) -> asyncio.BaseEventLoop: - return self._loop - - @property - def client_config(self) -> AsyncClientConfig: - return self._client_config - - @property - def config(self) -> dict: - return self._config - - 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() - - 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: + async def login(self) -> AsyncClient: """Login to the matrix homeserver defined in the config file. """ - self._client_config = AsyncClientConfig( + self.logger.debug('Starting login process') + self.__client_config = AsyncClientConfig( max_limit_exceeded=0, max_timeouts=0, store_sync_tokens=True, encryption_enabled=True, ) # If there are no previously-saved credentials, we'll use the password - if not os.path.exists(CONFIG_FILE): + if not os.path.exists(Config.CONFIG_FILE): + self.logger.debug('Starting password verification process') click.secho( 'First time use. Did not find credential file. Asking ' 'for homeserver, user, and password to create ' @@ -126,8 +48,8 @@ class Bot(): bold=True, ) - if not os.path.exists(STORE_PATH): - os.makedirs(STORE_PATH) + if not os.path.exists(Config.STORE_PATH): + os.makedirs(Config.STORE_PATH) credentials = self._ask_credentials() @@ -135,7 +57,7 @@ class Bot(): client = AsyncClient( credentials['homeserver'], credentials['user_id'], - store_path=STORE_PATH, + store_path=Config.STORE_PATH, config=self.client_config, ) @@ -149,20 +71,16 @@ class Bot(): # check that we logged in succesfully if isinstance(resp, LoginResponse): - write_details_to_disk(resp, credentials) + self.__write_details_to_disk(resp, credentials) else: - click.secho( - f'homeserver = {credentials["homeserver"]}; ' - f' user = {credentials["user_id"]}', - fg='red', - ) - click.secho(f'Failed to log in: {resp}', fg='red') + self.logger.debug(f'homeserver = {credentials["homeserver"]}; ' + f' user = {credentials["user_id"]}') + self.logger.warn(f'Failed to log in: {resp}') sys.exit(1) - self._config = { + self.__config = { 'user_id': credentials['user_id'], 'homeserver': credentials['homeserver'], - 'room_id': credentials['room_id'], 'device_name': credentials['device_name'], 'device_id': resp.device_id, 'access_token': resp.access_token, @@ -177,15 +95,16 @@ class Bot(): # Otherwise the config file exists, so we'll use the stored credentials else: + self.logger.debug('Reading credentials.json') # open the file in read-only mode - with open(CONFIG_FILE, 'r') as f: - self._config = json.load(f) + with open(Config.CONFIG_FILE, 'r') as f: + self.__config = json.load(f) # Initialize the matrix client based on credentials from file client = AsyncClient( self.config['homeserver'], self.config['user_id'], device_id=self.config['device_id'], - store_path=STORE_PATH, + store_path=Config.STORE_PATH, config=self.client_config, ) @@ -194,36 +113,49 @@ class Bot(): device_id=self.config['device_id'], access_token=self.config['access_token'], ) - # click.secho('Logged in using stored credentials.', fg='green') + self.logger.debug('Logged in using stored credentials.') - self._client = client - await self.client.set_presence('online') + self.__client = client return client - async def shutdown(self, sig: any) -> None: - await self.client.close() - tasks = [ - t for t in asyncio.all_tasks() if t is not asyncio.current_task() - ] - for task in tasks: - task.cancel() - await task + async def sync(self) -> None: + self.logger.debug('Starting sync') + next_batch = self.__read_next_batch() - await asyncio.gather(*tasks, return_exceptions=False) - self.loop.stop() + if self.client.should_upload_keys: + await self.client.keys_upload() - async def _verify(self) -> None: + if self.client.should_query_keys: + await self.client.keys_query() + + if self.client.should_claim_keys: + await self.client.keys_claim(self.get_users_for_key_claiming()) + + await self.client.sync(timeout=30000, + full_state=True, + since=next_batch) + + async def sync_forever(self) -> None: + # next_batch = self.__read_next_batch() + + 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, + client = await self.login() + + self.logger.debug('Adding callbacks') + 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('\nStarting verification process...', + bold=True, + fg='green') click.secho( '\nThis program is ready and waiting for the other ' 'party to initiate an emoji verification with us by ' @@ -231,56 +163,30 @@ class Bot(): 'client.', fg='green', ) - await self.client.sync_forever(timeout=30000, full_state=True) + await self.sync_forever() - def verify(self) -> None: - self._run_async(self._verify()) + async def run(self) -> None: + await self.login() - def run(self) -> None: - self._run_async(self._run()) + self.client.add_response_callback(self.__sync_callback, + (SyncResponse, )) + self.client.add_event_callback(self.__message_callback, + (RoomMessage, )) + self.client.add_event_callback(self.__invite_callback, (InviteEvent, )) - async def _run(self) -> None: - fifo_name = FIFO_PATH + await self.sync_forever() - 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) + async def find_room_by_id(self, room_id: str) -> (MatrixRoom, None): + rooms = self.client.rooms.keys() + if room_id in rooms: + return self.client.rooms[room_id] + return None def _ask_credentials(self) -> dict: """Ask the user for credentials """ try: - homeserver = HOMESERVER_URL + homeserver = Config.HOMESERVER_URL homeserver = click.prompt( click.style('Enter your homeserver URL', bold=True), default=homeserver, @@ -300,11 +206,6 @@ class Bot(): 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, - ) except click.exceptions.Abort: sys.exit(0) @@ -312,10 +213,11 @@ class Bot(): 'homeserver': homeserver, 'user_id': user_id, 'device_name': device_name, - 'room_id': room_id, } - async def _to_device_callback(self, event): # noqa + # Callbacks + + async def __to_device_callback(self, event): # noqa """Handle events sent to device.""" try: client = self.client @@ -351,24 +253,24 @@ class Bot(): """ if 'emoji' not in event.short_authentication_string: - click.secho( + click.echo( 'Other device does not support emoji verification ' - f'{event.short_authentication_string}.', - fg='red', - ) + f'{event.short_authentication_string}.') return resp = await client.accept_key_verification( event.transaction_id) if isinstance(resp, ToDeviceError): - click.secho(f'accept_key_verification failed with {resp}', - fg='red') + self.logger.warning( + f'accept_key_verification failed with {resp}', + fg='red') sas = client.key_verifications[event.transaction_id] todevice_msg = sas.share_key() resp = await client.to_device(todevice_msg) if isinstance(resp, ToDeviceError): - click.secho(f'to_device failed with {resp}', fg='red') + self.logger.warning(f'to_device failed with {resp}', + fg='red') elif isinstance(event, KeyVerificationCancel): # anytime """ at any time: receive KeyVerificationCancel @@ -388,11 +290,8 @@ class Bot(): # client.cancel_key_verification(tx_id, reject=False) # here. The SAS flow is already cancelled. # We only need to inform the user. - click.secho( - '\nVerification has been cancelled by ' - f'{event.sender} for reason "{event.reason}".', - fg='yellow', - ) + click.echo('\nVerification has been cancelled by ' + f'{event.sender} for reason "{event.reason}".') elif isinstance(event, KeyVerificationKey): # second step """ Second step is to receive KeyVerificationKey @@ -458,7 +357,8 @@ class Bot(): resp = await client.cancel_key_verification( event.transaction_id, reject=False) if isinstance(resp, ToDeviceError): - print(f'cancel_key_verification failed with {resp}') + self.logger.warn( + f'cancel_key_verification failed with {resp}') elif isinstance(event, KeyVerificationMac): # third step """ Third step is to receive KeyVerificationMac @@ -490,7 +390,7 @@ class Bot(): else: resp = await client.to_device(todevice_msg) if isinstance(resp, ToDeviceError): - print(f'to_device failed with {resp}') + self.logger.warn(f'to_device failed with {resp}') # print(f'sas.we_started_it = {sas.we_started_it}\n' # f'sas.sas_accepted = {sas.sas_accepted}\n' # f'sas.canceled = {sas.canceled}\n' @@ -502,76 +402,102 @@ class Bot(): 'to exit.', fg='green') else: - print(f'Received unexpected event type {type(event)}. ' - f'Event is {event}. Event will be ignored.') + self.logger.warn( + f'Received unexpected event type {type(event)}. ' + f'Event is {event}. Event will be ignored.') except BaseException: - print(traceback.format_exc()) + self.logger.critical(traceback.format_exc()) - def _sync_callback(self, event: any) -> None: - with open(NEXT_BATCH_PATH, 'w') as next_batch_token: + async def shutdown(self) -> None: + self.logger.info('Shutdown Bot') + for task in asyncio.Task.all_tasks(): + task.cancel() + await self.client.close() + + async def __sync_callback(self, event: any) -> None: + self.logger.debug('Client syncing and saving next batch token') + if self.__first_sync and len(self.client.invited_rooms) > 0: + for room in self.client.invited_rooms: + await self.client.join(room) + self.__first_sync = False + with open(Config.NEXT_BATCH_PATH, 'w') as next_batch_token: next_batch_token.write(event.next_batch) - 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 + async def __invite_callback(self, source: MatrixRoom, sender: any) -> None: + await self.client.join(source.room_id) -**!help** - displays this help message - """) + async def __text_message_callback(self, source: MatrixRoom, + message: RoomMessageText) -> None: + self.logger.debug('Text Message Recieved: %s %s: %s', source.room_id, + message.sender, message.body) - 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) + async def __message_callback(self, source: MatrixRoom, + message: RoomMessage) -> None: + print(message) + self.logger.debug('Message Recieved') + if (isinstance(message, RoomMessageText)): + await self.__text_message_callback(source, message) + def __signal_handler(self) -> None: + self.loop.create_task(self.shutdown()) -@click.group(invoke_without_command=True) -@click.option('-v', '--verbose', count=True) -@click.pass_context -def cli(ctx: click.Context, verbose: int) -> None: - # click.secho('Matrix Bot\n', bold='true') - if ctx.invoked_subcommand is None: - bot = Bot() - bot.run() + # Files + def __write_details_to_disk(self, resp: LoginResponse, + credentials: dict) -> None: + """Write the required login details to disk. -@cli.command('verify') -def verify_command() -> None: - bot = Bot() - bot.verify() + It will allow following logins to be made without password. + Arguments: + --------- + resp : LoginResponse - successful client login response + credentials : dict - The credentials used to sign in -@cli.command('message') -@click.argument('message') -@click.option('-r', '--room', help='the room to send to') -def send_command(message: str, room: str) -> None: - bot = Bot() - bot._run_async(bot.send_text_to_room(room, message)) + """ + # open the config file in write-mode + with open(Config.CONFIG_FILE, 'w') as f: + # write the login details to disk + json.dump( + { + 'credentials': { + 'homeserver': credentials['homeserver'], + 'device_name': credentials['device_name'], + 'user_id': resp.user_id, + 'device_id': resp.device_id, + 'access_token': resp.access_token, + } + }, + f, + ) + def __read_next_batch(self) -> (str, None): + # we read the previously-written token... + next_batch_name = Config.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() + return self.client.next_batch -@cli.command('run') -def run() -> None: - bot = Bot() - bot.run() + # Properties + @property + def loop(self) -> asyncio.AbstractEventLoop: + return self.__loop -if __name__ == '__main__': - cli() + @property + def logger(self) -> logging.Logger: + return self.__logger + + @property + def config(self) -> dict: + return self.__config + + @property + def client_config(self) -> AsyncClientConfig: + return self.__client_config + + @property + def client(self) -> AsyncClient: + return self.__client diff --git a/matrix_bot/botctl.py b/matrix_bot/botctl.py deleted file mode 100644 index 2cf375f8..00000000 --- a/matrix_bot/botctl.py +++ /dev/null @@ -1,35 +0,0 @@ -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 message(message: list) -> None: - send_message(' '.join(message)) - - -if __name__ == '__main__': - cli() diff --git a/matrix_bot/cli.py b/matrix_bot/cli.py new file mode 100644 index 00000000..45cf7b46 --- /dev/null +++ b/matrix_bot/cli.py @@ -0,0 +1,117 @@ +import logging +import sys + +import click + +from .bot import Bot +from .utils import run_async, setup_logger +from .config import Config +from .message import TextMessage, MarkdownMessage +from .exceptions import NoRoomException + +logger = setup_logger(__name__) + + +def get_loglevel(verbosity: int) -> int: + + level = logging.ERROR + if verbosity == 1: + level = logging.WARNING + elif verbosity == 2: + level = logging.INFO + elif verbosity >= 3: + level = logging.DEBUG + + return level + + +async def send_message(room_id: str, message: str, markdown: bool) -> None: + logger.debug('Sending a message to %s', room_id) + bot = Bot() + client = await bot.login() + await bot.sync() + room = await bot.find_room_by_id(room_id) + if markdown: + message = MarkdownMessage(message) + else: + message = TextMessage(message) + try: + await message.send(client, room) + except NoRoomException: + click.echo(f'No Room with id {room_id} found') + finally: + await bot.shutdown() + + +async def main_run() -> None: + logger.debug('Starting') + bot = Bot() + await bot.run() + + +async def main_verify() -> None: + logger.debug('Starting') + bot = Bot() + await bot.verify() + + +@click.group(invoke_without_command=True) +@click.option('-v', + '--verbose', + count=True, + help='Set the loglevel (Can be used multiple times e.g. -vvv)') +@click.option('-b', + '--botname', + default='default', + help='The name of the bot to start') +@click.pass_context +def cli(ctx: click.Context, + verbose: bool, + botname: str, + help: str = 'matrix-bot - A Bot That listens in matrix chatrooms' + ) -> None: + ctx.ensure_object(dict) + + Config.init(botname, get_loglevel(verbose)) + + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + sys.exit(1) + + +@cli.command(help='Start the Bot') +@click.pass_context +@click.option('-v', + '--verbose', + count=True, + help='Set the loglevel (Can be used multiple times e.g. -vvv)') +def run(ctx: click.Context, verbose: int) -> None: + run_async(main_run()) + + +@cli.command(help='Verify the bot with a matrix homeserver') +@click.pass_context +@click.option('-v', + '--verbose', + count=True, + help='Set the loglevel (Can be used multiple times e.g. -vvv)') +def verify(ctx: click.Context, verbose: int) -> None: + run_async(main_verify()) + + +@cli.command(help='Send a message with the bot') +@click.pass_context +@click.argument('room_id') +@click.argument('message', nargs=-1, required=True) +@click.option('-m', '--markdown/--no-markdown', default=False) +@click.option('-v', + '--verbose', + count=True, + help='Set the loglevel (Can be used multiple times e.g. -vvv)') +def send(ctx: click.Context, room_id: str, message: list, markdown: bool, + verbose: int) -> None: + run_async(send_message(room_id, ' '.join(message), markdown)) + + +if __name__ == '__main__': + cli(obj={}) diff --git a/matrix_bot/config.py b/matrix_bot/config.py index 2e565e96..3eca3a19 100644 --- a/matrix_bot/config.py +++ b/matrix_bot/config.py @@ -1,15 +1,32 @@ import os +import appdirs -# 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' +class Config: + APP_NAME = 'matrix-bot' + # Default Homeserver URL + HOMESERVER_URL = 'https://matrix.gaja-group.com' + + @classmethod + def init(cls, botname: str = 'default', loglevel: int = 40) -> None: + cls.LOGLEVEL = loglevel + cls.CONFIG_DIRECTORY = os.path.join(appdirs.user_config_dir(), + cls.APP_NAME, botname) + cls.DATA_DIRECTORY = os.path.join(appdirs.user_data_dir(), + cls.APP_NAME, botname) + + if not os.path.exists(cls.CONFIG_DIRECTORY): + os.makedirs(cls.CONFIG_DIRECTORY) + + if not os.path.exists(cls.DATA_DIRECTORY): + os.makedirs(cls.DATA_DIRECTORY) + cls.CONFIG_FILE = os.path.join(cls.CONFIG_DIRECTORY, 'config.json') + # directory to store persistent data for end-to-end encryption + cls.STORE_PATH = os.path.join(cls.DATA_DIRECTORY, + 'store') # local directory + cls.NEXT_BATCH_PATH = os.path.join(cls.DATA_DIRECTORY, + 'next_batch') # local directory + print(cls.CONFIG_DIRECTORY) + + +Config.init() diff --git a/matrix_bot/exceptions.py b/matrix_bot/exceptions.py new file mode 100644 index 00000000..2478993e --- /dev/null +++ b/matrix_bot/exceptions.py @@ -0,0 +1,2 @@ +class NoRoomException(BaseException): + pass diff --git a/matrix_bot/message.py b/matrix_bot/message.py new file mode 100644 index 00000000..f8952e38 --- /dev/null +++ b/matrix_bot/message.py @@ -0,0 +1,89 @@ +import re +import logging + +from nio import AsyncClient, MatrixRoom +from markdown import markdown + +from .utils import setup_logger +from .exceptions import NoRoomException + + +class Message(object): + def __init__(self, + _type: str = 'm.room.message', + loglevel: int = 10) -> None: + self.__type = _type + self.__ignore_unverified_devices = True + self.__logger = setup_logger(__name__) + + async def send(self, client: AsyncClient, + room: (MatrixRoom, None)) -> None: + if room: + self.logger.debug('Sending message: %s', self.content) + await client.room_send( + room.room_id, + self.type, + self.content, + ignore_unverified_devices=self.ignore_unverified_devices) + else: + raise NoRoomException() + + @property + def type(self) -> str: + return self.__type + + @property + def ignore_unverified_devices(self) -> bool: + return self.__ignore_unverified_devices + + @property + def logger(self) -> logging.Logger: + return self.__logger + + @property + def content(self) -> str: + raise NotImplementedError('content must be implemented') + + +class TextMessage(Message): + def __init__(self, + body: str, + msgtype: str = 'm.notice', + _type: str = 'm.room.message') -> None: + super().__init__(_type) + self.body = body + self.msgtype = msgtype + + @property + def content(self) -> dict: + content = {'msgtype': self.msgtype, 'body': self.body} + return content + + +class MarkdownMessage(Message): + + clean_regexp = re.compile('<.*?>') + + def __init__(self, + body: str, + msgtype: str = 'm.notice', + _type: str = 'm.room.message') -> None: + super().__init__(_type) + self.formatted_body = markdown(body) + self.format = 'org.matrix.custom.html' + self.body = self.__clean_html(self.formatted_body) + self.msgtype = msgtype + + def __clean_html(self, raw_html: str) -> str: + cleantext = re.sub(self.clean_regexp, '', raw_html) + return cleantext + + @property + def content(self) -> dict: + content = { + 'msgtype': self.msgtype, + 'body': self.body, + 'formatted_body': self.formatted_body, + 'format': self.format + } + return content diff --git a/matrix_bot/utils.py b/matrix_bot/utils.py new file mode 100644 index 00000000..f16bb44e --- /dev/null +++ b/matrix_bot/utils.py @@ -0,0 +1,30 @@ +import asyncio +import logging + +from .config import Config + + +def run_async(future: asyncio.Future) -> None: + global logger + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(future) + except asyncio.exceptions.CancelledError: + loop.run_until_complete(asyncio.sleep(0.25)) + finally: + loop.stop() + + +def setup_logger(name: str = __name__) -> logging.Logger: + logger = logging.getLogger(name) + + logger.setLevel(Config.LOGLEVEL) + ch = logging.StreamHandler() + ch.setLevel(Config.LOGLEVEL) + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s') + ch.setFormatter(formatter) + + logger.addHandler(ch) + + return logger diff --git a/setup.py b/setup.py index d067e4a5..5f3ab073 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,8 @@ setup( install_requires=['Click', 'matrix-nio[e2e]', 'markdown'], entry_points=""" [console_scripts] - matrix-bot=matrix_bot.bot:cli - matrix-botctl=matrix_bot.botctl:cli + matrix-bot=matrix_bot.cli:cli + matrix-send=matrix_bot.cli:send + matrix-verify=matrix_bot.cli:verify """, )