]> 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):
175         if self.debug_chan_id is None:
176             logger.error("debug_chan() called, but debug_chan_id is unspecified.")
177             return
178         self.api.create_post(self.debug_chan_id, "``AUTODELETE-DAY``\n"+message)
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
323                 if att:
324                     data.update({"attachments": att})
325
326                 self.respond(http_code, data)
327
328
329             # Send a ephemeral response to the user.
330             def respond_cmd_temp(self, message, props=None, att=None, http_code=200):
331                 data = {"skip_slack_parsing":True, "response_type":"ephemeral", "text":message}
332
333                 if props:
334                     data.update({"props": props})
335
336                 if att:
337                     data.update({"attachments": att})
338
339                 self.respond(http_code, data)
340
341
342             def respond_interactive_temp(self, message, http_code=200):
343                 # cant be secondary, because no response url (interactive messages only?)
344                 self.respond(http_code, {"ephemeral_text":message})
345
346
347             # Use to send a failure to the user. Use only the first time during a request. Should retain input on clientside.
348             def respond_cmd_err(self, message, props=None, http_code=400, if_nonzero_secondary='exc'):
349                 data = {"skip_slack_parsing":True, "response_type":"ephemeral", "text": "## :x: Failure! :(\n#### "+message}
350
351                 if props:
352                     data.update({"props": props})
353
354                 # must be 2 separatecalls, as the message is ignored in a non-200.
355                 self.respond(http_code=http_code, if_nonzero_secondary=if_nonzero_secondary)
356                 self.respond(000, data)
357
358
359             def respond(self, http_code=200, data=None, if_nonzero_secondary='exc'):
360                 """
361                     First response call must have a valid http code.
362                     Secondary responses should have http_code = 0.
363                       use if_nonzero_secondary = 'ignore' to ignore response with http_code != 0.
364                       use if_nonzero_secondary = 'force' to send secondary with http_code != 0.
365                 """
366
367                 if data is None:
368                     data = {}
369
370                 # First response
371                 if not self.handled:
372                     if http_code >= 600 or http_code < 100:
373                         raise Exception("respond(): Primary response must have a valid http code.")
374
375                     self.handled = True
376                     self.send_response(http_code)
377                     self.send_header("Content-Type", "application/json")
378                     self.send_header("Content-Length", len(bytes(json.dumps(data), "utf8")))
379                     self.end_headers()
380                     self.wfile.write(bytes(json.dumps(data), "utf8"))
381                     logger.debug("respond(): Primary response send.")
382
383                 # Secondary responses
384                 else:
385                     if http_code != 0 and if_nonzero_secondary == "ignore":
386                         logger.info("respond(): Secondary responses must have a zero http code, but if_nonzero_secondary='ignore'. Doing nothing.")
387                         return
388
389                     elif http_code != 0 and if_nonzero_secondary == "force":
390                         logger.warning("respond(): Secondary responses must have a zero http code, but if_nonzero_secondary='force'. Sending anyway.")
391                         traceback.print_stack()
392
393                     elif http_code != 0:
394                         raise Exception("respond(): Secondary responses must have a zero http code.")
395
396
397                     if not self.responseURL:
398                         raise Exception("respond(): Secondary response attempt without response url.")
399
400                     logger.debug("respond(): Secondary response. Using responseURL: %s", self.responseURL)
401                     req = Request(self.responseURL, data=bytes(json.dumps(data), "utf8"), method='POST')
402                     req.add_header("Content-Type", "application/json")
403                     conn = urlopen(req, timeout=3)
404                     logger.debug("respond(): Secondary response send. Status: %s", conn.status)
405
406
407         class MyHTTPServer(ThreadingMixIn, HTTPServer):
408             def serve_forever(self, bot):
409                 self.RequestHandlerClass.bot = bot
410                 HTTPServer.serve_forever(self)
411
412         self.httpd = MyHTTPServer((self.local_websrv_hostname, self.local_websrv_port), HTTPRequestHandler)
413         threading.current_thread().rename_current_thread("HTTPServer", threading.current_thread())
414         self.httpd.serve_forever(self)