diff --git a/example/plugins/signals.py b/example/plugins/signals.py new file mode 100644 index 0000000..e08a5ee --- /dev/null +++ b/example/plugins/signals.py @@ -0,0 +1,51 @@ +"""Example file for signals.""" + +from pyiab.plugins import plugin_class +from pyaib.components import observe, awaits_signal +from pyaib.signals import emit_signal, await_signal +import re + +@plugin_class('names') +class Names: + """This plugin provides a command ('names') that outputs a list of all + nicks currently in the channel.""" + def __init__(self, irc_c, config): + print("Names plugin loaded") + + @keyword('names') + def get_list_of_names(self, irc_c, message, trigger, args, kwargs): + # Sends a NAMES request to the server, to get a list of nicks for the + # current channel. + # Issue the NAMES request: + irc_c.RAW("NAMES %s" % message.channel) + # The request has been sent. + # pyaib is asynchronous, so another function will recieve the response + # from this request. + # That function must send the data here via a signal. + try: + # Wait for the signal (up to 10 seconds). + response = await_signal(irc_c, 'NAMES_RESPONSE', timeout=10.0) + # await_signal returns whatever data we choose to send, or True. + except TimeoutError: + message.reply("The request timed out.") + return + # The NAMES response is now saved. + channel = response[0] + names = response[1] + assert channel == message.channel + message.reply("List of channel members: %s" % ", ".join(names)) + # Warning, this will annoy everyone in the channel. + + @observe('IRC_MSG_353') # 353 indicates a NAMES response. + def recieve_names(self, irc_c, message): + # The response is in message.args as a single string. + # "MYNICK = #channel :nick1 nick2 nick3" + # Split that up into individual names: + response = re.split(r"\s:?", message.args.strip())[2:] + channel = response[0] + names = response[1:] + # Great, we've caught the NAMES response. + # Now send it back to the function that wanted it. + emit_signal(irc_c, 'NAMES_RESPONSE', data=(channel, names)) + # The signal name can be anything, so long as emit_signal and + # await_signal use the same one. diff --git a/pyaib/components.py b/pyaib/components.py index b86fbb0..1b73d97 100644 --- a/pyaib/components.py +++ b/pyaib/components.py @@ -109,6 +109,15 @@ def wrapper(func): handles = watches +def awaits_signal(*signals): + """Define a series of signals to later be subscribed to""" + def wrapper(func): + splugs = _get_plugs(func, 'signals') + splugs.extend([signal for signal in signals if signal not in splugs]) + return func + return wrapper + + class _Ignore(EasyDecorator): """Only pass if triggers is from user not ignored""" def wrapper(dec, irc_c, msg, *args): @@ -335,6 +344,9 @@ def _install_hooks(self, context, hooked_methods): elif kind == 'parsers': for name, chain in args: self._add_parsers(method, name, chain) + elif kind == 'signals': + for signal in args: + context.signals(signal).observe(method) def _add_parsers(self, method, name, chain): """ Handle Message parser adding and chaining """ diff --git a/pyaib/ircbot.py b/pyaib/ircbot.py index 6bac5f8..4a6570f 100644 --- a/pyaib/ircbot.py +++ b/pyaib/ircbot.py @@ -35,6 +35,7 @@ from .config import Config from .events import Events from .timers import Timers +from .signals import Signals from .components import ComponentManager from . import irc @@ -54,6 +55,7 @@ def __init__(self, *args, **kargs): #Install most basic fundamental functionality install('events', self._loadComponent(Events, False)) install('timers', self._loadComponent(Timers, False)) + install('signals', self._loadComponent(Signals, False)) #Load the ComponentManager and load components autoload = ['triggers', 'channels', 'plugins'] # Force these to load diff --git a/pyaib/signals.py b/pyaib/signals.py new file mode 100644 index 0000000..234f655 --- /dev/null +++ b/pyaib/signals.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python + +import collections +import gevent.event +import gevent.queue +import gevent + +from . import irc + +def emit_signal(irc_c, name, *, data=None): + """Emits the signal of the given name.""" + if not isinstance(irc_c, irc.Context): + raise TypeError("First argument must be IRC context") + if data is False: + raise ValueError("Signalled data cannot be False") + # create signal if it doesn't already exist + signal = irc_c.signals(name) + signal.fire(irc_c, data) + +def await_signal(irc_c, name, *, timeout=None): + """Blocks until the signal of the given name is recieved, returning any + data that was passed to it.""" + if not isinstance(irc_c, irc.Context): + raise TypeError("First argument must be IRC context") + # create signal if it doesn't already exist + signal = irc_c.signals(name) + return signal.wait(timeout) + +class Signal: + def __init__(self, name): + self.__event = gevent.event.Event() + self.__observers = [] # decorated observers + self.__waiters = [] # waiting greenlets + self.name = name + + def observe(self, observer): + if isinstance(observer, collections.Callable): + self.__observers.append(observer) + else: + raise TypeError("%s not callable" % repr(observer)) + return self + + def unobserve(self, observer): + self.__observers.remove(observer) + return self + + def fire(self, irc_c, data): + assert isinstance(irc_c, irc.Context) + # resume waiting greenlets + waiters = list(self.__waiters) + self.__waiters.clear() + gevent.spawn(self._notify, waiters, data) + # manually initiate decorated observers + for observer in self.__observers: + if isinstance(observer, collections.Callable): + irc_c.bot_greenlets.spawn(observer, irc_c, copy(data)) + else: + raise TypeError("%s not callable" % repr(observer)) + + @staticmethod + def _notify(waiters, data): + for queue in waiters: + queue.put_nowait(data) + + def wait(self, timeout): + queue = gevent.queue.Channel() + self.__waiters.append(queue) + data = queue.get(timeout) + if data is False: + raise TimeoutError("The request timed out.") + return data + +class Signals: + # Stores all the different signals. + # There are no pre-defined signals - they will be created by the end user. + def __init__(self, irc_c): + self.__signals = {} + self.__nullSignal = NullSignal() # is this necessary? + + def list(self): + return self.__signals.keys() + + def isSignal(self, name): + return name.lower() in self.__signals + + def getOrMake(self, name): + if not self.isSignal(name): + #Make Event if it does not exist + self.__signals[name.lower()] = Signal(name) + return self.get(name) + + #Return the null signal on non existent signal + def get(self, name): + signal = self.__signals.get(name.lower()) + if signal is None: # Only on undefined events + return self.__nullSignal + return signal + + __contains__ = isSignal + __call__ = getOrMake + __getitem__ = get + +class NullSignal: + # not sure this is even needed + pass