From aa0535b50f03aaeeee265086bc110f075568cbbe Mon Sep 17 00:00:00 2001 From: Someone Date: Thu, 6 Jan 2022 22:15:39 +0100 Subject: [PATCH] modified: core/MMBot.py --- core/MMBot.py | 245 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 177 insertions(+), 68 deletions(-) diff --git a/core/MMBot.py b/core/MMBot.py index 625a026..0216866 100644 --- a/core/MMBot.py +++ b/core/MMBot.py @@ -2,7 +2,6 @@ # Copyright (c) 2016-2021 by Someone (aka. Jan Vales ) # published under MIT-License -import asyncio import atexit import json import logging @@ -10,6 +9,7 @@ import os import pprint import signal import sys +import threading import time import traceback import urllib @@ -35,12 +35,11 @@ class MMBot(): 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.modules = {} + self.wsmodules = {} self.api = None self.debug_chan_id = debug_chan_id - self.loop = asyncio.new_event_loop() - self.command_stats = dict() + self.command_stats = {} self.admin_ids = [] self.mmws = None @@ -51,6 +50,29 @@ class MMBot(): signal.signal(signal.SIGTERM, self.shutdown) signal.signal(signal.SIGINT, self.shutdown) + # monkey-patch thread naming + try: + import pyprctl + def _rename_current_thread(self, name, thread): + if thread == threading.current_thread(): + thread.setName(name) + pyprctl.set_name(name) + + def _bootstrap_named_thread(self): + self.rename_current_thread(self._name, threading.current_thread()) + self.original_bootstrap() + + threading.Thread.original_bootstrap = threading.Thread._bootstrap + threading.Thread._bootstrap = _bootstrap_named_thread + + except ImportError: + logger.error('pyprctl module is not installed. You will not be able to see thread names') + def _rename_current_thread(self, name, thread): + if thread == threading.current_thread(): + thread.setName(name) + + threading.Thread.rename_current_thread = _rename_current_thread + # 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. @@ -70,10 +92,11 @@ class MMBot(): 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() + self.modules[module.TEAM_ID] = {} if module.TRIGGER not in self.modules[module.TEAM_ID]: self.modules[module.TEAM_ID][module.TRIGGER] = module @@ -83,12 +106,13 @@ class MMBot(): 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() + self.wsmodules[evtype] = {} if module.NAME not in self.wsmodules[evtype]: self.wsmodules[evtype][module.NAME] = module @@ -98,6 +122,7 @@ class MMBot(): 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)) @@ -110,6 +135,7 @@ class MMBot(): self.start_webserver() + def on_shutdown(self): logger.info("Shutting down ...") @@ -134,8 +160,8 @@ class MMBot(): ######## # misc # ######## - def shutdown(self, unk1=None, unk2=None, err=0): - sys.exit(err) + def shutdown(self, unk1=None, unk2=None, exit_code=0): + sys.exit(exit_code) def on_SIGUSR1(self, unk1=None, unk2=None): @@ -155,13 +181,15 @@ class MMBot(): 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.") + logger.error("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 @@ -169,9 +197,11 @@ class MMBot(): 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() + self.command_stats = {} + def dump_stats_json(self, stats_data, file_path, header="", footer="", no_data_text=""): @@ -217,108 +247,186 @@ class MMBot(): logger.error(exctxt) + ################### # Bot's webserver # ################### def start_webserver(self): logger.info("Starting webserver.") - class RequestHandler(BaseHTTPRequestHandler): + class HTTPRequestHandler(BaseHTTPRequestHandler): bot = None handled = False responseURL = None def do_POST(self): + threading.current_thread().rename_current_thread("HTTPRequestHandler", threading.current_thread()) 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]] + logger.info("do_POST(): request incomming.") + pprint.pprint(data) + try: + module = 'not yet known' + splitpath = self.path.strip("/").split("/") + if splitpath[0] in self.bot.modules and splitpath[1] in self.bot.modules[splitpath[0]]: + 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.error("do_POST(): Auth problem: Shutting down: mm_secret_token mismatch expected/got -%s-%s-", module.mm_secret_token, data["token"]) + traceback.print_stack() + 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) - # /command - if len(splitpath) > 2 and splitpath[2] == "command": - if "token" in data and module.mm_secret_token == data["token"]: + # dialog-handler: TODO: auth! + elif len(splitpath) > 2 and splitpath[2] == "dialog": 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) + module._on_POST_dialog(self, data) - # 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) + else: # Invalid command action + logger.error("do_POST(): Invalid command action.") + self.bot.debug_chan("do_POST(): Invalid command action.\n# :boom::boom::boom::boom::boom:\n```\n"+self.path+"\n```") + self.respond_cmd_err("Invalid command action") - # 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: # Invalid command/unknown command + logger.error("do_POST(): Invalid command/unknown command.") + self.bot.debug_chan("do_POST(): Invalid command/unknown command.\n# :boom::boom::boom::boom::boom:\n```\n"+self.path+"\n```") + self.respond_cmd_err("Invalid command/unknown command") - else: #something weird. - self.bot.command_stats_inc("/"+splitpath[1]+' --- SOMETHINGS WEIRD!!!') + except: + self.bot.debug_chan("##### Error in module: ``"+repr(module)+"``\n# :boom::boom::boom::boom::boom:\n```\n"+traceback.format_exc()+"\n```") - # always try to fail. If previously responded to, nothing will happen. - self.respond(400, only_direct=True) + # always try to fail to retain userinput. If previously responded to, nothing will happen. + self.respond(400, if_nonzero_secondary='ignore') - 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}) + + # Send a response to the channel. + def respond_cmd_chan(self, message, props=None, att=None, http_code=200): + data = {"skip_slack_parsing":True, "response_type":"in_channel", "text":message} + + if props: + data.update({"props": props}) + + if att: + data.update({"attachments": att}) + + self.respond(http_code, data) - 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}) + + # Send a ephemeral response to the user. + def respond_cmd_temp(self, message, props=None, att=None, http_code=200): + data = {"skip_slack_parsing":True, "response_type":"ephemeral", "text":message} + + if props: + data.update({"props": props}) + + if att: + data.update({"attachments": att}) + + self.respond(http_code, data) + + + def respond_interactive_temp(self, message, http_code=200): + # cant be secondary, because no response url (interactive messages only?) + self.respond(http_code, {"ephemeral_text":message}) + + + + # Use to send a failure to the user. Use only the first time during a request. Should retain input on clientside. + def respond_cmd_err(self, message, props=None): + data = {"skip_slack_parsing":True, "response_type":"ephemeral", "text": "# :warning: Somewhing failed :boom::boom::boom::boom::boom: :(\n### "+message} + + if props: + data.update({"props": props}) + + self.respond(400) + self.respond(000, data) + + + + def respond(self, http_code=200, data=None, if_nonzero_secondary='exc'): + """ + First response call must have a valid http code. + Secondary responses should have http_code = 0. + use if_nonzero_secondary = 'ignore' to ignore response with http_code != 0. + use if_nonzero_secondary = 'force' to send secondary with http_code != 0. + """ + + if data is None: + data = {} + + # First response + #pprint.pprint(http_code) + #pprint.pprint(data) + if not self.handled: + if http_code >= 600 or http_code < 100: + raise Exception("respond(): Primary response must have a valid http code.") + + self.handled = True + self.send_response(http_code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", len(bytes(json.dumps(data), "utf8"))) + self.end_headers() + self.wfile.write(bytes(json.dumps(data), "utf8")) + logger.info("respond(): Primary response send.") + + + # Secondary responses else: - self.respond(http, {"skip_slack_parsing":True, "response_type":"in_channel", "text":message, "props":props}) + if http_code != 0 and if_nonzero_secondary == "ignore": + logger.info("respond(): Secondary responses must have a zero http code, but if_nonzero_secondary='ignore'. Doing nothing.") + return + elif http_code != 0 and if_nonzero_secondary == "force": + logger.warning("respond(): Secondary responses must have a zero http code, but if_nonzero_secondary='force'. Sending anyway.") + traceback.print_stack() - 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 http_code != 0: + raise Exception("respond(): Secondary responses must have a zero http code.") - 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') + if not self.responseURL: + raise Exception("respond(): Secondary response attempt without response url.") + + + logger.info("respond(): Secondary response. Using responseURL: "+self.responseURL) + req = Request(self.responseURL, data=bytes(json.dumps(data), "utf8"), method='POST') req.add_header("Content-Type", "application/json") conn = urlopen(req, timeout=3) - return + logger.info("respond(): Secondary response send. Status: "+str(conn.status)) - 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): @@ -326,5 +434,6 @@ class MMBot(): self.RequestHandlerClass.bot = bot HTTPServer.serve_forever(self) - self.httpd = MyHTTPServer((self.local_websrv_hostname, self.local_websrv_port), RequestHandler) + self.httpd = MyHTTPServer((self.local_websrv_hostname, self.local_websrv_port), HTTPRequestHandler) + threading.current_thread().rename_current_thread("HTTPServer", threading.current_thread()) self.httpd.serve_forever(self) -- 2.43.0