Example #1
0
    def __init__(self, config_file="config.yml"):
        self.services = {}
        self.clients = {}
        self.event_loop = EventLoop()

        self.config_class = _config_class_factory(self)
        self.config_file = config_file

        self.stopping = False

        self.rehash()
        self._connect_to_db()
Example #2
0
    def __init__(self,
                 fallback_nicknames=[],
                 username=None,
                 realname=None,
                 **kwargs):
        super(BotBot, self).__init__("botbot-defaultnickname",
                                     fallback_nicknames=fallback_nicknames,
                                     username=username,
                                     realname=realname,
                                     **kwargs)
        self.config = None
        self.join_channels = None
        self.trigger = None
        self.config_location = None
        self.plugin_mgr = None
        self.commands = {}
        self.channel_hooks = []
        self.periodic_tasks = []
        self.ignored_users = set()

        self.event_loop = EventLoop()
        self.webapp, self.webserver = setup_webserver(self)
        self.webapp._ctx = self
        self.webserver_listening = False
        self.sentry = None
Example #3
0
    def __init__(self, config_file="config.yml"):
        self.services = {}
        self.clients = {}
        self.event_loop = EventLoop()

        self.config_class = _config_class_factory(self)
        self.config_file = config_file

        self.stopping = False

        self.rehash()
        self._connect_to_db()
Example #4
0
    def __init__(self, config, fallback_nicknames=[], username=None, realname=None, **kwargs):
        super(BotBot, self).__init__(config['IRC']['nick'],
                                     fallback_nicknames=fallback_nicknames,
                                     username=username,
                                     realname=realname,
                                     **kwargs)
        self.join_channels = config['IRC']['channel'].split()
        self.trigger = config['IRC']['trigger']
        self.config = config
        self.commands = {}
        self.pm_commands = {}
        self.channel_hooks = []
        self.ignored_users = set()

        self.event_loop = EventLoop()
Example #5
0
    def __init__(self, config=None, filename=None, data=None, **kwargs):
        """
        Creates a new Bot.

        :param config: Configuration object.
        :param filename: Filename to load config from.  Ignored if `config` is not None.
        :param data: Data to load config from.  Ignored if `config` is not None.
        :param kwargs: Keyword arguments passed to superclass.  Overrides config if there is a conflict.
        """
        self.server_index = -1

        if config is None:
            config = Config(filename=filename, data=data)
        self.config = config
        main = self.config.main

        self.event_factory = kwargs.pop('event_factory', Event)
        kwargs.setdefault('nickname', main.nicknames[0])
        kwargs.setdefault('fallback_nicknames', main.nicknames[1:])
        for attr in (
            'tls_client_cert', 'tls_client_cert_key', 'tls_client_cert_password',
            'username', 'realname'
        ):
            kwargs.setdefault(attr, getattr(main, attr))

        super().__init__(**kwargs)

        self.global_throttle = Throttle(self.config.throttle.burst, self.config.throttle.rate)
        self.target_throttles = {}
        self.throttle_lock = tornado.locks.Lock()
        self.rules = DependencyDict()

        self.command_registry = Registry(prefix=main.prefix)
        self.rule(self.command_registry.match, key='commands', fn=self.command_registry.dispatch)

        self.textwrapper = textwrap.TextWrapper(
            width=main.wrap_length, subsequent_indent=main.wrap_indent,
            replace_whitespace=False, tabsize=4, drop_whitespace=True
        )
        if not self.eventloop:
            self.eventloop = EventLoop()  # Don't wait until we're connected to establish this.

        self.data = {}
