From 00b47c5a4c28aaf262fc8749d4ce9c169bbfc130 Mon Sep 17 00:00:00 2001 From: Someone Date: Fri, 19 Jun 2020 01:32:13 +0200 Subject: [PATCH] [somebot] Base system without any modules. --- somebot/.gitignore | 4 + somebot/README.md | 22 +++ somebot/command_stats_printer.py | 18 ++ somebot/config.py.example | 44 +++++ somebot/core/AbstractCommand.py | 129 ++++++++++++++ somebot/core/AbstractWSHandler.py | 39 +++++ somebot/core/MMBot.py | 268 ++++++++++++++++++++++++++++++ somebot/main.py | 26 +++ somebot/somebot.service | 27 +++ 9 files changed, 577 insertions(+) create mode 100644 somebot/.gitignore create mode 100644 somebot/README.md create mode 100755 somebot/command_stats_printer.py create mode 100644 somebot/config.py.example create mode 100644 somebot/core/AbstractCommand.py create mode 100644 somebot/core/AbstractWSHandler.py create mode 100644 somebot/core/MMBot.py create mode 100755 somebot/main.py create mode 100644 somebot/somebot.service diff --git a/somebot/.gitignore b/somebot/.gitignore new file mode 100644 index 0000000..76fd6a6 --- /dev/null +++ b/somebot/.gitignore @@ -0,0 +1,4 @@ +config.py +config*.py +modules/legacy +data/** diff --git a/somebot/README.md b/somebot/README.md new file mode 100644 index 0000000..05b64f2 --- /dev/null +++ b/somebot/README.md @@ -0,0 +1,22 @@ +# Someone's Mattermost bot. + + Copyright (c) 2016-2020 by Someone (aka. Jan Vales ) + + published under MIT-License + +Started out as a simple means to lock channels into "read-only" mode by creating outgoing webhooks and deleting every incomming message. ++ This code is long gone/rebased away. + +Currently this bot's uses range from fun `/threads` `/order` to something helpful `/join-all` and to moderative features like `/ta-wipe-channel` or `/ta-mod-del`. + + +## Installation: ++ pip3 install --user --upgrade -r requirements.txt ++ create a config.py file from config.py.example. ++ make systemd start this bot (see somebot.service) ++ periodically run some cronjobs, like these: + +``` +# bot command stats +*/5 * * * * (XDG_RUNTIME_DIR=/run/user/1002 systemctl --user kill -s SIGUSR1 somebot.service) &> /dev/null +59 11 * * * (cd /home/someone/mattermost/; python3 -u somebot/command_stats_printer.py | python3 -u post_stdin_to_mm.py bot-username bot-pw debug-channel-id '``BOT-AUTODELETE-FAST`` #command_usage #mmstats\n```\n' '\n```'; rm /tmp/somebot_command_stats.json) &> /dev/null + +``` diff --git a/somebot/command_stats_printer.py b/somebot/command_stats_printer.py new file mode 100755 index 0000000..142ed8d --- /dev/null +++ b/somebot/command_stats_printer.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +# +# Mattermost Bot. +# Copyright (c) 2016-2020 by Someone (aka. Jan Vales ) +# published under MIT-License +# +# This is started by the init script. +# + +import json + +try: + with open("/tmp/somebot_command_stats.json", "r", encoding="utf-8") as f: + stats = json.load(f) + for c, count in sorted(stats.items()): + print(c+" ["+str(count)+"]") +except FileNotFoundError: + pass diff --git a/somebot/config.py.example b/somebot/config.py.example new file mode 100644 index 0000000..7d30c3d --- /dev/null +++ b/somebot/config.py.example @@ -0,0 +1,44 @@ +# +# Someone's Mattermost scripts. +# Copyright (c) 2016-2020 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/somebot/core/AbstractCommand.py b/somebot/core/AbstractCommand.py new file mode 100644 index 0000000..a981d93 --- /dev/null +++ b/somebot/core/AbstractCommand.py @@ -0,0 +1,129 @@ +# Mattermost Bot. +# Copyright (c) 2016-2020 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 + + # 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_POST(self, request, data): + try: + 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.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.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_per_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_team_admin(self, data): + """ + 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"]: + raise RequireException("### You are not TEAM_ADMIN. :(") + + + def _require_channel_admin(self, data): + """ + Require at least channel admin priviledges. + Throws RequireException if not priviledged. + """ + raise RequireException("### Priviledge-check for CHANNEL_ADMIN not implemented/TODO, but required. yeah. nope.") diff --git a/somebot/core/AbstractWSHandler.py b/somebot/core/AbstractWSHandler.py new file mode 100644 index 0000000..86a1d17 --- /dev/null +++ b/somebot/core/AbstractWSHandler.py @@ -0,0 +1,39 @@ +# Mattermost Bot. +# Copyright (c) 2016-2020 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 + + # 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() diff --git a/somebot/core/MMBot.py b/somebot/core/MMBot.py new file mode 100644 index 0000000..51767df --- /dev/null +++ b/somebot/core/MMBot.py @@ -0,0 +1,268 @@ +# Mattermost Bot. +# Copyright (c) 2016-2020 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 threading +import traceback +import urllib + +from http.server import BaseHTTPRequestHandler, HTTPServer +from socketserver import ThreadingMixIn + +import mattermost +import mattermost.ws + + +class MMBot(): + def __init__(self, local_websrv_hostname="localhost", local_websrv_port=18065, api_user=None, api_user_pw=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 = None + self.loop = asyncio.new_event_loop() + self.command_stats = dict() + self.admin_ids = [] + + self.mmws = None + + atexit.register(self.shutdown) + signal.signal(signal.SIGUSR1, self.command_stats_dump) + signal.signal(signal.SIGTERM, self.sig_shutdown) + signal.signal(signal.SIGINT, self.sig_shutdown) + + + # Core-Command: /use-data + self.USETOPICS = {"bot":("##### 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.\n" + "Written by ``@someone`` in python. Big thanks to contributors: ``@ju``, ``@gittenburg``\n" + "Inspired by: ``@bearza``, ``@frunobulax``\n" + "\n" + "Feel like contributing too? Talk to ``@someone``. :)\n" + "The repository is here: https://git.somenet.org/pub/jan/mattermost.git")} + + if api_user is not None and api_user_pw is not None: + logging.info("User credentials given. Trying to login.") + self.api = mattermost.MMApi(self.mm_api_url) + self.api.login(api_user, api_user_pw) + self.debug_chan_id = debug_chan_id + + + # 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): + logging.info("Starting: Almost there.") + logging.info(pprint.pformat(self.modules)) + logging.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 shutdown(self): + logging.info("Shutting down ...") + + 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() + logging.info("BYE.") + + + + ######## + # misc # + ######## + def debug_chan(self, message): + if self.debug_chan_id is None: + logging.info("debug_chan() called, but debug_chan_id is unspecified.") + return + + self.api.create_post(self.debug_chan_id, "``BOT-AUTODELETE-FAST``\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 sig_shutdown(self, unk1=None, unk2=None): + sys.exit(0) + + + def command_stats_dump(self, unk1=None, unk2=None): + self.command_stats_inc("internal::command_stats_dump") + stats = self.command_stats.copy() + self.command_stats = dict() + + print("updating command stats: /tmp/somebot_command_stats.json", file=sys.stderr) + try: + with open("/tmp/somebot_command_stats.json", "r") as file: + old_stats = json.load(file) + for item, cnt in old_stats.items(): + if item in stats: + stats[item] += cnt + else: + stats[item] = cnt + except: + pass + + with open("/tmp/somebot_command_stats.json", "w", encoding="utf-8") as file: + json.dump(stats, 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: + print(module_name) + if self.wsmodules[evtype][module_name].on_WS_EVENT(event_data): + self.command_stats_inc("ws::"+evtype+"::"+module_name) + except: + exctxt = "".join(traceback.format_exc()) + logging.error(exctxt) + + + ################### + # Bot's webserver # + ################### + def start_webserver(self): + logging.info("Starting webserver.") + + class RequestHandler(BaseHTTPRequestHandler): + bot = 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 = dict([(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 + + 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: + logging.warning("mm_secret_token mismatch expected/got -"+module.mm_secret_token+"-"+data["token"]+"-") + self.respond(403) + + # 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) + + + def cmd_respond_text_temp(self, message, props=None): + if props is None: + self.respond(200, {"skip_slack_parsing":True, "response_type":"ephemeral", "text":message}) + else: + self.respond(200, {"skip_slack_parsing":True, "response_type":"ephemeral", "text":message, "props":props}) + + + def cmd_respond_text_chan(self, message, props=None): + if props is None: + self.respond(200, {"skip_slack_parsing":True, "response_type":"in_channel", "text":message}) + else: + self.respond(200, {"skip_slack_parsing":True, "response_type":"in_channel", "text":message, "props":props}) + + + def respond(self, err, message=None): + if self.handled: + return + + self.handled = True + self.send_response(err) + self.send_header("Content-Type", "application/json") + 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/somebot/main.py b/somebot/main.py new file mode 100755 index 0000000..d968e05 --- /dev/null +++ b/somebot/main.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# +# Mattermost Bot. +# Copyright (c) 2016-2020 by Someone (aka. Jan Vales ) +# published under MIT-License +# +# This is started by the init script. +# + +import importlib +import os +import re +import signal +import sys + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "core"))) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "modules"))) + +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/somebot/somebot.service b/somebot/somebot.service new file mode 100644 index 0000000..b29661a --- /dev/null +++ b/somebot/somebot.service @@ -0,0 +1,27 @@ +# +# Mattermost Bot. +# Copyright (c) 2016-2020 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 main.py +Restart=always +RestartSec=30 + +[Install] +WantedBy=default.target -- 2.43.0