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
enum

View File

@ -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):

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):
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)

View File

@ -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<nick> [^!@\s]+ (?=!|$) )? !?
(?P<ident> [^!@\s]+ )? @?
(?P<host> \S+ )?
\Z
""",
re.VERBOSE
)
_raw_nick_pattern = re.compile(r"^(?P<nick>.+?)(?:!(?P<ident>.+?)@(?P<host>.+))?$")
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()

View File

@ -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":