]> git.somenet.org - pub/jan/mattermost-bot.git/blob - core/MMBot.py
modified: 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 time
14 import traceback
15 import urllib
16
17 from inspect import cleandoc
18 from http.server import BaseHTTPRequestHandler, HTTPServer
19 from urllib.request import Request, urlopen
20 from socketserver import ThreadingMixIn
21
22 import mattermost
23 import mattermost.ws
24
25
26 logger = logging.getLogger(__name__)
27
28
29 class MMBot():
30     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):
31         self.local_websrv_hostname = local_websrv_hostname
32         self.local_websrv_port = local_websrv_port
33         self.api_user = api_user
34         self.mm_api_url = mm_api_url
35         self.mm_ws_url = mm_ws_url
36
37         self.local_websrv_url = ("http://"+self.local_websrv_hostname+":"+str(self.local_websrv_port)+"/").strip("/")
38         self.modules = {}
39         self.wsmodules = {}
40         self.api = None
41         self.debug_chan_id = debug_chan_id
42         self.command_stats = {}
43         self.admin_ids = []
44
45         self.mmws = None
46         self.sigusr1_cnt = 0
47
48         atexit.register(self.on_shutdown)
49         signal.signal(signal.SIGUSR1, self.on_SIGUSR1)
50         signal.signal(signal.SIGTERM, self.shutdown)
51         signal.signal(signal.SIGINT, self.shutdown)
52
53         # monkey-patch thread naming
54         try:
55             import pyprctl
56             def _rename_current_thread(self, name, thread):
57                 if thread == threading.current_thread():
58                     thread.setName(name)
59                     pyprctl.set_name(name)
60
61             def _bootstrap_named_thread(self):
62                 self.rename_current_thread(self._name, threading.current_thread())
63                 self.original_bootstrap()
64
65             threading.Thread.original_bootstrap = threading.Thread._bootstrap
66             threading.Thread._bootstrap = _bootstrap_named_thread
67
68         except ImportError:
69             logger.error('pyprctl module is not installed. You will not be able to see thread names')
70             def _rename_current_thread(self, name, thread):
71                 if thread == threading.current_thread():
72                     thread.setName(name)
73
74         threading.Thread.rename_current_thread = _rename_current_thread
75
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
96     # Register a module with the bot.
97     def register(self, module):
98         if module.TEAM_ID not in self.modules:
99             self.modules[module.TEAM_ID] = {}
100
101         if module.TRIGGER not in self.modules[module.TEAM_ID]:
102             self.modules[module.TEAM_ID][module.TRIGGER] = module
103             module._on_register(self)
104
105         else:
106             raise Exception("Multiple registration attempts for module: "+module.TRIGGER+" and team: "+module.TEAM_ID)
107
108
109
110     # Register a websocket handling module with the bot.
111     # There is no useful way to discriminate WS-events by originating teams atm. :(
112     def register_ws(self, module, eventlist):
113         for evtype in eventlist:
114             if evtype not in self.wsmodules:
115                 self.wsmodules[evtype] = {}
116
117             if module.NAME not in self.wsmodules[evtype]:
118                 self.wsmodules[evtype][module.NAME] = module
119                 module._on_register_ws_evtype(self, evtype)
120
121             else:
122                 raise Exception("Multiple registration attempts for module: "+module.NAME+" and evtype: "+evtype)
123
124
125
126     def start(self):
127         logger.info("Starting: Almost there.")
128         logger.info(pprint.pformat(self.modules))
129         logger.info(pprint.pformat(self.wsmodules))
130
131         if self.mm_ws_url is not None:
132             self.mmws = mattermost.ws.MMws(self.websocket_handler, self.api, self.mm_ws_url)
133
134         if self.local_websrv_hostname is not None and self.local_websrv_port is not None:
135             self.start_webserver()
136
137
138
139     def on_shutdown(self):
140         logger.info("Shutting down ...")
141
142         if self.mmws:
143             self.mmws.close_websocket()
144             # todo: stop webserver + WS?
145
146         for team_modules in self.modules:
147             for module in self.modules[team_modules]:
148                 self.modules[team_modules][module]._on_shutdown()
149
150         for evtype in self.wsmodules:
151             for module in self.wsmodules[evtype]:
152                 self.wsmodules[evtype][module]._on_shutdown()
153
154         self.api.logout()
155         self.command_stats_dump()
156         logger.info("BYE.")
157
158
159
160     ########
161     # misc #
162     ########
163     def shutdown(self, unk1=None, unk2=None, exit_code=0):
164         sys.exit(exit_code)
165
166
167     def on_SIGUSR1(self, unk1=None, unk2=None):
168         logger.info("on_SIGUSR1()")
169         self.sigusr1_cnt += 1
170         self.command_stats_inc("internal::SIGUSR1")
171         self.command_stats_dump()
172
173         # TODO: reinit teams.
174
175         for team_modules in self.modules:
176             for module in self.modules[team_modules]:
177                 self.modules[team_modules][module]._on_SIGUSR1(self.sigusr1_cnt)
178
179         for evtype in self.wsmodules:
180             for module in self.wsmodules[evtype]:
181                 self.wsmodules[evtype][module]._on_SIGUSR1(self.sigusr1_cnt)
182
183
184
185     def debug_chan(self, message):
186         if self.debug_chan_id is None:
187             logger.error("debug_chan() called, but debug_chan_id is unspecified.")
188             return
189         self.api.create_post(self.debug_chan_id, "``AUTODELETE-DAY``\n"+message)
190
191
192
193     def command_stats_inc(self, command, amount=1):
194         if command in self.command_stats:
195             self.command_stats[command] += amount
196         else:
197             self.command_stats[command] = amount
198
199
200
201     def command_stats_dump(self):
202         self.dump_stats_json(self.command_stats, "/tmp/somebot_command_stats.json", "#command_usage #mmstats")
203         self.command_stats = {}
204
205
206
207     def dump_stats_json(self, stats_data, file_path, header="", footer="", no_data_text=""):
208         do_write = False
209         if stats_data:
210             do_write = True
211
212         try:
213             with open(file_path, "r") as file:
214                 old_stats = json.load(file)
215             for item, cnt in old_stats["data"].items():
216                 if item in stats_data:
217                     stats_data[item] += cnt
218                 else:
219                     stats_data[item] = cnt
220         except (FileNotFoundError, json.JSONDecodeError, KeyError):
221             do_write = True
222
223         # if no data, but file exists: skip write
224         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:
225             return
226
227         logger.info("dump_stats_json(): writing file: %s", file_path)
228         self.command_stats_inc("internal::dump_stats_json:"+file_path)
229
230         with open(file_path, "w", encoding="utf-8") as file:
231             json.dump({"header":header, "footer":footer, "no_data_text":no_data_text, "data":dict(sorted(stats_data.items()))}, file, ensure_ascii=False, indent=2)
232
233
234
235     ##########################
236     # Bot's websocket client #
237     ##########################
238     def websocket_handler(self, mmws, event_data):
239         for evtype in self.wsmodules:
240             if evtype == event_data["event"]:
241                 for module_name in self.wsmodules[evtype]:
242                     try:
243                         if self.wsmodules[evtype][module_name].on_WS_EVENT(event_data):
244                             self.command_stats_inc("ws::"+evtype+"::"+module_name)
245                     except Exception:
246                         exctxt = "".join(traceback.format_exc())
247                         logger.error(exctxt)
248
249
250
251     ###################
252     # Bot's webserver #
253     ###################
254     def start_webserver(self):
255         logger.info("Starting webserver.")
256
257         class HTTPRequestHandler(BaseHTTPRequestHandler):
258             bot = None
259             handled = False
260             responseURL = None
261
262             def do_POST(self):
263                 threading.current_thread().rename_current_thread("HTTPRequestHandler", threading.current_thread())
264                 self.handled = False
265
266                 if self.headers["Content-Type"] == "application/x-www-form-urlencoded":
267                     data = urllib.parse.parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"), keep_blank_values=True)
268                     # only accept first occurence
269                     data = {k: v[0] for k, v in data.items()}
270
271                 elif self.headers["Content-Type"] == "application/json":
272                     data = json.loads(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"))
273
274                 else:
275                     self.respond(415)
276                     return
277
278
279                 # store responseURL
280                 if "response_url" in data:
281                     self.responseURL = data["response_url"]
282
283
284                 # handle call
285                 logger.info("do_POST(): request incomming.")
286                 pprint.pprint(data)
287                 try:
288                     module = 'not yet known'
289                     splitpath = self.path.strip("/").split("/")
290                     if splitpath[0] in self.bot.modules and splitpath[1] in self.bot.modules[splitpath[0]]:
291                         module = self.bot.modules[splitpath[0]][splitpath[1]]
292
293                         # /command
294                         if len(splitpath) > 2 and splitpath[2] == "command":
295                             if "token" in data and module.mm_secret_token == data["token"]:
296                                 self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
297                                 module._on_POST(self, data)
298
299                             else:
300                                 logger.error("do_POST(): Auth problem: Shutting down: mm_secret_token mismatch expected/got -%s-%s-", module.mm_secret_token, data["token"])
301                                 traceback.print_stack()
302                                 self.respond(403)
303                                 self.bot.on_shutdown()
304                                 os._exit(1)
305
306                         # interactive button-handler. TODO auth!
307                         elif len(splitpath) > 2 and splitpath[2] == "interactive":
308                             self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
309                             module._on_POST_interactive(self, data)
310
311                         # dialog-handler: TODO: auth!
312                         elif len(splitpath) > 2 and splitpath[2] == "dialog":
313                             self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
314                             module._on_POST_dialog(self, data)
315
316                         else: # Invalid command action
317                             logger.error("do_POST(): Invalid command action.")
318                             self.bot.debug_chan("do_POST(): Invalid command action.\n# :boom::boom::boom::boom::boom:\n```\n"+self.path+"\n```")
319                             self.respond_cmd_err("Invalid command action")
320
321                     else: # Invalid command/unknown command
322                         logger.error("do_POST(): Invalid command/unknown command.")
323                         self.bot.debug_chan("do_POST(): Invalid command/unknown command.\n# :boom::boom::boom::boom::boom:\n```\n"+self.path+"\n```")
324                         self.respond_cmd_err("Invalid command/unknown command")
325
326                 except:
327                     self.bot.debug_chan("##### Error in module: ``"+repr(module)+"``\n# :boom::boom::boom::boom::boom:\n```\n"+traceback.format_exc()+"\n```")
328
329                 # always try to fail to retain userinput. If previously responded to, nothing will happen.
330                 self.respond(400, if_nonzero_secondary='ignore')
331
332
333
334             # Send a response to the channel.
335             def respond_cmd_chan(self, message, props=None, att=None, http_code=200):
336                 data = {"skip_slack_parsing":True, "response_type":"in_channel", "text":message}
337
338                 if props:
339                     data.update({"props": props})
340
341                 if att:
342                     data.update({"attachments": att})
343
344                 self.respond(http_code, data)
345
346
347
348             # Send a ephemeral response to the user.
349             def respond_cmd_temp(self, message, props=None, att=None, http_code=200):
350                 data = {"skip_slack_parsing":True, "response_type":"ephemeral", "text":message}
351
352                 if props:
353                     data.update({"props": props})
354
355                 if att:
356                     data.update({"attachments": att})
357
358                 self.respond(http_code, data)
359
360
361             def respond_interactive_temp(self, message, http_code=200):
362                 # cant be secondary, because no response url (interactive messages only?)
363                 self.respond(http_code, {"ephemeral_text":message})
364
365
366
367             # Use to send a failure to the user. Use only the first time during a request. Should retain input on clientside.
368             def respond_cmd_err(self, message, props=None):
369                 data = {"skip_slack_parsing":True, "response_type":"ephemeral", "text": "# :warning: Somewhing failed :boom::boom::boom::boom::boom: :(\n### "+message}
370
371                 if props:
372                     data.update({"props": props})
373
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: "+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: "+str(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)