]> git.somenet.org - pub/jan/mattermost.git/blob - somebot/core/MMBot.py
[somebot] Base system without any modules.
[pub/jan/mattermost.git] / somebot / core / MMBot.py
1 # Mattermost Bot.
2 #  Copyright (c) 2016-2020 by Someone <someone@somenet.org> (aka. Jan Vales <jan@jvales.net>)
3 #  published under MIT-License
4
5
6 import asyncio
7 import atexit
8 import json
9 import logging
10 import os
11 import pprint
12 import signal
13 import sys
14 import threading
15 import traceback
16 import urllib
17
18 from http.server import BaseHTTPRequestHandler, HTTPServer
19 from socketserver import ThreadingMixIn
20
21 import mattermost
22 import mattermost.ws
23
24
25 class MMBot():
26     def __init__(self, local_websrv_hostname="localhost", local_websrv_port=18065, api_user=None, api_user_pw=None, mm_api_url="http://localhost:8065/api", mm_ws_url="ws://localhost:8065/api/v4/websocket", debug_chan_id=None):
27         self.local_websrv_hostname = local_websrv_hostname
28         self.local_websrv_port = local_websrv_port
29         self.api_user = api_user
30         self.mm_api_url = mm_api_url
31         self.mm_ws_url = mm_ws_url
32
33         self.local_websrv_url = ("http://"+self.local_websrv_hostname+":"+str(self.local_websrv_port)+"/").strip("/")
34         self.modules = dict()
35         self.wsmodules = dict()
36         self.api = None
37         self.debug_chan_id = None
38         self.loop = asyncio.new_event_loop()
39         self.command_stats = dict()
40         self.admin_ids = []
41
42         self.mmws = None
43
44         atexit.register(self.shutdown)
45         signal.signal(signal.SIGUSR1, self.command_stats_dump)
46         signal.signal(signal.SIGTERM, self.sig_shutdown)
47         signal.signal(signal.SIGINT, self.sig_shutdown)
48
49
50         # Core-Command: /use-data
51         self.USETOPICS = {"bot":("##### 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.\n"
52                                  "Written by ``@someone`` in python. Big thanks to contributors: ``@ju``, ``@gittenburg``\n"
53                                  "Inspired by: ``@bearza``, ``@frunobulax``\n"
54                                  "\n"
55                                  "Feel like contributing too? Talk to ``@someone``. :)\n"
56                                  "The repository is here: https://git.somenet.org/pub/jan/mattermost.git")}
57
58         if api_user is not None and api_user_pw is not None:
59             logging.info("User credentials given. Trying to login.")
60             self.api = mattermost.MMApi(self.mm_api_url)
61             self.api.login(api_user, api_user_pw)
62             self.debug_chan_id = debug_chan_id
63
64
65     # Register a module with the bot.
66     def register(self, module):
67         if module.TEAM_ID not in self.modules:
68             self.modules[module.TEAM_ID] = dict()
69
70         if module.TRIGGER not in self.modules[module.TEAM_ID]:
71             self.modules[module.TEAM_ID][module.TRIGGER] = module
72             module._on_register(self)
73
74         else:
75             raise Exception("Multiple registration attempts for module: "+module.TRIGGER+" and team: "+module.TEAM_ID)
76
77
78     # Register a websocket handling module with the bot.
79     # There is no useful way to discriminate WS-events by originating teams atm. :(
80     def register_ws(self, module, eventlist):
81         for evtype in eventlist:
82             if evtype not in self.wsmodules:
83                 self.wsmodules[evtype] = dict()
84
85             if module.NAME not in self.wsmodules[evtype]:
86                 self.wsmodules[evtype][module.NAME] = module
87                 module._on_register_ws_evtype(self, evtype)
88
89             else:
90                 raise Exception("Multiple registration attempts for module: "+module.NAME+" and evtype: "+evtype)
91
92
93     def start(self):
94         logging.info("Starting: Almost there.")
95         logging.info(pprint.pformat(self.modules))
96         logging.info(pprint.pformat(self.wsmodules))
97
98         if self.mm_ws_url is not None:
99             self.mmws = mattermost.ws.MMws(self.websocket_handler, self.api, self.mm_ws_url)
100
101         if self.local_websrv_hostname is not None and self.local_websrv_port is not None:
102             self.start_webserver()
103
104
105     def shutdown(self):
106         logging.info("Shutting down ...")
107
108         self.mmws.close_websocket()
109         # todo: stop webserver + WS?
110
111         for team_modules in self.modules:
112             for module in self.modules[team_modules]:
113                 self.modules[team_modules][module]._on_shutdown()
114
115         for evtype in self.wsmodules:
116             for module in self.wsmodules[evtype]:
117                 self.wsmodules[evtype][module]._on_shutdown()
118
119         self.api.logout()
120         self.command_stats_dump()
121         logging.info("BYE.")
122
123
124
125     ########
126     # misc #
127     ########
128     def debug_chan(self, message):
129         if self.debug_chan_id is None:
130             logging.info("debug_chan() called, but debug_chan_id is unspecified.")
131             return
132
133         self.api.create_post(self.debug_chan_id, "``BOT-AUTODELETE-FAST``\n"+message)
134
135
136     def command_stats_inc(self, command, amount=1):
137         if command in self.command_stats:
138             self.command_stats[command] += amount
139         else:
140             self.command_stats[command] = amount
141
142
143     def sig_shutdown(self, unk1=None, unk2=None):
144         sys.exit(0)
145
146
147     def command_stats_dump(self, unk1=None, unk2=None):
148         self.command_stats_inc("internal::command_stats_dump")
149         stats = self.command_stats.copy()
150         self.command_stats = dict()
151
152         print("updating command stats: /tmp/somebot_command_stats.json", file=sys.stderr)
153         try:
154             with open("/tmp/somebot_command_stats.json", "r") as file:
155                 old_stats = json.load(file)
156             for item, cnt in old_stats.items():
157                 if item in stats:
158                     stats[item] += cnt
159                 else:
160                     stats[item] = cnt
161         except:
162             pass
163
164         with open("/tmp/somebot_command_stats.json", "w", encoding="utf-8") as file:
165             json.dump(stats, file, ensure_ascii=False, indent=2)
166
167
168     ##########################
169     # Bot's websocket client #
170     ##########################
171     def websocket_handler(self, mmws, event_data):
172         for evtype in self.wsmodules:
173             if evtype == event_data["event"]:
174                 for module_name in self.wsmodules[evtype]:
175                     try:
176                         print(module_name)
177                         if self.wsmodules[evtype][module_name].on_WS_EVENT(event_data):
178                             self.command_stats_inc("ws::"+evtype+"::"+module_name)
179                     except:
180                         exctxt = "".join(traceback.format_exc())
181                         logging.error(exctxt)
182
183
184     ###################
185     # Bot's webserver #
186     ###################
187     def start_webserver(self):
188         logging.info("Starting webserver.")
189
190         class RequestHandler(BaseHTTPRequestHandler):
191             bot = None
192
193             def do_POST(self):
194                 self.handled = False
195
196                 if self.headers["Content-Type"] == "application/x-www-form-urlencoded":
197                     data = urllib.parse.parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"), keep_blank_values=True)
198                     # only accept first occurence
199                     data = dict([(k, v[0]) for k, v in data.items()])
200                 elif self.headers["Content-Type"] == "application/json":
201                     data = json.loads(self.rfile.read(int(self.headers["Content-Length"])).decode("utf-8"))
202                 else:
203                     self.respond(415)
204                     return
205
206                 splitpath = self.path.strip("/").split("/")
207                 if self.bot.modules[splitpath[0]] and self.bot.modules[splitpath[0]][splitpath[1]]:
208                     module = self.bot.modules[splitpath[0]][splitpath[1]]
209
210                     # /command
211                     if len(splitpath) > 2 and splitpath[2] == "command":
212                         if "token" in data and module.mm_secret_token == data["token"]:
213                             self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
214                             module._on_POST(self, data)
215                         else:
216                             logging.warning("mm_secret_token mismatch expected/got -"+module.mm_secret_token+"-"+data["token"]+"-")
217                             self.respond(403)
218
219                     # interactive button-handler. TODO auth!
220                     elif len(splitpath) > 2 and splitpath[2] == "interactive":
221                         self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
222                         module._on_POST_interactive(self, data)
223
224                     # dialog-handler: TODO: auth!
225                     elif len(splitpath) > 2 and splitpath[2] == "dialog":
226                         self.bot.command_stats_inc("/"+splitpath[1]+"/"+splitpath[2])
227                         module._on_POST_dialog(self, data)
228
229                     else: #something weird.
230                         self.bot.command_stats_inc("/"+splitpath[1]+' --- SOMETHINGS WEIRD!!!')
231
232                 # always try to fail. If previously responded to, nothing will happen.
233                 self.respond(400)
234
235
236             def cmd_respond_text_temp(self, message, props=None):
237                 if props is None:
238                     self.respond(200, {"skip_slack_parsing":True, "response_type":"ephemeral", "text":message})
239                 else:
240                     self.respond(200, {"skip_slack_parsing":True, "response_type":"ephemeral", "text":message, "props":props})
241
242
243             def cmd_respond_text_chan(self, message, props=None):
244                 if props is None:
245                     self.respond(200, {"skip_slack_parsing":True, "response_type":"in_channel", "text":message})
246                 else:
247                     self.respond(200, {"skip_slack_parsing":True, "response_type":"in_channel", "text":message, "props":props})
248
249
250             def respond(self, err, message=None):
251                 if self.handled:
252                     return
253
254                 self.handled = True
255                 self.send_response(err)
256                 self.send_header("Content-Type", "application/json")
257                 self.end_headers()
258                 if message is not None:
259                     self.wfile.write(bytes(json.dumps(message), "utf8"))
260
261
262         class MyHTTPServer(ThreadingMixIn, HTTPServer):
263             def serve_forever(self, bot):
264                 self.RequestHandlerClass.bot = bot
265                 HTTPServer.serve_forever(self)
266
267         self.httpd = MyHTTPServer((self.local_websrv_hostname, self.local_websrv_port), RequestHandler)
268         self.httpd.serve_forever(self)