From 2877abea550772e019a4309a6a10ae6348c45d33 Mon Sep 17 00:00:00 2001 From: "Vgr E. Barry" Date: Wed, 26 Oct 2016 20:06:29 -0400 Subject: [PATCH] Update as per @skizzerz's comments --- requirements.txt | 1 + src/channels.py | 107 ++++++++++++++++++++++++++++++++--------------- src/context.py | 21 +++++----- src/users.py | 60 +++++++++----------------- wolfbot.py | 3 +- 5 files changed, 107 insertions(+), 85 deletions(-) diff --git a/requirements.txt b/requirements.txt index c997f36..47a51ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ typing +enum diff --git a/src/channels.py b/src/channels.py index ead70f2..391a9ab 100644 --- a/src/channels.py +++ b/src/channels.py @@ -1,48 +1,63 @@ import time +from enum import Enum + from src.context import IRCContext, Features from src.logger import debuglog from src import users Main = None # main channel -all_channels = {} +_channels = {} _states = ("not yet joined", "pending join", "joined", "pending leave", "left channel", "", "deleted", "cleared") -def _strip(name): - return name.lstrip("".join(Features["STATUSMSG"])) +class _States(Enum): + NotJoined = "not yet joined" + PendingJoin = "pending join" + Joined = "joined" + PendingLeave = "pending leave" + Left = "left channel" + Deleted = "deleted" + Cleared = "cleared" + +# This is used to tell if this is a fake channel or not. If this +# function returns a true value, then it's a fake channel. This is +# useful for testing, where we might want the users in fake channels. def predicate(name): return not name.startswith(tuple(Features["CHANTYPES"])) -def get(name): - """Return an existing channel, or raise a KeyError if it doesn't exist.""" +get = _channels.__getitem__ - return all_channels[_strip(name)] - -def add(name, cli): +def add(name, cli, key=""): """Add and return a new channel, or an existing one if it exists.""" - name = _strip(name) + # We use add() in a bunch of places where the channel probably (but + # not surely) already exists. If it does, obviously we want to use + # that one. However, if the client is *not* the same, that means we + # would be trying to send something from one connection over to + # another one (or some other weird stuff like that). Instead of + # jumping through hoops, we just disallow it here. - if name in all_channels: - if cli is not all_channels[name].client: + if name in _channels: + if cli is not _channels[name].client: raise RuntimeError("different IRC client for channel {0}".format(name)) - return all_channels[name] + return _channels[name] cls = Channel if predicate(name): cls = FakeChannel - chan = all_channels[name] = cls(name, cli) - chan.join() + chan = _channels[name] = cls(name, cli) + chan.join(key) return chan -def exists(name): - """Return True if a channel with the name exists, False otherwise.""" +exists = _channels.__contains__ - return _strip(name) in all_channels +def channels(): + """Iterate over all the current channels.""" + yield from _channels.values() class Channel(IRCContext): @@ -53,37 +68,51 @@ class Channel(IRCContext): self.users = set() self.modes = {} self.timestamp = None - self.state = 0 + self.state = _States.NotJoined def __del__(self): self.users.clear() self.modes.clear() - self.state = -2 + self.state = _States.Deleted self.client = None self.timestamp = None def __str__(self): - return "{self.__class__.__name__}: {self.name} ({0})".format(_states[self.state], self=self) + return "{self.__class__.__name__}: {self.name} ({0})".format(self.state.value, self=self) def __repr__(self): return "{self.__class__.__name__}({self.name!r})".format(self=self) def join(self, key=""): - if self.state in (0, 4): - self.state = 1 + if self.state in (_States.NotJoined, _States.Left): + self.state = _States.PendingJoin self.client.send("JOIN {0} :{1}".format(self.name, key)) def part(self, message=""): - if self.state == 2: - self.state = 3 + if self.state is _States.Joined: + self.state = _States.PendingLeave self.client.send("PART {0} :{1}".format(self.name, message)) def kick(self, target, message=""): - if self.state == 2: + if self.state is _States.Joined: self.client.send("KICK {0} {1} :{2}".format(self.name, target, message)) def mode(self, *changes): - if not changes: + """Perform a mode change on the channel. + + Usage: + + chan.mode() # Will get back the modes on the channel + chan.mode("b") # Will get the banlist back + chan.mode("-m") + chan.mode(["-v", "woffle"], ["+o", "Vgr"]) + chan.mode("-m", ("+v", "jacob1"), ("-o", "nyuszika7h")) + + This performs both single and complex mode changes. + + """ + + if not changes: # bare call; get channel modes self.client.send("MODE", self.name) return @@ -93,7 +122,7 @@ class Channel(IRCContext): if isinstance(change, str): change = (change, None) params.append(change) - params.sort(key=lambda x: x[0][0]) + params.sort(key=lambda x: x[0][0]) # sort by prefix while params: cur, params = params[:max_modes], params[max_modes:] @@ -109,13 +138,23 @@ class Channel(IRCContext): final.append(mode) for target in targets: - if target is not None: + if target is not None: # target will be None if the mode is parameter-less final.append(" ") final.append(target) self.client.send("MODE", self.name, "".join(final)) def update_modes(self, rawnick, mode, targets): + """Update the channel's mode registry with the new modes. + + This is called whenever a MODE event is received. All of the + modes are kept up-to-date in the channel, even if we don't need + it. For instance, banlists are updated properly when the bot + receives them. We don't need all the mode information, but it's + better to have everything stored than only some parts. + + """ + set_time = int(time.time()) # for list modes timestamp list_modes, all_set, only_set, no_set = Features["CHANMODES"] status_modes = Features["PREFIX"].values() @@ -127,7 +166,7 @@ class Channel(IRCContext): continue if prefix == "+": - if c in status_modes: + if c in status_modes: # op/voice status; keep it here and update the user's registry too if c not in self.modes: self.modes[c] = set() user = users.get(targets[i], allow_bot=True) @@ -135,14 +174,14 @@ class Channel(IRCContext): user.channels[self].add(c) i += 1 - elif c in list_modes: + elif c in list_modes: # stuff like bans, quiets, and ban and invite exempts if c not in self.modes: self.modes[c] = {} self.modes[c][targets[i]] = (rawnick, set_time) i += 1 else: - if c in no_set: + if c in no_set: # everything else; e.g. +m, +i, +f, etc. targ = None else: targ = targets[i] @@ -182,14 +221,14 @@ class Channel(IRCContext): del self.modes[mode] del user.channels[self] - def clear(self): + def _clear(self): for user in self.users: del user.channels[self] self.users.clear() self.modes.clear() - self.state = -1 + self.state = _States.Cleared self.timestamp = None - del all_channels[self.name] + del _channels[self.name] class FakeChannel(Channel): diff --git a/src/context.py b/src/context.py index e3af548..bcc6992 100644 --- a/src/context.py +++ b/src/context.py @@ -1,4 +1,4 @@ -Features = {"CASEMAPPING": "rfc1459", "CHARSET": "utf-8", "STATUSMSG": {"@", "+"}, "CHANTYPES": {"#"}} # IRC server features (these are defaults) +Features = {"CASEMAPPING": "rfc1459", "CHARSET": "utf-8", "STATUSMSG": {"@", "+"}, "CHANTYPES": {"#"}} def lower(nick): if nick is None: @@ -41,17 +41,18 @@ class IRCContext: return "PRIVMSG" @staticmethod - def raw_send(data, client, send_type, name): + def _send(data, client, send_type, name): full_address = "{cli.nickname}!{cli.ident}@{cli.hostmask}".format(cli=client) - # Maximum length of sent data is 0x200 (512) bytes. However, - # we have to reduce the maximum length allowed to account for: + # Maximum length of sent data is 512 bytes. However, we have to + # reduce the maximum length allowed to account for: # 1 (1) - The initial colon at the front of the data - # 2 (1) - The space between the command and the target - # 2 (1) - The space between the target and the data - # 3 (1) - The colon at the front of the data to send - # 4 (3) - I don't know why, but we need 3 more/less characters - length = 0x200 - 7 + # 2 (1) - The space between the sender (us) and the command + # 3 (1) - The space between the command and the target + # 4 (1) - The space between the target and the data + # 5 (1) - The colon at the front of the data to send + # 6 (2) - The trailing \r\n + length = 512 - 7 # Next, we need to reduce the length to account for our address length -= len(full_address) # Then we also need to account for the target's length @@ -66,4 +67,4 @@ class IRCContext: def send(self, data, target=None, *, notice=False, privmsg=False): send_type = self.get_send_type(is_notice=notice, is_privmsg=privmsg) - self.raw_send(data, self.client, send_type, self.name) + self._send(data, self.client, send_type, self.name) diff --git a/src/users.py b/src/users.py index a3f7234..c548999 100644 --- a/src/users.py +++ b/src/users.py @@ -12,17 +12,20 @@ import botconfig Bot = None # bot instance -all_users = WeakSet() +_users = WeakSet() _arg_msg = "(nick={0}, ident={1}, host={2}, realname={3}, account={4}, allow_bot={5})" +# This is used to tell if this is a fake nick or not. If this function +# returns a true value, then it's a fake nick. This is useful for +# testing, where we might want everyone to be fake nicks. predicate = re.compile(r"^[0-9]+$").search -def get(nick=None, ident=None, host=None, realname=None, account=None, *, allow_multiple=False, allow_none=False, allow_bot=False, raw_nick=False): +def get(nick=None, ident=None, host=None, realname=None, account=None, *, allow_multiple=False, allow_none=False, allow_bot=False): """Return the matching user(s) from the user list. This takes up to 5 positional arguments (nick, ident, host, realname, - account) and may take up to four keyword-only arguments: + account) and may take up to three keyword-only arguments: - allow_multiple (defaulting to False) allows multiple matches, and returns a list, even if there's only one match; @@ -34,23 +37,17 @@ def get(nick=None, ident=None, host=None, realname=None, account=None, *, allow_ - allow_bot (defaulting to False) allows the bot to be matched and returned; - - raw_nick (defaulting to False) means that the nick has not been - yet parsed, and so ident and host will be None, and nick will be - a raw nick of the form nick!ident@host. - If allow_multiple is not set and multiple users match, a ValueError will be raised. If allow_none is not set and no users match, a KeyError will be raised. """ - if raw_nick: - if ident is not None or host is not None: - raise ValueError("ident and host need to be None if raw_nick is True") + if ident is None and host is None and nick is not None: nick, ident, host = parse_rawnick(nick) potential = [] - users = set(all_users) + users = set(_users) if allow_bot: users.add(Bot) @@ -83,7 +80,7 @@ def get(nick=None, ident=None, host=None, realname=None, account=None, *, allow_ return potential[0] -def add(cli, *, nick, ident=None, host=None, realname=None, account=None, channels=None, raw_nick=False): +def add(cli, *, nick, ident=None, host=None, realname=None, account=None, channels=None): """Create a new user, add it to the user list and return it. This function takes up to 6 keyword-only arguments (and no positional @@ -91,15 +88,9 @@ def add(cli, *, nick, ident=None, host=None, realname=None, account=None, channe With the exception of the first one, any parameter can be omitted. If a matching user already exists, a ValueError will be raised. - The raw_nick keyword argument may be set if the nick has not yet - been parsed. In that case, ident and host must both be None, and - nick must be in the form nick!ident@host. - """ - if raw_nick: - if ident is not None or host is not None: - raise ValueError("ident and host need to be None if raw_nick is True") + if ident is None and host is None and nick is not None: nick, ident, host = parse_rawnick(nick) if exists(nick, ident, host, realname, account, allow_multiple=True, allow_bot=True): @@ -115,10 +106,10 @@ def add(cli, *, nick, ident=None, host=None, realname=None, account=None, channe cls = FakeUser new = cls(cli, nick, ident, host, realname, account, channels) - all_users.add(new) + _users.add(new) return new -def exists(*args, allow_none=False, **kwargs): +def exists(nick=None, ident=None, host=None, realname=None, account=None, *, allow_multiple=False, allow_bot=False): """Return True if a matching user exists. Positional and keyword arguments are the same as get(), with the @@ -127,29 +118,18 @@ def exists(*args, allow_none=False, **kwargs): """ - if allow_none: # why would you even want to do that? - raise RuntimeError("Cannot use allow_none=True with exists()") - try: - get(*args, **kwargs) + get(nick, ident, host, realname, account, allow_multiple=allow_multiple, allow_bot=allow_bot) except (KeyError, ValueError): return False return True -_raw_nick_pattern = re.compile( +def users(): + """Iterate over the users in the registry.""" + yield from _users - r""" - \A - (?P [^!@\s]+ (?=!|$) )? !? - (?P [^!@\s]+ )? @? - (?P \S+ )? - \Z - """, - - re.VERBOSE - -) +_raw_nick_pattern = re.compile(r"^(?P.+?)(?:!(?P.+?)@(?P.+))?$") def parse_rawnick(rawnick, *, default=None): """Return a tuple of (nick, ident, host) from rawnick.""" @@ -190,7 +170,7 @@ class User(IRCContext): def is_owner(self): if self.is_fake: - return False # fake nicks can't ever be owner + return False hosts = set(botconfig.OWNERS) accounts = set(botconfig.OWNERS_ACCOUNTS) @@ -208,7 +188,7 @@ class User(IRCContext): def is_admin(self): if self.is_fake: - return False # they can't be admin, either + return False flags = var.FLAGS[self.rawnick] + var.FLAGS_ACCS[self.account] @@ -377,7 +357,7 @@ class User(IRCContext): max_targets = Features["TARGMAX"][send_type] while targets: using, targets = targets[:max_targets], targets[max_targets:] - cls.raw_send(message, targets[0].client, send_type, ",".join([t.nick for t in using])) + cls._send(message, targets[0].client, send_type, ",".join([t.nick for t in using])) cls._messages.clear() diff --git a/wolfbot.py b/wolfbot.py index 951a5b0..da5d985 100755 --- a/wolfbot.py +++ b/wolfbot.py @@ -28,7 +28,8 @@ if sys.version_info < (3, 3): sys.exit(1) try: # need to manually add dependencies here - import typing + import typing # Python >= 3.5 + import enum # Python >= 3.4 except ImportError: command = "python3" if os.name == "nt":