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