Example #6
0
class Bot:
    """
    The core bot.
    """

    def __init__(self, config_file="config.yml"):
        self.services = {}
        self.clients = {}
        self.event_loop = EventLoop()

        self.config_class = _config_class_factory(self)
        self.config_file = config_file

        self.stopping = False

        self.rehash()
        self._connect_to_db()

    def run(self):
        self.executor = ThreadPoolExecutor(self.config.core.max_workers or multiprocessing.cpu_count())
        self.scheduler = Scheduler(self)

        signal.signal(signal.SIGHUP, self._handle_sighup)

        self._load_services()
        self._connect_to_irc()

        signal.signal(signal.SIGTERM, self._handle_sigterm)
        signal.signal(signal.SIGINT, self._handle_sigterm)

        self.event_loop.run()

    def stop(self):
        self.stopping = True
        self.event_loop.stop()
        for service in list(self.services.keys()):
            self.unload_service(service)

    def connect(self, name):
        client = Client.from_config(self, name,
                                    self.config.clients[name])
        self.clients[name] = client
        return client

    def disconnect(self, name):
        client = self.clients[name]

        # schedule this for the next iteration of the ioloop so we can handle
        # pending messages
        self.event_loop.schedule(client.quit)

        del self.clients[name]

    def _connect_to_db(self):
        db_name = self.config.core.database
        database.initialize(SqliteDatabase(db_name, check_same_thread=True))
        logger.info("Opened database connection: %s", db_name)
        UserDataKVPair.create_table(True)

    def _connect_to_irc(self):
        for name, config in self.config.clients.items():
            if config.autoconnect:
                self.connect(name)

    def _load_services(self):
        for service, config in self.config.services.items():
            if config.autoload:
                try:
                    self.load_service(service)
                except:
                    pass # it gets logged

    def defer_from_thread(self, fn, *args, **kwargs):
        fut = Future()

        @coroutine
        def _callback():
            try:
                r = fn(*args, **kwargs)
            except Exception as e:
                fut.set_exception(e)
            else:
                if isinstance(r, Future):
                    try:
                        r = yield r
                    except Exception as e:
                        fut.set_exception(e)
                    else:
                        fut.set_result(r)
                else:
                    fut.set_result(r)

        self.event_loop.schedule(_callback)
        return fut

    def load_service(self, name, reload=False):
        """
        Load a service into the bot.

        The service should expose a variable named ``service`` which is an
        instance of ``kochira.service.Service`` and configured appropriately.
        """

        if name[0] == ".":
            name = services.__name__ + name

        # ensure that the service's shutdown routine is run
        if name in self.services:
            service = self.services[name].service
            service.run_shutdown(self)

        # we create an expando storage first for bots to load any locals they
        # need
        service = None

        try:
            module = importlib.import_module(name)

            if reload:
                module = imp.reload(module)

            if not hasattr(module, "service"):
                raise RuntimeError("{} is not a valid service".format(name))

            service = module.service
            self.services[service.name] = BoundService(service)

            service.run_setup(self)
        except:
            logger.exception("Couldn't load service %s", name)
            if service is not None:
                del self.services[service.name]
            raise

        logger.info("Loaded service %s", name)

    def unload_service(self, name):
        """
        Unload a service from the bot.
        """

        # if we can't find the service name immediately, try removing
        # services.__name__ from the start
        if name[0] == "." and name not in self.services:
            name = services.__name__ + name

        try:
            service = self.services[name].service
            service.run_shutdown(self)
            del self.services[name]
        except:
            logger.exception("Couldn't unload service %s", name)
            raise

    def get_hooks(self, hook):
        """
        Create an ordering of hooks to run.
        """

        return (hook for _, _, hook in heapq.merge(*[
            bound.service.hooks.get(hook, [])
            for bound in list(self.services.values())
        ]))

    def run_hooks(self, hook, *args, **kwargs):
        """
        Attempt to dispatch a command to all command handlers.
        """

        for hook in self.get_hooks(hook):
            ctx = HookContext(hook.service, self)

            try:
                r = hook(ctx, *args, **kwargs)

                if r is Service.EAT:
                    return Service.EAT
            except BaseException:
                logger.exception("Hook processing failed")

    def rehash(self):
        """
        Reload configuration information.
        """

        with open(self.config_file, "r") as f:
            self.config = self.config_class(yaml.load(f))

    def _handle_sighup(self, signum, frame):
        logger.info("Received SIGHUP; running SIGHUP hooks and rehashing")

        try:
            self.rehash()
        except Exception as e:
            logger.exception("Could not rehash configuration")

        self.run_hooks("sighup")

    def _handle_sigterm(self, signum, frame):
        if self.stopping:
            raise KeyboardInterrupt

        logger.info("Received termination signal; unloading all services")
        self.stop()
