Base system without any modules
authorSomeone <someone@somenet.org>
Wed, 8 Dec 2021 18:38:31 +0000 (19:38 +0100)
committerSomeone <someone@somenet.org>
Wed, 8 Dec 2021 18:38:31 +0000 (19:38 +0100)
config.py.example [new file with mode: 0644]
core/AbstractCommand.py [new file with mode: 0644]
core/AbstractWSHandler.py [new file with mode: 0644]
core/MMBot.py [new file with mode: 0644]
json_stats_printer.py [new file with mode: 0755]
main.py [new file with mode: 0755]
modules/__init__.py [new file with mode: 0644]
somebot.service [new file with mode: 0644]

diff --git a/config.py.example b/config.py.example
new file mode 100644 (file)
index 0000000..81cdfdb
--- /dev/null
@@ -0,0 +1,44 @@
+#
+# Someone's Mattermost scripts.
+#  Copyright (c) 2016-2021 by Someone <someone@somenet.org> (aka. Jan Vales <jan@jvales.net>)
+#  published under MIT-License
+#
+# Example config file.
+#  mv to config.py.examle config.py + edit.
+#
+
+####################################################################################################
+# currently everything is done here and main.py is only used by the init script to launch the bot. #
+####################################################################################################
+
+import logging
+logging.basicConfig(level = logging.INFO, format = "%(levelname)s::%(message)s")
+#logging.basicConfig(level = logging.DEBUG, format = "%(asctime)s::%(levelname)s::%(message)s")
+
+from MMBot import *
+from CoreCommandUse import *
+
+bot = MMBot(api_user="...", api_user_pw="...", local_websrv_hostname="bot\"s hostname", local_websrv_port=18065, mm_api_url="http://mattermostserver:8065/api", mm_ws_url="ws://mattermostserver:8065/api/v4/websocket")
+# local_websrv_hostname = None or local_websrv_port = None to disable bot-local webserver
+# mm_ws_url = None  to disable websocket client
+
+
+bot.admin_ids = ["..."] # user_ids of bot admins. Basically useless.
+bot.teams = { # "bot-internal-alias": ["<team_id>", <allow bot to add users to team>],
+    "some team": ["...", False],
+    "other team":["...", True],
+    }
+
+
+# global commands
+for _,team_info in bot.teams.items():
+    bot.register(CoreCommandUse(team_info[0]))
+
+
+# websocket handlers
+bot.register_ws(WSOnboarding(), ["new_user"])
+
+
+# non-global commands
+bot.register(CommandTissJoin(bot.teams["other team"]))
+
diff --git a/core/AbstractCommand.py b/core/AbstractCommand.py
new file mode 100644 (file)
index 0000000..0205713
--- /dev/null
@@ -0,0 +1,175 @@
+# Mattermost Bot.
+#  Copyright (c) 2016-2021 by Someone <someone@somenet.org> (aka. Jan Vales <jan@jvales.net>)
+#  published under MIT-License
+
+
+class RequireException(Exception):
+    """Required precondition not met-exception. Mostly used for permission checks. Message will be forwarded to command-caller/user"""
+
+
+class AbstractCommand():
+    URL = None
+    TEAM_ID = None
+    TRIGGER = None
+    CONFIG = {"display_name": "somebot-command", "auto_complete": True}
+    USEINFO = None
+
+    bot = None
+    mm_secret_token = None
+
+
+    def __init__(self, team_id):
+        self.TEAM_ID = team_id
+
+
+#    def __str__(self):
+#        return str(self.__class__)+" for team_id: "+str(self.TEAM_ID)
+#    def __repr__(self):
+#        return self.__str__()
+
+
+    # can/should be overridden by the user
+    def on_register(self):
+        """Consider to override. Handles the post-command-registration logic at bot startup."""
+        self._create_slash_command()
+
+    # can/should be overridden by the user
+    def on_shutdown(self):
+        """Consider to override. Handles the shutdown-procedure."""
+        return
+
+    # can/should be overridden by the user
+    def on_SIGUSR1(self, sigusr1_cnt):
+        """Consider to override. Handles the SIGUSR1-signal."""
+        return
+
+    # should be overridden by the user
+    def on_POST(self, request, data):
+        """Override. Handles the post-command logic."""
+        return
+
+    # should be overridden by the user
+    # manual command authentication needed!
+    def on_POST_interactive(self, request, data):
+        """Consider to override. Handles the interactive-message logic."""
+        return
+
+    # should be overridden by the user
+    # manual command authentication needed!
+    def on_POST_dialog(self, request, data):
+        """Consider to override. Handles the dialog logic."""
+        return
+
+
+    def _on_register(self, bot):
+        self.bot = bot
+        self.URL = self.bot.local_websrv_url+"/"+self.TEAM_ID+"/"+self.TRIGGER
+        if self.USEINFO:
+            self.bot.USETOPICS.setdefault("/"+self.TRIGGER, self.USEINFO)
+        self.on_register()
+
+
+    def _on_shutdown(self):
+        self.on_shutdown()
+
+
+    def _on_SIGUSR1(self, sigusr1_cnt):
+        self.on_SIGUSR1(sigusr1_cnt)
+
+
+    def _on_POST(self, request, data):
+        try:
+            self._require_not_guest(data)
+            self.on_POST(request, data)
+        except RequireException as ex:
+            request.cmd_respond_text_temp(str(ex))
+
+
+    def _on_POST_interactive(self, request, data):
+        try:
+            self._require_not_guest(data)
+            self.on_POST_interactive(request, data)
+        except RequireException as ex:
+            request.cmd_respond_text_temp(str(ex))
+
+
+    def _on_POST_dialog(self, request, data):
+        try:
+            self._require_not_guest(data)
+            self.on_POST_dialog(request, data)
+        except RequireException as ex:
+            request.cmd_respond_text_temp(str(ex))
+
+
+    def _create_slash_command(self):
+        # (possibly) delete old version of command
+        for command in self.bot.api.list_custom_slash_commands_for_team(self.TEAM_ID):
+            if command["url"] == self.URL or command["trigger"].lower() == self.TRIGGER.lower():
+                self.bot.api.delete_slash_command(command["id"])
+
+        # create slash command
+        res = self.bot.api.create_slash_command(self.TEAM_ID, self.TRIGGER.lower(), self.URL+"/command")
+        res.update(self.CONFIG)
+        self.bot.api.update_slash_command(res)
+        self.mm_secret_token = res["token"]
+
+
+    def _require_bot_admin(self, data):
+        """
+        Require exactly bot admin priviledges.
+        Throws RequireException if not priviledged.
+        """
+        if not data["user_id"] in self.bot.admin_ids:
+            raise RequireException("### Leave me alone. You are not by real dad.")
+
+
+    def _require_system_admin(self, data, exc=True):
+        """
+        Require at least team admin priviledges.
+        Throws RequireException if not priviledged.
+        """
+        user = self.bot.api.get_user(data["user_id"])
+        if "system_admin" not in user["roles"]:
+            if exc:
+                raise RequireException("### You are not SYSTEM_ADMIN. :(")
+            return False
+        return True
+
+
+    def _require_team_admin(self, data, exc=True):
+        """
+        Require at least team admin priviledges.
+        Throws RequireException if not priviledged.
+        """
+        team_member = self.bot.api.get_team_member(data["team_id"], data["user_id"])
+        if "team_admin" not in team_member["roles"] and not self._require_system_admin(data, exc=False):
+            if exc:
+                raise RequireException("### You are not TEAM_ADMIN. :(")
+            return False
+        return True
+
+
+    def _require_channel_admin(self, data, exc=True):
+        """
+        Require at least channel admin priviledges.
+        Throws RequireException if not priviledged.
+        """
+        channel_member = self.bot.api.get_channel_member(data["channel_id"], data["user_id"])
+        if "channel_admin" not in channel_member["roles"] and not self._require_team_admin(data, exc=False):
+            if exc:
+                raise RequireException("### You are not CHANNEL_ADMIN. :(")
+            return False
+        return True
+
+
+    def _require_not_guest(self, data, exc=True):
+        """
+        Require to not be a guest.
+        Throws RequireException if guest.
+        """
+        channel_member = self.bot.api.get_channel_member(data["channel_id"], data["user_id"])
+        if "channel_guest" in channel_member["roles"]:
+            if exc:
+                raise RequireException("### The bot cannot be used by guests. :(")
+            return False
+        return True
diff --git a/core/AbstractWSHandler.py b/core/AbstractWSHandler.py
new file mode 100644 (file)
index 0000000..28722d9
--- /dev/null
@@ -0,0 +1,45 @@
+# Mattermost Bot.
+#  Copyright (c) 2016-2021 by Someone <someone@somenet.org> (aka. Jan Vales <jan@jvales.net>)
+#  published under MIT-License
+
+
+class AbstractWSHandler():
+    NAME = None
+    bot = None
+
+
+    def __init__(self):
+        pass
+
+    # should be overridden by the user
+    def on_register_ws_evtype(self, evtype):
+        pass
+
+    # can/should be overridden by the user
+    def on_shutdown(self):
+        """Consider to override. Handles the shutdown-procedure."""
+        return
+
+    # can/should be overridden by the user
+    def on_SIGUSR1(self, sigusr1_cnt):
+        """Consider to override. Handles the SIGUSR1-signal."""
+        return
+
+    # should be overridden by the user
+    def on_WS_EVENT(self, data):
+        return False
+
+    # should be overridden by the user
+    # manual command authentication needed!
+    def on_POST_interactive(self, request, data):
+        return
+
+    def _on_register_ws_evtype(self, bot, evtype):
+        self.bot = bot
+        self.on_register_ws_evtype(evtype)
+
+    def _on_shutdown(self):
+        self.on_shutdown()
+
+    def _on_SIGUSR1(self, sigusr1_cnt):
+        self.on_SIGUSR1(sigusr1_cnt)
diff --git a/core/MMBot.py b/core/MMBot.py
new file mode 100644 (file)
index 0000000..625a026
--- /dev/null
@@ -0,0 +1,330 @@
+# Mattermost Bot.
+#  Copyright (c) 2016-2021 by Someone <someone@somenet.org> (aka. Jan Vales <jan@jvales.net>)
+#  published under MIT-License
+
+import asyncio
+import atexit
+import json
+import logging
+import os
+import pprint
+import signal
+import sys
+import time
+import traceback
+import urllib
+
+from inspect import cleandoc
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from urllib.request import Request, urlopen
+from socketserver import ThreadingMixIn
+
+import mattermost
+import mattermost.ws
+
+
+logger = logging.getLogger(__name__)
+
+
+class MMBot():
+    def __init__(self, local_websrv_hostname="localhost", local_websrv_port=18065, api_user=None, api_user_pw=None, api_bearer=None, mm_api_url="http://localhost:8065/api", mm_ws_url="ws://localhost:8065/api/v4/websocket", debug_chan_id=None):
+        self.local_websrv_hostname = local_websrv_hostname
+        self.local_websrv_port = local_websrv_port
+        self.api_user = api_user
+        self.mm_api_url = mm_api_url
+        self.mm_ws_url = mm_ws_url
+
+        self.local_websrv_url = ("http://"+self.local_websrv_hostname+":"+str(self.local_websrv_port)+"/").strip("/")
+        self.modules = dict()
+        self.wsmodules = dict()
+        self.api = None
+        self.debug_chan_id = debug_chan_id
+        self.loop = asyncio.new_event_loop()
+        self.command_stats = dict()
+        self.admin_ids = []
+
+        self.mmws = None
+        self.sigusr1_cnt = 0
+
+        atexit.register(self.on_shutdown)
+        signal.signal(signal.SIGUSR1, self.on_SIGUSR1)
+        signal.signal(signal.SIGTERM, self.shutdown)
+        signal.signal(signal.SIGINT, self.shutdown)
+
+
+        # Core-Command: /use-data
+        self.USETOPICS = {"bot":cleandoc("""##### Here I am, brain the size of a planet, and they ask me to fill in some missing MM-features and be fun ... It gives me a headache.
+                                 Written by **``@someone``** in python3. Big thanks to contributors: **``@ju``**, **``@gittenburg``**
+                                 Inspiring ideas by: ``@bearza``, ``@frunobulax``, **``@x5468656f``**
+
+                                 Feel like contributing too? Talk to **``@someone``**. :)
+                                 The repository is here: https://git.somenet.org/pub/jan/mattermost-bot.git
+                                 """)}
+
+        if api_user is not None and api_user_pw is not None:
+            logger.info("User credentials given. Trying to login.")
+            self.api = mattermost.MMApi(self.mm_api_url)
+            self.api.login(api_user, api_user_pw)
+        elif api_user is not None:
+            self.api = mattermost.MMApi(self.mm_api_url)
+            self.api.login(bearer=api_bearer)
+
+
+    # Register a module with the bot.
+    def register(self, module):
+        if module.TEAM_ID not in self.modules:
+            self.modules[module.TEAM_ID] = dict()
+
+        if module.TRIGGER not in self.modules[module.TEAM_ID]:
+            self.modules[module.TEAM_ID][module.TRIGGER] = module
+            module._on_register(self)
+
+        else:
+            raise Exception("Multiple registration attempts for module: "+module.TRIGGER+" and team: "+module.TEAM_ID)
+
+
+    # Register a websocket handling module with the bot.
+    # There is no useful way to discriminate WS-events by originating teams atm. :(
+    def register_ws(self, module, eventlist):
+        for evtype in eventlist:
+            if evtype not in self.wsmodules:
+                self.wsmodules[evtype] = dict()
+
+            if module.NAME not in self.wsmodules[evtype]:
+                self.wsmodules[evtype][module.NAME] = module
+                module._on_register_ws_evtype(self, evtype)
+
+            else:
+                raise Exception("Multiple registration attempts for module: "+module.NAME+" and evtype: "+evtype)
+
+
+    def start(self):
+        logger.info("Starting: Almost there.")
+        logger.info(pprint.pformat(self.modules))
+        logger.info(pprint.pformat(self.wsmodules))
+
+        if self.mm_ws_url is not None:
+            self.mmws = mattermost.ws.MMws(self.websocket_handler, self.api, self.mm_ws_url)
+
+        if self.local_websrv_hostname is not None and self.local_websrv_port is not None:
+            self.start_webserver()
+
+
+    def on_shutdown(self):
+        logger.info("Shutting down ...")
+
+        if self.mmws:
+            self.mmws.close_websocket()
+            # todo: stop webserver + WS?
+
+        for team_modules in self.modules:
+            for module in self.modules[team_modules]:
+                self.modules[team_modules][module]._on_shutdown()
+
+        for evtype in self.wsmodules:
+            for module in self.wsmodules[evtype]:
+                self.wsmodules[evtype][module]._on_shutdown()
+
+        self.api.logout()
+        self.command_stats_dump()
+        logger.info("BYE.")
+
+
+
+    ########
+    # misc #
+    ########
+    def shutdown(self, unk1=None, unk2=None, err=0):
+        sys.exit(err)
+
+
+    def on_SIGUSR1(self, unk1=None, unk2=None):
+        logger.info("on_SIGUSR1()")
+        self.sigusr1_cnt += 1
+        self.command_stats_inc("internal::SIGUSR1")
+        self.command_stats_dump()
+
+        # TODO: reinit teams.
+
+        for team_modules in self.modules:
+            for module in self.modules[team_modules]:
+                self.modules[team_modules][module]._on_SIGUSR1(self.sigusr1_cnt)
+
+        for evtype in self.wsmodules:
+            for module in self.wsmodules[evtype]:
+                self.wsmodules[evtype][module]._on_SIGUSR1(self.sigusr1_cnt)
+
+
+    def debug_chan(self, message):
+        if self.debug_chan_id is None:
+            logger.info("debug_chan() called, but debug_chan_id is unspecified.")
+            return
+        self.api.create_post(self.debug_chan_id, "``AUTODELETE-DAY``\n"+message)
+
+
+    def command_stats_inc(self, command, amount=1):
+        if command in self.command_stats:
+            self.command_stats[command] += amount
+        else:
+            self.command_stats[command] = amount
+
+
+    def command_stats_dump(self):
+        self.dump_stats_json(self.command_stats, "/tmp/somebot_command_stats.json", "#command_usage #mmstats")
+        self.command_stats = dict()
+
+
+    def dump_stats_json(self, stats_data, file_path, header="", footer="", no_data_text=""):
+        do_write = False
+        if stats_data:
+            do_write = True
+
+        try:
+            with open(file_path, "r") as file:
+                old_stats = json.load(file)
+            for item, cnt in old_stats["data"].items():
+                if item in stats_data:
+                    stats_data[item] += cnt
+                else:
+                    stats_data[item] = cnt
+        except (FileNotFoundError, json.JSONDecodeError, KeyError):
+            do_write = True
+
+        # if no data, but file exists: skip write
+        if not do_write and "header" in old_stats and "footer" in old_stats and "no_data_text" in old_stats and old_stats["header"] == header and old_stats["footer"] == footer and old_stats["no_data_text"] == no_data_text:
+            return
+
+        logger.info("dump_stats_json(): writing file: %s", file_path)
+        self.command_stats_inc("internal::dump_stats_json:"+file_path)
+
+        with open(file_path, "w", encoding="utf-8") as file:
+            json.dump({"header":header, "footer":footer, "no_data_text":no_data_text, "data":dict(sorted(stats_data.items()))}, file, ensure_ascii=False, indent=2)
+
+
+
+    ##########################
+    # Bot's websocket client #
+    ##########################
+    def websocket_handler(self, mmws, event_data):
+        for evtype in self.wsmodules:
+            if evtype == event_data["event"]:
+                for module_name in self.wsmodules[evtype]:
+                    try:
+                        if self.wsmodules[evtype][module_name].on_WS_EVENT(event_data):
+                            self.command_stats_inc("ws::"+evtype+"::"+module_name)
+                    except Exception:
+                        exctxt = "".join(traceback.format_exc())
+                        logger.error(exctxt)
+
+
+    ###################
+    # Bot's webserver #
+    ###################
+    def start_webserver(self):
+        logger.info("Starting webserver.")
+
+        class RequestHandler(BaseHTTPRequestHandler):
+            bot = None
+            handled = False
+            responseURL = None
+
+            def do_POST(self):
+                self.handled = False
+
+                if self.headers["Content-Type"] == "application/x-www-form-urlencoded":
+                    data = urllib.parse.parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"), keep_blank_values=True)
+                    # only accept first occurence
+                    data = {k: v[0] for k, v in data.items()}
+                elif self.headers["Content-Type"] == "application/json":
+                    data = json.loads(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"))
+                else:
+                    self.respond(415)
+                    return
+
+                # store responseURL
+                if "response_url" in data:
+                    self.responseURL = data["response_url"]
+
+                # handle call
+                splitpath = self.path.strip("/").split("/")
+                if self.bot.modules[splitpath[0]] and self.bot.modules[splitpath[0]][splitpath[1]]:
+                    module = self.bot.modules[splitpath[0]][splitpath[1]]
+
+                    # /command
+                    if len(splitpath) > 2 and splitpath[2] == "command":
+                        if "token" in data and module.mm_secret_token == data["token"]:
+                            self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
+                            module._on_POST(self, data)
+                        else:
+                            logger.warning("mm_secret_token mismatch expected/got -%s-%s-", module.mm_secret_token, data["token"])
+                            self.respond(403)
+                            self.bot.on_shutdown()
+                            os._exit(1)
+
+                    # interactive button-handler. TODO auth!
+                    elif len(splitpath) > 2 and splitpath[2] == "interactive":
+                        self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
+                        module._on_POST_interactive(self, data)
+
+                    # dialog-handler: TODO: auth!
+                    elif len(splitpath) > 2 and splitpath[2] == "dialog":
+                        self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
+                        module._on_POST_dialog(self, data)
+
+                    else: #something weird.
+                        self.bot.command_stats_inc("/"+splitpath[1]+' --- SOMETHINGS WEIRD!!!')
+
+                # always try to fail. If previously responded to, nothing will happen.
+                self.respond(400, only_direct=True)
+
+
+            def cmd_respond_text_temp(self, message, props=None, http=200):
+                if props is None:
+                    self.respond(http, {"skip_slack_parsing":True, "response_type":"ephemeral", "text":message})
+                else:
+                    self.respond(http, {"skip_slack_parsing":True, "response_type":"ephemeral", "text":message, "props":props})
+
+
+            def cmd_respond_text_chan(self, message, props=None, http=200):
+                if props is None:
+                    self.respond(http, {"skip_slack_parsing":True, "response_type":"in_channel", "text":message})
+                else:
+                    self.respond(http, {"skip_slack_parsing":True, "response_type":"in_channel", "text":message, "props":props})
+
+
+            def respond(self, err, message=None, only_direct=False):
+                # first response handled by returning in-request.
+                # Must be fast before mm drops the connection.
+                if self.handled and only_direct:
+                    #logger.warning("Multiple responses but only_direct set. ignoring.")
+                    #traceback.print_stack()
+                    return
+
+                elif self.handled and not self.responseURL:
+                    logger.error("Multiple responses without response url. ignoring.")
+                    traceback.print_stack()
+                    return
+
+                elif self.handled:
+                    logger.info("Multiple responses. Using responseURL: "+self.responseURL)
+                    req = Request(self.responseURL, data=bytes(json.dumps(message), "utf8"), method='POST')
+                    req.add_header("Content-Type", "application/json")
+                    conn = urlopen(req, timeout=3)
+                    return
+
+                self.handled = True
+                self.send_response(err)
+                self.send_header("Content-Type", "application/json")
+                self.send_header("Content-Length", len(bytes(json.dumps(message), "utf8")))
+                self.end_headers()
+                if message is not None:
+                    self.wfile.write(bytes(json.dumps(message), "utf8"))
+
+
+        class MyHTTPServer(ThreadingMixIn, HTTPServer):
+            def serve_forever(self, bot):
+                self.RequestHandlerClass.bot = bot
+                HTTPServer.serve_forever(self)
+
+        self.httpd = MyHTTPServer((self.local_websrv_hostname, self.local_websrv_port), RequestHandler)
+        self.httpd.serve_forever(self)
diff --git a/json_stats_printer.py b/json_stats_printer.py
new file mode 100755 (executable)
index 0000000..4b6ed7f
--- /dev/null
@@ -0,0 +1,33 @@
+#!/usr/bin/env python3
+#
+# Mattermost Bot.
+#  Copyright (c) 2016-2021 by Someone <someone@somenet.org> (aka. Jan Vales <jan@jvales.net>)
+#  published under MIT-License
+#
+# This is started by the init script.
+#
+
+import json
+import sys
+
+def main():
+    with open(sys.argv[2], "r", encoding="utf-8") as f:
+        stats = json.load(f)
+
+        print(stats["header"])
+        if stats["data"]:
+            if sys.argv[1] == "code":
+                print("```")
+            for c, count in sorted(stats["data"].items(), key=lambda x: x[1], reverse=True):
+                if sys.argv[1] == "code":
+                    print(c+" ["+str(count)+"]")
+                else:
+                    print("|"+sys.argv[3]+c+"|"+str(count)+"|")
+            if sys.argv[1] == "code":
+                print("```")
+        else:
+            print(stats["no_data_text"])
+        print(stats["footer"])
+
+
+main()
diff --git a/main.py b/main.py
new file mode 100755 (executable)
index 0000000..0f7054f
--- /dev/null
+++ b/main.py
@@ -0,0 +1,24 @@
+#!/usr/bin/env -S python3 -Bu
+#
+# Mattermost Bot.
+#  Copyright (c) 2016-2021 by Someone <someone@somenet.org> (aka. Jan Vales <jan@jvales.net>)
+#  published under MIT-License
+#
+# This is started by the init script.
+#
+
+import importlib
+import os
+import re
+import sys
+
+sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "core")))
+
+config = None
+if len(sys.argv) == 1:
+    config = importlib.import_module("config")
+else:
+    sys.argv[1] = re.sub(".py$", "", sys.argv[1])
+    config = importlib.import_module(sys.argv[1])
+
+config.bot.start()
diff --git a/modules/__init__.py b/modules/__init__.py
new file mode 100644 (file)
index 0000000..5a79a74
--- /dev/null
@@ -0,0 +1,21 @@
+from inspect import isclass
+from pkgutil import iter_modules
+from pathlib import Path
+from importlib import import_module
+
+# iterate through the modules in the current package
+PACKAGE_DIR = Path(__file__).resolve().parent
+MODULES = [module_name for (_, module_name, _) in iter_modules([PACKAGE_DIR])]
+
+for module_name in MODULES:
+    # import the module and iterate through its attributes
+    module = import_module(f"{__name__}.{module_name}")
+    for attribute_name in dir(module):
+        attribute = getattr(module, attribute_name)
+
+        if isclass(attribute):
+            # Add the class to this package's variables
+            globals()[attribute_name] = attribute
+
+# cleanup temp helpers
+del module_name, module, attribute_name, attribute, isclass, iter_modules, Path, import_module
diff --git a/somebot.service b/somebot.service
new file mode 100644 (file)
index 0000000..d5c806c
--- /dev/null
@@ -0,0 +1,27 @@
+#
+# Mattermost Bot.
+#  Copyright (c) 2016-2021 by Someone <someone@somenet.org> (aka. Jan Vales <jan@jvales.net>)
+#  published under MIT-License
+#
+# user systemd unit
+#
+# sudo touch /var/lib/systemd/linger/$USER
+# mkdir -p ~/.config/systemd/user/
+# cp somebot.service ~/.config/systemd/user/
+# export XDG_RUNTIME_DIR=/run/user/$UID
+# systemctl --user daemon-reload
+# systemctl --user enable systemd-tmpfiles-setup.service systemd-tmpfiles-clean.timer somebot.service
+# systemctl --user start systemd-tmpfiles-setup.service systemd-tmpfiles-clean.timer somebot.service
+
+[Unit]
+Description=Someone's Mattermost bot
+
+[Service]
+Type=idle
+WorkingDirectory=/%h/mattermost-somebot/
+ExecStart=/usr/bin/python3 -Bu main.py
+Restart=always
+RestartSec=15
+
+[Install]
+WantedBy=default.target