2 # Copyright (c) 2016-2021 by Someone <someone@somenet.org> (aka. Jan Vales <jan@jvales.net>)
3 # published under MIT-License
17 from inspect import cleandoc
18 from http.server import BaseHTTPRequestHandler, HTTPServer
19 from urllib.request import Request, urlopen
20 from socketserver import ThreadingMixIn
26 logger = logging.getLogger(__name__)
30 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):
31 self.local_websrv_hostname = local_websrv_hostname
32 self.local_websrv_port = local_websrv_port
33 self.api_user = api_user
34 self.mm_api_url = mm_api_url
35 self.mm_ws_url = mm_ws_url
37 self.local_websrv_url = ("http://"+self.local_websrv_hostname+":"+str(self.local_websrv_port)+"/").strip("/")
41 self.debug_chan_id = debug_chan_id
42 self.command_stats = {}
48 atexit.register(self.on_shutdown)
49 signal.signal(signal.SIGUSR1, self.on_SIGUSR1)
50 signal.signal(signal.SIGTERM, self.shutdown)
51 signal.signal(signal.SIGINT, self.shutdown)
53 # monkey-patch thread naming
56 def _rename_current_thread(self, name, thread):
57 if thread == threading.current_thread():
59 pyprctl.set_name(name)
61 def _bootstrap_named_thread(self):
62 self.rename_current_thread(self._name, threading.current_thread())
63 self.original_bootstrap()
65 threading.Thread.original_bootstrap = threading.Thread._bootstrap
66 threading.Thread._bootstrap = _bootstrap_named_thread
69 logger.error('pyprctl module is not installed. You will not be able to see thread names')
70 def _rename_current_thread(self, name, thread):
71 if thread == threading.current_thread():
74 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)
96 # Register a module with the bot.
97 def register(self, module):
98 if module.TEAM_ID not in self.modules:
99 self.modules[module.TEAM_ID] = {}
101 if module.TRIGGER not in self.modules[module.TEAM_ID]:
102 self.modules[module.TEAM_ID][module.TRIGGER] = module
103 module._on_register(self)
106 raise Exception("Multiple registration attempts for module: "+module.TRIGGER+" and team: "+module.TEAM_ID)
110 # Register a websocket handling module with the bot.
111 # There is no useful way to discriminate WS-events by originating teams atm. :(
112 def register_ws(self, module, eventlist):
113 for evtype in eventlist:
114 if evtype not in self.wsmodules:
115 self.wsmodules[evtype] = {}
117 if module.NAME not in self.wsmodules[evtype]:
118 self.wsmodules[evtype][module.NAME] = module
119 module._on_register_ws_evtype(self, evtype)
122 raise Exception("Multiple registration attempts for module: "+module.NAME+" and evtype: "+evtype)
127 logger.info("Starting: Almost there.")
128 logger.info(pprint.pformat(self.modules))
129 logger.info(pprint.pformat(self.wsmodules))
131 if self.mm_ws_url is not None:
132 self.mmws = mattermost.ws.MMws(self.websocket_handler, self.api, self.mm_ws_url)
134 if self.local_websrv_hostname is not None and self.local_websrv_port is not None:
135 self.start_webserver()
139 def on_shutdown(self):
140 logger.info("Shutting down ...")
143 self.mmws.close_websocket()
144 # todo: stop webserver + WS?
146 for team_modules in self.modules:
147 for module in self.modules[team_modules]:
148 self.modules[team_modules][module]._on_shutdown()
150 for evtype in self.wsmodules:
151 for module in self.wsmodules[evtype]:
152 self.wsmodules[evtype][module]._on_shutdown()
155 self.command_stats_dump()
163 def shutdown(self, unk1=None, unk2=None, exit_code=0):
167 def on_SIGUSR1(self, unk1=None, unk2=None):
168 logger.info("on_SIGUSR1()")
169 self.sigusr1_cnt += 1
170 self.command_stats_inc("internal::SIGUSR1")
171 self.command_stats_dump()
173 # TODO: reinit teams.
175 for team_modules in self.modules:
176 for module in self.modules[team_modules]:
177 self.modules[team_modules][module]._on_SIGUSR1(self.sigusr1_cnt)
179 for evtype in self.wsmodules:
180 for module in self.wsmodules[evtype]:
181 self.wsmodules[evtype][module]._on_SIGUSR1(self.sigusr1_cnt)
185 def debug_chan(self, message):
186 if self.debug_chan_id is None:
187 logger.error("debug_chan() called, but debug_chan_id is unspecified.")
189 self.api.create_post(self.debug_chan_id, "``AUTODELETE-DAY``\n"+message)
193 def command_stats_inc(self, command, amount=1):
194 if command in self.command_stats:
195 self.command_stats[command] += amount
197 self.command_stats[command] = amount
201 def command_stats_dump(self):
202 self.dump_stats_json(self.command_stats, "/tmp/somebot_command_stats.json", "#command_usage #mmstats")
203 self.command_stats = {}
207 def dump_stats_json(self, stats_data, file_path, header="", footer="", no_data_text=""):
213 with open(file_path, "r") as file:
214 old_stats = json.load(file)
215 for item, cnt in old_stats["data"].items():
216 if item in stats_data:
217 stats_data[item] += cnt
219 stats_data[item] = cnt
220 except (FileNotFoundError, json.JSONDecodeError, KeyError):
223 # if no data, but file exists: skip write
224 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:
227 logger.info("dump_stats_json(): writing file: %s", file_path)
228 self.command_stats_inc("internal::dump_stats_json:"+file_path)
230 with open(file_path, "w", encoding="utf-8") as file:
231 json.dump({"header":header, "footer":footer, "no_data_text":no_data_text, "data":dict(sorted(stats_data.items()))}, file, ensure_ascii=False, indent=2)
235 ##########################
236 # Bot's websocket client #
237 ##########################
238 def websocket_handler(self, mmws, event_data):
239 for evtype in self.wsmodules:
240 if evtype == event_data["event"]:
241 for module_name in self.wsmodules[evtype]:
243 if self.wsmodules[evtype][module_name].on_WS_EVENT(event_data):
244 self.command_stats_inc("ws::"+evtype+"::"+module_name)
246 exctxt = "".join(traceback.format_exc())
254 def start_webserver(self):
255 logger.info("Starting webserver.")
257 class HTTPRequestHandler(BaseHTTPRequestHandler):
263 threading.current_thread().rename_current_thread("HTTPRequestHandler", threading.current_thread())
266 if self.headers["Content-Type"] == "application/x-www-form-urlencoded":
267 data = urllib.parse.parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"), keep_blank_values=True)
268 # only accept first occurence
269 data = {k: v[0] for k, v in data.items()}
271 elif self.headers["Content-Type"] == "application/json":
272 data = json.loads(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"))
280 if "response_url" in data:
281 self.responseURL = data["response_url"]
285 logger.info("do_POST(): request incomming.")
288 module = 'not yet known'
289 splitpath = self.path.strip("/").split("/")
290 if splitpath[0] in self.bot.modules and splitpath[1] in self.bot.modules[splitpath[0]]:
291 module = self.bot.modules[splitpath[0]][splitpath[1]]
294 if len(splitpath) > 2 and splitpath[2] == "command":
295 if "token" in data and module.mm_secret_token == data["token"]:
296 self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
297 module._on_POST(self, data)
300 logger.error("do_POST(): Auth problem: Shutting down: mm_secret_token mismatch expected/got -%s-%s-", module.mm_secret_token, data["token"])
301 traceback.print_stack()
303 self.bot.on_shutdown()
306 # interactive button-handler. TODO auth!
307 elif len(splitpath) > 2 and splitpath[2] == "interactive":
308 self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
309 module._on_POST_interactive(self, data)
311 # dialog-handler: TODO: auth!
312 elif len(splitpath) > 2 and splitpath[2] == "dialog":
313 self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
314 module._on_POST_dialog(self, data)
316 else: # Invalid command action
317 logger.error("do_POST(): Invalid command action.")
318 self.bot.debug_chan("do_POST(): Invalid command action.\n# :boom::boom::boom::boom::boom:\n```\n"+self.path+"\n```")
319 self.respond_cmd_err("Invalid command action")
321 else: # Invalid command/unknown command
322 logger.error("do_POST(): Invalid command/unknown command.")
323 self.bot.debug_chan("do_POST(): Invalid command/unknown command.\n# :boom::boom::boom::boom::boom:\n```\n"+self.path+"\n```")
324 self.respond_cmd_err("Invalid command/unknown command")
327 self.bot.debug_chan("##### Error in module: ``"+repr(module)+"``\n# :boom::boom::boom::boom::boom:\n```\n"+traceback.format_exc()+"\n```")
329 # always try to fail to retain userinput. If previously responded to, nothing will happen.
330 self.respond(400, if_nonzero_secondary='ignore')
334 # Send a response to the channel.
335 def respond_cmd_chan(self, message, props=None, att=None, http_code=200):
336 data = {"skip_slack_parsing":True, "response_type":"in_channel", "text":message}
339 data.update({"props": props})
342 data.update({"attachments": att})
344 self.respond(http_code, data)
348 # Send a ephemeral response to the user.
349 def respond_cmd_temp(self, message, props=None, att=None, http_code=200):
350 data = {"skip_slack_parsing":True, "response_type":"ephemeral", "text":message}
353 data.update({"props": props})
356 data.update({"attachments": att})
358 self.respond(http_code, data)
361 def respond_interactive_temp(self, message, http_code=200):
362 # cant be secondary, because no response url (interactive messages only?)
363 self.respond(http_code, {"ephemeral_text":message})
367 # Use to send a failure to the user. Use only the first time during a request. Should retain input on clientside.
368 def respond_cmd_err(self, message, props=None):
369 data = {"skip_slack_parsing":True, "response_type":"ephemeral", "text": "# :warning: Somewhing failed :boom::boom::boom::boom::boom: :(\n### "+message}
372 data.update({"props": props})
375 self.respond(000, data)
379 def respond(self, http_code=200, data=None, if_nonzero_secondary='exc'):
381 First response call must have a valid http code.
382 Secondary responses should have http_code = 0.
383 use if_nonzero_secondary = 'ignore' to ignore response with http_code != 0.
384 use if_nonzero_secondary = 'force' to send secondary with http_code != 0.
391 #pprint.pprint(http_code)
394 if http_code >= 600 or http_code < 100:
395 raise Exception("respond(): Primary response must have a valid http code.")
398 self.send_response(http_code)
399 self.send_header("Content-Type", "application/json")
400 self.send_header("Content-Length", len(bytes(json.dumps(data), "utf8")))
402 self.wfile.write(bytes(json.dumps(data), "utf8"))
403 logger.info("respond(): Primary response send.")
406 # Secondary responses
408 if http_code != 0 and if_nonzero_secondary == "ignore":
409 logger.info("respond(): Secondary responses must have a zero http code, but if_nonzero_secondary='ignore'. Doing nothing.")
412 elif http_code != 0 and if_nonzero_secondary == "force":
413 logger.warning("respond(): Secondary responses must have a zero http code, but if_nonzero_secondary='force'. Sending anyway.")
414 traceback.print_stack()
417 raise Exception("respond(): Secondary responses must have a zero http code.")
420 if not self.responseURL:
421 raise Exception("respond(): Secondary response attempt without response url.")
424 logger.info("respond(): Secondary response. Using responseURL: "+self.responseURL)
425 req = Request(self.responseURL, data=bytes(json.dumps(data), "utf8"), method='POST')
426 req.add_header("Content-Type", "application/json")
427 conn = urlopen(req, timeout=3)
428 logger.info("respond(): Secondary response send. Status: "+str(conn.status))
432 class MyHTTPServer(ThreadingMixIn, HTTPServer):
433 def serve_forever(self, bot):
434 self.RequestHandlerClass.bot = bot
435 HTTPServer.serve_forever(self)
437 self.httpd = MyHTTPServer((self.local_websrv_hostname, self.local_websrv_port), HTTPRequestHandler)
438 threading.current_thread().rename_current_thread("HTTPServer", threading.current_thread())
439 self.httpd.serve_forever(self)