Example #1
0
class UserTrack(BaseExtension):

    """Track various user metrics, such as account info, and some channel
    tracking.

    This extension adds ``base.user_track`` as itself as an alias for
    ``get_extension("UserTrack").``.

    :ivar users:
        Mapping of users, where the keys are casemapped nicks, and values are
        User instances. You should probably prefer
        :py:class:`~PyIRC.extensions.usertrack.Usertrack.get_user` to direct
        lookups on this dictionary.
    """

    caps = {
        "account-notify": [],
        "away-notify": [],
        "chghost": [],
    }

    requires = ["BasicRFC", "ISupport", "BaseTrack"]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.base.user_track = self

        self.u_expire_timers = IRCDict(self.case)
        self.who_timers = IRCDict(self.case)

        self.users = IRCDict(self.case)
        self.whois_send = IRCSet(self.case)

        # Authentication callbacks
        self.auth_cb = IRCDefaultDict(self.case, list)

        # WHOX sent list
        self.whox_send = list()

        # Whether or not to time users out
        self.do_timeout = kwargs.get("do_timeout", True)
        self.timeout = kwargs.get("timeout", 30)

        # Remove a user when they are out of all channels
        self.remove_no_channels = True

        # Create ourselves
        basicrfc = self.base.basic_rfc
        self.add_user(basicrfc.nick, user=self.username,
                      gecos=self.gecos)

    def authenticate(self, nick, callback):
        """Get authentication for a user and return result in a callback.

        :param nick:
            Nickname of user to check authentication for

        :param callback:
            Callback to call for user when authentication is discovered. The
            User instance is passed in as the first parameter, or None if the
            user is not found. Use functools.partial to pass other arguments.
        """
        user = self.get_user(nick)
        if not user:
            # Add a user for now, get details later.
            self.users[nick] = User(self.case, nick)

        if user.account is not None:
            # User account is known
            callback(user)
        elif nick not in self.whois_send:
            # Defer for a whois
            self.auth_cb[nick].append(callback)
            self.send("WHOIS", ["*", user.nick])
            self.whois_send.add(nick)

    def get_user(self, nick):
        """Retrieve a user from the tracking dictionary based on nick.

        Use of this method is preferred to directly accessing the user
        dictionary.

        :param nick:
            Nickname of the user to retrieve.

        :returns: A :class:`User` instance, or None if user not found.
        """
        return self.users.get(nick)

    def add_user(self, nick, **kwargs):
        """Add a user to the tracking dictionary.

        Avoid using this method directly unless you know what you are
        doing.
        """
        user = self.get_user(nick)
        if not user:
            user = User(self.case, nick, **kwargs)
            self.users[nick] = user

        self.call_event("user", "user_create", user)

        return user

    def remove_user(self, nick):
        """Remove a user from the tracking dictionary.

        Avoid using this method directly unless you know what you are
        doing.
        """
        if nick not in self.users:
            _logger.warning("Deleting nonexistent user: %s", nick)
            return

        self.call_event("user", "user_delete", self.users[nick])

        _logger.debug("Deleted user: %s", nick)

        del self.users[nick]

    def timeout_user(self, nick):
        """Time a user out, cancelling existing timeouts.

        Avoid using this method directly unless you know what you are
        doing.
        """
        if not self.do_timeout:
            return

        if nick in self.u_expire_timers:
            self.unschedule(self.u_expire_timers[nick])

        callback = partial(self.remove_user, nick)
        self.u_expire_timers[nick] = self.schedule(self.timeout, callback)

    def update_username_host(self, hostmask_or_line):
        """Update a user's basic info, based on line hostmask info.

        Avoid using this method directly unless you know what you are doing.

        .. note::
            This mostly exists for brain-dead networks that don't quit users
            when they get cloaked.
        """
        if hasattr(hostmask_or_line, 'hostmask'):
            hostmask = hostmask_or_line.hostmask
        elif hasattr(hostmask_or_line, 'nick'):
            hostmask = hostmask_or_line
        else:
            raise ValueError("Expected a Hostmask or Line instance")

        if not hostmask or hostmask.nick:
            return

        user = self.get_user(hostmask.nick)
        if not user:
            return

        # Update
        user.nick = hostmask.nick

        if hostmask.username:
            user.username = hostmask.username

        if hostmask.host:
            user.host = hostmask.host

    @event("protocol", "case_change")
    def case_change(self, _):
        case = self.case

        self.u_expire_timers = self.u_expire_timers.convert(case)
        self.who_timers = self.who_timers.convert(case)

        self.users = self.users.convert(case)
        self.whois_send = self.whois_send.convert(case)

        self.auth_cb = self.auth_cb.convert(case)

    @event("link", "disconnected")
    def close(self, _):
        timers = chain(self.u_expire_timers.values(),
                       self.who_timers.values())
        for timer in timers:
            try:
                self.unschedule(timer)
            except ValueError:
                pass

        self.users.clear()
        self.whox_send.clear()

    @event("modes", "mode_prefix")
    def prefix(self, _, setter, target, mode):
        """Update the channel mode of a user."""
        # pylint: disable=unused-argument
        # Parse into hostmask in case of usernames-in-host
        hostmask = Hostmask.parse(mode.param)

        assert hostmask

        user = self.get_user(hostmask.nick)
        if user is None:
            # This can happen from override or ChanServ guard off
            user = self.add_user(hostmask.nick, username=hostmask.username,
                                 host=hostmask.host)
            self.timeout_user(hostmask.nick)

        channel = user.channels[target]
        if mode.adding:
            channel.add(mode.mode)
        else:
            channel.discard(mode.mode)

    @event("scope", "user_burst")
    def burst(self, _, scope):
        """Create or update users from a join burst."""
        target = scope.target
        channel = scope.scope
        modes = {m[0] for m in scope.modes} if scope.modes else set()

        if target.nick in self.u_expire_timers:
            # They're back.  Cancel pending expiry.
            self.unschedule(self.u_expire_timers[target.nick])
            del self.u_expire_timers[target.nick]

        user = self.get_user(target.nick)
        if not user:
            user = self.add_user(target.nick, username=target.username,
                                 host=target.host, gecos=scope.gecos,
                                 account=scope.account)
        else:
            self.update_username_host(target)

        # Add the channel
        user.channels[channel] = modes

    @event("scope", "user_join")
    def join(self, caller, scope):
        """Handle a user join.

        Schedule a WHO(X) for them.
        """
        self.burst(caller, scope)

        target = scope.target
        channel = scope.scope

        basicrfc = self.base.basic_rfc
        if self.casecmp(target.nick, basicrfc.nick):
            # It's us!
            isupport = self.base.isupport
            params = [channel]
            if isupport.get("WHOX"):
                # Use WHOX if possible
                num = ''.join(str(randint(0, 9)) for x in range(randint(1, 3)))
                params.append("%tcuihsnflar," + num)
                self.whox_send.append(num)

            sched = self.schedule(2, partial(self.send, "WHO", params))
            self.who_timers[channel] = sched

    @event("scope", "user_part")
    @event("scope", "user_kick")
    def part(self, _, scope):
        """Handle a user leaving, possibly removing them if we no longer share
        any channels with them."""
        target = scope.target
        channel = scope.scope

        user = self.get_user(target.nick)
        if not user:
            _logger.warning("Got a part/kick for a user not found: %s (in %s)",
                            target.nick, channel)
            return

        if channel not in user.channels:
            _logger.warning("Got a part/kick for a user not in a channel: %s "
                            "(in %s)", target.nick, channel)
            return

        user.channels.pop(channel)

        basicrfc = self.base.basic_rfc
        if self.casecmp(target.nick, basicrfc.nick):
            # We left the channel, scan all users to remove unneeded ones
            for u_nick, u_user in list(self.users.items()):
                if channel in u_user.channels:
                    # Purge from the cache since we don't know for certain.
                    del u_user.channels[channel]

                if self.casecmp(u_nick, basicrfc.nick):
                    # Don't delete ourselves!
                    continue

                if not u_user.channels:
                    # Delete the user outright to purge any cached data
                    # The data must be considered invalid when we leave
                    # TODO - possible WATCH support?
                    self.remove_user(u_nick)
                    continue

        elif not user.channels:
            if self.do_timeout:
                self.timeout_user(target.nick)
            elif self.remove_no_channels:
                self.remove_user(target.nick)

    @event("scope", "user_quit")
    def quit(self, _, scope):
        """Remove a user from tracking since they have quit."""
        # User's gone
        self.remove_user(scope.target.nick)

    @event("commands", Numerics.RPL_WELCOME)
    def welcome(self, _, line):
        """Retrieve our current host on-connect."""
        # Obtain our own host
        self.send("USERHOST", [line.params[0]])

    @event("commands", Numerics.RPL_USERHOST)
    def userhost(self, _, line):
        """Update a user's host - possibly our own."""
        params = line.params
        if not (len(params) > 1 and params[1]):
            return

        basicrfc = self.base.basic_rfc

        for mask in params[1].split(' '):
            if not mask:
                continue

            parse = userhost_parse(mask)
            hostmask = parse.hostmask
            user = self.get_user(hostmask.nick)
            if not user:
                continue

            if hostmask.username:
                user.username = hostmask.username

            user.operator = parse.operator
            if not parse.away:
                user.away = False

            if self.casecmp(hostmask.nick, basicrfc.nick):
                user.realhost = hostmask.host
            else:
                user.host = hostmask.host

    @event("commands", Numerics.RPL_HOSTHIDDEN)
    def host_hidden(self, _, line):
        """Update our own host."""
        params = line.params

        user = self.get_user(params[0])
        assert user  # This should NEVER fire!

        user.host = params[1]

    @event("commands", "ACCOUNT")
    def account(self, _, line):
        """Update a user's account information."""
        self.update_username_host(line)

        account = line.params[0]

        user = self.get_user(line.hostmask.nick)
        assert user

        user.account = '' if account == '*' else account

        if user.nick in self.auth_cb:
            # User is awaiting authentication
            for callback in self.auth_cb[user.nick]:
                callback(user)

            del self.auth_cb[user.nick]

    @event("commands", "AWAY")
    def away(self, _, line):
        """Update a user's away flag."""
        self.update_username_host(line)

        user = self.get_user(line.hostmask.nick)
        assert user

        user.away = bool(line.params)

    @event("commands", "CHGHOST")
    def chghost(self, _, line):
        # NB - we don't know if a user is cloaking or uncloaking, or changing
        # cloak, so do NOT update user's cloak.
        self.update_username_host(line)

        user = self.get_user(line.hostmask.nick)
        assert user

        user.username = line.params[0]
        user.host = line.params[1]

    @event("commands", "NICK")
    def nick(self, _, line):
        """Update a user's nickname."""
        self.update_username_host(line)

        oldnick = line.hostmask.nick
        newnick = line.params[0]

        assert self.get_user(oldnick)

        self.users[newnick] = self.get_user(oldnick)
        self.users[newnick].nick = newnick

        if not self.casecmp(oldnick, newnick):
            del self.users[oldnick]

    @event("commands", Numerics.ERR_NOSUCHNICK)
    def notfound(self, _, line):
        """Remove a non-existent user."""
        nick = line.params[1]
        if nick in self.auth_cb:
            # User doesn't exist, call back
            for callback in self.auth_cb[nick]:
                callback(None)

            del self.auth_cb[nick]

        self.remove_user(nick)

    @event("commands", "PRIVMSG")
    @event("commands", "NOTICE")
    def message(self, _, line):
        if line.params[0] == '*':
            # We are not registered, do nothing.
            return

        hostmask = line.hostmask
        if not hostmask.nick:
            return

        if hostmask.nick in self.u_expire_timers:
            # User is expiring
            user = self.get_user(hostmask.nick)
            if hostmask.username != user.username or hostmask.host != user.host:
                # User is suspect, delete and find out more.
                self.remove_user(hostmask.nick)
            else:
                # Rearm timeout
                self.timeout_user(hostmask.nick)

        if not self.get_user(hostmask.nick):
            if not self.do_timeout:
                return

            # Obtain more information about the user
            user = self.add_user(hostmask.nick, user=hostmask.username,
                                 host=hostmask.host)

            if hostmask.nick not in self.whois_send:
                self.send("WHOIS", ['*', hostmask.nick])
                self.whois_send.add(hostmask.nick)

            self.timeout_user(hostmask.nick)

    @event("commands", Numerics.RPL_ENDOFWHO)
    def who_end(self, _, line):
        if not self.whox_send:
            return

        channel = line.params[1]
        del self.who_timers[channel]
        del self.whox_send[0]

    @event("commands", Numerics.RPL_ENDOFWHOIS)
    def whois_end(self, _, line):
        """Finish updating a user's information."""
        nick = line.params[1]

        self.whois_send.discard(nick)

        user = self.get_user(nick)

        # If the user is awaiting auth, we aren't gonna find out their auth
        # status through whois. If it's not in whois, we probably aren't going
        # to find it any other way (sensibly at least).
        if nick in self.auth_cb:
            # User is awaiting authentication
            for callback in self.auth_cb[nick]:
                callback(user)

            del self.auth_cb[nick]

    @event("commands", Numerics.RPL_WHOISUSER)
    def whois_user(self, _, line):
        """Update a user's identity information."""
        nick = line.params[1]
        username = line.params[2]
        host = line.params[3]
        gecos = line.params[5]

        user = self.get_user(nick)
        if not user:
            return

        user.nick = nick
        user.username = username
        user.host = host
        user.gecos = gecos

    @event("commands", Numerics.RPL_WHOISCHANNELS)
    def whois_channels(self, _, line):
        """Update a user's channel set."""
        user = self.get_user(line.params[1])
        if not user:
            return

        isupport = self.base.isupport
        prefix = prefix_parse(isupport.get("PREFIX"))

        for channel in line.params[-1].split():
            mode = set()
            mode, channel = status_prefix_parse(channel, prefix)
            user.channels[channel] = mode

    @event("commands", Numerics.RPL_WHOISHOST)
    def whois_host(self, _, line):
        """Update a user's IP and/or host."""
        user = self.get_user(line.params[1])
        if not user:
            return

        # F*****g unreal did this shit.
        string, _, ip_ = line.params[-1].rpartition(' ')
        string, _, realhost = string.rpartition(' ')

        user.ip = ip_
        user.realhost = realhost

    @event("commands", Numerics.RPL_WHOISIDLE)
    def whois_idle(self, _, line):
        user = self.get_user(line.params[1])
        if not user:
            return

        user.signon = int(line.params[3])

    @event("commands", Numerics.RPL_WHOISOPERATOR)
    def whois_operator(self, _, line):
        """Update a user's operator flag."""
        user = self.get_user(line.params[1])
        if not user:
            return

        user.operator = True

    @event("commands", Numerics.RPL_WHOISSECURE)
    def whois_secure(self, _, line):
        """Update a user's SSL flag."""
        user = self.get_user(line.params[1])
        if not user:
            return

        user.secure = True

    @event("commands", Numerics.RPL_WHOISSERVER)
    def whois_server(self, _, line):
        """Update a user's currently connected server name."""
        user = self.get_user(line.params[1])
        if not user:
            return

        user.server = line.params[2]
        user.server_desc = line.params[3]

    @event("commands", Numerics.RPL_WHOISLOGGEDIN)
    def whois_account(self, _, line):
        """Update a user's account, possibly firing auth callbacks."""
        user = self.get_user(line.params[1])
        if not user:
            return

        user.account = line.params[2]

        nick = user.nick
        if nick in self.auth_cb:
            # User is awaiting authentication
            for callback in self.auth_cb[nick]:
                callback(user)

            del self.auth_cb[nick]

    # pylint: disable=too-many-locals
    @event("commands", Numerics.RPL_WHOREPLY)
    def who(self, _, line):
        """Process a WHO response, updating the corresponding user object."""
        if len(line.params) < 8:
            # Some bizarre RFC breaking server
            _logger.warning("Malformed WHO from server")
            return

        channel = line.params[1]
        username = line.params[2]
        host = line.params[3]
        server = line.params[4]
        nick = line.params[5]
        flags = who_flag_parse(line.params[6])
        other = line.params[7]
        # TODO: find something to do with this stuff
        # pylint: disable=unused-variable
        hopcount, _, other = other.partition(' ')

        user = self.get_user(nick)
        if not user:
            return

        isupport = self.base.isupport

        if isupport.get("RFC2812"):
            # IRCNet, for some stupid braindead reason, sends SID here. Why? I
            # don't know. They mentioned it might break clients in the commit
            # log. I really have no idea why it exists, why it's useful to
            # anyone, or anything like that. But honestly, WHO sucks enough...
            sid, _, gecos = other.partition(' ')
        else:
            sid = None
            gecos = other

        if channel != '*':
            # Convert symbols to modes
            prefix = prefix_parse(isupport.get("PREFIX")).prefix_to_mode

            mode = set()
            for char in flags.modes:
                char = prefix.get(char)
                if char is not None:
                    mode.add(char)

            user.channels[channel] = mode

        away = flags.away
        operator = flags.operator

        # NB - these two members aren't guaranteed to exist (yet?)
        user.sid = sid
        user.server = server

        user.username = username
        user.host = host
        user.gecos = gecos
        user.away = away
        user.operator = operator

    # pylint: disable=too-many-locals
    @event("commands", Numerics.RPL_WHOSPCRPL)
    def whox(self, _, line):
        """Process a WHOX response, updating the corresponding user object."""
        if len(line.params) != 12:
            # Not from us!
            return

        # Verify the server supports WHOX for real because Bahamut has its own
        # Eldritch abomination we don't support (RWHO... you don't wanna know)
        isupport = self.base.isupport
        if not isupport.get("WHOX"):
            return

        whoxid = line.params[1]
        channel = line.params[2]
        username = line.params[3]
        ip_ = line.params[4]
        host = line.params[5]
        server = line.params[6]
        nick = line.params[7]
        flags = who_flag_parse(line.params[8])
        idle = line.params[9]
        account = line.params[10]
        gecos = line.params[11]

        user = self.get_user(nick)
        if not user:
            return

        if whoxid not in self.whox_send:
            # Not sent by us, weird!
            return

        if channel != '*':
            # Convert symbols to modes
            prefix = prefix_parse(isupport.get("PREFIX")).prefix_to_mode

            mode = set()
            for char in flags.modes:
                char = prefix.get(char)
                if char is not None:
                    mode.add(char)

            user.channels[channel] = mode

        away = flags.away
        operator = flags.operator

        if account == '0':
            # Not logged in
            account = ''

        if ip_ == '255.255.255.255':
            # Cloaked
            ip_ = None

        user.server = server
        user.idle = idle
        user.username = username
        user.host = host
        user.server = server
        user.gecos = gecos
        user.away = away
        user.operator = operator
        user.account = account
        user.ip = ip_
