]> 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
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
144         for team_modules in self.modules:
145             for module in self.modules[team_modules]:
146                 self.modules[team_modules][module]._on_shutdown()
147
148         for evtype in self.wsmodules:
149             for module in self.wsmodules[evtype]:
150                 self.wsmodules[evtype][module]._on_shutdown()
151
152         self.api.logout()
153         self.command_stats_dump()
154         logger.info("BYE.")
155
156
157
158     ########
159     # misc #
160     ########
161     def shutdown(self, unk1=None, unk2=None, exit_code=0):
162         sys.exit(exit_code)
163
164
165     def on_SIGUSR1(self, unk1=None, unk2=None):
166         logger.info("on_SIGUSR1()")
167         self.sigusr1_cnt += 1
168         self.command_stats_inc("internal::SIGUSR1")
169         self.command_stats_dump()
170
171         for team_modules in self.modules:
172             for module in self.modules[team_modules]:
173                 self.modules[team_modules][module]._on_SIGUSR1(self.sigusr1_cnt)
174
175         for evtype in self.wsmodules:
176             for module in self.wsmodules[evtype]:
177                 self.wsmodules[evtype][module]._on_SIGUSR1(self.sigusr1_cnt)
178
179
180
181     def debug_chan(self, message):
182         if self.debug_chan_id is None:
183             logger.error("debug_chan() called, but debug_chan_id is unspecified.")
184             return
185         self.api.create_post(self.debug_chan_id, "``AUTODELETE-DAY``\n"+message)
186
187
188
189     def command_stats_inc(self, command, amount=1):
190         if command in self.command_stats:
191             self.command_stats[command] += amount
192         else:
193             self.command_stats[command] = amount
194
195
196
197     def command_stats_dump(self):
198         self.dump_stats_json(self.command_stats, "/tmp/somebot_command_stats.json", "#command_usage #mmstats")
199         self.command_stats = {}
200
201
202
203     def dump_stats_json(self, stats_data, file_path, header="", footer="", no_data_text=""):
204         do_write = False
205         if stats_data:
206             do_write = True
207
208         try:
209             with open(file_path, "r") as file:
210                 old_stats = json.load(file)
211             for item, cnt in old_stats["data"].items():
212                 if item in stats_data:
213                     stats_data[item] += cnt
214                 else:
215                     stats_data[item] = cnt
216         except (FileNotFoundError, json.JSONDecodeError, KeyError):
217             do_write = True
218
219         # if no data, but file exists: skip write
220         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:
221             return
222
223         logger.info("dump_stats_json(): writing file: %s", file_path)
224         self.command_stats_inc("internal::dump_stats_json:"+file_path)
225
226         with open(file_path, "w", encoding="utf-8") as file:
227             json.dump({"header":header, "footer":footer, "no_data_text":no_data_text, "data":dict(sorted(stats_data.items()))}, file, ensure_ascii=False, indent=2)
228
229
230
231     ##########################
232     # Bot's websocket client #
233     ##########################
234     def websocket_handler(self, mmws, event_data):
235         for evtype in self.wsmodules:
236             if evtype == event_data["event"]:
237                 for module_name in self.wsmodules[evtype]:
238                     try:
239                         if self.wsmodules[evtype][module_name].on_WS_EVENT(event_data):
240                             self.command_stats_inc("ws::"+evtype+"::"+module_name)
241                     except Exception:
242                         exctxt = "".join(traceback.format_exc())
243                         logger.error(exctxt)
244
245
246
247     ###################
248     # Bot's webserver #
249     ###################
250     def start_webserver(self):
251         logger.info("Starting webserver.")
252
253         class HTTPRequestHandler(BaseHTTPRequestHandler):
254             bot = None
255             handled = False
256             responseURL = None
257
258             def do_POST(self):
259                 threading.current_thread().rename_current_thread("HTTPRequestHandler", threading.current_thread())
260                 self.handled = False
261
262                 if self.headers["Content-Type"] == "application/x-www-form-urlencoded":
263                     data = urllib.parse.parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"), keep_blank_values=True)
264                     # only accept first occurence
265                     data = {k: v[0] for k, v in data.items()}
266
267                 elif self.headers["Content-Type"] == "application/json":
268                     data = json.loads(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"))
269
270                 else:
271                     self.respond(415)
272                     return
273
274
275                 # store responseURL
276                 if "response_url" in data:
277                     self.responseURL = data["response_url"]
278
279
280                 # handle call
281                 logger.debug("do_POST(): request incomming.")
282                 try:
283                     module = 'not yet known'
284                     splitpath = self.path.strip("/").split("/")
285                     if splitpath[0] in self.bot.modules and splitpath[1] in self.bot.modules[splitpath[0]]:
286                         module = self.bot.modules[splitpath[0]][splitpath[1]]
287
288                         # /command
289                         if len(splitpath) > 2 and splitpath[2] == "command":
290                             if "token" in data and module.mm_secret_token == data["token"]:
291                                 self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
292                                 module._on_POST(self, data)
293
294                             else:
295                                 logger.error("do_POST(): Auth problem: Shutting down: mm_secret_token mismatch expected/got -%s-%s-", module.mm_secret_token, data["token"])
296                                 traceback.print_stack()
297                                 self.respond(403)
298                                 self.bot.on_shutdown()
299                                 os._exit(1)
300
301                         # interactive button-handler. TODO auth!
302                         elif len(splitpath) > 2 and splitpath[2] == "interactive":
303                             self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
304                             module._on_POST_interactive(self, data)
305
306                         # dialog-handler: TODO: auth!
307                         elif len(splitpath) > 2 and splitpath[2] == "dialog":
308                             self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
309                             module._on_POST_dialog(self, data)
310
311                         else: # Invalid command action
312                             logger.error("do_POST(): Invalid command action.")
313                             self.bot.debug_chan("do_POST(): Invalid command action.\n# :boom::boom::boom::boom::boom:\n```\n"+self.path+"\n```")
314                             self.respond_cmd_err("Invalid command action")
315
316                     else: # Invalid command/unknown command
317                         logger.error("do_POST(): Invalid command/unknown command.")
318                         self.bot.debug_chan("do_POST(): Invalid command/unknown command.\n# :boom::boom::boom::boom::boom:\n```\n"+self.path+"\n```")
319                         self.respond_cmd_err("Invalid command/unknown command")
320
321                 except:
322                     self.bot.debug_chan("##### Error in module: ``"+repr(module)+"``\n# :boom::boom::boom::boom::boom:\n```\n"+traceback.format_exc()+"\n```")
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
328
329             # Send a response to the channel.
330             def respond_cmd_chan(self, message, props=None, att=None, http_code=200):
331                 data = {"skip_slack_parsing":True, "response_type":"in_channel", "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
343             # Send a ephemeral response to the user.
344             def respond_cmd_temp(self, message, props=None, att=None, http_code=200):
345                 data = {"skip_slack_parsing":True, "response_type":"ephemeral", "text":message}
346
347                 if props:
348                     data.update({"props": props})
349
350                 if att:
351                     data.update({"attachments": att})
352
353                 self.respond(http_code, data)
354
355
356             def respond_interactive_temp(self, message, http_code=200):
357                 # cant be secondary, because no response url (interactive messages only?)
358                 self.respond(http_code, {"ephemeral_text":message})
359
360
361
362             # Use to send a failure to the user. Use only the first time during a request. Should retain input on clientside.
363             def respond_cmd_err(self, message, props=None):
364                 data = {"skip_slack_parsing":True, "response_type":"ephemeral", "text": "## :x: Failure! :(\n### "+message}
365
366                 if props:
367                     data.update({"props": props})
368
369                 # must be 2 separatecalls, as the message is ignored in a non-200.
370                 self.respond(400)
371                 self.respond(000, data)
372
373
374
375             def respond(self, http_code=200, data=None, if_nonzero_secondary='exc'):
376                 """
377                     First response call must have a valid http code.
378                     Secondary responses should have http_code = 0.
379                       use if_nonzero_secondary = 'ignore' to ignore response with http_code != 0.
380                       use if_nonzero_secondary = 'force' to send secondary with http_code != 0.
381                 """
382
383                 if data is None:
384                     data = {}
385
386                 # First response
387                 if not self.handled:
388                     if http_code >= 600 or http_code < 100:
389                         raise Exception("respond(): Primary response must have a valid http code.")
390
391                     self.handled = True
392                     self.send_response(http_code)
393                     self.send_header("Content-Type", "application/json")
394                     self.send_header("Content-Length", len(bytes(json.dumps(data), "utf8")))
395                     self.end_headers()
396                     self.wfile.write(bytes(json.dumps(data), "utf8"))
397                     logger.debug("respond(): Primary response send.")
398
399
400                 # Secondary responses
401                 else:
402                     if http_code != 0 and if_nonzero_secondary == "ignore":
403                         logger.info("respond(): Secondary responses must have a zero http code, but if_nonzero_secondary='ignore'. Doing nothing.")
404                         return
405
406                     elif http_code != 0 and if_nonzero_secondary == "force":
407                         logger.warning("respond(): Secondary responses must have a zero http code, but if_nonzero_secondary='force'. Sending anyway.")
408                         traceback.print_stack()
409
410                     elif http_code != 0:
411                         raise Exception("respond(): Secondary responses must have a zero http code.")
412
413
414                     if not self.responseURL:
415                         raise Exception("respond(): Secondary response attempt without response url.")
416
417
418                     logger.debug("respond(): Secondary response. Using responseURL: %s", self.responseURL)
419                     req = Request(self.responseURL, data=bytes(json.dumps(data), "utf8"), method='POST')
420                     req.add_header("Content-Type", "application/json")
421                     conn = urlopen(req, timeout=3)
422                     logger.debug("respond(): Secondary response send. Status: %s", conn.status)
423
424
425
426         class MyHTTPServer(ThreadingMixIn, HTTPServer):
427             def serve_forever(self, bot):
428                 self.RequestHandlerClass.bot = bot
429                 HTTPServer.serve_forever(self)
430
431         self.httpd = MyHTTPServer((self.local_websrv_hostname, self.local_websrv_port), HTTPRequestHandler)
432         threading.current_thread().rename_current_thread("HTTPServer", threading.current_thread())
433         self.httpd.serve_forever(self)