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