]> 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 do_POST(self):
265                 self.handled = False
266
267                 if self.headers["Content-Type"] == "application/x-www-form-urlencoded":
268                     data = urllib.parse.parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"), keep_blank_values=True)
269                     # only accept first occurence
270                     data = {k: v[0] for k, v in data.items()}
271
272                 elif self.headers["Content-Type"] == "application/json":
273                     data = json.loads(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"))
274
275                 else:
276                     self.respond(415)
277                     return
278
279                 # store responseURL
280                 if "response_url" in data:
281                     self.responseURL = data["response_url"]
282
283                 # handle call
284                 logger.debug("do_POST(): request incomming.")
285                 try:
286                     module = 'not yet known'
287                     splitpath = self.path.strip("/").split("/")
288                     if splitpath[0] in self.bot.modules and splitpath[1] in self.bot.modules[splitpath[0]]:
289                         module = self.bot.modules[splitpath[0]][splitpath[1]]
290
291                         # /command
292                         if len(splitpath) > 2 and splitpath[2] == "command":
293                             if "token" in data and module.mm_secret_token == data["token"]:
294                                 self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
295                                 module._on_POST(self, data)
296
297                             else:
298                                 logger.error("do_POST(): Auth problem: Shutting down: mm_secret_token mismatch expected/got -%s-%s-", module.mm_secret_token, data["token"])
299                                 traceback.print_stack()
300                                 self.respond(403)
301                                 self.bot.on_shutdown()
302                                 os._exit(1)
303
304                         # interactive button-handler. TODO auth!
305                         elif len(splitpath) > 2 and splitpath[2] == "interactive":
306                             self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
307                             module._on_POST_interactive(self, data)
308
309                         # dialog-handler: TODO: auth!
310                         elif len(splitpath) > 2 and splitpath[2] == "dialog":
311                             self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
312                             module._on_POST_dialog(self, data)
313
314                         else: # Invalid command action
315                             logger.error("do_POST(): Invalid command action.")
316                             self.bot.debug_chan("do_POST(): Invalid command action.\n# :boom::boom::boom::boom::boom:\n```\n"+self.path+"\n```")
317                             self.respond_cmd_err("Invalid command action")
318
319                     else: # Invalid command/unknown command
320                         logger.error("do_POST(): Invalid command/unknown command.")
321                         self.bot.debug_chan("do_POST(): Invalid command/unknown command.\n# :boom::boom::boom::boom::boom:\n```\n"+self.path+"\n```")
322                         self.respond_cmd_err("Invalid command/unknown command")
323
324                     # always try to fail to retain userinput. If previously responded to, nothing will happen.
325                     self.respond(400, if_nonzero_secondary='ignore')
326
327                 except Exception as exc:
328                     self.bot.debug_chan("##### Exception in ``"+self.path.strip("/")+"``: ``"+repr(exc)+"``\n# :boom::boom::boom::boom::boom:")
329                     logger.exception("do_POST(): Exception in: %s\nRequest-data:%s", self.path.strip("/"), pprint.pformat(data))
330                     self.respond_cmd_err("A serverside error occured. @someone should have been contacted.", http_code=500, if_nonzero_secondary='ignore')
331
332
333             # Send a response to the channel.
334             def respond_cmd_chan(self, message, props=None, att=None, http_code=200):
335                 data = {"skip_slack_parsing":True, "response_type":"in_channel", "text":message}
336
337                 if props:
338                     data.update({"props": props})
339                 else:
340                     props={}
341
342                 if att:
343                     props.update({"attachments": att})
344                     data.update({"props": props})
345
346                 self.respond(http_code, data)
347
348
349             # Send a ephemeral response to the user.
350             def respond_cmd_temp(self, message, props=None, att=None, http_code=200):
351                 data = {"skip_slack_parsing":True, "response_type":"ephemeral", "text":message}
352
353                 if props:
354                     data.update({"props": props})
355                 else:
356                     props={}
357
358                 if att:
359                     props.update({"attachments": att})
360                     data.update({"props": props})
361
362                 self.respond(http_code, data)
363
364
365             def respond_interactive_temp(self, message):
366                 # cant be secondary, because no response url (interactive messages only?)
367                 self.respond(200, {"skip_slack_parsing":True, "ephemeral_text": message})
368
369
370             # Use to send a failure to the user. Use only the first time during a request. Should retain input on clientside.
371             def respond_cmd_err(self, message, props=None, http_code=400, if_nonzero_secondary='exc'):
372                 data = {"skip_slack_parsing":True, "response_type":"ephemeral", "text": "## :x: Failure! :(\n#### "+message}
373
374                 if props:
375                     data.update({"props": props})
376
377                 # must be 2 separatecalls, as the message is ignored in a non-200.
378                 self.respond(http_code=http_code, if_nonzero_secondary=if_nonzero_secondary)
379                 self.respond(000, data)
380
381
382             def respond_interactive_err(self, message):
383                 # cant be secondary, because no response url (interactive messages only?)
384                 self.respond(200, {"skip_slack_parsing":True, "ephemeral_text":"## :x: Failure! :(\n#### "+message})
385
386
387             def respond(self, http_code=200, data=None, if_nonzero_secondary='exc'):
388                 """
389                     First response call must have a valid http code.
390                     Secondary responses should have http_code = 0.
391                       use if_nonzero_secondary = 'ignore' to ignore response with http_code != 0.
392                       use if_nonzero_secondary = 'force' to send secondary with http_code != 0.
393                 """
394
395                 if data is None:
396                     data = {}
397
398                 # First response
399                 if not self.handled:
400                     if http_code >= 600 or http_code < 100:
401                         raise Exception("respond(): Primary response must have a valid http code.")
402
403                     self.handled = True
404                     self.send_response(http_code)
405                     self.send_header("Content-Type", "application/json")
406                     self.send_header("Content-Length", len(bytes(json.dumps(data), "utf8")))
407                     self.end_headers()
408                     self.wfile.write(bytes(json.dumps(data), "utf8"))
409                     logger.debug("respond(): Primary response send.")
410
411                 # Secondary responses
412                 else:
413                     if http_code != 0 and if_nonzero_secondary == "ignore":
414                         logger.info("respond(): Secondary responses must have a zero http code, but if_nonzero_secondary='ignore'. Doing nothing.")
415                         return
416
417                     elif http_code != 0 and if_nonzero_secondary == "force":
418                         logger.warning("respond(): Secondary responses must have a zero http code, but if_nonzero_secondary='force'. Sending anyway.")
419                         traceback.print_stack()
420
421                     elif http_code != 0:
422                         raise Exception("respond(): Secondary responses must have a zero http code.")
423
424
425                     if not self.responseURL:
426                         raise Exception("respond(): Secondary response attempt without response url.")
427
428                     logger.debug("respond(): Secondary response. Using responseURL: %s", self.responseURL)
429                     req = Request(self.responseURL, data=bytes(json.dumps(data), "utf8"), method='POST')
430                     req.add_header("Content-Type", "application/json")
431                     conn = urlopen(req, timeout=3)
432                     logger.debug("respond(): Secondary response send. Status: %s", conn.status)
433
434
435         class MyHTTPServer(ThreadingMixIn, HTTPServer):
436             def serve_forever(self, bot):
437                 self.RequestHandlerClass.bot = bot
438                 HTTPServer.serve_forever(self)
439
440         self.httpd = MyHTTPServer((self.local_websrv_hostname, self.local_websrv_port), HTTPRequestHandler)
441         self.httpd.serve_forever(self)
442
443
444     #################################
445     # Bot's public facing webserver #
446     #################################
447     def start_webserver_public(self):
448         logger.info("Starting public facing webserver.")
449
450         class HTTPPublicRequestHandler(BaseHTTPRequestHandler):
451             def do_POST(self):
452                 self.handled = False
453
454                 logger.debug("do_POST(): request incomming.")
455                 try:
456                     module = 'not yet known'
457                     splitpath = self.path.strip("/").split("/")
458                     if splitpath[0] in self.bot.modules:
459                         module = self.bot.modules[splitpath[0]]
460
461                         self.bot.command_stats_inc("public::"+splitpath[0])
462                         module._on_public_POST(self, data)
463
464                     else: # Invalid command/unknown command
465                         logger.error("do_POST(): Invalid command/unknown command.")
466                         self.bot.debug_chan("do_POST(): Invalid command/unknown command.\n# :boom::boom::boom::boom::boom:\n```\n"+self.path+"\n```")
467                         self.respond_cmd_err("Invalid command/unknown command")
468
469                     # always try to fail to retain userinput. If previously responded to, nothing will happen.
470                     self.respond(400, if_nonzero_secondary='ignore')
471
472                 except Exception as exc:
473                     self.bot.debug_chan("##### Exception in ``"+self.path.strip("/")+"``: ``"+repr(exc)+"``\n# :boom::boom::boom::boom::boom:")
474                     logger.exception("do_POST(): Exception in: %s\nRequest-data:%s", self.path.strip("/"), pprint.pformat(data))
475                     self.respond_cmd_err("A serverside error occured. @someone should have been contacted.", http_code=500, if_nonzero_secondary='ignore')
476
477             def do_GET(self):
478                 self.handled = False
479
480
481         class MyHTTPServer(ThreadingMixIn, HTTPServer):
482             def serve_forever(self, bot):
483                 self.RequestHandlerClass.bot = bot
484                 HTTPServer.serve_forever(self)
485
486         self.httpd = MyHTTPServer((self.local_public_websrv_hostname, self.local_public_websrv_port), HTTPPublicRequestHandler)
487         self.httpd.serve_forever(self)