Add basic Python plugins support
This commit is contained in:
		
							parent
							
								
									90855d4d89
								
							
						
					
					
						commit
						720d17f6b9
					
				
							
								
								
									
										2
									
								
								COPYING
								
								
								
								
							
							
						
						
									
										2
									
								
								COPYING
								
								
								
								
							|  | @ -1,6 +1,6 @@ | |||
| Copyright (c) 2014 cxindex | ||||
| Copyright (c) 2015 Alexey Kharlamov <derlafff@ya.ru> | ||||
| Copyright (c) 2016 Alexei Sorokin <sor.alexei@meowr.ru> | ||||
| Copyright (c) 2017 Alexei Sorokin <sor.alexei@meowr.ru> | ||||
| 
 | ||||
| Permission is hereby granted, free of charge, to any person obtaining a copy of | ||||
| this software and associated documentation files (the ""Software""), to deal in | ||||
|  |  | |||
|  | @ -1,11 +0,0 @@ | |||
| #!/bin/sh | ||||
| 
 | ||||
| user="$1"  # User nickname. | ||||
| admin="$2"  # If one is an admin. | ||||
| 
 | ||||
| if [ "$admin" != 'true' ]; then | ||||
|     printf "%s\n" "$user: you are not an admin" | ||||
| else | ||||
|     printf "%s\n" "$user: you are an admin" | ||||
| fi | ||||
| 
 | ||||
|  | @ -0,0 +1,12 @@ | |||
| # -*- python -*- | ||||
| 
 | ||||
| class Plugin: | ||||
|     def command(self, command, body, nick, from_id, is_admin): | ||||
|         if command != "example": | ||||
|             return {"handled": False} | ||||
| 
 | ||||
|         if is_admin: | ||||
|             reply = "%s: you are an admin" % nick | ||||
|         else: | ||||
|             reply = "%s: you are not an admin" % nick | ||||
|         return {"handled": True, "reply": reply} | ||||
|  | @ -0,0 +1,62 @@ | |||
| # -*- python -*- | ||||
| import asyncio | ||||
| import os | ||||
| import re | ||||
| 
 | ||||
| class Plugin: | ||||
|     _trim_regexp = re.compile("(`|\\$|\\.\\.)") | ||||
|     _quote_regexp = re.compile("(\"|')") | ||||
| 
 | ||||
|     @classmethod | ||||
|     def _trim(cls, s): | ||||
|         result = cls._trim_regexp.sub("", s) | ||||
|         result = cls._quote_regexp.sub("“", result).strip() | ||||
|         return result | ||||
| 
 | ||||
|     # letter(ASCII or cyrillic), number, underscore only. | ||||
|     _cmd_validator_regexp = re.compile("^(\\w|\\p{Cyrillic})*$") | ||||
| 
 | ||||
|     @asyncio.coroutine | ||||
|     def _exec_cmd(self, cmd, body, nick, dir_path, is_admin): | ||||
|         is_admin = "true" if is_admin else "false" | ||||
|         path = os.path.join(dir_path, self._trim(cmd)) | ||||
| 
 | ||||
|         if not self._cmd_validator_regexp.match(cmd) or \ | ||||
|            not os.access(path, os.F_OK | os.X_OK) or not os.path.isfile(path): | ||||
|             return {"handled": False} | ||||
| 
 | ||||
|         if not os.access(path, os.R_OK): | ||||
|             return {"handled": True, | ||||
|                     "error": "\"%s\" is not readable" % path} | ||||
| 
 | ||||
|         cmd = [path, self._trim(nick), is_admin, self._trim(body)] | ||||
|         try: | ||||
|             pipe = asyncio.subprocess.PIPE | ||||
|             proc = yield from asyncio.create_subprocess_exec(*cmd, | ||||
|                                                              stdout=pipe, | ||||
|                                                              stderr=pipe) | ||||
|             cmd_reply, cmd_error = yield from proc.communicate() | ||||
|         except OSError as e: | ||||
|             return {"handled": True, | ||||
|                     "error": "Execute: %s" % str(e)} | ||||
| 
 | ||||
|         result = {} | ||||
|         if cmd_error and len(cmd_error.strip()) > 0: | ||||
|             result["error"] = "Process: %s" % cmd_error.strip() | ||||
|         if cmd_reply and len(cmd_reply.strip()) > 0: | ||||
|             result["reply"] = cmd_reply.decode().strip() | ||||
|         if result: | ||||
|             result["handled"] = True | ||||
|         return result | ||||
| 
 | ||||
