2 # Copyright (c) 2016-2020 by Someone <someone@somenet.org> (aka. Jan Vales <jan@jvales.net>)
3 # published under MIT-License
18 from http.server import BaseHTTPRequestHandler, HTTPServer
19 from socketserver import ThreadingMixIn
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
33 self.local_websrv_url = ("http://"+self.local_websrv_hostname+":"+str(self.local_websrv_port)+"/").strip("/")
35 self.wsmodules = dict()
37 self.debug_chan_id = None
38 self.loop = asyncio.new_event_loop()
39 self.command_stats = dict()
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)
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"
55 "Feel like contributing too? Talk to ``@someone``. :)\n"
56 "The repository is here: https://git.somenet.org/pub/jan/mattermost.git")}
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
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()
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)
75 raise Exception("Multiple registration attempts for module: "+module.TRIGGER+" and team: "+module.TEAM_ID)
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()
85 if module.NAME not in self.wsmodules[evtype]:
86 self.wsmodules[evtype][module.NAME] = module
87 module._on_register_ws_evtype(self, evtype)
90 raise Exception("Multiple registration attempts for module: "+module.NAME+" and evtype: "+evtype)
94 logging.info("Starting: Almost there.")
95 logging.info(pprint.pformat(self.modules))
96 logging.info(pprint.pformat(self.wsmodules))
98 if self.mm_ws_url is not None:
99 self.mmws = mattermost.ws.MMws(self.websocket_handler, self.api, self.mm_ws_url)
101 if self.local_websrv_hostname is not None and self.local_websrv_port is not None:
102 self.start_webserver()
106 logging.info("Shutting down ...")
108 self.mmws.close_websocket()
109 # todo: stop webserver + WS?
111 for team_modules in self.modules:
112 for module in self.modules[team_modules]:
113 self.modules[team_modules][module]._on_shutdown()
115 for evtype in self.wsmodules:
116 for module in self.wsmodules[evtype]:
117 self.wsmodules[evtype][module]._on_shutdown()
120 self.command_stats_dump()
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.")
133 self.api.create_post(self.debug_chan_id, "``BOT-AUTODELETE-FAST``\n"+message)
136 def command_stats_inc(self, command, amount=1):
137 if command in self.command_stats:
138 self.command_stats[command] += amount
140 self.command_stats[command] = amount
143 def sig_shutdown(self, unk1=None, unk2=None):
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()
152 print("updating command stats: /tmp/somebot_command_stats.json", file=sys.stderr)
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():
164 with open("/tmp/somebot_command_stats.json", "w", encoding="utf-8") as file:
165 json.dump(stats, file, ensure_ascii=False, indent=2)
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]:
177 if self.wsmodules[evtype][module_name].on_WS_EVENT(event_data):
178 self.command_stats_inc("ws::"+evtype+"::"+module_name)
180 exctxt = "".join(traceback.format_exc())
181 logging.error(exctxt)
187 def start_webserver(self):
188 logging.info("Starting webserver.")
190 class RequestHandler(BaseHTTPRequestHandler):
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"))
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]]
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)
216 logging.warning("mm_secret_token mismatch expected/got -"+module.mm_secret_token+"-"+data["token"]+"-")
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)
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)
229 else: #something weird.
230 self.bot.command_stats_inc("/"+splitpath[1]+' --- SOMETHINGS WEIRD!!!')
232 # always try to fail. If previously responded to, nothing will happen.
236 def cmd_respond_text_temp(self, message, props=None):
238 self.respond(200, {"skip_slack_parsing":True, "response_type":"ephemeral", "text":message})
240 self.respond(200, {"skip_slack_parsing":True, "response_type":"ephemeral", "text":message, "props":props})
243 def cmd_respond_text_chan(self, message, props=None):
245 self.respond(200, {"skip_slack_parsing":True, "response_type":"in_channel", "text":message})
247 self.respond(200, {"skip_slack_parsing":True, "response_type":"in_channel", "text":message, "props":props})
250 def respond(self, err, message=None):
255 self.send_response(err)
256 self.send_header("Content-Type", "application/json")
258 if message is not None:
259 self.wfile.write(bytes(json.dumps(message), "utf8"))
262 class MyHTTPServer(ThreadingMixIn, HTTPServer):
263 def serve_forever(self, bot):
264 self.RequestHandlerClass.bot = bot
265 HTTPServer.serve_forever(self)
267 self.httpd = MyHTTPServer((self.local_websrv_hostname, self.local_websrv_port), RequestHandler)
268 self.httpd.serve_forever(self)