Example #2
0
class ChannelTrack(BaseExtension):

    """Tracks channels and the users on the channels.

    Only the user's casemapped nicks are stored, as well as their statuses.
    They are stored casemapped to make it easier to look them up in other
    extensions.

    This extension adds ``base.channel_track`` as itself as an alias for
    ``get_extension("ChannelTrack").``.

    channels
        Mapping of channels, where the keys are casemapped channel names, and
        the values are Channel instances.

    For more elaborate user tracking, see
    :py:module:`~PyIRC.extensions.usertrack`.
    """

    requires = ["BaseTrack", "BasicRFC", "ISupport"]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        # Convenience method
        self.base.channel_track = self

        # Our channel set
        self.channels = IRCDict(self.case)

        # Scheduled items
        self.mode_timers = IRCDict(self.case)

    def get_channel(self, name):
        """Retrieve a channel from the tracking dictionary based on name.

        Use of this method is preferred to directly accessing the channels
        dictionary.

        Returns None if channel not found.

        Arguments:

        :param name:
            Name of the channel to retrieve.
        """

        return self.channels.get(name)

    def add_channel(self, name, **kwargs):
        """Add a channel to the tracking dictionary.

        Avoid using this method directly unless you know what you are
        doing.
        """
        channel = self.get_channel(name)
        if channel is None:
            _logger.debug("Adding channel: %s", name)

            channel = Channel(self.case, name, **kwargs)
            self.channels[name] = channel

        self.call_event("channel", "channel_create", channel)

        return channel

    def remove_channel(self, name):
        """Remove a channel from the tracking dictionary.

        Avoid using this method directly unless you know what you are
        doing.
        """

        channel = self.get_channel(name)
        if channel is None:
            return

        self.call_event("channel", "channel_delete", channel)

        del self.channels[name]

    @event("protocol", "case_change")
    def case_change(self, _):
        self.channels = self.channels.convert(self.case)
        self.mode_timers = self.mode_timers.convert(self.case)

    @event("link", "disconnected")
    def close(self, _):
        self.channels.clear()
        for timer in self.mode_timers.values():
            try:
                self.unschedule(timer)
            except ValueError:
                pass

    # pylint: disable=unused-argument
    @event("modes", "mode_prefix")
    def prefix(self, _, setter, target, mode):
        # Parse into hostmask in case of usernames-in-host
        channel = self.get_channel(target)
        if channel is None:
            _logger.warning("Got a PREFIX event for an unknown channel: %s",
                            target)
            return

        hostmask = Hostmask.parse(mode.param)
        if mode.adding:
            channel.users[hostmask.nick].add(mode.mode)
        else:
            channel.users[hostmask.nick].discard(mode.mode)

    @event("modes", "mode_key")
    @event("modes", "mode_param")
    @event("modes", "mode_normal")
    def modes(self, _, setter, target, mode):
        """Update a channel's modes."""
        channel = self.get_channel(target)
        if channel is None:
            return

        if mode.adding:
            channel.modes[mode.mode] = mode.param
        else:
            channel.modes.pop(mode.mode, None)

    @event("scope", "user_join")
    def join(self, caller, scope):
        """Handle a user (possibly us) joining a channel."""
        # JOIN event
        basicrfc = self.base.basic_rfc
        if self.casecmp(scope.target.nick, basicrfc.nick):
            # We're joining
            self.add_channel(scope.scope)

        self.burst(caller, scope)

    @event("scope", "user_burst")
    def burst(self, _, scope):
        """Add the users being bursted to the channel."""
        # NAMES event
        channel = self.get_channel(scope.scope)
        if channel is None:
            return

        user = scope.target.nick

        if user not in channel.users:
            channel.users[user] = set()

        modes = {m[0] for m in scope.modes} if scope.modes else set()
        channel.users[user] = modes

    @event("scope", "user_part")
    @event("scope", "user_kick")
    def part(self, _, scope):
        """Remove a user from a channel."""
        channel = self.get_channel(scope.scope)
        assert channel

        user = scope.target.nick

        basicrfc = self.base.basic_rfc
        if self.casecmp(user, basicrfc.nick):
            # We are leaving
            self.remove_channel(channel.name)
            timer = self.mode_timers.pop(channel.name, None)
            if timer is not None:
                try:
                    self.unschedule(timer)
                except ValueError:
                    pass
            return

        _logger.debug("users before deletion: %r", channel.users)

        del channel.users[user]

    @event("scope", "user_quit")
    def quit(self, _, scope):
        """Remove a user from all the channels which they were joined."""
        user = scope.target.nick

        for channel in self.channels.values():
            channel.users.pop(user, None)

    @event("commands", Numerics.RPL_TOPIC)
    @event("commands", "TOPIC")
    def topic(self, _, line):
        """Update a channel's topic."""
        if line.command.lower() == "topic":
            channel = self.get_channel(line.params[0])
            if channel is None:
                _logger.debug("Topic for unknown channel: %s", line.params[0])
                return

            # TODO server/local time deltas for more accurate timestamps
            channel.topicwho = line.hostmask
            channel.topictime = int(time())
        else:
            channel = self.get_channel(line.params[1])
            if channel is None:
                _logger.debug("RPL_TOPIC for unknown channel: %s",
                              line.params[1])
                return

        channel.topic = line.params[-1]

    @event("commands", Numerics.RPL_NOTOPIC)
    def no_topic(self, _, line):
        """Update the fact that a channel has no topic."""
        channel = self.get_channel(line.params[1])
        if channel is None:
            _logger.debug("RPL_NOTOPIC for unknown channel: %s",
                          line.params[1])
            return

        channel.topic = ''

    @event("commands", Numerics.RPL_TOPICWHOTIME)
    def topic_who_time(self, _, line):
        """Update the channel's topic metadata."""
        channel = self.get_channel(line.params[1])
        if channel is None:
            _logger.debug("Topic time for unknown channel: %s",
                          line.params[1])
            return

        channel.topicwho = Hostmask.parse(line.params[2])
        channel.topictime = int(line.params[3])

    @event("commands", Numerics.RPL_CHANNELURL)
    def url(self, _, line):
        """Update the channel's URL."""
        channel = self.get_channel(line.params[1])
        if channel is None:
            _logger.debug("URL for unknown channel: %s", line.params[1])
            return

        channel.url = line.params[-1]

    @event("commands", Numerics.RPL_CREATIONTIME)
    def timestamp(self, _, line):
        """Update the channel's creation time."""
        channel = self.get_channel(line.params[1])
        if channel is None:
            _logger.debug("Creation time for unknown channel: %s",
                          line.params[1])
            return

        channel.timestamp = int(line.params[-1])

        # Cancel
        timer = self.mode_timers.pop(channel.name, None)
        if timer is not None:
            try:
                self.unschedule(timer)
            except ValueError:
                pass

    @event("commands", Numerics.RPL_ENDOFNAMES)
    def names_end(self, _, line):
        """Schedule a MODE timer since we are finished bursting this
        channel."""
        channel = self.get_channel(line.params[1])
        if channel is None:
            return

        timer = self.schedule(5, partial(self.send, "MODE",
                                         [line.params[1]]))
        self.mode_timers[channel.name] = timer

    @event("commands", "NICK")
    def nick(self, _, line):
        """Rename a user in all channels."""
        oldnick = line.hostmask.nick
        newnick = line.params[-1]

        # Change the nick in all channels
        for channel in self.channels.values():
            if oldnick not in channel.users:
                continue

            # Change the nick
            channel.users[newnick] = channel.users.pop(oldnick)