|     @asyncio.coroutine | ||||
|     def command(self, command, body, nick, from_id, is_admin): | ||||
|         result = yield from self._exec_cmd(command, body, nick, | ||||
|                                            "plugins", is_admin) | ||||
|         return result | ||||
| 
 | ||||
|     @asyncio.coroutine | ||||
|     def question(self, body, nick, from_id, is_admin): | ||||
|         result = yield from self._exec_cmd("answer", body, nick, | ||||
|                                            "chat", is_admin) | ||||
|         return result | ||||
							
								
								
									
										278
									
								
								hptoad.py
								
								
								
								
							
							
						
						
									
										278
									
								
								hptoad.py
								
								
								
								
							|  | @ -1,12 +1,14 @@ | |||
| #!/usr/bin/env python3 | ||||
| 
 | ||||
| import asyncio | ||||
| import functools | ||||
| import importlib.util | ||||
| import logging | ||||
| import os | ||||
| import re | ||||
| import signal | ||||
| import sys | ||||
| import time | ||||
| import types | ||||
| import slixmpp | ||||
| 
 | ||||
| opts = { | ||||
|  | @ -19,7 +21,69 @@ opts = { | |||
| } | ||||
| 
 | ||||
| 
 | ||||
| class HptoadPlugin: | ||||
|     name = None | ||||
| 
 | ||||
|     def __init__(self, name): | ||||
|         spec = importlib.util.find_spec("plugins.%s" % name) | ||||
|         module = types.ModuleType(spec.name) | ||||
|         spec.loader.exec_module(module) | ||||
| 
 | ||||
|         self.name = "plugins.%s" % name | ||||
|         self._obj = module.Plugin() | ||||
| 
 | ||||
|     # Calls a function in the plugin, returns a dict with three variables. | ||||
|     # If the called function returned something else, all extras will be | ||||
|     # discarded. | ||||
|     # "handled" must be set to True if the plugin did something with the given | ||||
|     # information, and if the call did nothing, then False. | ||||
|     # "reply" is a string with the answer and "error" is a string with an error | ||||
|     # information or an empty string. | ||||
|     @asyncio.coroutine | ||||
|     def _call(self, cb_name, *args, **kwargs): | ||||
|         ret = {} | ||||
|         try: | ||||
|             if hasattr(self._obj, cb_name): | ||||
|                 func = getattr(self._obj, cb_name) | ||||
|                 if asyncio.iscoroutinefunction(func): | ||||
|                     ret = yield from func(*args, **kwargs) | ||||
|                 else: | ||||
|                     ret = func(*args, **kwargs) | ||||
|                 if not ret or type(ret) != dict: | ||||
|                     ret = {} | ||||
|         except Exception as e: | ||||
|             ret = {"error": "%s: %s" % (type(e).__name__, str(e))} | ||||
|         return {"handled": bool(ret.get("handled", False)), | ||||
|                 "reply": str(ret.get("reply", "")), | ||||
|                 "error": str(ret.get("error", ""))} | ||||
| 
 | ||||
|     @asyncio.coroutine | ||||
|     def call_initiate(self): | ||||
|         result = yield from self._call("initiate") | ||||
|         return result | ||||
| 
 | ||||
|     @asyncio.coroutine | ||||
|     def call_question(self, body, nick, from_id, is_admin): | ||||
|         result = yield from self._call("question", body, nick, | ||||
|                                        from_id, is_admin) | ||||
|         return result | ||||
| 
 | ||||
|     @asyncio.coroutine | ||||
|     def call_command(self, command, body, nick, from_id, is_admin): | ||||
|         result = yield from self._call("command", command, body, nick, | ||||
|                                        from_id, is_admin) | ||||
|         return result | ||||
| 
 | ||||
|     @asyncio.coroutine | ||||
|     def call_chat_message(self, body, nick, from_id, is_admin): | ||||
|         result = yield from self._call("chat_message", body, nick, | ||||
|                                        from_id, is_admin) | ||||
|         return result | ||||
| 
 | ||||
| 
 | ||||
| class Hptoad: | ||||
|     plugins = {} | ||||
| 
 | ||||
|     def __init__(self, opts): | ||||
|         self.client = slixmpp.ClientXMPP("%s/%s" % (opts["jid"], | ||||
|                                                     opts["resource"]), | ||||
|  | @ -80,141 +144,95 @@ class Hptoad: | |||
|         affiliation = self.muc_obj.get_jid_property(muc, nick, "affiliation") | ||||
|         return True if affiliation in ("admin", "owner") else False | ||||
| 
 | ||||
|     _trim_regexp = re.compile("(`|\\$|\\.\\.)") | ||||
|     _quote_regexp = re.compile("(\"|')") | ||||
| 
 | ||||
|     @classmethod | ||||
|     def trim(cls, s): | ||||
|         result = cls._trim_regexp.sub("", s) | ||||
|         result = cls._quote_regexp.sub("“", result).strip() | ||||
|         return result | ||||
| 
 | ||||
|     # letter(ASCII or cyrillic), number, underscore only. | ||||
|     _cmd_validator_regexp = re.compile("^!(\\w|\\p{Cyrillic})*$") | ||||
| 
 | ||||
|     @asyncio.coroutine | ||||
|     def prep_extern_cmd(self, body, nick, dir_path, is_admin=False): | ||||
|         cmd = body.split(" ", 1) | ||||
|         cmd[0] = cmd[0].strip() | ||||
|     def handle_command(self, command, body, nick, from_id, is_admin): | ||||
|         if command == "megakick":  # Megakick. | ||||
|             victim = body | ||||
|             if victim: | ||||
|                 is_bot_admin = self.is_muc_admin(self.muc, self.bot_nick) | ||||
|                 is_victim_admin = self.is_muc_admin(self.muc, victim) | ||||
| 
 | ||||
|         is_admin = "true" if is_admin else "false" | ||||
| 
 | ||||
|         if not self._cmd_validator_regexp.match(cmd[0]): | ||||
|             return None, "Bad command \"%s\"" % cmd[0] | ||||
| 
 | ||||
|         path = os.path.join(dir_path, self.trim(cmd[0][1:])) | ||||
| 
 | ||||
|         if not os.access(path, os.F_OK): | ||||
|             return None, "\"%s\" does not exist" % path | ||||
|         if not os.path.isfile(path): | ||||
|             return None, "\"%s\" is not a file" % path | ||||
|         if not os.access(path, os.R_OK | os.X_OK): | ||||
|             return None, "\"%s\" is not readable or executable" % path | ||||
| 
 | ||||
|         proc_args = [path, self.trim(nick), is_admin] | ||||
|         if len(cmd) > 1: | ||||
|             proc_args.append(self.trim(cmd[1])) | ||||
| 
 | ||||
|         return proc_args, None | ||||
| 
 | ||||
|     @asyncio.coroutine | ||||
|     def extern_cmd(self, body, nick, from_id, dir_path, is_admin=False): | ||||
|         reply = "" | ||||
|         err = None | ||||
| 
 | ||||
|         cmd, prep_err = yield from self.prep_extern_cmd(body, nick, dir_path, | ||||
|                                                         is_admin=is_admin) | ||||
| 
 | ||||
|         if prep_err: | ||||
|             reply = "%s: WAT" % nick | ||||
|             err = "Command: %s" % prep_err | ||||
|             return reply, err | ||||
| 
 | ||||
|         try: | ||||
|             pipe = asyncio.subprocess.PIPE | ||||
|             proc = yield from asyncio.create_subprocess_exec(*cmd, | ||||
|                                                              stdout=pipe, | ||||
|                                                              stderr=pipe) | ||||
|             cmd_reply, cmd_err = yield from proc.communicate() | ||||
|         except OSError as e: | ||||
|             reply = "%s: WAT" % nick | ||||
|             err = "Execute: %s" % str(e) | ||||
|             return reply, err | ||||
| 
 | ||||
|         if cmd_err and len(cmd_err.strip()) > 0: | ||||
|             err = "Process: %s" % cmd_err.strip() | ||||
|         if cmd_reply and len(cmd_reply.strip()) > 0: | ||||
|             reply = cmd_reply.decode().strip() | ||||
|         return reply, err | ||||
| 
 | ||||
|     @asyncio.coroutine | ||||
|     def handle_cmd(self, body, nick, from_id, is_admin=False): | ||||
|         reply = "" | ||||
|         err = None | ||||
| 
 | ||||
|         if body == "!megakick":  # Incomplete megakick. | ||||
|             reply = "%s: WAT" % nick | ||||
| 
 | ||||
|         elif body.startswith("!megakick "):  # Megakick. | ||||
|             victim = body.split("!megakick ", 1)[1] | ||||
| 
 | ||||
|             is_bot_admin = self.is_muc_admin(self.muc, self.bot_nick) | ||||
|             is_victim_admin = self.is_muc_admin(self.muc, victim) | ||||
| 
 | ||||
|             if is_admin and victim != self.bot_nick: | ||||
|                 if is_bot_admin and not is_victim_admin and \ | ||||
|                   victim in self.muc_obj.rooms[self.muc]: | ||||
|                     self.muc_obj.set_role(self.muc, victim, "none") | ||||
|                 if is_admin and victim != self.bot_nick: | ||||
|                     if is_bot_admin and not is_victim_admin and \ | ||||
|                        victim in self.muc_obj.rooms[self.muc]: | ||||
|                         self.muc_obj.set_role(self.muc, victim, "none") | ||||
|                     else: | ||||
|                         reply = "%s: Can't megakick %s." % (nick, victim) | ||||
|                 else: | ||||
|                     reply = "%s: Can't megakick %s." % (nick, victim) | ||||
|                     reply = "%s: GTFO" % nick | ||||
|             else: | ||||
|                 reply = "%s: GTFO" % nick | ||||
|                 reply = "%s: WAT" % nick | ||||
| 
 | ||||
|         elif body.startswith("!"):  # Any external command. | ||||
|             reply, err = yield from self.extern_cmd(body, nick, | ||||
|                                                     from_id, "plugins", | ||||
|                                                     is_admin=is_admin) | ||||
|             self.client.send_message(mto=self.muc, mbody=reply, | ||||
|                                      mtype="groupchat") | ||||
| 
 | ||||
|         return reply, err | ||||
|         else:  # Any plugin command. | ||||
|             futures = [] | ||||
|             for plugin in self.plugins.values(): | ||||
|                 future = asyncio.async(plugin.call_command(command, body, nick, | ||||
|                                                            from_id, is_admin)) | ||||
|                 callback = functools.partial(self.on_plugin_got_result, | ||||
|                                              nick=nick, from_id=from_id, | ||||
|                                              is_admin=is_admin) | ||||
|                 future.add_done_callback(callback) | ||||
|                 futures.append(future) | ||||
| 
 | ||||
|             if futures: | ||||
|                 results = yield from asyncio.gather(*futures) | ||||
|                 if not [i["handled"] for i in results if i["handled"]]: | ||||
|                     self.client.send_message(mto=self.muc, | ||||
|                                              mbody="%s: WAT" % nick, | ||||
|                                              mtype="groupchat") | ||||
| 
 | ||||
|     @asyncio.coroutine | ||||
|     def handle_self_message(self, body, nick, from_id): | ||||
|         if body.startswith("!"): | ||||
|             msg, err = yield from self.handle_cmd(body, nick, from_id, | ||||
|                                                   is_admin=True) | ||||
|         else: | ||||
|             msg = body.strip() | ||||
|             split = body.split(" ", 1) | ||||
|             command = split[0].strip()[1:] | ||||
|             message = split[1] if len(split) > 1 else "" | ||||
| 
 | ||||
|         if msg and len(msg) > 0: | ||||
|             self.client.send_message(mto=self.muc, mbody=msg, | ||||
|             yield from self.handle_command(command, message, nick, | ||||
|                                            from_id, True) | ||||
| 
 | ||||
|         elif body and len(body) > 0: | ||||
|             self.client.send_message(mto=self.muc, mbody=body.strip(), | ||||
|                                      mtype="groupchat") | ||||
| 
 | ||||
|     @asyncio.coroutine | ||||
|     def handle_muc_message(self, body, nick, from_id): | ||||
|         is_admin = self.is_muc_admin(self.muc, nick) | ||||
| 
 | ||||
|         reply = "" | ||||
|         err = None | ||||
|         futures = [] | ||||
| 
 | ||||
|         # Has to be redone with the current bot nick. | ||||
|         call_regexp = re.compile("^%s[:,]" % self.bot_nick) | ||||
| 
 | ||||
|         if body.startswith("!"):  # Any external command. | ||||
|             reply, err = yield from self.handle_cmd(body, nick, from_id, | ||||
|                                                     is_admin=is_admin) | ||||
|         for plugin in self.plugins.values(): | ||||
|             future = asyncio.async(plugin.call_chat_message(body, nick, | ||||
|                                                             from_id, is_admin)) | ||||
|             future.add_done_callback(self.on_plugin_got_result) | ||||
|             futures.append(future) | ||||
| 
 | ||||
|         if body.startswith("!"):  # Any plugin command. | ||||
|             split = body.split(" ", 1) | ||||
|             command = split[0].strip()[1:] | ||||
|             message = split[1] if len(split) > 1 else "" | ||||
| 
 | ||||
|             yield from self.handle_command(command, message, nick, from_id, | ||||
|                                            is_admin=is_admin) | ||||
| 
 | ||||
|         elif call_regexp.match(body):  # Chat. | ||||
|             cmd_body = call_regexp.sub("!answer", body) | ||||
|             reply, err = yield from self.extern_cmd(cmd_body, nick, from_id, | ||||
|                                                     "chat", is_admin=is_admin) | ||||
|             message = call_regexp.sub("", body).lstrip() | ||||
|             for plugin in self.plugins.values(): | ||||
|                 future = asyncio.async(plugin.call_question(message, nick, | ||||
|                                                             from_id, is_admin)) | ||||
|                 callback = functools.partial(self.on_plugin_got_result, | ||||
|                                              nick=nick, from_id=from_id, | ||||
|                                              is_admin=is_admin) | ||||
|                 future.add_done_callback(callback) | ||||
|                 futures.append(future) | ||||
| 
 | ||||
|         if err: | ||||
|             self.logger.error(err) | ||||
|             if is_admin: | ||||
|                 self.client.send_message(mto=from_id, mbody=err, mtype="chat") | ||||
|         if reply: | ||||
|             self.client.send_message(mto=self.muc, mbody=reply, | ||||
|                                      mtype="groupchat") | ||||
|         if futures: | ||||
|             yield from asyncio.gather(*futures) | ||||
| 
 | ||||
|     def on_failed_all_auth(self, event): | ||||
|         self.logger.critical("Auth: Could not connect to the server, or " + | ||||
|  | @ -278,7 +296,41 @@ class Hptoad: | |||
|         except Exception as e: | ||||
|             self.log_exception(e) | ||||
| 
 | ||||
|     def on_plugin_got_result(self, future, nick="", from_id="", is_admin=False): | ||||
|         result = future.result() | ||||
| 
 | ||||
|         if not result or not result["handled"]: | ||||
|             return | ||||
| 
 | ||||
|         if result["reply"]: | ||||
|             self.client.send_message(mto=self.muc, mbody=result["reply"], | ||||
|                                      mtype="groupchat") | ||||
| 
 | ||||
|         if result["error"]: | ||||
|             self.logger.error(result["error"]) | ||||
|             if is_admin and from_id: | ||||
|                 self.client.send_message(mto=from_id, mbody=result["error"], | ||||
|                                          mtype="chat") | ||||
| 
 | ||||
|             if nick: | ||||
|                 self.client.send_message(mto=self.muc, mbody="%s: WAT" % nick, | ||||
|                                          mtype="groupchat") | ||||
| 
 | ||||
|     def import_plugins(self): | ||||
|         plugins = {} | ||||
|         _, _, filenames = next(os.walk("./plugins"), (None, None, [])) | ||||
|         for filename in (i for i in filenames if i.endswith(".py")): | ||||
|             try: | ||||
|                 plugin = HptoadPlugin(filename[:-3]) | ||||
|                 plugins[filename[:-3]] = plugin | ||||
|                 future = asyncio.async(plugin.call_initiate()) | ||||
|                 future.add_done_callback(self.on_plugin_got_result) | ||||
|             except Exception as e: | ||||
|                 self.log_exception(e) | ||||
|         return plugins | ||||
| 
 | ||||
|     def run(self): | ||||
|         self.plugins = self.import_plugins() | ||||
|         self.register_handlers() | ||||
|         self.connect() | ||||
|         self.client.process(forever=True) | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue