]> git.somenet.org - pub/jan/mattermost-bot.git/blob - core/MMBot.py
core/MMBot.py
[pub/jan/mattermost-bot.git] / core / MMBot.py
1 # Mattermost Bot.
2 #  Copyright (c) 2016-2022 by Someone <someone@somenet.org> (aka. Jan Vales <jan@jvales.net>)
3 #  published under MIT-License
4
5 import atexit
6 import json
7 import logging
8 import os
9 import pprint
10 import signal
11 import sys
12 import threading
13 import traceback
14 import urllib
15
16 from inspect import cleandoc
17 from http.server import BaseHTTPRequestHandler, HTTPServer
18 from urllib.request import Request, urlopen
19 from socketserver import ThreadingMixIn
20
21 import mattermost
22 import mattermost.ws
23
24
25 logger = logging.getLogger(__name__)
26
27
28 class MMBot():
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
37
38         self.modules = {}
39         self.wsmodules = {}
40         self.modules_public = {}
41         self.api = None
42         self.debug_chan_id = debug_chan_id
43         self.command_stats = {}
44         self.admin_ids = []
45
46         self.mmws = None
47         self.sigusr1_cnt = 0
48
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)
53
54         # monkey-patch thread naming
55         try:
56             import pyprctl
57             def _rename_current_thread(self, name, thread):
58                 if thread == threading.current_thread():
59                     thread.setName(name)
60                     pyprctl.set_name(name)
61
62             def _bootstrap_named_thread(self):
63                 self.rename_current_thread(self._name, threading.current_thread())
64                 self.original_bootstrap()
65
66             threading.Thread.original_bootstrap = threading.Thread._bootstrap
67             threading.Thread._bootstrap = _bootstrap_named_thread
68
69         except ImportError:
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():
73                     thread.setName(name)
74
75         threading.Thread.rename_current_thread = _rename_current_thread
76
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``**
81
82                                  Feel like contributing too? Talk to **``@someone``**. :)
83                                  The repository is here: https://git.somenet.org/pub/jan/mattermost-bot.git
84                                  """)}
85
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)
93
94
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] = {}
99
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)
103
104         else:
105             raise Exception("Multiple registration attempts for module: "+module.TRIGGER+" and team: "+module.TEAM_ID)
106
107
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] = {}
114
115             if module.NAME not in self.wsmodules[evtype]:
116                 self.wsmodules[evtype][module.NAME] = module
117                 module._on_register_ws_evtype(self, evtype)
118
119             else:
120                 raise Exception("Multiple registration attempts for module: "+module.NAME+" and evtype: "+evtype)
121
122     # Register a public facing module with the bot.
123     def register_public(self, module):
124         self.modules_public = {}
125
126         if module.TRIGGER not in self.modules_public:
127             self.modules_public[module.TRIGGER] = module
128             module._on_register_public(self)
129
130         else:
131             raise Exception("Multiple registration attempts for module: "+module.TRIGGER+" and team: "+module.TEAM_ID)
132
133     def start(self):
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))
138
139         if self.mm_ws_url is not None:
140             self.mmws = mattermost.ws.MMws(self.websocket_handler, self.api, self.mm_ws_url)
141
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")
145             th.start()
146
147         if self.local_websrv_hostname is not None and self.local_websrv_port is not None:
148             self.start_webserver_mattermost()
149
150
151     def on_shutdown(self):
152         logger.info("Shutting down ...")
153
154         if self.mmws:
155             self.mmws.close_websocket()
156
157         for team_modules in self.modules:
158             for module in self.modules[team_modules]:
159                 self.modules[team_modules][module]._on_shutdown()
160
161         for evtype in self.wsmodules:
162             for module in self.wsmodules[evtype]:
163                 self.wsmodules[evtype][module]._on_shutdown()
164
165         self.api.logout()
166         self.command_stats_dump()
167         logger.info("BYE.")
168
169
170     ########
171     # misc #
172     ########
173     def shutdown(self, unk1=None, unk2=None, exit_code=0):
174         sys.exit(exit_code)
175
176
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()
182
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)
186
187         for evtype in self.wsmodules:
188             for module in self.wsmodules[evtype]:
189                 self.wsmodules[evtype][module]._on_SIGUSR1(self.sigusr1_cnt)
190
191
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.")
195             return None
196         return self.api.create_post(self.debug_chan_id, "``AUTODELETE-DAY``\n"+message, root_id=root_id)
197
198
199     def command_stats_inc(self, command, amount=1):
200         if command in self.command_stats:
201             self.command_stats[command] += amount
202         else:
203             self.command_stats[command] = amount
204
205
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 = {}
209
210
211     def dump_stats_json(self, stats_data, file_path, header="", footer="", no_data_text=""):
212         do_write = False
213         if stats_data:
214             do_write = True
215
216         try:
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
222                 else:
223                     stats_data[item] = cnt
224         except (FileNotFoundError, json.JSONDecodeError, KeyError):
225             do_write = True
226
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:
229             return
230
231         logger.info("dump_stats_json(): writing file: %s", file_path)
232         self.command_stats_inc("internal::dump_stats_json:"+file_path)
233
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)
236
237
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]:
245                     try:
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))
251
252
253     ##############################
254     # Bot's mattermost webserver #
255     ##############################
256     def start_webserver_mattermost(self):
257         logger.info("Starting mattermost facing webserver.")
258
259         class HTTPRequestHandler(BaseHTTPRequestHandler):
260             bot = None
261             handled = False
262             responseURL = None
263
264             def version_string(self):
265                 return "SomeBot"
266
267             def do_POST(self):
268                 self.handled = False
269
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()}
274
275                 elif self.headers["Content-Type"] == "application/json":
276                     data = json.loads(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"))
277
278                 else:
279                     self.respond(415)
280                     return
281
282                 # store responseURL
283                 if "response_url" in data:
284                     self.responseURL = data["response_url"]
285
286                 # handle call
287                 logger.debug("do_POST(): request incomming.")
288                 try:
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]]
293
294                         # /command
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)
299
300                             else:
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()
303                                 self.respond(403)
304                                 self.bot.on_shutdown()
305                                 os._exit(1)
306
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)
311
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)
316
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")
321
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")
326
327                     # always try to fail to retain userinput. If previously responded to, nothing will happen.
328                     self.respond(400, if_nonzero_secondary='ignore')
329
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')
334
335
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}
339
340                 if props:
341                     data.update({"props": props})
342                 else:
343                     props={}
344
345                 if att:
346                     props.update({"attachments": att})
347                     data.update({"props": props})
348
349                 self.respond(http_code, data)
350
351
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}
355
356                 if props:
357                     data.update({"props": props})
358                 else:
359                     props={}
360
361                 if att:
362                     props.update({"attachments": att})
363                     data.update({"props": props})
364
365                 self.respond(http_code, data)
366
367
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})
371
372
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}
376
377                 if props:
378                     data.update({"props": props})
379
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)
383
384
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})
388
389
390             def respond(self, http_code=200, data=None, if_nonzero_secondary='exc'):
391                 """
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.
396                 """
397
398                 if data is None:
399                     data = {}
400
401                 # First response
402                 if not self.handled:
403                     if http_code >= 600 or http_code < 100:
404                         raise Exception("respond(): Primary response must have a valid http code.")
405
406                     self.handled = True
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")))
410                     self.end_headers()
411                     self.wfile.write(bytes(json.dumps(data), "utf8"))
412                     logger.debug("respond(): Primary response send.")
413
414                 # Secondary responses
415                 else:
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.")
418                         return
419
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()
423
424                     elif http_code != 0:
425                         raise Exception("respond(): Secondary responses must have a zero http code.")
426
427
428                     if not self.responseURL:
429                         raise Exception("respond(): Secondary response attempt without response url.")
430
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)
436
437
438         class MyHTTPServer(ThreadingMixIn, HTTPServer):
439             def serve_forever(self, bot):
440                 self.RequestHandlerClass.bot = bot
441                 HTTPServer.serve_forever(self)
442
443         self.httpd = MyHTTPServer((self.local_websrv_hostname, self.local_websrv_port), HTTPRequestHandler)
444         self.httpd.serve_forever(self)
445
446
447     #################################
448     # Bot's public facing webserver #
449     #################################
450     def start_webserver_public(self):
451         logger.info("Starting public facing webserver.")
452
453         class HTTPPublicRequestHandler(BaseHTTPRequestHandler):
454             bot = None
455             handled = False
456
457             def version_string(self):
458                 return "SomeBot"
459
460             def do_POST(self):
461                 self.handled = False
462                 data = None
463
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()}
468
469                 elif self.headers["Content-Type"] == "application/json":
470                     data = json.loads(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"))
471
472 #                else:
473 #                    self.respond_public(415)
474 #                    return
475
476                 logger.debug("do_public_POST(): request incomming.")
477                 try:
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]]
482
483                         self.bot.command_stats_inc("public-post::"+splitpath[0])
484                         module._on_public_POST(self, data)
485
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')
490
491                     # always try to fail to retain userinput. If previously responded to, nothing will happen.
492                     self.respond_public(400, if_nonzero_secondary='ignore')
493
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')
498
499
500             def do_GET(self):
501                 self.handled = False
502                 data = None
503
504                 logger.debug("do_public_GET(): request incomming.")
505                 try:
506                     module = 'not yet known'
507                     splitpath = self.path.strip("/").split("/")
508                     if splitpath[0] in self.bot.modules_public:
509                         module = self.bot.modules_public[splitpath[0]]
510
511                         self.bot.command_stats_inc("public-get::"+splitpath[0])
512                         module._on_public_GET(self, data)
513
514                     else: # Invalid command/unknown command
515                         logger.error("do_public_GET(): Invalid command/unknown command")
516                         #self.bot.debug_chan("do_public_GET(): Invalid command/unknown command.\n# :boom::boom::boom::boom::boom:\n```\n"+self.path+"\n```")
517                         self.respond_public(400, {"error":"Invalid command/unknown command"} ,if_nonzero_secondary='ignore')
518
519                     # always try to fail to retain userinput. If previously responded to, nothing will happen.
520                     self.respond_public(400, if_nonzero_secondary='ignore')
521
522                 except Exception as exc:
523                     self.bot.debug_chan("##### Exception in ``"+self.path.strip("/")+"``: ``"+repr(exc)+"``\n# :boom::boom::boom::boom::boom:")
524                     logger.exception("do_public_GET(): Exception in: %s\nRequest-data:%s", self.path.strip("/"), pprint.pformat(data))
525                     self.respond_public(500, {"error":"A serverside error occured. @someone should have been contacted."}, if_nonzero_secondary='ignore')
526
527
528             def respond_public(self, http_code=200, data=None, if_nonzero_secondary='exc'):
529                 """
530                     First response call must have a valid http code.
531                 """
532
533                 if data is None:
534                     data = {}
535
536                 # First response
537                 if not self.handled:
538                     if http_code >= 600 or http_code < 100:
539                         raise Exception("respond_public(): Primary response must have a valid http code.")
540
541                     self.handled = True
542                     self.send_response(http_code)
543                     self.send_header("Content-Type", "application/json")
544                     self.send_header("Content-Length", len(bytes(json.dumps(data), "utf8")))
545                     self.end_headers()
546                     self.wfile.write(bytes(json.dumps(data), "utf8"))
547                     logger.debug("respond_public(): Primary response send.")
548
549                 # Secondary responses
550                 else:
551                     if http_code != 0 and if_nonzero_secondary == "ignore":
552                         logger.info("respond_public(): Secondary responses must have a zero http code, but if_nonzero_secondary='ignore'. Doing nothing.")
553                         return
554
555                     elif if_nonzero_secondary != "ignore":
556                         raise Exception("respond_public(): Secondary responses must be ignored.")
557
558
559
560         class MyHTTPServer(ThreadingMixIn, HTTPServer):
561             def serve_forever(self, bot):
562                 self.RequestHandlerClass.bot = bot
563                 HTTPServer.serve_forever(self)
564
565         self.httpd = MyHTTPServer((self.local_public_websrv_hostname, self.local_public_websrv_port), HTTPPublicRequestHandler)
566         self.httpd.serve_forever(self)