]> git.somenet.org - pub/jan/mattermost-bot.git/blob - core/MMBot.py
[somebot] /order ["oida"|"oida minusf"]
[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 asyncio
6 import atexit
7 import json
8 import logging
9 import os
10 import pprint
11 import signal
12 import sys
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 = dict()
39         self.wsmodules = dict()
40         self.api = None
41         self.debug_chan_id = debug_chan_id
42         self.loop = asyncio.new_event_loop()
43         self.command_stats = dict()
44         self.admin_ids = []
45
46         self.mmws = None
47         self.sigusr1_cnt = 0
48
49         atexit.register(self.on_shutdown)
50         signal.signal(signal.SIGUSR1, self.on_SIGUSR1)
51         signal.signal(signal.SIGTERM, self.shutdown)
52         signal.signal(signal.SIGINT, self.shutdown)
53
54
55         # Core-Command: /use-data
56         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.
57                                  Written by **``@someone``** in python3. Big thanks to contributors: **``@ju``**, **``@gittenburg``**
58                                  Inspiring ideas by: ``@bearza``, ``@frunobulax``, **``@x5468656f``**
59
60                                  Feel like contributing too? Talk to **``@someone``**. :)
61                                  The repository is here: https://git.somenet.org/pub/jan/mattermost-bot.git
62                                  """)}
63
64         if api_user is not None and api_user_pw is not None:
65             logger.info("User credentials given. Trying to login.")
66             self.api = mattermost.MMApi(self.mm_api_url)
67             self.api.login(api_user, api_user_pw)
68         elif api_user is not None:
69             self.api = mattermost.MMApi(self.mm_api_url)
70             self.api.login(bearer=api_bearer)
71
72
73     # Register a module with the bot.
74     def register(self, module):
75         if module.TEAM_ID not in self.modules:
76             self.modules[module.TEAM_ID] = dict()
77
78         if module.TRIGGER not in self.modules[module.TEAM_ID]:
79             self.modules[module.TEAM_ID][module.TRIGGER] = module
80             module._on_register(self)
81
82         else:
83             raise Exception("Multiple registration attempts for module: "+module.TRIGGER+" and team: "+module.TEAM_ID)
84
85
86     # Register a websocket handling module with the bot.
87     # There is no useful way to discriminate WS-events by originating teams atm. :(
88     def register_ws(self, module, eventlist):
89         for evtype in eventlist:
90             if evtype not in self.wsmodules:
91                 self.wsmodules[evtype] = dict()
92
93             if module.NAME not in self.wsmodules[evtype]:
94                 self.wsmodules[evtype][module.NAME] = module
95                 module._on_register_ws_evtype(self, evtype)
96
97             else:
98                 raise Exception("Multiple registration attempts for module: "+module.NAME+" and evtype: "+evtype)
99
100
101     def start(self):
102         logger.info("Starting: Almost there.")
103         logger.info(pprint.pformat(self.modules))
104         logger.info(pprint.pformat(self.wsmodules))
105
106         if self.mm_ws_url is not None:
107             self.mmws = mattermost.ws.MMws(self.websocket_handler, self.api, self.mm_ws_url)
108
109         if self.local_websrv_hostname is not None and self.local_websrv_port is not None:
110             self.start_webserver()
111
112
113     def on_shutdown(self):
114         logger.info("Shutting down ...")
115
116         if self.mmws:
117             self.mmws.close_websocket()
118             # todo: stop webserver + WS?
119
120         for team_modules in self.modules:
121             for module in self.modules[team_modules]:
122                 self.modules[team_modules][module]._on_shutdown()
123
124         for evtype in self.wsmodules:
125             for module in self.wsmodules[evtype]:
126                 self.wsmodules[evtype][module]._on_shutdown()
127
128         self.api.logout()
129         self.command_stats_dump()
130         logger.info("BYE.")
131
132
133
134     ########
135     # misc #
136     ########
137     def shutdown(self, unk1=None, unk2=None, err=0):
138         sys.exit(err)
139
140
141     def on_SIGUSR1(self, unk1=None, unk2=None):
142         logger.info("on_SIGUSR1()")
143         self.sigusr1_cnt += 1
144         self.command_stats_inc("internal::SIGUSR1")
145         self.command_stats_dump()
146
147         # TODO: reinit teams.
148
149         for team_modules in self.modules:
150             for module in self.modules[team_modules]:
151                 self.modules[team_modules][module]._on_SIGUSR1(self.sigusr1_cnt)
152
153         for evtype in self.wsmodules:
154             for module in self.wsmodules[evtype]:
155                 self.wsmodules[evtype][module]._on_SIGUSR1(self.sigusr1_cnt)
156
157
158     def debug_chan(self, message):
159         if self.debug_chan_id is None:
160             logger.info("debug_chan() called, but debug_chan_id is unspecified.")
161             return
162         self.api.create_post(self.debug_chan_id, "``AUTODELETE-DAY``\n"+message)
163
164
165     def command_stats_inc(self, command, amount=1):
166         if command in self.command_stats:
167             self.command_stats[command] += amount
168         else:
169             self.command_stats[command] = amount
170
171
172     def command_stats_dump(self):
173         self.dump_stats_json(self.command_stats, "/tmp/somebot_command_stats.json", "#command_usage #mmstats")
174         self.command_stats = dict()
175
176
177     def dump_stats_json(self, stats_data, file_path, header="", footer="", no_data_text=""):
178         do_write = False
179         if stats_data:
180             do_write = True
181
182         try:
183             with open(file_path, "r") as file:
184                 old_stats = json.load(file)
185             for item, cnt in old_stats["data"].items():
186                 if item in stats_data:
187                     stats_data[item] += cnt
188                 else:
189                     stats_data[item] = cnt
190         except (FileNotFoundError, json.JSONDecodeError, KeyError):
191             do_write = True
192
193         # if no data, but file exists: skip write
194         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:
195             return
196
197         logger.info("dump_stats_json(): writing file: %s", file_path)
198         self.command_stats_inc("internal::dump_stats_json:"+file_path)
199
200         with open(file_path, "w", encoding="utf-8") as file:
201             json.dump({"header":header, "footer":footer, "no_data_text":no_data_text, "data":dict(sorted(stats_data.items()))}, file, ensure_ascii=False, indent=2)
202
203
204
205     ##########################
206     # Bot's websocket client #
207     ##########################
208     def websocket_handler(self, mmws, event_data):
209         for evtype in self.wsmodules:
210             if evtype == event_data["event"]:
211                 for module_name in self.wsmodules[evtype]:
212                     try:
213                         if self.wsmodules[evtype][module_name].on_WS_EVENT(event_data):
214                             self.command_stats_inc("ws::"+evtype+"::"+module_name)
215                     except Exception:
216                         exctxt = "".join(traceback.format_exc())
217                         logger.error(exctxt)
218
219
220     ###################
221     # Bot's webserver #
222     ###################
223     def start_webserver(self):
224         logger.info("Starting webserver.")
225
226         class RequestHandler(BaseHTTPRequestHandler):
227             bot = None
228             handled = False
229             responseURL = None
230
231             def do_POST(self):
232                 self.handled = False
233
234                 if self.headers["Content-Type"] == "application/x-www-form-urlencoded":
235                     data = urllib.parse.parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"), keep_blank_values=True)
236                     # only accept first occurence
237                     data = {k: v[0] for k, v in data.items()}
238                 elif self.headers["Content-Type"] == "application/json":
239                     data = json.loads(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"))
240                 else:
241                     self.respond(415)
242                     return
243
244                 # store responseURL
245                 if "response_url" in data:
246                     self.responseURL = data["response_url"]
247
248                 # handle call
249                 splitpath = self.path.strip("/").split("/")
250                 if self.bot.modules[splitpath[0]] and self.bot.modules[splitpath[0]][splitpath[1]]:
251                     module = self.bot.modules[splitpath[0]][splitpath[1]]
252
253                     # /command
254                     if len(splitpath) > 2 and splitpath[2] == "command":
255                         if "token" in data and module.mm_secret_token == data["token"]:
256                             self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
257                             module._on_POST(self, data)
258                         else:
259                             logger.warning("mm_secret_token mismatch expected/got -%s-%s-", module.mm_secret_token, data["token"])
260                             self.respond(403)
261                             self.bot.on_shutdown()
262                             os._exit(1)
263
264                     # interactive button-handler. TODO auth!
265                     elif len(splitpath) > 2 and splitpath[2] == "interactive":
266                         self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
267                         module._on_POST_interactive(self, data)
268
269                     # dialog-handler: TODO: auth!
270                     elif len(splitpath) > 2 and splitpath[2] == "dialog":
271                         self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
272                         module._on_POST_dialog(self, data)
273
274                     else: #something weird.
275                         self.bot.command_stats_inc("/"+splitpath[1]+' --- SOMETHINGS WEIRD!!!')
276
277                 # always try to fail. If previously responded to, nothing will happen.
278                 self.respond(400, only_direct=True)
279
280
281             def cmd_respond_text_temp(self, message, props=None, http=200):
282                 if props is None:
283                     self.respond(http, {"skip_slack_parsing":True, "response_type":"ephemeral", "text":message})
284                 else:
285                     self.respond(http, {"skip_slack_parsing":True, "response_type":"ephemeral", "text":message, "props":props})
286
287
288             def cmd_respond_text_chan(self, message, props=None, http=200):
289                 if props is None:
290                     self.respond(http, {"skip_slack_parsing":True, "response_type":"in_channel", "text":message})
291                 else:
292                     self.respond(http, {"skip_slack_parsing":True, "response_type":"in_channel", "text":message, "props":props})
293
294
295             def respond(self, err, message=None, only_direct=False):
296                 # first response handled by returning in-request.
297                 # Must be fast before mm drops the connection.
298                 if self.handled and only_direct:
299                     #logger.warning("Multiple responses but only_direct set. ignoring.")
300                     #traceback.print_stack()
301                     return
302
303                 elif self.handled and not self.responseURL:
304                     logger.error("Multiple responses without response url. ignoring.")
305                     traceback.print_stack()
306                     return
307
308                 elif self.handled:
309                     logger.info("Multiple responses. Using responseURL: "+self.responseURL)
310                     req = Request(self.responseURL, data=bytes(json.dumps(message), "utf8"), method='POST')
311                     req.add_header("Content-Type", "application/json")
312                     conn = urlopen(req, timeout=3)
313                     return
314
315                 self.handled = True
316                 self.send_response(err)
317                 self.send_header("Content-Type", "application/json")
318                 self.send_header("Content-Length", len(bytes(json.dumps(message), "utf8")))
319                 self.end_headers()
320                 if message is not None:
321                     self.wfile.write(bytes(json.dumps(message), "utf8"))
322
323
324         class MyHTTPServer(ThreadingMixIn, HTTPServer):
325             def serve_forever(self, bot):
326                 self.RequestHandlerClass.bot = bot
327                 HTTPServer.serve_forever(self)
328
329         self.httpd = MyHTTPServer((self.local_websrv_hostname, self.local_websrv_port), RequestHandler)
330         self.httpd.serve_forever(self)