Add basic Python plugins support

This commit is contained in:
Alexei Sorokin 2017-06-09 18:28:28 +03:00
parent 90855d4d89
commit 720d17f6b9
5 changed files with 240 additions and 125 deletions

View File

@ -1,6 +1,6 @@
Copyright (c) 2014 cxindex Copyright (c) 2014 cxindex
Copyright (c) 2015 Alexey Kharlamov <derlafff@ya.ru> 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 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 this software and associated documentation files (the ""Software""), to deal in

View File

@ -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

View File

@ -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}

View File

@ -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
View File

@ -1,12 +1,14 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import asyncio import asyncio
import functools
import importlib.util
import logging import logging
import os import os
import re import re
import signal import signal
import sys import sys
import time import time
import types
import slixmpp import slixmpp
opts = { 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: class Hptoad:
plugins = {}
def __init__(self, opts): def __init__(self, opts):
self.client = slixmpp.ClientXMPP("%s/%s" % (opts["jid"], self.client = slixmpp.ClientXMPP("%s/%s" % (opts["jid"],
opts["resource"]), opts["resource"]),
@ -80,141 +144,95 @@ class Hptoad:
affiliation = self.muc_obj.get_jid_property(muc, nick, "affiliation") affiliation = self.muc_obj.get_jid_property(muc, nick, "affiliation")
return True if affiliation in ("admin", "owner") else False 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 @asyncio.coroutine
def prep_extern_cmd(self, body, nick, dir_path, is_admin=False): def handle_command(self, command, body, nick, from_id, is_admin):
cmd = body.split(" ", 1) if command == "megakick": # Megakick.
cmd[0] = cmd[0].strip() 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 is_admin and victim != self.bot_nick:
if is_bot_admin and not is_victim_admin and \
if not self._cmd_validator_regexp.match(cmd[0]): victim in self.muc_obj.rooms[self.muc]:
return None, "Bad command \"%s\"" % cmd[0] self.muc_obj.set_role(self.muc, victim, "none")
else:
path = os.path.join(dir_path, self.trim(cmd[0][1:])) reply = "%s: Can't megakick %s." % (nick, victim)
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")
else: else:
reply = "%s: Can't megakick %s." % (nick, victim) reply = "%s: GTFO" % nick
else: else:
reply = "%s: GTFO" % nick reply = "%s: WAT" % nick
elif body.startswith("!"): # Any external command. self.client.send_message(mto=self.muc, mbody=reply,
reply, err = yield from self.extern_cmd(body, nick, mtype="groupchat")
from_id, "plugins",
is_admin=is_admin)
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 @asyncio.coroutine
def handle_self_message(self, body, nick, from_id): def handle_self_message(self, body, nick, from_id):
if body.startswith("!"): if body.startswith("!"):
msg, err = yield from self.handle_cmd(body, nick, from_id, split = body.split(" ", 1)
is_admin=True) command = split[0].strip()[1:]
else: message = split[1] if len(split) > 1 else ""
msg = body.strip()
if msg and len(msg) > 0: yield from self.handle_command(command, message, nick,
self.client.send_message(mto=self.muc, mbody=msg, from_id, True)
elif body and len(body) > 0:
self.client.send_message(mto=self.muc, mbody=body.strip(),
mtype="groupchat") mtype="groupchat")
@asyncio.coroutine @asyncio.coroutine
def handle_muc_message(self, body, nick, from_id): def handle_muc_message(self, body, nick, from_id):
is_admin = self.is_muc_admin(self.muc, nick) is_admin = self.is_muc_admin(self.muc, nick)
futures = []
reply = ""
err = None
# Has to be redone with the current bot nick. # Has to be redone with the current bot nick.
call_regexp = re.compile("^%s[:,]" % self.bot_nick) call_regexp = re.compile("^%s[:,]" % self.bot_nick)
if body.startswith("!"): # Any external command. for plugin in self.plugins.values():
reply, err = yield from self.handle_cmd(body, nick, from_id, future = asyncio.async(plugin.call_chat_message(body, nick,
is_admin=is_admin) 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. elif call_regexp.match(body): # Chat.
cmd_body = call_regexp.sub("!answer", body) message = call_regexp.sub("", body).lstrip()
reply, err = yield from self.extern_cmd(cmd_body, nick, from_id, for plugin in self.plugins.values():
"chat", is_admin=is_admin) 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: if futures:
self.logger.error(err) yield from asyncio.gather(*futures)
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")
def on_failed_all_auth(self, event): def on_failed_all_auth(self, event):
self.logger.critical("Auth: Could not connect to the server, or " + self.logger.critical("Auth: Could not connect to the server, or " +
@ -278,7 +296,41 @@ class Hptoad:
except Exception as e: except Exception as e:
self.log_exception(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): def run(self):
self.plugins = self.import_plugins()
self.register_handlers() self.register_handlers()
self.connect() self.connect()
self.client.process(forever=True) self.client.process(forever=True)