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 __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
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()
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 = {}
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()
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))