Example #7
0
class Bot:
    """
    The core bot.
    """
    def __init__(self, config_file="config.yml"):
        self.services = {}
        self.clients = {}
        self.event_loop = EventLoop()

        self.config_class = _config_class_factory(self)
        self.config_file = config_file

        self.stopping = False

        self.rehash()
        self._connect_to_db()

    def run(self):
        self.executor = ThreadPoolExecutor(self.config.core.max_workers
                                           or multiprocessing.cpu_count())
        self.scheduler = Scheduler(self)

        signal.signal(signal.SIGHUP, self._handle_sighup)

        self._load_services()
        self._connect_to_irc()

        signal.signal(signal.SIGTERM, self._handle_sigterm)
        signal.signal(signal.SIGINT, self._handle_sigterm)

        self.event_loop.run()

    def stop(self):
        self.stopping = True
        self.event_loop.stop()
        for service in list(self.services.keys()):
            self.unload_service(service)

    def connect(self, name):
        client = Client.from_config(self, name, self.config.clients[name])
        self.clients[name] = client
        return client

    def disconnect(self, name):
        client = self.clients[name]

        # schedule this for the next iteration of the ioloop so we can handle
        # pending messages
        self.event_loop.schedule(client.quit)

        del self.clients[name]

    def _connect_to_db(self):
        db_name = self.config.core.database
        database.initialize(SqliteDatabase(db_name, check_same_thread=True))
        logger.info("Opened database connection: %s", db_name)
        UserDataKVPair.create_table(True)

    def _connect_to_irc(self):
        for name, config in self.config.clients.items():
            if config.autoconnect:
                self.connect(name)

    def _load_services(self):
        for service, config in self.config.services.items():
            if config.autoload:
                try:
                    self.load_service(service)
                except:
                    pass  # it gets logged

    def defer_from_thread(self, fn, *args, **kwargs):
        fut = Future()

        @coroutine
        def _callback():
            try:
                r = fn(*args, **kwargs)
            except Exception as e:
                fut.set_exception(e)
            else:
                if isinstance(r, Future):
                    try:
                        r = yield r
                    except Exception as e:
                        fut.set_exception(e)
                    else:
                        fut.set_result(r)
                else:
                    fut.set_result(r)

        self.event_loop.schedule(_callback)
        return fut

    def load_service(self, name, reload=False):
        """
        Load a service into the bot.

        The service should expose a variable named ``service`` which is an
        instance of ``kochira.service.Service`` and configured appropriately.
        """

        if name[0] == ".":
            name = services.__name__ + name

        # ensure that the service's shutdown routine is run
        if name in self.services:
            service = self.services[name].service
            service.run_shutdown(self)

        # we create an expando storage first for bots to load any locals they
        # need
        service = None

        try:
            module = importlib.import_module(name)

            if reload:
                module = imp.reload(module)

            if not hasattr(module, "service"):
                raise RuntimeError("{} is not a valid service".format(name))

            service = module.service
            self.services[service.name] = BoundService(service)

            service.run_setup(self)
        except:
            logger.exception("Couldn't load service %s", name)
            if service is not None:
                del self.services[service.name]
            raise

        logger.info("Loaded service %s", name)

    def unload_service(self, name):
        """
        Unload a service from the bot.
        """

        # if we can't find the service name immediately, try removing
        # services.__name__ from the start
        if name[0] == "." and name not in self.services:
            name = services.__name__ + name

        try:
            service = self.services[name].service
            service.run_shutdown(self)
            del self.services[name]
        except:
            logger.exception("Couldn't unload service %s", name)
            raise

    def get_hooks(self, hook):
        """
        Create an ordering of hooks to run.
        """

        return (hook for _, _, hook in heapq.merge(*[
            bound.service.hooks.get(hook, [])
            for bound in list(self.services.values())
        ]))

    def run_hooks(self, hook, *args, **kwargs):
        """
        Attempt to dispatch a command to all command handlers.
        """

        for hook in self.get_hooks(hook):
            ctx = HookContext(hook.service, self)

            try:
                r = hook(ctx, *args, **kwargs)

                if r is Service.EAT:
                    return Service.EAT
            except BaseException:
                logger.exception("Hook processing failed")

    def rehash(self):
        """
        Reload configuration information.
        """

        with open(self.config_file, "r") as f:
            self.config = self.config_class(yaml.load(f))

    def _handle_sighup(self, signum, frame):
        logger.info("Received SIGHUP; running SIGHUP hooks and rehashing")

        try:
            self.rehash()
        except Exception as e:
            logger.exception("Could not rehash configuration")

        self.run_hooks("sighup")

    def _handle_sigterm(self, signum, frame):
        if self.stopping:
            raise KeyboardInterrupt

        logger.info("Received termination signal; unloading all services")
        self.stop()
