2 # Copyright (c) 2016-2022 by Someone <someone@somenet.org> (aka. Jan Vales <jan@jvales.net>)
3 # published under MIT-License
16 from inspect import cleandoc
17 from http.server import BaseHTTPRequestHandler, HTTPServer
18 from urllib.request import Request, urlopen
19 from socketserver import ThreadingMixIn
25 logger = logging.getLogger(__name__)
29 def __init__(self, local_websrv_hostname="localhost", local_websrv_port=18065, local_public_websrv_hostname="localhost", local_public_websrv_port=18066, 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):
30 self.local_websrv_hostname = local_websrv_hostname
31 self.local_websrv_port = local_websrv_port
32 self.local_public_websrv_hostname = local_public_websrv_hostname
33 self.local_public_websrv_port = local_public_websrv_port
34 self.api_user = api_user
35 self.mm_api_url = mm_api_url
36 self.mm_ws_url = mm_ws_url
40 self.modules_public = {}
42 self.debug_chan_id = debug_chan_id
43 self.command_stats = {}
49 atexit.register(self.on_shutdown)
50 signal.signal(signal.SIGUSR1, self.on_SIGUSR1)
51 signal.signal(signal.SIGTERM, self.shutdown)
52 signal.signal(signal.SIGINT, self.shutdown)
54 # monkey-patch thread naming
57 def _rename_current_thread(self, name, thread):
58 if thread == threading.current_thread():
60 pyprctl.set_name(name)
62 def _bootstrap_named_thread(self):
63 self.rename_current_thread(self._name, threading.current_thread())
64 self.original_bootstrap()
66 threading.Thread.original_bootstrap = threading.Thread._bootstrap
67 threading.Thread._bootstrap = _bootstrap_named_thread
70 logger.error('pyprctl module is not installed. You will not be able to see thread names')
71 def _rename_current_thread(self, name, thread):
72 if thread == threading.current_thread():
75 threading.Thread.rename_current_thread = _rename_current_thread
77 # Core-Command: /use-data
78 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.
79 Written by **``@someone``** in python3. Big thanks to contributors: **``@ju``**, **``@gittenburg``**
80 Inspiring ideas by: ``@bearza``, ``@frunobulax``, **``@x5468656f``**
82 Feel like contributing too? Talk to **``@someone``**. :)
83 The repository is here: https://git.somenet.org/pub/jan/mattermost-bot.git
86 if api_user is not None and api_user_pw is not None:
87 logger.info("User credentials given. Trying to login.")
88 self.api = mattermost.MMApi(self.mm_api_url)
89 self.api.login(api_user, api_user_pw)
90 elif api_user is not None:
91 self.api = mattermost.MMApi(self.mm_api_url)
92 self.api.login(bearer=api_bearer)
95 # Register a module with the bot.
96 def register(self, module):
97 if module.TEAM_ID not in self.modules:
98 self.modules[module.TEAM_ID] = {}
100 if module.TRIGGER not in self.modules[module.TEAM_ID]:
101 self.modules[module.TEAM_ID][module.TRIGGER] = module
102 module._on_register(self)
105 raise Exception("Multiple registration attempts for module: "+module.TRIGGER+" and team: "+module.TEAM_ID)
108 # Register a websocket handling module with the bot.
109 # There is no useful way to discriminate WS-events by originating teams atm. :(
110 def register_ws(self, module, eventlist):
111 for evtype in eventlist:
112 if evtype not in self.wsmodules:
113 self.wsmodules[evtype] = {}
115 if module.NAME not in self.wsmodules[evtype]:
116 self.wsmodules[evtype][module.NAME] = module
117 module._on_register_ws_evtype(self, evtype)
120 raise Exception("Multiple registration attempts for module: "+module.NAME+" and evtype: "+evtype)
122 # Register a public facing module with the bot.
123 def register_public(self, module):
124 self.modules_public = {}
126 if module.TRIGGER not in self.modules_public:
127 self.modules_public[module.TRIGGER] = module
128 module._on_register_public(self)
131 raise Exception("Multiple registration attempts for module: "+module.TRIGGER+" and team: "+module.TEAM_ID)
134 logger.info("Starting: Almost there.")
135 logger.info(pprint.pformat(self.modules))
136 logger.info(pprint.pformat(self.wsmodules))
137 logger.info(pprint.pformat(self.modules_public))
139 if self.mm_ws_url is not None:
140 self.mmws = mattermost.ws.MMws(self.websocket_handler, self.api, self.mm_ws_url)
142 if self.local_public_websrv_hostname is not None and self.local_public_websrv_port is not None:
143 th = threading.Thread(target=self.start_webserver_public, daemon=True)
144 th.setName("http public")
147 if self.local_websrv_hostname is not None and self.local_websrv_port is not None:
148 self.start_webserver_mattermost()
151 def on_shutdown(self):
152 logger.info("Shutting down ...")
155 self.mmws.close_websocket()
157 for team_modules in self.modules:
158 for module in self.modules[team_modules]:
159 self.modules[team_modules][module]._on_shutdown()
161 for evtype in self.wsmodules:
162 for module in self.wsmodules[evtype]:
163 self.wsmodules[evtype][module]._on_shutdown()
166 self.command_stats_dump()
173 def shutdown(self, unk1=None, unk2=None, exit_code=0):
177 def on_SIGUSR1(self, unk1=None, unk2=None):
178 logger.info("on_SIGUSR1()")
179 self.sigusr1_cnt += 1
180 self.command_stats_inc("internal::SIGUSR1")
181 self.command_stats_dump()
183 for team_modules in self.modules:
184 for module in self.modules[team_modules]:
185 self.modules[team_modules][module]._on_SIGUSR1(self.sigusr1_cnt)
187 for evtype in self.wsmodules:
188 for module in self.wsmodules[evtype]:
189 self.wsmodules[evtype][module]._on_SIGUSR1(self.sigusr1_cnt)
192 def debug_chan(self, message, root_id=None):
193 if self.debug_chan_id is None:
194 logger.error("debug_chan() called, but debug_chan_id is unspecified.")
196 return self.api.create_post(self.debug_chan_id, "``AUTODELETE-DAY``\n"+message, root_id=root_id)
199 def command_stats_inc(self, command, amount=1):
200 if command in self.command_stats:
201 self.command_stats[command] += amount
203 self.command_stats[command] = amount
206 def command_stats_dump(self):
207 self.dump_stats_json(self.command_stats, "/tmp/somebot_command_stats.json", "#command_usage #mmstats")
208 self.command_stats = {}
211 def dump_stats_json(self, stats_data, file_path, header="", footer="", no_data_text=""):
217 with open(file_path, "r") as file:
218 old_stats = json.load(file)
219 for item, cnt in old_stats["data"].items():
220 if item in stats_data:
221 stats_data[item] += cnt
223 stats_data[item] = cnt
224 except (FileNotFoundError, json.JSONDecodeError, KeyError):
227 # if no data, but file exists: skip write
228 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:
231 logger.info("dump_stats_json(): writing file: %s", file_path)
232 self.command_stats_inc("internal::dump_stats_json:"+file_path)
234 with open(file_path, "w", encoding="utf-8") as file:
235 json.dump({"header":header, "footer":footer, "no_data_text":no_data_text, "data":dict(sorted(stats_data.items()))}, file, ensure_ascii=False, indent=2)
238 ##########################
239 # Bot's websocket client #
240 ##########################
241 def websocket_handler(self, mmws, event_data):
242 for evtype in self.wsmodules:
243 if evtype == event_data["event"]:
244 for module_name in self.wsmodules[evtype]:
246 if self.wsmodules[evtype][module_name].on_WS_EVENT(event_data):
247 self.command_stats_inc("ws::"+evtype+"::"+module_name)
248 except Exception as exc:
249 self.debug_chan("##### Exception in ``"+evtype+"::"+module_name+"``: ``"+repr(exc)+"``\n# :boom::boom::boom::boom::boom:")
250 logger.exception("websocket_handler(): Exception in: %s\nEvent-data:%s", evtype+"::"+module_name, pprint.pformat(event_data))
253 ##############################
254 # Bot's mattermost webserver #
255 ##############################
256 def start_webserver_mattermost(self):
257 logger.info("Starting mattermost facing webserver.")
259 class HTTPRequestHandler(BaseHTTPRequestHandler):
264 def version_string(self):
270 if self.headers["Content-Type"] == "application/x-www-form-urlencoded":
271 data = urllib.parse.parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"), keep_blank_values=True)
272 # only accept first occurence
273 data = {k: v[0] for k, v in data.items()}
275 elif self.headers["Content-Type"] == "application/json":
276 data = json.loads(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"))
283 if "response_url" in data:
284 self.responseURL = data["response_url"]
287 logger.debug("do_POST(): request incomming.")
289 module = 'not yet known'
290 splitpath = self.path.strip("/").split("/")
291 if splitpath[0] in self.bot.modules and splitpath[1] in self.bot.modules[splitpath[0]]:
292 module = self.bot.modules[splitpath[0]][splitpath[1]]
295 if len(splitpath) > 2 and splitpath[2] == "command":
296 if "token" in data and module.mm_secret_token == data["token"]:
297 self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
298 module._on_POST(self, data)
301 logger.error("do_POST(): Auth problem: Shutting down: mm_secret_token mismatch expected/got -%s-%s-", module.mm_secret_token, data["token"])
302 traceback.print_stack()
304 self.bot.on_shutdown()
307 # interactive button-handler. TODO auth!
308 elif len(splitpath) > 2 and splitpath[2] == "interactive":
309 self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
310 module._on_POST_interactive(self, data)
312 # dialog-handler: TODO: auth!
313 elif len(splitpath) > 2 and splitpath[2] == "dialog":
314 self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
315 module._on_POST_dialog(self, data)
317 else: # Invalid command action
318 logger.error("do_POST(): Invalid command action.")
319 self.bot.debug_chan("do_POST(): Invalid command action.\n# :boom::boom::boom::boom::boom:\n```\n"+self.path+"\n```")
320 self.respond_cmd_err("Invalid command action")
322 else: # Invalid command/unknown command
323 logger.error("do_POST(): Invalid command/unknown command.")
324 self.bot.debug_chan("do_POST(): Invalid command/unknown command.\n# :boom::boom::boom::boom::boom:\n```\n"+self.path+"\n```")
325 self.respond_cmd_err("Invalid command/unknown command")
327 # always try to fail to retain userinput. If previously responded to, nothing will happen.
328 self.respond(400, if_nonzero_secondary='ignore')
330 except Exception as exc:
331 self.bot.debug_chan("##### Exception in ``"+self.path.strip("/")+"``: ``"+repr(exc)+"``\n# :boom::boom::boom::boom::boom:")
332 logger.exception("do_POST(): Exception in: %s\nRequest-data:%s", self.path.strip("/"), pprint.pformat(data))
333 self.respond_cmd_err("A serverside error occured. @someone should have been contacted.", http_code=500, if_nonzero_secondary='ignore')
336 # Send a response to the channel.
337 def respond_cmd_chan(self, message, props=None, att=None, http_code=200):
338 data = {"skip_slack_parsing":True, "response_type":"in_channel", "text":message}
341 data.update({"props": props})
346 props.update({"attachments": att})
347 data.update({"props": props})
349 self.respond(http_code, data)
352 # Send a ephemeral response to the user.
353 def respond_cmd_temp(self, message, props=None, att=None, http_code=200):
354 data = {"skip_slack_parsing":True, "response_type":"ephemeral", "text":message}
357 data.update({"props": props})
362 props.update({"attachments": att})
363 data.update({"props": props})
365 self.respond(http_code, data)
368 def respond_interactive_temp(self, message):
369 # cant be secondary, because no response url (interactive messages only?)
370 self.respond(200, {"skip_slack_parsing":True, "ephemeral_text": message})
373 # Use to send a failure to the user. Use only the first time during a request. Should retain input on clientside.
374 def respond_cmd_err(self, message, props=None, http_code=400, if_nonzero_secondary='exc'):
375 data = {"skip_slack_parsing":True, "response_type":"ephemeral", "text": "## :x: Failure! :(\n#### "+message}
378 data.update({"props": props})
380 # must be 2 separatecalls, as the message is ignored in a non-200.
381 self.respond(http_code=http_code, if_nonzero_secondary=if_nonzero_secondary)
382 self.respond(000, data)
385 def respond_interactive_err(self, message):
386 # cant be secondary, because no response url (interactive messages only?)
387 self.respond(200, {"skip_slack_parsing":True, "ephemeral_text":"## :x: Failure! :(\n#### "+message})
390 def respond(self, http_code=200, data=None, if_nonzero_secondary='exc'):
392 First response call must have a valid http code.
393 Secondary responses should have http_code = 0.
394 use if_nonzero_secondary = 'ignore' to ignore response with http_code != 0.
395 use if_nonzero_secondary = 'force' to send secondary with http_code != 0.
403 if http_code >= 600 or http_code < 100:
404 raise Exception("respond(): Primary response must have a valid http code.")
407 self.send_response(http_code)
408 self.send_header("Content-Type", "application/json")
409 self.send_header("Content-Length", len(bytes(json.dumps(data), "utf8")))
411 self.wfile.write(bytes(json.dumps(data), "utf8"))
412 logger.debug("respond(): Primary response send.")
414 # Secondary responses
416 if http_code != 0 and if_nonzero_secondary == "ignore":
417 logger.info("respond(): Secondary responses must have a zero http code, but if_nonzero_secondary='ignore'. Doing nothing.")
420 elif http_code != 0 and if_nonzero_secondary == "force":
421 logger.warning("respond(): Secondary responses must have a zero http code, but if_nonzero_secondary='force'. Sending anyway.")
422 traceback.print_stack()
425 raise Exception("respond(): Secondary responses must have a zero http code.")
428 if not self.responseURL:
429 raise Exception("respond(): Secondary response attempt without response url.")
431 logger.debug("respond(): Secondary response. Using responseURL: %s", self.responseURL)
432 req = Request(self.responseURL, data=bytes(json.dumps(data), "utf8"), method='POST')
433 req.add_header("Content-Type", "application/json")
434 conn = urlopen(req, timeout=3)
435 logger.debug("respond(): Secondary response send. Status: %s", conn.status)
438 class MyHTTPServer(ThreadingMixIn, HTTPServer):
439 def serve_forever(self, bot):
440 self.RequestHandlerClass.bot = bot
441 HTTPServer.serve_forever(self)
443 self.httpd = MyHTTPServer((self.local_websrv_hostname, self.local_websrv_port), HTTPRequestHandler)
444 self.httpd.serve_forever(self)
447 #################################
448 # Bot's public facing webserver #
449 #################################
450 def start_webserver_public(self):
451 logger.info("Starting public facing webserver.")
453 class HTTPPublicRequestHandler(BaseHTTPRequestHandler):
457 def version_string(self):
464 if self.headers["Content-Type"] == "application/x-www-form-urlencoded":
465 data = urllib.parse.parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"), keep_blank_values=True)
466 # only accept first occurence
467 data = {k: v[0] for k, v in data.items()}
469 elif self.headers["Content-Type"] == "application/json":
470 data = json.loads(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"))
473 # self.respond_public(415)
476 logger.debug("do_public_POST(): request incomming.")
478 module = 'not yet known'
479 splitpath = self.path.strip("/").split("/")
480 if splitpath[0] in self.bot.modules_public:
481 module = self.bot.modules_public[splitpath[0]]
483 self.bot.command_stats_inc("public-post::"+splitpath[0])
484 module._on_public_POST(self, data)
486 else: # Invalid command/unknown command
487 logger.error("do_public_POST(): Invalid command/unknown command")
488 #self.bot.debug_chan("do_public_POST(): Invalid command/unknown command.\n# :boom::boom::boom::boom::boom:\n```\n"+self.path+"\n```")
489 self.respond_public(400, {"error":"Invalid command/unknown command"} ,if_nonzero_secondary='ignore')
491 # always try to fail to retain userinput. If previously responded to, nothing will happen.
492 self.respond_public(400, if_nonzero_secondary='ignore')
494 except Exception as exc:
495 self.bot.debug_chan("##### Exception in ``"+self.path.strip("/")+"``: ``"+repr(exc)+"``\n# :boom::boom::boom::boom::boom:")
496 logger.exception("do_public_POST(): Exception in: %s\nRequest-data:%s", self.path.strip("/"), pprint.pformat(data))
497 self.respond_public(500, {"error":"A serverside error occured. @someone should have been contacted."}, if_nonzero_secondary='ignore')
504 if self.headers["Content-Type"] == "application/x-www-form-urlencoded":
505 data = urllib.parse.parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"), keep_blank_values=True)
506 # only accept first occurence
507 data = {k: v[0] for k, v in data.items()}
509 elif self.headers["Content-Type"] == "application/json":
510 data = json.loads(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"))
513 # self.respond_public(415)
516 logger.debug("do_public_GET(): request incomming.")
518 module = 'not yet known'
519 splitpath = self.path.strip("/").split("/")
520 if splitpath[0] in self.bot.modules_public:
521 module = self.bot.modules_public[splitpath[0]]
523 self.bot.command_stats_inc("public-get::"+splitpath[0])
524 module._on_public_GET(self, data)
526 else: # Invalid command/unknown command
527 logger.error("do_public_GET(): Invalid command/unknown command")
528 #self.bot.debug_chan("do_public_GET(): Invalid command/unknown command.\n# :boom::boom::boom::boom::boom:\n```\n"+self.path+"\n```")
529 self.respond_public(400, {"error":"Invalid command/unknown command"} ,if_nonzero_secondary='ignore')
531 # always try to fail to retain userinput. If previously responded to, nothing will happen.
532 self.respond_public(400, if_nonzero_secondary='ignore')
534 except Exception as exc:
535 self.bot.debug_chan("##### Exception in ``"+self.path.strip("/")+"``: ``"+repr(exc)+"``\n# :boom::boom::boom::boom::boom:")
536 logger.exception("do_public_GET(): Exception in: %s\nRequest-data:%s", self.path.strip("/"), pprint.pformat(data))
537 self.respond_public(500, {"error":"A serverside error occured. @someone should have been contacted."}, if_nonzero_secondary='ignore')
540 def respond_public(self, http_code=200, data=None, if_nonzero_secondary='exc'):
542 First response call must have a valid http code.
550 if http_code >= 600 or http_code < 100:
551 raise Exception("respond_public(): Primary response must have a valid http code.")
554 self.send_response(http_code)
555 self.send_header("Content-Type", "application/json")
556 self.send_header("Content-Length", len(bytes(json.dumps(data), "utf8")))
558 self.wfile.write(bytes(json.dumps(data), "utf8"))
559 logger.debug("respond_public(): Primary response send.")
561 # Secondary responses
563 if http_code != 0 and if_nonzero_secondary == "ignore":
564 logger.info("respond_public(): Secondary responses must have a zero http code, but if_nonzero_secondary='ignore'. Doing nothing.")
567 elif if_nonzero_secondary != "ignore":
568 raise Exception("respond_public(): Secondary responses must be ignored.")
572 class MyHTTPServer(ThreadingMixIn, HTTPServer):
573 def serve_forever(self, bot):
574 self.RequestHandlerClass.bot = bot
575 HTTPServer.serve_forever(self)
577 self.httpd = MyHTTPServer((self.local_public_websrv_hostname, self.local_public_websrv_port), HTTPPublicRequestHandler)
578 self.httpd.serve_forever(self)