diff options
-rw-r--r-- | README | 1 | ||||
-rw-r--r-- | data/default_config.cfg | 7 | ||||
-rw-r--r-- | plugins/day_change.py | 31 | ||||
-rw-r--r-- | plugins/exec.py | 43 | ||||
-rw-r--r-- | plugins/screen_detach.py | 46 | ||||
-rw-r--r-- | plugins/test.py | 16 | ||||
-rw-r--r-- | plugins/translate.py | 34 | ||||
-rw-r--r-- | src/core.py | 44 | ||||
-rw-r--r-- | src/data_forms.py | 21 | ||||
-rw-r--r-- | src/plugin.py | 54 | ||||
-rw-r--r-- | src/plugin_manager.py | 116 |
11 files changed, 412 insertions, 1 deletions
@@ -89,6 +89,7 @@ the Creative Commons BY license (http://creativecommons.org/licenses/by/2.0/) Thanks ======================= = People = + Todd Eisenberger (todd@teisen.be) - Plugin system Link Mauve - Code, testing Gaëtan Ribémont (http://www.bonbref.com) - Logo design Ovart - Testing diff --git a/data/default_config.cfg b/data/default_config.cfg index 0c16ef97..b061cbf4 100644 --- a/data/default_config.cfg +++ b/data/default_config.cfg @@ -125,6 +125,13 @@ use_log = false # you want to use instead. This directory will be created if it doesn't exist log_dir = +# If plugins_dir is not set, plugins will be loaded from $XDG_DATA_HOME/poezio/plugins. +# You can specify an other directory to use. It will be created if it doesn't exist +plugins_dir = + +# Space separated list of plugins to load on startup +plugins_autoload = + # the full path to the photo (avatar) you want to use # it should be less than 16Ko # The avatar is not set by default, because it slows diff --git a/plugins/day_change.py b/plugins/day_change.py new file mode 100644 index 00000000..cac69a75 --- /dev/null +++ b/plugins/day_change.py @@ -0,0 +1,31 @@ +from gettext import gettext as _ +from plugin import BasePlugin +import datetime +import tabs +import timed_events + +class Plugin(BasePlugin): + def init(self): + self.schedule_event() + + def cleanup(self): + self.core.remove_timed_event(self.next_event) + + def schedule_event(self): + day_change = datetime.datetime.combine(datetime.date.today(), datetime.time()) + day_change += datetime.timedelta(1) + self.next_event = timed_events.TimedEvent(day_change, self.day_change) + self.core.add_timed_event(self.next_event) + + def day_change(self): + msg = datetime.date.today().strftime(_("Day changed to %x")) + + for tab in self.core.tabs: + if (isinstance(tab, tabs.MucTab) or + isinstance(tab, tabs.PrivateTab) or + isinstance(tab, tabs.ConversationTab)): + room = tab.get_room() + room.add_message(msg) + + self.core.refresh_window() + self.schedule_event() diff --git a/plugins/exec.py b/plugins/exec.py new file mode 100644 index 00000000..f7f451df --- /dev/null +++ b/plugins/exec.py @@ -0,0 +1,43 @@ +# A plugin that can execute a command and send the result in the conversation + +from plugin import BasePlugin +import os +import common +import shlex +import subprocess + +class Plugin(BasePlugin): + def init(self): + self.add_command('exec', self.command_exec, "Usage: /exec [-o|-O] <command>\nExec: Execute a shell command and prints the result in the information buffer. The command should be ONE argument, that means it should be between \"\". The first argument (before the command) can be -o or -O. If -o is specified, it sends the result in the current conversation. If -O is specified, it sends the command and its result in the current conversation.\nExample: /exec -O \"uptime\" will send “uptime\n20:36:19 up 3:47, 4 users, load average: 0.09, 0.13, 0.09” in the current conversation.") + + def command_exec(self, args): + args = common.shell_split(args) + if len(args) == 1: + command = args[0] + arg = None + elif len(args) == 2: + command = args[1] + arg = args[0] + else: + self.core.command_help('exec') + return + try: + cut_command = shlex.split(command) + except Exception as e: + self.core.information('Failed to parse command: %s' % (e,), 'Error') + return + try: + process = subprocess.Popen(cut_command, stdout=subprocess.PIPE) + except OSError as e: + self.core.information('Failed to execute command: %s' % (e,), 'Error') + return + result = process.communicate()[0].decode('utf-8') + if arg and arg == '-o': + if not self.core.send_message('%s' % (result,)): + self.core.information('Cannot send result (%s), this is not a conversation tab' % result) + elif arg and arg == '-O': + if not self.core.send_message('%s:\n%s' % (command, result)): + self.core.information('Cannot send result (%s), this is not a conversation tab' % result) + else: + self.core.information('%s:\n%s' % (command, result), 'Info') + return diff --git a/plugins/screen_detach.py b/plugins/screen_detach.py new file mode 100644 index 00000000..6ee96896 --- /dev/null +++ b/plugins/screen_detach.py @@ -0,0 +1,46 @@ +from plugin import BasePlugin +import os +import stat +import pyinotify + +SCREEN_DIR = '/var/run/screen/S-%s' % (os.getlogin(),) + +class Plugin(BasePlugin): + def init(self): + self.timed_event = None + sock_path = None + self.thread = None + for f in os.listdir(SCREEN_DIR): + path = os.path.join(SCREEN_DIR, f) + if screen_attached(path): + sock_path = path + self.attached = True + break + + # Only actually do something if we found an attached screen (assuming only one) + if sock_path: + wm = pyinotify.WatchManager() + wm.add_watch(sock_path, pyinotify.EventsCodes.ALL_FLAGS['IN_ATTRIB']) + self.thread = pyinotify.ThreadedNotifier(wm, default_proc_fun=HandleScreen(plugin=self)) + self.thread.start() + + def cleanup(self): + if self.thread: + self.thread.stop() + + def update_screen_state(self, socket): + attached = screen_attached(socket) + if attached != self.attached: + self.attached = attached + status = 'available' if self.attached else 'away' + self.core.command_status(status) + +def screen_attached(socket): + return (os.stat(socket).st_mode & stat.S_IXUSR) != 0 + +class HandleScreen(pyinotify.ProcessEvent): + def my_init(self, **kwargs): + self.plugin = kwargs['plugin'] + + def process_IN_ATTRIB(self, event): + self.plugin.update_screen_state(event.path) diff --git a/plugins/test.py b/plugins/test.py new file mode 100644 index 00000000..13ba1e9c --- /dev/null +++ b/plugins/test.py @@ -0,0 +1,16 @@ +from plugin import BasePlugin + +class Plugin(BasePlugin): + def init(self): + self.add_command('plugintest', self.command_plugintest, 'Test command') + self.add_event_handler('message', self.on_message) + self.core.information("Plugin loaded") + + def cleanup(self): + self.core.information("Plugin unloaded") + + def on_message(self, message): + self.core.information("Test plugin received message: {}".format(message)) + + def command_plugintest(self, args): + self.core.information("Command! With args {}".format(args)) diff --git a/plugins/translate.py b/plugins/translate.py new file mode 100644 index 00000000..880c8af1 --- /dev/null +++ b/plugins/translate.py @@ -0,0 +1,34 @@ +from plugin import BasePlugin +import urllib.request +from urllib.parse import urlencode +import xhtml +import json + +TARGET_LANG = 'en' + +def translate(s, target=TARGET_LANG, source=''): + f = urllib.request.urlopen('http://ajax.googleapis.com/ajax/services/language/translate', urlencode({ 'v': '1.0', 'q': s, 'langpair': '%s|%s' % (source, target) })) + response = json.loads(str(f.read(), 'utf-8'))['responseData'] + return (response['translatedText'], response['detectedSourceLanguage']) + +class Plugin(BasePlugin): + def init(self): + self.add_event_handler('groupchat_message', self.on_groupchat_message) + + def on_groupchat_message(self, message): + try: + room_from = message.getMucroom() + if message['type'] == 'error': + return + + if room_from == 'poezio@kikoo.louiz.org': + nick_from = message['mucnick'] + body = xhtml.get_body_from_message_stanza(message) + room = self.core.get_room_by_name(room_from) + text, lang = translate(body) + if lang != TARGET_LANG: + room.add_message(text, nickname=nick_from) + self.core.refresh_window() + except Exception as e: + import traceback + self.core.information("Exception in translator! %s" % (traceback.format_exc(),)) diff --git a/src/core.py b/src/core.py index 766be909..0e09cf04 100644 --- a/src/core.py +++ b/src/core.py @@ -36,6 +36,8 @@ import windows import connection import timed_events +from plugin_manager import PluginManager + from data_forms import DataFormsTab from config import config, options from logger import logger @@ -102,6 +104,7 @@ class Core(object): # a completion function, taking a Input as argument. Can be None) # The completion function should return True if a completion was # made ; False otherwise + self.plugin_manager = PluginManager(self) self.commands = { 'help': (self.command_help, '\_o< KOIN KOIN KOIN', self.completion_help), 'join': (self.command_join, _("Usage: /join [room_name][@server][/nick] [password]\nJoin: Join the specified room. You can specify a nickname after a slash (/). If no nickname is specified, you will use the default_nick in the configuration file. You can omit the room name: you will then join the room you\'re looking at (useful if you were kicked). You can also provide a room_name without specifying a server, the server of the room you're currently in will be used. You can also provide a password to join the room.\nExamples:\n/join room@server.tld\n/join room@server.tld/John\n/join room2\n/join /me_again\n/join\n/join room@server.tld/my_nick password\n/join / password"), self.completion_join), @@ -125,7 +128,8 @@ class Core(object): 'connect': (self.command_reconnect, _('Usage: /connect\nConnect: disconnect from the remote server if you are currently connected and then connect to it again'), None), 'server_cycle': (self.command_server_cycle, _('Usage: /server_cycle [domain] [message]\nServer Cycle: disconnect and reconnects in all the rooms in domain.'), None), 'bind': (self.command_bind, _('Usage: /bind <key> <equ>\nBind: bind a key to an other key or to a “command”. For example "/bind ^H KEY_UP" makes Control + h do the same same than the Up key.'), None), -# nope 'pubsub': (self.command_pubsub, _('Usage: /pubsub <domain>\nPubsub: Open a pubsub browser on the given domain'), None), + 'load': (self.command_load, _('Usage: /load <plugin>\nLoad: Load the specified plugin'), self.plugin_manager.completion_load), + 'unload': (self.command_unload, _('Usage: /unload <plugin>\nUnload: Unload the specified plugin'), self.plugin_manager.completion_unload), } self.key_func = { @@ -170,6 +174,12 @@ class Core(object): self.xmpp.add_event_handler("chatstate_inactive", self.on_chatstate_inactive) self.timed_events = set() + self.autoload_plugins() + + def autoload_plugins(self): + plugins = config.get('plugins_autoload', '') + for plugin in plugins.split(): + self.plugin_manager.load(plugin) def coucou(self): self.command_pubsub('pubsub.louiz.org') @@ -1124,6 +1134,28 @@ class Core(object): def completion_status(self, the_input): return the_input.auto_completion([status for status in possible_show], ' ') + def command_load(self, arg): + """ + /load <plugin> + """ + args = arg.split() + if len(args) != 1: + self.command_help('load') + return + filename = args[0] + self.plugin_manager.load(filename) + + def command_unload(self, arg): + """ + /unload <plugin> + """ + args = arg.split() + if len(args) != 1: + self.command_help('unload') + return + filename = args[0] + self.plugin_manager.unload(filename) + def command_message(self, arg): """ /message <jid> [message] @@ -1604,3 +1636,13 @@ class Core(object): if not self.running or self.background is True: return curses.doupdate() + + def send_message(self, msg): + """ + Function to use in plugins to send a message in the current conversation. + Returns False if the current tab is not a conversation tab + """ + if not isinstance(self.current_tab(), tabs.ChatTab): + return False + self.current_tab().command_say(msg) + return True diff --git a/src/data_forms.py b/src/data_forms.py index 873aef85..99d08caa 100644 --- a/src/data_forms.py +++ b/src/data_forms.py @@ -152,6 +152,26 @@ class DummyInput(FieldInput, windows.Win): def is_dummy(self): return True +class ColoredLabel(windows.Win): + def __init__(self, text): + self.text = text + self.color = 14 + windows.Win.__init__(self) + + def resize(self, height, width, y, x): + self._resize(height, width, y, x) + + def set_color(self, color): + self.color = color + self.refresh() + + def refresh(self): + with g_lock: + self._win.attron(curses.color_pair(self.color)) + self.addstr(0, 0, self.text) + self._win.attroff(curses.color_pair(self.color)) + self._refresh() + class BooleanWin(FieldInput, windows.Win): def __init__(self, field): FieldInput.__init__(self, field) @@ -502,6 +522,7 @@ class FormWin(object): for i, inp in enumerate(self.inputs): if i >= self.height: break + inp['label'].refresh() inp['input'].refresh() inp['label'].refresh() if self.current_input < self.height-1: diff --git a/src/plugin.py b/src/plugin.py new file mode 100644 index 00000000..d332ca01 --- /dev/null +++ b/src/plugin.py @@ -0,0 +1,54 @@ +import os +from configparser import ConfigParser + +class PluginConfig(ConfigParser): + def __init__(self, filename): + ConfigParser.__init__(self) + self.__config_file__ = filename + self.read() + + def read(self): + """Read the config file""" + ConfigParser.read(self, self.__config_file__) + + def write(self): + """Write the config to the disk""" + try: + fp = open(self.__config_file__, 'w') + ConfigParser.write(self, fp) + fp.close() + return True + except IOError: + return False + +class BasePlugin(object): + """ + Class that all plugins derive from. Any methods beginning with command_ + are interpreted as a command and beginning with on_ are interpreted as + event handlers + """ + + def __init__(self, plugin_manager, core, plugins_conf_dir): + self.core = core + self.plugin_manager = plugin_manager + conf = os.path.join(plugins_conf_dir, self.__module__+'.cfg') + self.config = PluginConfig(conf) + self.init() + + def init(self): + pass + + def cleanup(self): + pass + + def unload(self): + self.cleanup() + + def add_command(self, name, handler, help, completion=None): + return self.plugin_manager.add_command(self.__module__, name, handler, help, completion) + + def add_event_handler(self, event_name, handler): + return self.plugin_manager.add_event_handler(self.__module__, event_name, handler) + + def del_event_handler(self, event_name, handler): + return self.plugin_manager.del_event_handler(self.__module__, event_name, handler) diff --git a/src/plugin_manager.py b/src/plugin_manager.py new file mode 100644 index 00000000..df96e9ab --- /dev/null +++ b/src/plugin_manager.py @@ -0,0 +1,116 @@ +import imp +import os +import sys +from config import config +from gettext import gettext as _ + +plugins_dir = config.get('plugins_dir', '') +plugins_dir = plugins_dir or\ + os.path.join(os.environ.get('XDG_DATA_HOME') or\ + os.path.join(os.environ.get('HOME'), '.local', 'share'), + 'poezio', 'plugins') + +config_home = os.environ.get("XDG_CONFIG_HOME") +if not config_home: + config_home = os.path.join(os.environ.get('HOME'), '.config') +plugins_conf_dir = os.path.join(config_home, 'poezio', 'plugins') + +try: + os.makedirs(plugins_dir) +except OSError: + pass + +try: + os.makedirs(plugins_conf_dir) +except OSError: + pass + +sys.path.append(plugins_dir) + +class PluginManager(object): + def __init__(self, core): + self.core = core + self.modules = {} # module name -> module object + self.plugins = {} # module name -> plugin object + self.commands = {} # module name -> dict of commands loaded for the module + self.event_handlers = {} # module name -> list of event_name/handler pairs loaded for the module + + def load(self, name): + if name in self.plugins: + self.unload(name) + + try: + if name in self.modules: + imp.acquire_lock() + module = imp.reload(self.modules[name]) + imp.release_lock() + else: + file, filename, info = imp.find_module(name, [plugins_dir]) + imp.acquire_lock() + module = imp.load_module(name, file, filename, info) + imp.release_lock() + except Exception as e: + import traceback + self.core.information(_("Could not load plugin: ") + traceback.format_exc(), 'Error') + return + finally: + if imp.lock_held(): + imp.release_lock() + + self.modules[name] = module + self.commands[name] = {} + self.event_handlers[name] = [] + self.plugins[name] = module.Plugin(self, self.core, plugins_conf_dir) + + def unload(self, name): + if name in self.plugins: + try: + for command in self.commands[name].keys(): + del self.core.commands[command] + for event_name, handler in self.event_handlers[name]: + self.core.xmpp.del_event_handler(event_name, handler) + + self.plugins[name].unload() + del self.plugins[name] + del self.commands[name] + del self.event_handlers[name] + except Exception as e: + import traceback + self.core.information(_("Could not unload plugin (may not be safe to try again): ") + traceback.format_exc()) + + def add_command(self, module_name, name, handler, help, completion=None): + if name in self.core.commands: + raise Exception(_("Command '%s' already exists") % (name,)) + + commands = self.commands[module_name] + commands[name] = (handler, help, completion) + self.core.commands[name] = (handler, help, completion) + + def add_event_handler(self, module_name, event_name, handler): + eh = self.event_handlers[module_name] + eh.append((event_name, handler)) + self.core.xmpp.add_event_handler(event_name, handler) + + def del_event_handler(self, module_name, event_name, handler): + self.core.xmpp.del_event_handler(event_name, handler) + eh = self.event_handlers[module_name] + eh = list(filter(lambda e : e != (event_name, handler), eh)) + + def completion_load(self, the_input): + """ + completion function that completes the name of the plugins, from + all .py files in plugins_dir + """ + try: + names = os.listdir(plugins_dir) + except OSError as e: + self.core.information(_('Completion failed: %s' % e), 'Error') + return + plugins_files = [name[:-3] for name in names if name.endswith('.py')] + return the_input.auto_completion(plugins_files, '') + + def completion_unload(self, the_input): + """ + completion function that completes the name of the plugins that are loaded + """ + return the_input.auto_completion(list(self.plugins.keys()), '') |