Esempio n. 1
0
class Bot(SpecialPlugin, IRCClient):
    # TODO: use IRCUser instances instead of raw user string

    #: Default configuration values
    CONFIG_DEFAULTS = {
        'nickname': 'csyorkbot',
        'password': None,
        'username': '******',
        'realname': 'cs-york bot',
        'sourceURL': 'http://github.com/csyork/csbot/',
        'lineRate': '1',
        'irc_host': 'irc.freenode.net',
        'irc_port': '6667',
        'command_prefix': '!',
        'use_notice': True,
        'channels': ' '.join([
            '#cs-york-dev',
        ]),
        'plugins': ' '.join([
            'example',
        ]),
    }

    #: Environment variable fallbacks
    CONFIG_ENVVARS = {
        'password': ['IRC_PASS'],
    }

    #: Dictionary containing available plugins for loading, using
    #: straight.plugin to discover plugin classes under a namespace.
    available_plugins = build_plugin_dict(straight.plugin.load(
        'csbot.plugins', subclasses=Plugin))

    _WHO_IDENTIFY = ('1', '%na')

    def __init__(self, config=None, loop=None):
        # Initialise plugin
        SpecialPlugin.__init__(self, self)

        # Load configuration
        self.config_root = configparser.ConfigParser(interpolation=None,
                                                     allow_no_value=True)
        self.config_root.optionxform = str  # No lowercase option names
        if config is not None:
            self.config_root.read_file(config)

        # Initialise IRCClient from Bot configuration
        IRCClient.__init__(
            self,
            loop=loop,
            nick=self.config_get('nickname'),
            username=self.config_get('username'),
            host=self.config_get('irc_host'),
            port=self.config_get('irc_port'),
            password=self.config_get('password'),
        )

        # Plumb in reply(...) method
        if self.config_getboolean('use_notice'):
            self.reply = self.notice
        else:
            self.reply = self.msg

        # Plugin management
        self.plugins = PluginManager([self], self.available_plugins,
                                     self.config_get('plugins').split(),
                                     [self])
        self.commands = {}

        # Event runner
        self.events = events.AsyncEventRunner(self._fire_hooks, self.loop)

        # Keeps partial name lists between RPL_NAMREPLY and
        # RPL_ENDOFNAMES events
        self.names_accumulator = collections.defaultdict(list)

        # Acknowledged capabilities
        self.capabilities = set()

    def bot_setup(self):
        """Load plugins defined in configuration and run setup methods.
        """
        self.plugins.setup()

    def bot_teardown(self):
        """Run plugin teardown methods.
        """
        self.plugins.teardown()

    def _fire_hooks(self, event):
        results = self.plugins.fire_hooks(event)
        return list(itertools.chain(*results))

    def post_event(self, event):
        return self.events.post_event(event)

    def register_command(self, cmd, metadata, f, tag=None):
        # Bail out if the command already exists
        if cmd in self.commands:
            self.log.warn('tried to overwrite command: {}'.format(cmd))
            return False

        self.commands[cmd] = (f, metadata, tag)
        self.log.info('registered command: ({}, {})'.format(cmd, tag))
        return True

    def unregister_command(self, cmd, tag=None):
        if cmd in self.commands:
            f, m, t = self.commands[cmd]
            if t == tag:
                del self.commands[cmd]
                self.log.info('unregistered command: ({}, {})'
                              .format(cmd, tag))
            else:
                self.log.error(('tried to remove command {} ' +
                                'with wrong tag {}').format(cmd, tag))

    def unregister_commands(self, tag):
        delcmds = [c for c, (_, _, t) in self.commands.items() if t == tag]
        for cmd in delcmds:
            f, _, tag = self.commands[cmd]
            del self.commands[cmd]
            self.log.info('unregistered command: ({}, {})'.format(cmd, tag))

    @Plugin.hook('core.self.connected')
    def signedOn(self, event):
        for c in self.config_get('channels').split():
            event.bot.join(c)

    @Plugin.hook('core.message.privmsg')
    def privmsg(self, event):
        """Handle commands inside PRIVMSGs."""
        # See if this is a command
        command = CommandEvent.parse_command(
            event, self.config_get('command_prefix'), event.bot.nick)
        if command is not None:
            self.post_event(command)

    @Plugin.hook('core.command')
    @asyncio.coroutine
    def fire_command(self, event):
        """Dispatch a command event to its callback.
        """
        # Ignore unknown commands
        if event['command'] not in self.commands:
            return

        f, _, _ = self.commands[event['command']]
        if not asyncio.iscoroutinefunction(f):
            f = asyncio.coroutine(f)
        yield from f(event)

    @Plugin.command('help', help=('help [command]: show help for command, or '
                                  'show available commands'))
    def show_commands(self, e):
        args = e.arguments()
        if len(args) > 0:
            cmd = args[0]
            if cmd in self.commands:
                f, meta, tag = self.commands[cmd]
                e.reply(meta.get('help', cmd + ': no help string'))
            else:
                e.reply(cmd + ': no such command')
        else:
            e.reply(', '.join(sorted(self.commands)))

    @Plugin.command('plugins')
    def show_plugins(self, e):
        e.reply('loaded plugins: ' + ', '.join(self.plugins))

    # Implement IRCClient events

    def emit_new(self, event_type, data=None):
        """Shorthand for firing a new event.
        """
        event = Event(self, event_type, data)
        return self.bot.post_event(event)

    def emit(self, event):
        """Shorthand for firing an existing event.
        """
        self.bot.post_event(event)

    def connection_made(self):
        super().connection_made()
        # TODO: do this in on_welcome() instead?
        self.request_capabilities(['account-notify', 'extended-join'])
        self.emit_new('core.raw.connected')

    def connection_lost(self, exc):
        super().connection_lost(exc)
        self.emit_new('core.raw.disconnected', {'reason': repr(exc)})

    def send_line(self, line):
        super().send_line(line)
        self.emit_new('core.raw.sent', {'message': line})

    def line_received(self, line):
        fut = self.emit_new('core.raw.received', {'message': line})
        super().line_received(line)
        return fut

    def on_welcome(self):
        self.emit_new('core.self.connected')

    def on_joined(self, channel):
        self.identify(channel)
        self.emit_new('core.self.joined', {'channel': channel})

    def on_left(self, channel):
        self.emit_new('core.self.left', {'channel': channel})

    def on_privmsg(self, user, channel, message):
        self.emit_new('core.message.privmsg', {
            'channel': channel,
            'user': user.raw,
            'message': message,
            'is_private': channel == self.nick,
            'reply_to': user.nick if channel == self.nick else channel,
        })

    def on_notice(self, user, channel, message):
        self.emit_new('core.message.notice', {
            'channel': channel,
            'user': user.raw,
            'message': message,
            'is_private': channel == self.nick,
            'reply_to': user.nick if channel == self.nick else channel,
        })

    def on_action(self, user, channel, message):
        self.emit_new('core.message.action', {
            'channel': channel,
            'user': user.raw,
            'message': message,
            'is_private': channel == self.nick,
            'reply_to': user.nick if channel == self.nick else channel,
        })

    def on_user_joined(self, user, channel):
        self.emit_new('core.channel.joined', {
            'channel': channel,
            'user': user.raw,
        })

    def on_user_left(self, user, channel, message):
        self.emit_new('core.channel.left', {
            'channel': channel,
            'user': user.raw,
        })

    def on_user_quit(self, user, message):
        self.emit_new('core.user.quit', {
            'user': user.raw,
            'message': message,
        })

    def on_user_renamed(self, oldnick, newnick):
        self.emit_new('core.user.renamed', {
            'oldnick': oldnick,
            'newnick': newnick,
        })

    def on_topic_changed(self, user, channel, topic):
        self.emit_new('core.channel.topic', {
            'channel': channel,
            'author': user.raw,     # might be server name or nick
            'topic': topic,
        })

    # Implement NAMES handling

    def irc_RPL_NAMREPLY(self, msg):
        channel = msg.params[2]
        self.names_accumulator[channel].extend(msg.params[3].split())

    def irc_RPL_ENDOFNAMES(self, msg):
        # Get channel and raw names list
        channel = msg.params[1]
        raw_names = self.names_accumulator.pop(channel, [])

        # TODO: restore this functionality
        # Get a mapping from status characters to mode flags
        #prefixes = self.supported.getFeature('PREFIX')
        #inverse_prefixes = dict((v[0], k) for k, v in prefixes.items())

        # Get mode characters from name prefix
        #def f(name):
        #    if name[0] in inverse_prefixes:
        #        return (name[1:], set(inverse_prefixes[name[0]]))
        #    else:
        #        return (name, set())
        def f(name):
            return name.lstrip('@+'), set()
        names = list(map(f, raw_names))

        # Fire the event
        self.on_names(channel, names, raw_names)

    def on_names(self, channel, names, raw_names):
        """Called when the NAMES list for a channel has been received.
        """
        self.emit_new('core.channel.names', {
            'channel': channel,
            'names': names,
            'raw_names': raw_names,
        })

    # Implement "IRC Client Capabilities Extension"

    def request_capabilities(self, capabilities):
        """Request "IRC Client Capabilities".

        Wait for an appropriate response (e.g. :meth:`on_capabilities_changed`)
        before assuming the request was successful.
        """
        self.send_line('CAP REQ :' + ' '.join(capabilities))

    def irc_CAP(self, msg):
        """Handle "IRC Client Capabilities Extension" messages."""
        _, subcmd, data = msg.params
        data = data.split()
        if subcmd == 'ACK':
            self.capabilities |= set(data)
            self.on_capabilities_changed(self.capabilities)
        elif subcmd == 'NAK':
            self.capabilities -= set(data)
            self.on_capabilities_changed(self.capabilities)

    def on_capabilities_changed(self, capabilities):
        self.emit_new('core.self.capabilities', {
            'capabilities': capabilities,
        })

    # Implement active account discovery via "formatted WHO"

    def identify(self, target):
        """Find the account for a user or all users in a channel."""
        tag, query = self._WHO_IDENTIFY
        self.send_line('WHO {} {}t,{}'.format(target, query, tag))

    def irc_354(self, msg):
        """Handle "formatted WHO" responses."""
        tag = msg.params[1]
        if tag == self._WHO_IDENTIFY[0]:
            user, account = msg.params[2:]
            self.on_user_identified(user, None if account == '0' else account)

    def on_user_identified(self, user, account):
        self.emit_new('core.user.identified', {
            'user': user,
            'account': account,
        })

    # Implement passive account discovery via "Client Capabilities"

    def irc_ACCOUNT(self, msg):
        """Account change notification from ``account-notify`` capability."""
        account = msg.params[0]
        self.on_user_identified(msg.prefix, None if account == '*' else account)

    def irc_JOIN(self, msg):
        """Re-implement ``JOIN`` handler to account for ``extended-join`` info.
        """
        # Only do special handling if extended-join was enabled
        if 'extended-join' not in self.capabilities:
            return super().irc_JOIN(msg)

        user = IRCUser.parse(msg.prefix)
        nick = user.nick
        channel, account, _ = msg.params

        if nick == self.nick:
            self.on_joined(channel)
        else:
            self.on_user_identified(user.raw, None if account == '*' else account)
            self.on_user_joined(user, channel)

    def reply(self, to, message):
        """Reply to a nick/channel.

        This is not implemented because it should be replaced in the constructor
        with a reference to a real method, e.g. ``self.reply = self.msg``.
        """
        raise NotImplementedError