diff --git a/Pipfile b/Pipfile index 0d8aadc..011fa96 100644 --- a/Pipfile +++ b/Pipfile @@ -15,6 +15,7 @@ requests = "*" igramscraper = "*" twitterscraper = "*" httpx = "*" +pyyaml = "==5.3" [dev-packages] pylint = "*" diff --git a/README.md b/README.md index 270576f..3b709e0 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,7 @@ MATRIX_ACCESS_TOKEN=MDAxOGxvYlotofcharacters53CgYAYFgo MATRIX_SERVER=https://matrix.org JOIN_ON_INVITE=True BOT_OWNERS=@user1:matrix.org,@user2:matrix.org +DEBUG=False ``` Note: without quotes! @@ -227,21 +228,48 @@ docker-compose up ## Env variables -User, access token and server should be self-explanatory. Set JOIN_ON_INVITE to anything if you want the bot to -join invites automatically (do not set it if you don't want it to join). +`MATRIX_USER`, `MATRIX_ACCESS_TOKEN` and `MATRIX_SERVER` should be self-explanatory. +Set `JOIN_ON_INVITE` to anything if you want the bot to join invites automatically (do not set it if you don't want it to join). -You can set MATRIX_PASSWORD if you want to get access token. Normally you can use Riot to get it. +You can set `MATRIX_PASSWORD` if you want to get an access token automatically with a login. +Normally you can use Riot to get it. -BOT_OWNERS is a comma-separated list of matrix id's for the owners of the bot. Some commands require -sender to be bot owner. Typically set your own id into it. Don't include bot itself in BOT_OWNERS if cron -or any other module that can cause bot to send custom commands is used as it could potentially be used to run -owner commands as the bot itself. +`BOT_OWNERS` is a comma-separated list of matrix id's for the owners of the bot. +Some commands require sender to be bot owner. +Typically set your own id into it. + +__*ATTENTION:*__ Don't include bot itself in `BOT_OWNERS` if cron or any other module that can cause bot to send custom commands is used, as it could potentially be used to run owner commands as the bot itself. + +To enable debugging for the root logger set `DEBUG=True`. + +## Logging + +Uses [python logging facility](https://docs.python.org/3/library/logging.html) to print information to the console. Customize it to your needs editing `config/logging.yml`. +See [logging.config documentation](https://docs.python.org/3/library/logging.config.html) for further information. ## Module API Just write a python file with desired command name and place it in modules. See current modules for examples. No need to register it anywhere else. +*Simple skeleton for a bot module:* +```python + +class MatrixModule(BotModule): + + async def matrix_message(self, bot, room, event): + args = event.body.split() + args.pop(0) + + # Echo what they said back + self.logger.debug(f"room: {room.name} sender: {event.sender} wants an echo") + await bot.send_text(room, ' '.join(args)) + + def help(self): + return 'Echoes back what user has said' + +``` + Functions: * matrix_start - Called once on startup @@ -254,6 +282,10 @@ Functions: You only need to implement the ones you need. See existing bots for examples. +Logging: + +Use `self.logger` in your module to print information to the console. + Module settings are stored in Matrix account data. If you write a new module, please make a PR if it's something useful for others. diff --git a/bot.py b/bot.py index 2b4cc2a..6d1d029 100755 --- a/bot.py +++ b/bot.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 import asyncio +import functools import glob import importlib -import json +import yaml import os import re +import signal import sys import traceback import urllib.parse @@ -40,24 +42,28 @@ class Bot: self.pollcount = 0 self.poll_task = None self.owners = [] - self.debug = os.getenv("DEBUG") + self.debug = os.getenv("DEBUG", "false").lower() == "true" + self.logger = None - self.initializeLogger() - self.logger = logging.getLogger("hemppa") - self.logger.debug("Initialized") + self.initialize_logger() - def initializeLogger(self): + def initialize_logger(self): - if os.path.exists('config/logging.config'): - logging.config.fileConfig('config/logging.config') + if os.path.exists('config/logging.yml'): + with open('config/logging.yml') as f: + config = yaml.load(f, Loader=yaml.Loader) + logging.config.dictConfig(config) else: log_format = '%(levelname)s - %(name)s - %(message)s' logging.basicConfig(format=log_format) + self.logger = logging.getLogger("hemppa") + if self.debug: logging.root.setLevel(logging.DEBUG) - else: - logging.root.setLevel(logging.INFO) + self.logger.info("enabled debugging") + + self.logger.debug("Logger initialized") async def send_text(self, room, body): msg = { @@ -76,10 +82,10 @@ class Bot: await self.client.room_send(room.room_id, 'm.room.message', msg) def remove_callback(self, callback): - for cb_object in bot.client.event_callbacks: + for cb_object in self.client.event_callbacks: if cb_object.func == callback: self.logger.info("remove callback") - bot.client.event_callbacks.remove(cb_object) + self.client.event_callbacks.remove(cb_object) def get_room_by_id(self, room_id): return self.client.rooms[room_id] @@ -146,18 +152,16 @@ class Bot: if moduleobject is not None: if moduleobject.enabled: try: - await moduleobject.matrix_message(bot, room, event) + await moduleobject.matrix_message(self, room, event) except CommandRequiresAdmin: await self.send_text(room, f'Sorry, you need admin power level in this room to run that command.') except CommandRequiresOwner: await self.send_text(room, f'Sorry, only bot owner can run that command.') except Exception: - await self.send_text(room, - f'Module {command} experienced difficulty: {sys.exc_info()[0]} - see log for details') + await self.send_text(room, f'Module {command} experienced difficulty: {sys.exc_info()[0]} - see log for details') traceback.print_exc(file=sys.stderr) else: self.logger.error(f"Unknown command: {command}") - # TODO Make this configurable # await self.send_text(room, # f"Sorry. I don't know what to do. Execute !help to get a list of available commands.") @@ -219,7 +223,7 @@ class Bot: for modulename, moduleobject in self.modules.items(): if moduleobject.enabled: try: - await moduleobject.matrix_poll(bot, self.pollcount) + await moduleobject.matrix_poll(self, self.pollcount) except Exception: traceback.print_exc(file=sys.stderr) await asyncio.sleep(10) @@ -280,7 +284,6 @@ class Bot: self.logger.error("The environment variables MATRIX_SERVER, MATRIX_USER and BOT_OWNERS are mandatory") sys.exit(1) - def start(self): self.load_settings(self.get_account_data()) enabled_modules = [module for module_name, module in self.modules.items() if module.enabled] @@ -288,7 +291,7 @@ class Bot: for modulename, moduleobject in self.modules.items(): if moduleobject.enabled: try: - moduleobject.matrix_start(bot) + moduleobject.matrix_start(self) except Exception: traceback.print_exc(file=sys.stderr) @@ -296,7 +299,7 @@ class Bot: self.logger.info(f'Stopping {len(self.modules)} modules..') for modulename, moduleobject in self.modules.items(): try: - moduleobject.matrix_stop(bot) + moduleobject.matrix_stop(self) except Exception: traceback.print_exc(file=sys.stderr) @@ -354,15 +357,30 @@ class Bot: else: await self.client.client_session.close() + def handle_exit(self, signame, loop): + self.logger.info(f"Received signal {signame}") + if self.poll_task: + self.poll_task.cancel() + self.bot_task.cancel() + self.stop() + + +async def main(): + bot = Bot() + bot.init() + + loop = asyncio.get_running_loop() + + for signame in {'SIGINT', 'SIGTERM'}: + loop.add_signal_handler( + getattr(signal, signame), + functools.partial(bot.handle_exit, signame, loop)) + + await bot.run() + await bot.shutdown() + -bot = Bot() -bot.init() try: - asyncio.get_event_loop().run_until_complete(bot.run()) -except KeyboardInterrupt: - if bot.poll_task: - bot.poll_task.cancel() - bot.bot_task.cancel() - -bot.stop() -asyncio.get_event_loop().run_until_complete(bot.shutdown()) + asyncio.run(main()) +except Exception as e: + print(e) diff --git a/config/logging.yml b/config/logging.yml new file mode 100644 index 0000000..a5fd89b --- /dev/null +++ b/config/logging.yml @@ -0,0 +1,15 @@ +version: 1 +formatters: + hemppa: + format: '%(asctime)s - %(levelname)s - %(name)s - %(message)s' +handlers: + console: + class: logging.StreamHandler + formatter: hemppa + stream: ext://sys.stdout +loggers: + hemppa: + level: NOTSET +root: + level: INFO + handlers: [console] diff --git a/modules/bot.py b/modules/bot.py index b8fb306..3b3515e 100644 --- a/modules/bot.py +++ b/modules/bot.py @@ -95,7 +95,6 @@ class MatrixModule(BotModule): async def enable_module(self, bot, room, event, module_name): bot.must_be_admin(room, event) self.logger.info(f"asked to enable {module_name}") - if bot.modules.get(module_name): module = bot.modules.get(module_name) module.enable() @@ -108,7 +107,6 @@ class MatrixModule(BotModule): async def disable_module(self, bot, room, event, module_name): bot.must_be_admin(room, event) self.logger.info(f"asked to disable {module_name}") - if bot.modules.get(module_name): module = bot.modules.get(module_name) if module.can_be_disabled: diff --git a/modules/common/module.py b/modules/common/module.py index 4b63941..f4c0d43 100644 --- a/modules/common/module.py +++ b/modules/common/module.py @@ -32,7 +32,6 @@ class BotModule(ABC): self.can_be_disabled = True self.name = name self.logger = logging.getLogger("module " + self.name) - self.subcommands = dict() def matrix_start(self, bot): """Called once on startup diff --git a/modules/echo.py b/modules/echo.py index 0d73581..b60e54e 100644 --- a/modules/echo.py +++ b/modules/echo.py @@ -7,7 +7,8 @@ class MatrixModule(BotModule): args.pop(0) # Echo what they said back + self.logger.debug(f"room: {room.name} sender: {event.sender} wants an echo") await bot.send_text(room, ' '.join(args)) def help(self): - return ('Echoes back what user has said') + return 'Echoes back what user has said' diff --git a/modules/loc.py b/modules/loc.py index b1046b9..1740a8e 100644 --- a/modules/loc.py +++ b/modules/loc.py @@ -1,5 +1,6 @@ from geopy.geocoders import Nominatim -from nio import RoomMessageUnknown, AsyncClient +from nio import RoomMessageUnknown + from modules.common.module import BotModule from modules.common.module import BotModule diff --git a/modules/off/teamup.py b/modules/off/teamup.py index 6027307..4189cf9 100644 --- a/modules/off/teamup.py +++ b/modules/off/teamup.py @@ -3,7 +3,6 @@ from datetime import datetime from pyteamup import Calendar - # # TeamUp calendar notifications #