Example #3
0
class UserTrack(BaseExtension):

    """ Track various user metrics, such as account info, and some channel
    tracking.

    The following attribute is publlicly available:

    users
        Mapping of users, where the keys are casemapped nicks, and values are
        User instances.
    """

    caps = {
        "account-notify": [],
        "away-notify": [],
        "chghost": [],
        "extended-join": [],
        "multi-prefix": [],
        "userhost-in-names": [],
    }

    requires = ["BasicRFC", "ISupport", "ModeHandler"]

    def __init__(self, base, **kwargs):
        self.base = base

        self.u_expire_timers = IRCDict(self.case)
        self.who_timers = IRCDict(self.case)

        self.users = IRCDict(self.case)
        self.whois_send = IRCSet(self.case)

        # Authentication callbacks
        self.auth_cb = IRCDefaultDict(self.case, list)

        # WHOX sent list
        self.whox_send = list()

        # Create ourselves
        basicrfc = self.get_extension("BasicRFC")
        self.add_user(basicrfc.nick, user=self.username,
                      gecos=self.gecos)

    def authenticate(self, nick, callback):
        """Get authentication for a user and return result in a callback

        Arguments:

        :param nick:
            Nickname of user to check authentication for

        :param callback:
            Callback to call for user when authentication is discovered. The
            User instance is passed in as the first parameter, or None if the
            user is not found. Use functools.partial to pass other arguments.
        """
        user = self.get_user(nick)
        if not user:
            # Add a user for now, get details later.
            self.users[nick] = User(self.case, nick)

        if user.account is not None:
            # User account is known
            callback(user)
        elif nick not in self.whois_send:
            # Defer for a whois
            self.auth_cb[nick].append(callback)
            self.send("WHOIS", ["*", user.nick])
            self.whois_send.add(nick)

    def get_user(self, nick):
        """Retrieve a user from the tracking dictionary based on nick.

        Use of this method is preferred to directly accessing the user
        dictionary.

        Returns None if user not found.

        Arguments:

        :param nick:
            Nickname of the user to retrieve.
        """
        return self.users.get(nick)

    def add_user(self, nick, **kwargs):
        """Add a user to the tracking dictionary.

        Avoid using this method directly unless you know what you are doing.
        """
        user = self.get_user(nick)
        if not user:
            user = User(self.case, nick, **kwargs)
            self.users[nick] = user

        return user

    def remove_user(self, nick):
        """Remove a user from the tracking dictionary.

        Avoid using this method directly unless you know what you are doing.
        """
        if nick not in self.users:
            logger.warning("Deleting nonexistent user: %s", nick)
            return

        logger.debug("Deleted user: %s", nick)

        del self.users[nick]

    def timeout_user(self, nick):
        """Time a user out, cancelling existing timeouts

        Avoid using this method directly unless you know what you are doing.
        """
        if nick in self.u_expire_timers:
            self.unschedule(self.u_expire_timers[nick])

        callback = partial(self.remove_user, nick)
        self.u_expire_timers[nick] = self.schedule(30, callback)

    def update_username_host(self, line):
        """Update a user's basic info, based on line hostmask info.

        Avoid using this method directly unless you know what you are doing.

        .. note::
            This mostly exists for brain-dead networks that don't quit users
            when they get cloaked.
        """
        if not line.hostmask or not line.hostmask.nick:
            return

        hostmask = line.hostmask
        user = self.get_user(hostmask.nick)
        if not user:
            return

        # Update
        user.nick = hostmask.nick

        if hostmask.username:
            user.username = hostmask.username

        if hostmask.host:
            user.host = hostmask.host

    @hook("hooks", "case_change")
    def case_change(self, event):
        case = self.case

        self.u_expire_timers = self.u_expire_timers.convert(case)
        self.who_timers = self.who_timers.convert(case)

        self.users = self.users.convert(case)
        self.whois_send = self.whois_send.convert(case)

        self.auth_cb = self.auth_cb.convert(case)

    @hook("hooks", "disconnected")
    def close(self, event):
        timers = chain(self.u_expire_timers.values(),
                       self.who_timers.values())
        for timer in timers:
            try:
                self.unschedule(timer)
            except ValueError:
                pass

        self.users.clear()
        self.whox_send.clear()

    @hook("commands", Numerics.RPL_WELCOME)
    def welcome(self, event):
        # Obtain our own host
        self.send("USERHOST", [event.line.params[0]])

    @hook("commands", Numerics.RPL_USERHOST)
    def userhost(self, event):
        line = event.line
        params = line.params
        if not (len(params) > 1 and params[1]):
            return

        basicrfc = self.get_extension("BasicRFC")

        for mask in params[1].split(' '):
            if not mask:
                continue

            parse = userhost_parse(mask)
            hostmask = parse.hostmask
            user = self.get_user(hostmask.nick)
            if not user:
                continue

            if hostmask.username:
                user.username = hostmask.username

            user.operator = parse.operator
            if not parse.away:
                user.away = False

            if self.casecmp(hostmask.nick, basicrfc.nick):
                user.realhost = hostmask.host
            else:
                user.host = hostmask.host

    @hook("commands", Numerics.RPL_HOSTHIDDEN)
    def host_hidden(self, event):
        line = event.line
        params = line.params

        user = self.get_user(params[0])
        assert user  # This should NEVER fire!

        user.host = params[1]

    @hook("commands", "ACCOUNT")
    def account(self, event):
        self.update_username_host(event.line)

        user = self.get_user(event.line.hostmask.nick)
        assert user

        user.account = '' if account == '*' else account

        if user.nick in self.auth_cb:
            # User is awaiting authentication
            for callback in self.auth_cb[user.nick]:
                callback(user)

            del self.auth_cb[user.nick]

    @hook("commands", "AWAY")
    def away(self, event):
        self.update_username_host(event.line)

        user = self.get_user(event.line.hostmask.nick)
        assert user

        user.away = bool(event.line.params)

    @hook("commands", "CHGHOST")
    def chghost(self, event):
        # NB - we don't know if a user is cloaking or uncloaking, or changing
        # cloak, so do NOT update user's cloak.
        self.update_username_host(event.line)

        user = self.get_user(event.line.hostmask.nick)
        assert user

        user.username = event.line.params[0]
        user.host = event.line.params[1]

    @hook("commands", "JOIN")
    def join(self, event):
        channel = event.line.params[0]
        nick = event.line.hostmask.nick

        user = self.get_user(nick)
        if user:
            # Remove any pending expiration timer
            self.u_expire_timers.pop(nick, None)
        else:
            # Create a new user with available info
            cap_negotiate = self.get_extension("CapNegotiate")
            if cap_negotiate and 'extended-join' in cap_negotiate.local:
                account = event.line.params[1]
                if account == '*':
                    account = ''

                gecos = event.line.params[2]

            user = self.add_user(nick, account=account, gecos=gecos)

        # Update info
        self.update_username_host(event.line)

        basicrfc = self.get_extension("BasicRFC")
        if self.casecmp(nick, basicrfc.nick):
            # It's us!
            isupport = self.get_extension("ISupport")

            params = [channel]
            if isupport.get("WHOX"):
                # Use WHOX if possible
                num = ''.join(str(randint(0, 9)) for x in range(randint(1, 3)))
                params.append("%tcuihsnflar," + num)
                self.whox_send.append(num)

            sched = self.schedule(2, partial(self.send, "WHO", params))
            self.who_timers[channel] = sched

        # Add the channel
        user.channels[channel] = set()

    @hook("modes", "mode_prefix")
    def prefix(self, event):
        # Parse into hostmask in case of usernames-in-host
        hostmask = Hostmask.parse(event.param)

        assert hostmask

        user = self.get_user(hostmask.nick)
        if not user:
            # Add the user
            user = self.add_user(hostmask.nick, user=hostmask.username,
                                 host=hostmask.host)

        channel = user.channels[event.target]
        if event.adding:
            channel.add(event.mode)
        else:
            channel.discard(event.mode)

    @hook("commands", "NICK")
    def nick(self, event):
        self.update_username_host(event.line)

        oldnick = event.line.hostmask.nick
        newnick = event.line.params[0]

        assert self.get_user(oldnick)

        self.users[newnick] = self.get_user(oldnick)
        self.users[newnick].nick = newnick

        del self.users[oldnick]

    @hook("commands", Numerics.ERR_NOSUCHNICK)
    def notfound(self, event):
        nick = event.line.params[1]
        if nick in self.auth_cb:
            # User doesn't exist, call back
            for callback in self.auth_cb[nick]:
                callback(None)

            del self.auth_cb[nick]

        self.remove_user(nick)

    @hook("commands", "PRIVMSG")
    @hook("commands", "NOTICE")
    def message(self, event):
        if event.line.params[0] == '*':
            # We are not registered, do nothing.
            return

        hostmask = event.line.hostmask
        if not hostmask.nick:
            return

        if hostmask.nick in self.u_expire_timers:
            # User is expiring
            user = self.get_user(hostmask.nick)
            if hostmask.username != user.username or hostmask.host != user.host:
                # User is suspect, delete and find out more.
                self.remove_user(hostmask.nick)
            else:
                # Rearm timeout
                self.timeout_user(hostmask.nick)

        if not self.get_user(hostmask.nick):
            # Obtain more information about the user
            user = self.add_user(hostmask.nick, user=hostmask.username,
                                 host=hostmask.host)

            if hostmask.nick not in self.whois_send:
                self.send("WHOIS", ['*', hostmask.nick])
                self.whois_send.add(hostmask.nick)

            self.timeout_user(hostmask.nick)

    @hook("commands", "KICK")
    @hook("commands", "PART")
    def part(self, event):
        channel = event.line.params[0]

        if event.line.command.lower() == 'part':
            user = self.get_user(event.line.hostmask.nick)
        elif event.line.command.lower() == 'kick':
            user = self.get_user(event.line.params[1])
        assert user

        assert channel in user.channels
        del user.channels[channel]

        basicrfc = self.get_extension("BasicRFC")
        if self.casecmp(user.nick, basicrfc.nick):
            # We left the channel, scan all users to remove unneeded ones
            for u_nick, u_user in list(self.users.items()):
                if channel in u_user.channels:
                    # Purge from the cache since we don't know for certain.
                    del u_user.channels[channel]

                if self.casecmp(u_nick, basicrfc.nick):
                    # Don't delete ourselves!
                    continue

                if not u_user.channels:
                    # Delete the user outright to purge any cached data
                    # The data must be considered invalid when we leave
                    # TODO - possible MONITOR support?
                    self.remove_user(u_nick)
                    continue

        elif not user.channels:
            # No more channels and not us, delete
            # TODO - possible MONITOR support?
            self.remove_user(event.line.hostmask.nick)

    @hook("commands", "QUIT")
    def quit(self, event):
        assert event.line.hostmask.nick in self.users
        self.remove_user(event.line.hostmask.nick)

    @hook("commands", Numerics.RPL_ENDOFWHO)
    def who_end(self, event):
        if not self.whox_send:
            return

        channel = event.line.params[1]
        del self.who_timers[channel]
        del self.whox_send[0]

    @hook("commands", Numerics.RPL_ENDOFWHOIS)
    def whois_end(self, event):
        nick = event.line.params[1]

        self.whois_send.discard(nick)

        user = self.get_user(nick)

        # If the user is awaiting auth, we aren't gonna find out their auth
        # status through whois. If it's not in whois, we probably aren't going
        # to find it any other way (sensibly at least).
        if nick in self.auth_cb:
            # User is awaiting authentication
            for callback in self.auth_cb[nick]:
                callback(user)

            del self.auth_cb[nick]

    @hook("commands", Numerics.RPL_WHOISUSER)
    def whois_user(self, event):
        nick = event.line.params[1]
        username = event.line.params[2]
        host = event.line.params[3]
        gecos = event.line.params[5]

        user = self.get_user(nick)
        if not user:
            return

        user.nick = nick
        user.username = username
        user.host = host
        user.gecos = gecos

    @hook("commands", Numerics.RPL_WHOISCHANNELS)
    def whois_channels(self, event):
        user = self.get_user(event.line.params[1])
        if not user:
            return

        isupport = self.get_extension("ISupport")
        prefix = prefix_parse(isupport.get("PREFIX"))

        for channel in event.line.params[-1].split():
            mode = set()
            mode, channel = status_prefix_parse(channel, prefix)
            user.channels[channel] = mode

    @hook("commands", Numerics.RPL_WHOISHOST)
    def whois_host(self, event):
        user = self.get_user(event.line.params[1])
        if not user:
            return

        # F*****g unreal did this shit.
        string, _, ip = event.line.params[-1].rpartition(' ')
        string, _, realhost = string.rpartition(' ')

        user.ip = ip
        user.realhost = realhost

    @hook("commands", Numerics.RPL_WHOISIDLE)
    def whois_idle(self, event):
        user = self.get_user(event.line.params[1])
        if not user:
            return

        user.signon = int(event.line.params[3])

    @hook("commands", Numerics.RPL_WHOISOPERATOR)
    def whois_operator(self, event):
        user = self.get_user(event.line.params[1])
        if not user:
            return

        user.operator = True

    @hook("commands", Numerics.RPL_WHOISSECURE)
    def whois_secure(self, event):
        user = self.get_user(event.line.params[1])
        if not user:
            return

        user.secure = True

    @hook("commands", Numerics.RPL_WHOISSERVER)
    def whois_server(self, event):
        user = self.get_user(event.line.params[1])
        if not user:
            return

        user.server = event.line.params[2]
        user.server_desc = event.line.params[3]

    @hook("commands", Numerics.RPL_WHOISLOGGEDIN)
    def whois_account(self, event):
        user = self.get_user(event.line.params[1])
        if not user:
            return

        user.account = event.line.params[2]

        nick = user.nick
        if nick in self.auth_cb:
            # User is awaiting authentication
            for callback in self.auth_cb[nick]:
                callback(user)

            del self.auth_cb[nick]

    @hook("commands", Numerics.RPL_WHOREPLY)
    def who(self, event):
        if len(event.line.params) < 8:
            # Some bizarre RFC breaking server
            logger.warn("Malformed WHO from server")
            return

        channel = event.line.params[1]
        username = event.line.params[2]
        host = event.line.params[3]
        server = event.line.params[4]
        nick = event.line.params[5]
        flags = who_flag_parse(event.line.params[6])
        other = event.line.params[7]
        hopcount, _, other = other.partition(' ')

        user = self.get_user(nick)
        if not user:
            return

        isupport = self.get_extension("ISupport")

        if isupport.get("RFC2812"):
            # IRCNet, for some stupid braindead reason, sends SID here. Why? I
            # don't know. They mentioned it might break clients in the commit
            # log. I really have no idea why it exists, why it's useful to
            # anyone, or anything like that. But honestly, WHO sucks enough...
            sid, _, realname = other.partition(' ')
        else:
            sid = None
            realname = other

        if channel != '*':
            # Convert symbols to modes
            prefix = prefix_parse(isupport.get("PREFIX")).prefix_to_mode

            mode = set()
            for char in flags.modes:
                char = prefix.get(char)
                if char is not None:
                    mode.add(char)

            user.channels[channel] = mode

        away = flags.away
        operator = flags.operator

        if account == '0':
            # Not logged in
            account = ''

        if ip == '255.255.255.255':
            # Cloaked
            ip = None

        user.username = username
        user.host = host
        user.gecos = gecos
        user.away = away
        user.operator = operator
        user.account = account
        user.ip = ip

    @hook("commands", Numerics.RPL_WHOSPCRPL)
    def whox(self, event):
        if len(event.line.params) != 12:
            # Not from us!
            return

        whoxid = event.line.params[1]
        channel = event.line.params[2]
        username = event.line.params[3]
        ip = event.line.params[4]
        host = event.line.params[5]
        server = event.line.params[6]
        nick = event.line.params[7]
        flags = who_flag_parse(event.line.params[8])
        # idle = event.line.params[9]
        account = event.line.params[10]
        gecos = event.line.params[11]

        user = self.get_user(nick)
        if not user:
            return

        if whoxid not in self.whox_send:
            # Not sent by us, weird!
            return

        if channel != '*':
            # Convert symbols to modes
            isupport = self.get_extension("ISupport")
            prefix = prefix_parse(isupport.get("PREFIX")).prefix_to_mode

            mode = set()
            for char in flags.modes:
                char = prefix.get(char)
                if char is not None:
                    mode.add(char)

            user.channels[channel] = mode

        away = flags.away
        operator = flags.operator

        if account == '0':
            # Not logged in
            account = ''

        if ip == '255.255.255.255':
            # Cloaked
            ip = None

        user.username = username
        user.host = host
        user.server = server
        user.gecos = gecos
        user.away = away
        user.operator = operator
        user.account = account
        user.ip = ip