]> 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-2021 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, 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.api_user = api_user
33         self.mm_api_url = mm_api_url
34         self.mm_ws_url = mm_ws_url
35
36         self.local_websrv_url = ("http://"+self.local_websrv_hostname+":"+str(self.local_websrv_port)+"/").strip("/")
37         self.modules = {}
38         self.wsmodules = {}
39         self.api = None
40         self.debug_chan_id = debug_chan_id
41         self.command_stats = {}
42         self.admin_ids = []
43
44         self.mmws = None
45         self.sigusr1_cnt = 0
46
47         atexit.register(self.on_shutdown)
48         signal.signal(signal.SIGUSR1, self.on_SIGUSR1)
49         signal.signal(signal.SIGTERM, self.shutdown)
50         signal.signal(signal.SIGINT, self.shutdown)
51
52         # monkey-patch thread naming
53         try:
54             import pyprctl
55             def _rename_current_thread(self, name, thread):
56                 if thread == threading.current_thread():
57                     thread.setName(name)
58                     pyprctl.set_name(name)
59
60             def _bootstrap_named_thread(self):
61                 self.rename_current_thread(self._name, threading.current_thread())
62                 self.original_bootstrap()
63
64             threading.Thread.original_bootstrap = threading.Thread._bootstrap
65             threading.Thread._bootstrap = _bootstrap_named_thread
66
67         except ImportError:
68             logger.error('pyprctl module is not installed. You will not be able to see thread names')
69             def _rename_current_thread(self, name, thread):
70                 if thread == threading.current_thread():
71                     thread.setName(name)
72
73         threading.Thread.rename_current_thread = _rename_current_thread
74
75
76         # Core-Command: /use-data
77         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.
78                                  Written by **``@someone``** in python3. Big thanks to contributors: **``@ju``**, **``@gittenburg``**
79                                  Inspiring ideas by: ``@bearza``, ``@frunobulax``, **``@x5468656f``**
80
81                                  Feel like contributing too? Talk to **``@someone``**. :)
82                                  The repository is here: https://git.somenet.org/pub/jan/mattermost-bot.git
83                                  """)}
84
85         if api_user is not None and api_user_pw is not None:
86             logger.info("User credentials given. Trying to login.")
87             self.api = mattermost.MMApi(self.mm_api_url)
88             self.api.login(api_user, api_user_pw)
89         elif api_user is not None:
90             self.api = mattermost.MMApi(self.mm_api_url)
91             self.api.login(bearer=api_bearer)
92
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
109     # Register a websocket handling module with the bot.
110     # There is no useful way to discriminate WS-events by originating teams atm. :(
111     def register_ws(self, module, eventlist):
112         for evtype in eventlist:
113             if evtype not in self.wsmodules:
114                 self.wsmodules[evtype] = {}
115
116             if module.NAME not in self.wsmodules[evtype]:
117                 self.wsmodules[evtype][module.NAME] = module
118                 module._on_register_ws_evtype(self, evtype)
119
120             else:
121                 raise Exception("Multiple registration attempts for module: "+module.NAME+" and evtype: "+evtype)
122
123
124
125     def start(self):
126         logger.info("Starting: Almost there.")
127         logger.info(pprint.pformat(self.modules))
128         logger.info(pprint.pformat(self.wsmodules))
129
130         if self.mm_ws_url is not None:
131             self.mmws = mattermost.ws.MMws(self.websocket_handler, self.api, self.mm_ws_url)
132
133         if self.local_websrv_hostname is not None and self.local_websrv_port is not None:
134             self.start_webserver()
135
136
137
138     def on_shutdown(self):
139         logger.info("Shutting down ...")
140
141         if self.mmws:
142             self.mmws.close_websocket()
143             # todo: stop webserver + WS?
144
145         for team_modules in self.modules:
146             for module in self.modules[team_modules]:
147                 self.modules[team_modules][module]._on_shutdown()
148
149         for evtype in self.wsmodules:
150             for module in self.wsmodules[evtype]:
151                 self.wsmodules[evtype][module]._on_shutdown()
152
153         self.api.logout()
154         self.command_stats_dump()
155         logger.info("BYE.")
156
157
158
159     ########
160     # misc #
161     ########
162     def shutdown(self, unk1=None, unk2=None, exit_code=0):
163         sys.exit(exit_code)
164
165
166     def on_SIGUSR1(self, unk1=None, unk2=None):
167         logger.info("on_SIGUSR1()")
168         self.sigusr1_cnt += 1
169         self.command_stats_inc("internal::SIGUSR1")
170         self.command_stats_dump()
171
172         # TODO: reinit teams.
173
174         for team_modules in self.modules:
175             for module in self.modules[team_modules]:
176                 self.modules[team_modules][module]._on_SIGUSR1(self.sigusr1_cnt)
177
178         for evtype in self.wsmodules:
179             for module in self.wsmodules[evtype]:
180                 self.wsmodules[evtype][module]._on_SIGUSR1(self.sigusr1_cnt)
181
182
183
184     def debug_chan(self, message):
185         if self.debug_chan_id is None:
186             logger.error("debug_chan() called, but debug_chan_id is unspecified.")
187             return
188         self.api.create_post(self.debug_chan_id, "``AUTODELETE-DAY``\n"+message)
189
190
191
192     def command_stats_inc(self, command, amount=1):
193         if command in self.command_stats:
194             self.command_stats[command] += amount
195         else:
196             self.command_stats[command] = amount
197
198
199
200     def command_stats_dump(self):
201         self.dump_stats_json(self.command_stats, "/tmp/somebot_command_stats.json", "#command_usage #mmstats")
202         self.command_stats = {}
203
204
205
206     def dump_stats_json(self, stats_data, file_path, header="", footer="", no_data_text=""):
207         do_write = False
208         if stats_data:
209             do_write = True
210
211         try:
212             with open(file_path, "r") as file:
213                 old_stats = json.load(file)
214             for item, cnt in old_stats["data"].items():
215                 if item in stats_data:
216                     stats_data[item] += cnt
217                 else:
218                     stats_data[item] = cnt
219         except (FileNotFoundError, json.JSONDecodeError, KeyError):
220             do_write = True
221
222         # if no data, but file exists: skip write
223         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:
224             return
225
226         logger.info("dump_stats_json(): writing file: %s", file_path)
227         self.command_stats_inc("internal::dump_stats_json:"+file_path)
228
229         with open(file_path, "w", encoding="utf-8") as file:
230             json.dump({"header":header, "footer":footer, "no_data_text":no_data_text, "data":dict(sorted(stats_data.items()))}, file, ensure_ascii=False, indent=2)
231
232
233
234     ##########################
235     # Bot's websocket client #
236     ##########################
237     def websocket_handler(self, mmws, event_data):
238         for evtype in self.wsmodules:
239             if evtype == event_data["event"]:
240                 for module_name in self.wsmodules[evtype]:
241                     try:
242                         if self.wsmodules[evtype][module_name].on_WS_EVENT(event_data):
243                             self.command_stats_inc("ws::"+evtype+"::"+module_name)
244                     except Exception:
245                         exctxt = "".join(traceback.format_exc())
246                         logger.error(exctxt)
247
248
249
250     ###################
251     # Bot's webserver #
252     ###################
253     def start_webserver(self):
254         logger.info("Starting webserver.")
255
256         class HTTPRequestHandler(BaseHTTPRequestHandler):
257             bot = None
258             handled = False
259             responseURL = None
260
261             def do_POST(self):
262                 threading.current_thread().rename_current_thread("HTTPRequestHandler", threading.current_thread())
263                 self.handled = False
264
265                 if self.headers["Content-Type"] == "application/x-www-form-urlencoded":
266                     data = urllib.parse.parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"), keep_blank_values=True)
267                     # only accept first occurence
268                     data = {k: v[0] for k, v in data.items()}
269
270                 elif self.headers["Content-Type"] == "application/json":
271                     data = json.loads(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"))
272
273                 else:
274                     self.respond(415)
275                     return
276
277
278                 # store responseURL
279                 if "response_url" in data:
280                     self.responseURL = data["response_url"]
281
282
283                 # handle call
284                 logger.info("do_POST(): request incomming.")
285                 pprint.pprint(data)
286                 try:
287                     module = 'not yet known'
288                     splitpath = self.path.strip("/").split("/")
289                     if splitpath[0] in self.bot.modules and splitpath[1] in self.bot.modules[splitpath[0]]:
290                         module = self.bot.modules[splitpath[0]][splitpath[1]]
291
292                         # /command
293                         if len(splitpath) > 2 and splitpath[2] == "command":
294                             if "token" in data and module.mm_secret_token == data["token"]:
295                                 self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
296                                 module._on_POST(self, data)
297
298                             else:
299                                 logger.error("do_POST(): Auth problem: Shutting down: mm_secret_token mismatch expected/got -%s-%s-", module.mm_secret_token, data["token"])
300                                 traceback.print_stack()
301                                 self.respond(403)
302                                 self.bot.on_shutdown()
303                                 os._exit(1)
304
305                         # interactive button-handler. TODO auth!
306                         elif len(splitpath) > 2 and splitpath[2] == "interactive":
307                             self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
308                             module._on_POST_interactive(self, data)
309
310                         # dialog-handler: TODO: auth!
311                         elif len(splitpath) > 2 and splitpath[2] == "dialog":
312                             self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
313                             module._on_POST_dialog(self, data)
314
315                         else: # Invalid command action
316                             logger.error("do_POST(): Invalid command action.")
317                             self.bot.debug_chan("do_POST(): Invalid command action.\n# :boom::boom::boom::boom::boom:\n```\n"+self.path+"\n```")
318                             self.respond_cmd_err("Invalid command action")
319
320                     else: # Invalid command/unknown command
321                         logger.error("do_POST(): Invalid command/unknown command.")
322                         self.bot.debug_chan("do_POST(): Invalid command/unknown command.\n# :boom::boom::boom::boom::boom:\n```\n"+self.path+"\n```")
323                         self.respond_cmd_err("Invalid command/unknown command")
324
325                 except:
326                     self.bot.debug_chan("##### Error in module: ``"+repr(module)+"``\n# :boom::boom::boom::boom::boom:\n```\n"+traceback.format_exc()+"\n```")
327
328                 # always try to fail to retain userinput. If previously responded to, nothing will happen.
329                 self.respond(400, if_nonzero_secondary='ignore')
330
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
340                 if att:
341                     data.update({"attachments": att})
342
343                 self.respond(http_code, data)
344
345
346
347             # Send a ephemeral response to the user.
348             def respond_cmd_temp(self, message, props=None, att=None, http_code=200):
349                 data = {"skip_slack_parsing":True, "response_type":"ephemeral", "text":message}
350
351                 if props:
352                     data.update({"props": props})
353
354                 if att:
355                     data.update({"attachments": att})
356
357                 self.respond(http_code, data)
358
359
360             def respond_interactive_temp(self, message, http_code=200):
361                 # cant be secondary, because no response url (interactive messages only?)
362                 self.respond(http_code, {"ephemeral_text":message})
363
364
365
366             # Use to send a failure to the user. Use only the first time during a request. Should retain input on clientside.
367             def respond_cmd_err(self, message, props=None):
368                 data = {"skip_slack_parsing":True, "response_type":"ephemeral", "text": "## :x: Failure! :(\n### "+message}
369
370                 if props:
371                     data.update({"props": props})
372
373                 # must be 2 separatecalls, as the message is ignored in a non-200.
374                 self.respond(400)
375                 self.respond(000, data)
376
377
378
379             def respond(self, http_code=200, data=None, if_nonzero_secondary='exc'):
380                 """
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.
385                 """
386
387                 if data is None:
388                     data = {}
389
390                 # First response
391                 #pprint.pprint(http_code)
392                 #pprint.pprint(data)
393                 if not self.handled:
394                     if http_code >= 600 or http_code < 100:
395                         raise Exception("respond(): Primary response must have a valid http code.")
396
397                     self.handled = True
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")))
401                     self.end_headers()
402                     self.wfile.write(bytes(json.dumps(data), "utf8"))
403                     logger.info("respond(): Primary response send.")
404
405
406                 # Secondary responses
407                 else:
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.")
410                         return
411
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()
415
416                     elif http_code != 0:
417                         raise Exception("respond(): Secondary responses must have a zero http code.")
418
419
420                     if not self.responseURL:
421                         raise Exception("respond(): Secondary response attempt without response url.")
422
423
424                     logger.info("respond(): Secondary response. Using responseURL: %s", 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: %s", conn.status)
429
430
431
432         class MyHTTPServer(ThreadingMixIn, HTTPServer):
433             def serve_forever(self, bot):
434                 self.RequestHandlerClass.bot = bot
435                 HTTPServer.serve_forever(self)
436
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)