Update as per @skizzerz's comments

This commit is contained in:
Vgr E. Barry 2016-10-26 20:06:29 -04:00
parent 5ec273c6e0
commit 2877abea55
5 changed files with 107 additions and 85 deletions

View File

@ -1 +1,2 @@
typing typing
enum

View File

@ -1,48 +1,63 @@
import time import time
from enum import Enum
from src.context import IRCContext, Features from src.context import IRCContext, Features
from src.logger import debuglog from src.logger import debuglog
from src import users from src import users
Main = None # main channel Main = None # main channel
all_channels = {} _channels = {}
_states = ("not yet joined", "pending join", "joined", "pending leave", "left channel", "", "deleted", "cleared") _states = ("not yet joined", "pending join", "joined", "pending leave", "left channel", "", "deleted", "cleared")
def _strip(name): class _States(Enum):
return name.lstrip("".join(Features["STATUSMSG"])) 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): def predicate(name):
return not name.startswith(tuple(Features["CHANTYPES"])) return not name.startswith(tuple(Features["CHANTYPES"]))
def get(name): get = _channels.__getitem__
"""Return an existing channel, or raise a KeyError if it doesn't exist."""
return all_channels[_strip(name)] def add(name, cli, key=""):
def add(name, cli):
"""Add and return a new channel, or an existing one if it exists.""" """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 name in _channels:
if cli is not all_channels[name].client: if cli is not _channels[name].client:
raise RuntimeError("different IRC client for channel {0}".format(name)) raise RuntimeError("different IRC client for channel {0}".format(name))
return all_channels[name] return _channels[name]
cls = Channel cls = Channel
if predicate(name): if predicate(name):
cls = FakeChannel cls = FakeChannel
chan = all_channels[name] = cls(name, cli) chan = _channels[name] = cls(name, cli)
chan.join() chan.join(key)
return chan return chan
def exists(name): exists = _channels.__contains__
"""Return True if a channel with the name exists, False otherwise."""
return _strip(name) in all_channels def channels():
"""Iterate over all the current channels."""
yield from _channels.values()
class Channel(IRCContext): class Channel(IRCContext):
@ -53,37 +68,51 @@ class Channel(IRCContext):
self.users = set() self.users = set()
self.modes = {} self.modes = {}
self.timestamp = None self.timestamp = None
self.state = 0 self.state = _States.NotJoined
def __del__(self): def __del__(self):
self.users.clear() self.users.clear()
self.modes.clear() self.modes.clear()
self.state = -2 self.state = _States.Deleted
self.client = None self.client = None
self.timestamp = None self.timestamp = None
def __str__(self): 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): def __repr__(self):
return "{self.__class__.__name__}({self.name!r})".format(self=self) return "{self.__class__.__name__}({self.name!r})".format(self=self)
def join(self, key=""): def join(self, key=""):
if self.state in (0, 4): if self.state in (_States.NotJoined, _States.Left):
self.state = 1 self.state = _States.PendingJoin
self.client.send("JOIN {0} :{1}".format(self.name, key)) self.client.send("JOIN {0} :{1}".format(self.name, key))
def part(self, message=""): def part(self, message=""):
if self.state == 2: if self.state is _States.Joined:
self.state = 3 self.state = _States.PendingLeave
self.client.send("PART {0} :{1}".format(self.name, message)) self.client.send("PART {0} :{1}".format(self.name, message))
def kick(self, target, 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)) self.client.send("KICK {0} {1} :{2}".format(self.name, target, message))
def mode(self, *changes): 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) self.client.send("MODE", self.name)
return return
@ -93,7 +122,7 @@ class Channel(IRCContext):
if isinstance(change, str): if isinstance(change, str):
change = (change, None) change = (change, None)
params.append(change) params.append(change)
params.sort(key=lambda x: x[0][0]) params.sort(key=lambda x: x[0][0]) # sort by prefix
while params: while params:
cur, params = params[:max_modes], params[max_modes:] cur, params = params[:max_modes], params[max_modes:]
@ -109,13 +138,23 @@ class Channel(IRCContext):
final.append(mode) final.append(mode)
for target in targets: 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(" ")
final.append(target) final.append(target)
self.client.send("MODE", self.name, "".join(final)) self.client.send("MODE", self.name, "".join(final))
def update_modes(self, rawnick, mode, targets): 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 set_time = int(time.time()) # for list modes timestamp
list_modes, all_set, only_set, no_set = Features["CHANMODES"] list_modes, all_set, only_set, no_set = Features["CHANMODES"]
status_modes = Features["PREFIX"].values() status_modes = Features["PREFIX"].values()
@ -127,7 +166,7 @@ class Channel(IRCContext):
continue continue
if prefix == "+": 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: if c not in self.modes:
self.modes[c] = set() self.modes[c] = set()
user = users.get(targets[i], allow_bot=True) user = users.get(targets[i], allow_bot=True)
@ -135,14 +174,14 @@ class Channel(IRCContext):
user.channels[self].add(c) user.channels[self].add(c)
i += 1 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: if c not in self.modes:
self.modes[c] = {} self.modes[c] = {}
self.modes[c][targets[i]] = (rawnick, set_time) self.modes[c][targets[i]] = (rawnick, set_time)
i += 1 i += 1
else: else:
if c in no_set: if c in no_set: # everything else; e.g. +m, +i, +f, etc.
targ = None targ = None
else: else:
targ = targets[i] targ = targets[i]
@ -182,14 +221,14 @@ class Channel(IRCContext):
del self.modes[mode] del self.modes[mode]
del user.channels[self] del user.channels[self]
def clear(self): def _clear(self):
for user in self.users: for user in self.users:
del user.channels[self] del user.channels[self]
self.users.clear() self.users.clear()
self.modes.clear() self.modes.clear()
self.state = -1 self.state = _States.Cleared
self.timestamp = None self.timestamp = None
del all_channels[self.name] del _channels[self.name]
class FakeChannel(Channel): class FakeChannel(Channel):

View File

@ -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): def lower(nick):
if nick is None: if nick is None:
@ -41,17 +41,18 @@ class IRCContext:
return "PRIVMSG" return "PRIVMSG"
@staticmethod @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) full_address = "{cli.nickname}!{cli.ident}@{cli.hostmask}".format(cli=client)
# Maximum length of sent data is 0x200 (512) bytes. However, # Maximum length of sent data is 512 bytes. However, we have to
# we have to reduce the maximum length allowed to account for: # reduce the maximum length allowed to account for:
# 1 (1) - The initial colon at the front of the data # 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 sender (us) and the command
# 2 (1) - The space between the target and the data # 3 (1) - The space between the command and the target
# 3 (1) - The colon at the front of the data to send # 4 (1) - The space between the target and the data
# 4 (3) - I don't know why, but we need 3 more/less characters # 5 (1) - The colon at the front of the data to send
length = 0x200 - 7 # 6 (2) - The trailing \r\n
length = 512 - 7
# Next, we need to reduce the length to account for our address # Next, we need to reduce the length to account for our address
length -= len(full_address) length -= len(full_address)
# Then we also need to account for the target's length # 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): def send(self, data, target=None, *, notice=False, privmsg=False):
send_type = self.get_send_type(is_notice=notice, is_privmsg=privmsg) 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)

View File

@ -12,17 +12,20 @@ import botconfig
Bot = None # bot instance Bot = None # bot instance
all_users = WeakSet() _users = WeakSet()
_arg_msg = "(nick={0}, ident={1}, host={2}, realname={3}, account={4}, allow_bot={5})" _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 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. """Return the matching user(s) from the user list.
This takes up to 5 positional arguments (nick, ident, host, realname, 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, - allow_multiple (defaulting to False) allows multiple matches,
and returns a list, even if there's only one match; 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 - allow_bot (defaulting to False) allows the bot to be matched and
returned; 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 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 allow_none is not set and no users match, a KeyError
will be raised. will be raised.
""" """
if raw_nick: if ident is None and host is None and nick is not None:
if ident is not None or host is not None:
raise ValueError("ident and host need to be None if raw_nick is True")
nick, ident, host = parse_rawnick(nick) nick, ident, host = parse_rawnick(nick)
potential = [] potential = []
users = set(all_users) users = set(_users)
if allow_bot: if allow_bot:
users.add(Bot) users.add(Bot)
@ -83,7 +80,7 @@ def get(nick=None, ident=None, host=None, realname=None, account=None, *, allow_
return potential[0] 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. """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 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. With the exception of the first one, any parameter can be omitted.
If a matching user already exists, a ValueError will be raised. 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 None and host is None and nick is not None:
if ident is not None or host is not None:
raise ValueError("ident and host need to be None if raw_nick is True")
nick, ident, host = parse_rawnick(nick) nick, ident, host = parse_rawnick(nick)
if exists(nick, ident, host, realname, account, allow_multiple=True, allow_bot=True): 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 cls = FakeUser
new = cls(cli, nick, ident, host, realname, account, channels) new = cls(cli, nick, ident, host, realname, account, channels)
all_users.add(new) _users.add(new)
return 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. """Return True if a matching user exists.
Positional and keyword arguments are the same as get(), with the 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: try:
get(*args, **kwargs) get(nick, ident, host, realname, account, allow_multiple=allow_multiple, allow_bot=allow_bot)
except (KeyError, ValueError): except (KeyError, ValueError):
return False return False
return True return True
_raw_nick_pattern = re.compile( def users():
"""Iterate over the users in the registry."""
yield from _users
r""" _raw_nick_pattern = re.compile(r"^(?P<nick>.+?)(?:!(?P<ident>.+?)@(?P<host>.+))?$")
\A
(?P<nick> [^!@\s]+ (?=!|$) )? !?
(?P<ident> [^!@\s]+ )? @?
(?P<host> \S+ )?
\Z
""",
re.VERBOSE
)
def parse_rawnick(rawnick, *, default=None): def parse_rawnick(rawnick, *, default=None):
"""Return a tuple of (nick, ident, host) from rawnick.""" """Return a tuple of (nick, ident, host) from rawnick."""
@ -190,7 +170,7 @@ class User(IRCContext):
def is_owner(self): def is_owner(self):
if self.is_fake: if self.is_fake:
return False # fake nicks can't ever be owner return False
hosts = set(botconfig.OWNERS) hosts = set(botconfig.OWNERS)
accounts = set(botconfig.OWNERS_ACCOUNTS) accounts = set(botconfig.OWNERS_ACCOUNTS)
@ -208,7 +188,7 @@ class User(IRCContext):
def is_admin(self): def is_admin(self):
if self.is_fake: if self.is_fake:
return False # they can't be admin, either return False
flags = var.FLAGS[self.rawnick] + var.FLAGS_ACCS[self.account] flags = var.FLAGS[self.rawnick] + var.FLAGS_ACCS[self.account]
@ -377,7 +357,7 @@ class User(IRCContext):
max_targets = Features["TARGMAX"][send_type] max_targets = Features["TARGMAX"][send_type]
while targets: while targets:
using, targets = targets[:max_targets], targets[max_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() cls._messages.clear()

View File

@ -28,7 +28,8 @@ if sys.version_info < (3, 3):
sys.exit(1) sys.exit(1)
try: # need to manually add dependencies here try: # need to manually add dependencies here
import typing import typing # Python >= 3.5
import enum # Python >= 3.4
except ImportError: except ImportError:
command = "python3" command = "python3"
if os.name == "nt": if os.name == "nt":