From beb6c9b3ae8ef59928e5187c221db35a12c8fb80 Mon Sep 17 00:00:00 2001 From: Someone Date: Wed, 8 Dec 2021 19:38:31 +0100 Subject: [PATCH] Base system without any modules --- config.py.example | 44 +++++ core/AbstractCommand.py | 175 ++++++++++++++++++++ core/AbstractWSHandler.py | 45 ++++++ core/MMBot.py | 330 ++++++++++++++++++++++++++++++++++++++ json_stats_printer.py | 33 ++++ main.py | 24 +++ modules/__init__.py | 21 +++ somebot.service | 27 ++++ 8 files changed, 699 insertions(+) create mode 100644 config.py.example create mode 100644 core/AbstractCommand.py create mode 100644 core/AbstractWSHandler.py create mode 100644 core/MMBot.py create mode 100755 json_stats_printer.py create mode 100755 main.py create mode 100644 modules/__init__.py create mode 100644 somebot.service diff --git a/config.py.example b/config.py.example new file mode 100644 index 0000000..81cdfdb --- /dev/null +++ b/config.py.example @@ -0,0 +1,44 @@ +# +# Someone's Mattermost scripts. +# Copyright (c) 2016-2021 by Someone (aka. Jan Vales ) +# 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": ["", ], + "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 index 0000000..0205713 --- /dev/null +++ b/core/AbstractCommand.py @@ -0,0 +1,175 @@ +# Mattermost Bot. +# Copyright (c) 2016-2021 by Someone (aka. Jan Vales ) +# 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 index 0000000..28722d9 --- /dev/null +++ b/core/AbstractWSHandler.py @@ -0,0 +1,45 @@ +# Mattermost Bot. +# Copyright (c) 2016-2021 by Someone (aka. Jan Vales ) +# 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 index 0000000..625a026 --- /dev/null +++ b/core/MMBot.py @@ -0,0 +1,330 @@ +# Mattermost Bot. +# Copyright (c) 2016-2021 by Someone (aka. Jan Vales ) +# 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 index 0000000..4b6ed7f --- /dev/null +++ b/json_stats_printer.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +# +# Mattermost Bot. +# Copyright (c) 2016-2021 by Someone (aka. Jan Vales ) +# 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 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 (aka. Jan Vales ) +# 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 index 0000000..5a79a74 --- /dev/null +++ b/modules/__init__.py @@ -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 index 0000000..d5c806c --- /dev/null +++ b/somebot.service @@ -0,0 +1,27 @@ +# +# Mattermost Bot. +# Copyright (c) 2016-2021 by Someone (aka. Jan Vales ) +# 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 -- 2.43.0