Example #8
0
class Bot(EventEmitter):
    def __init__(self, config=None, filename=None, data=None, **kwargs):
        """
        Creates a new Bot.

        :param config: Configuration object.
        :param filename: Filename to load config from.  Ignored if `config` is not None.
        :param data: Data to load config from.  Ignored if `config` is not None.
        :param kwargs: Keyword arguments passed to superclass.  Overrides config if there is a conflict.
        """
        self.server_index = -1

        if config is None:
            config = Config(filename=filename, data=data)
        self.config = config
        main = self.config.main

        self.event_factory = kwargs.pop('event_factory', Event)
        kwargs.setdefault('nickname', main.nicknames[0])
        kwargs.setdefault('fallback_nicknames', main.nicknames[1:])
        for attr in (
            'tls_client_cert', 'tls_client_cert_key', 'tls_client_cert_password',
            'username', 'realname'
        ):
            kwargs.setdefault(attr, getattr(main, attr))

        super().__init__(**kwargs)

        self.global_throttle = Throttle(self.config.throttle.burst, self.config.throttle.rate)
        self.target_throttles = {}
        self.throttle_lock = tornado.locks.Lock()
        self.rules = DependencyDict()

        self.command_registry = Registry(prefix=main.prefix)
        self.rule(self.command_registry.match, key='commands', fn=self.command_registry.dispatch)

        self.textwrapper = textwrap.TextWrapper(
            width=main.wrap_length, subsequent_indent=main.wrap_indent,
            replace_whitespace=False, tabsize=4, drop_whitespace=True
        )
        if not self.eventloop:
            self.eventloop = EventLoop()  # Don't wait until we're connected to establish this.

        self.data = {}

    @contextlib.contextmanager
    def log_exceptions(self, target=None):
        """
        Log exceptions rather than allowing them to raise.  Contextmanager.

        :param target: If specified, the bot will also notice(target, str(exception)) if an exception is raised.

        Usage::

            with bot.log_exceptions("Adminuser"):
                raise ValueError("oh no!")
        """
        # TODO: Log to a configurable file rather than just stderr.
        try:
            yield None
        except Exception as ex:
            self.logger.error(traceback.format_exc())
            traceback.print_exc(file=sys.stderr)
            if target:
                self.notice(target, str(ex))

    def wraptext(self, text):
        return self.textwrapper.wrap(text)

    def connect(self, hostname=None, **kwargs):
        """
        Overrides the superclass's connect() to allow rotating between multiple servers if hostname is None.

        :param hostname: Passed to superclass.
        :param kwargs: Passed to superclass
        """
        kwargs['hostname'] = hostname
        if hostname is None and self.config.main.servers:
            self.server_index += 1
            if self.server_index >= len(self.config.main.servers):
                self.server_index = 0
            kwargs.update(self.config.main.servers[self.server_index])
        self.logger.info(
            "Connecting to {host}:{port}...".format(host=kwargs['hostname'], port=kwargs.get('port', 6667))
        )
        return super().connect(**kwargs)

    def on_connect(self):
        """
        Attempt to join channels on connect.
        """
        super().on_connect()
        self.logger.info("Connected.")
        if self.config.main.usermode:
            self.set_mode(self.nickname, self.config.main.usermode)

        for channel in self.config.main.channels:
            try:
                self.join(**channel)
            except pydle.AlreadyInChannel:
                pass
        self.eventloop.schedule(self.global_throttle.run)

    def on_disconnect(self, expected):
        # Clean up pending triggers
        if expected:
            self.logger.info("Disconnected from server (expected).")
        else:
            self.logger.error("Disconnected from server unexpectedly.")

        while self.target_throttles:
            target, throttle = self.target_throttles.popitem()
            throttle.on_clear = None
            self.logger.debug("Cleaning up event queue for {!r} ({} pending items)".format(target, len(throttle.queue)))
            throttle.reset()
        self.global_throttle.reset()
        super().on_disconnect(expected)

    def rule(self, pattern, key=None, flags=re.IGNORECASE, attr='fullmatch', fn=None, **kwargs):
        """
        Calls fn when text matches the specified pattern.  If fn is None, returns a decorator

        :param pattern: Pattern to match.  Can be anything accepted by :meth:`ircbot.util.patternize`
        :param key: Key to identify this rule.  Defaults to `fn` if omitted.
        :param flags: Flags used when compiling regular expressions.  Only used if `pattern` is a `str`
        :param attr: Name of the method on a compiled regex that actually does matching.  'match', 'fullmatch',
            'search', 'findall` and `finditer` might be good choices.
        :param fn: Function to call.  If None, returns a decorator
        :param kwargs: Passed to the DependencyItem's constructor to force rules to run in a specific order.
        :returns: Decorator or `fn`

        The result of whatever the pattern returns is stored in event.result, provided it is Truthy.
        """
        if fn is None:
            return functools.partial(self.rule, pattern, key, flags, attr, **kwargs)
        pattern = ircbot.util.patternize(pattern)
        if key is None:
            key = fn

        self.logger.debug("Rule {!r}: Match={!r}, Call={!r}".format(key, pattern, fn))
        kwargs['data'] = (pattern, fn)
        self.rules.add(key, **kwargs)

    def command(self, *args, **kwargs):
        """
        Same as :meth:`ircbot.commands.command`, but using our command registry by default.

        :param args: Passed to decorator
        :param kwargs: Passed to decorator
        :return: fn
        """
        kwargs.setdefault('registry', self.command_registry)
        result = ircbot.commands.command(*args, **kwargs)
        return result

    @pydle.coroutine
    def throttled(self, target, fn, cost=1):
        """
        Adds a throttled event.  Or calls it now if it makes sense to.

        :param target: Event target nickname or channel.  May be None for a global event
        :param fn: Function to queue or call
        :param cost: Event cost.
        """
        def _on_clear(k, t):
            t.reset()
            if self.target_throttles.get(k) is t:
                del self.target_throttles[k]

        def _relay(*args, **kwargs):
            self.global_throttle.add(*args, **kwargs)
            return self.eventloop.schedule(self.global_throttle.run)

        if not target:
            self.global_throttle.add(cost, target)
            return self.eventloop.schedule(self.global_throttle.run)

        throttle = self.target_throttles.get(target)
        if not throttle:
            is_channel = self.is_channel(target)
            if is_channel:
                burst, rate = self.config.throttle.channel_burst, self.config.throttle.channel_rate
            else:
                burst, rate = self.config.throttle.user_burst, self.config.throttle.user_rate
            if not rate:
                self.eventloop.schedule(fn)
                return
            throttle = Throttle(burst, rate, on_clear=functools.partial(_on_clear, target))
            self.target_throttles[target] = throttle
            self.eventloop.schedule(throttle.run)
        throttle.add(cost, _relay, cost, fn)

    def _unthrottled(self, fn):
        @functools.wraps(fn)
        def wrapper(*a, **kw):
            throttled = self.connection.throttle
            self.connection.throttle = False
            fn(*a, **kw)
            self.connection.throttle = throttled
        return wrapper

    def _msgwrapper(self, parent, target, message, wrap=True, throttle=True, cost=1):
        if wrap:
            message = "\n".join(self.wraptext(message))

        for line in message.replace('\r', '').split('\n'):
            if throttle:
                return self.throttled(target, functools.partial(self._unthrottled(parent), target, line), cost)
            else:
                return target(parent, target, line)

    def message_cost(self, length):
        """
        Returns the cost of a message of size length.
        :param length: Length of message
        :return: Message cost
        """
        return self.config.throttle.cost_base + (
            float(length) * self.config.throttle.cost_multiplier *
            (float(length) ** self.config.throttle.cost_exponent)
        )

    # Override the builtin message() and notice() methods to allow for throttling and our own wordwrap methods.
    def message(self, target, message, wrap=True, throttle=True, cost=None):
        """
        Sends a PRIVMSG

        :param target: Recipient
        :param message: Message text.  May contain newlines, which will be split into multiple messages.
        :param wrap: If True, text will be wordwrapped.
        :param throttle: If True, messaging will be throttled.
        :param cost: If throttled, the cost per message.
        """
        if cost is None:
            cost = self.message_cost(len(target) + len(message) + 10)
        self._msgwrapper(super().message, target, message, wrap, throttle, cost)

    def notice(self, target, message, wrap=True, throttle=True, cost=None):
        """
        Sends a NOTICE

        :param target: Recipient
        :param message: Message text.  May contain newlines, which will be split into multiple messages.
        :param wrap: If True, text will be wordwrapped.
        :param throttle: If True, messaging will be throttled.
        :param cost: If throttled, the cost per message.
        """
        if cost is None:
            cost = self.message_cost(len(target) + len(message) + 10)
        self._msgwrapper(super().notice, target, message, wrap, throttle, cost)

    # Convenience functions for bot events
    say = message

    def reply(self, target, message, reply_to=None, *args, **kwargs):
        """
        Same as :meth:`say`, but potentially prepending user name(s).

        :param target: Recipient
        :param message: Message text.  May contain newlines, which will be split into multiple messages.
        :param reply_to: A nickname (or sequence of nicknames) to address when sending the message.  Leaving this at
            `None` makes this identical to say().  Names are not prepended in private messages.
        :param args: Passed to :meth:`say`
        :param kwargs: Passed to :meth:`say`
        """
        if reply_to is not None and self.is_channel(target):
            if not isinstance(reply_to, str):
                reply_to = ", ".join(reply_to)
            message = reply_to + ": " + message
        return self.message(target, message, *args, **kwargs)

    @pydle.coroutine
    def handle_message(self, irc_command, target, nick, message):
        """
        Handles incoming messages
        :param irc_command: PRIVMSG or NOTICE
        :param target: Message target
        :param nick: Nickname of sender
        :param message: Message
        :return:
        """
        channel = target if self.is_channel(target) else None
        factory = functools.partial(
            self.event_factory, bot=self, irc_command=irc_command, nick=nick, channel=channel, message=message
        )

        with self.log_exceptions():
            for rule, item in self.rules.items():
                pattern, fn = item.data
                result = pattern(message)
                if result:
                    event = factory(rule=rule, result=result)
                    with self.log_exceptions(target):
                        try:
                            yield from self.adapt_result(fn(event))
                            # yield self.yield_if_needed(fn(event))
                        except StopHandling:
                            break
                        except ircbot.commands.UsageError as ex:
                            self.notice(nick, str(ex))