2 # Copyright (c) 2016-2021 by Someone <someone@somenet.org> (aka. Jan Vales <jan@jvales.net>)
3 # published under MIT-License
17 from inspect import cleandoc
18 from http.server import BaseHTTPRequestHandler, HTTPServer
19 from urllib.request import Request, urlopen
20 from socketserver import ThreadingMixIn
26 logger = logging.getLogger(__name__)
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
37 self.local_websrv_url = ("http://"+self.local_websrv_hostname+":"+str(self.local_websrv_port)+"/").strip("/")
39 self.wsmodules = dict()
41 self.debug_chan_id = debug_chan_id
42 self.loop = asyncio.new_event_loop()
43 self.command_stats = dict()
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)
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``**
60 Feel like contributing too? Talk to **``@someone``**. :)
61 The repository is here: https://git.somenet.org/pub/jan/mattermost-bot.git
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)
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()
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)
83 raise Exception("Multiple registration attempts for module: "+module.TRIGGER+" and team: "+module.TEAM_ID)
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()
93 if module.NAME not in self.wsmodules[evtype]:
94 self.wsmodules[evtype][module.NAME] = module
95 module._on_register_ws_evtype(self, evtype)
98 raise Exception("Multiple registration attempts for module: "+module.NAME+" and evtype: "+evtype)
102 logger.info("Starting: Almost there.")
103 logger.info(pprint.pformat(self.modules))
104 logger.info(pprint.pformat(self.wsmodules))
106 if self.mm_ws_url is not None:
107 self.mmws = mattermost.ws.MMws(self.websocket_handler, self.api, self.mm_ws_url)
109 if self.local_websrv_hostname is not None and self.local_websrv_port is not None:
110 self.start_webserver()
113 def on_shutdown(self):
114 logger.info("Shutting down ...")
117 self.mmws.close_websocket()
118 # todo: stop webserver + WS?
120 for team_modules in self.modules:
121 for module in self.modules[team_modules]:
122 self.modules[team_modules][module]._on_shutdown()
124 for evtype in self.wsmodules:
125 for module in self.wsmodules[evtype]:
126 self.wsmodules[evtype][module]._on_shutdown()
129 self.command_stats_dump()
137 def shutdown(self, unk1=None, unk2=None, err=0):
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()
147 # TODO: reinit teams.
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)
153 for evtype in self.wsmodules:
154 for module in self.wsmodules[evtype]:
155 self.wsmodules[evtype][module]._on_SIGUSR1(self.sigusr1_cnt)
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.")
162 self.api.create_post(self.debug_chan_id, "``AUTODELETE-DAY``\n"+message)
165 def command_stats_inc(self, command, amount=1):
166 if command in self.command_stats:
167 self.command_stats[command] += amount
169 self.command_stats[command] = amount
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()
177 def dump_stats_json(self, stats_data, file_path, header="", footer="", no_data_text=""):
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
189 stats_data[item] = cnt
190 except (FileNotFoundError, json.JSONDecodeError, KeyError):
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:
197 logger.info("dump_stats_json(): writing file: %s", file_path)
198 self.command_stats_inc("internal::dump_stats_json:"+file_path)
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)
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]:
213 if self.wsmodules[evtype][module_name].on_WS_EVENT(event_data):
214 self.command_stats_inc("ws::"+evtype+"::"+module_name)
216 exctxt = "".join(traceback.format_exc())
223 def start_webserver(self):
224 logger.info("Starting webserver.")
226 class RequestHandler(BaseHTTPRequestHandler):
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"))
245 if "response_url" in data:
246 self.responseURL = data["response_url"]
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]]
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)
259 logger.warning("mm_secret_token mismatch expected/got -%s-%s-", module.mm_secret_token, data["token"])
261 self.bot.on_shutdown()
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)
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)
274 else: #something weird.
275 self.bot.command_stats_inc("/"+splitpath[1]+' --- SOMETHINGS WEIRD!!!')
277 # always try to fail. If previously responded to, nothing will happen.
278 self.respond(400, only_direct=True)
281 def cmd_respond_text_temp(self, message, props=None, http=200):
283 self.respond(http, {"skip_slack_parsing":True, "response_type":"ephemeral", "text":message})
285 self.respond(http, {"skip_slack_parsing":True, "response_type":"ephemeral", "text":message, "props":props})
288 def cmd_respond_text_chan(self, message, props=None, http=200):
290 self.respond(http, {"skip_slack_parsing":True, "response_type":"in_channel", "text":message})
292 self.respond(http, {"skip_slack_parsing":True, "response_type":"in_channel", "text":message, "props":props})
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()
303 elif self.handled and not self.responseURL:
304 logger.error("Multiple responses without response url. ignoring.")
305 traceback.print_stack()
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)
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")))
320 if message is not None:
321 self.wfile.write(bytes(json.dumps(message), "utf8"))
324 class MyHTTPServer(ThreadingMixIn, HTTPServer):
325 def serve_forever(self, bot):
326 self.RequestHandlerClass.bot = bot
327 HTTPServer.serve_forever(self)
329 self.httpd = MyHTTPServer((self.local_websrv_hostname, self.local_websrv_port), RequestHandler)
330 self.httpd.serve_forever(self)