From ea7f14459d3babf1640de504903ee67cd3152878 Mon Sep 17 00:00:00 2001 From: Embolalia Date: Sun, 6 Dec 2015 13:02:21 -0500 Subject: [PATCH 1/8] Add tracking of users and their accounts This uses some of the code that @maxpowa wrote for #941, but gives a somewhat more intuitive API. It also paves the way for potentially adding direct support for away-notify and metadata-notify. --- sopel/bot.py | 3 + sopel/coretasks.py | 160 ++++++++++++++++++++++++++++++++++++------ sopel/tools/target.py | 64 +++++++++++++++++ 3 files changed, 206 insertions(+), 21 deletions(-) create mode 100644 sopel/tools/target.py diff --git a/sopel/bot.py b/sopel/bot.py index b4cc290076..210fbad087 100644 --- a/sopel/bot.py +++ b/sopel/bot.py @@ -99,6 +99,9 @@ def __init__(self, config, daemon=False): bitwise integer value, determined by combining the appropriate constants from `module`.""" + self.channels_ = dict() # name to chan obj + self.users = dict() # name to user obj + self.db = SopelDB(config) """The bot's database.""" diff --git a/sopel/coretasks.py b/sopel/coretasks.py index e6d2dec6a1..f33a343d60 100644 --- a/sopel/coretasks.py +++ b/sopel/coretasks.py @@ -16,12 +16,14 @@ from __future__ import unicode_literals, absolute_import, print_function, division +from random import randint import re import sys import time import sopel import sopel.module from sopel.tools import Identifier, iteritems +from sopel.tools.target import User, Channel import base64 from sopel.logger import get_logger @@ -31,6 +33,7 @@ LOGGER = get_logger(__name__) batched_caps = {} +who_reqs = {} # Keeps track of reqs coming from this module, rather than others def auth_after_register(bot): @@ -234,6 +237,11 @@ def track_nicks(bot, trigger): value = bot.privileges[channel].pop(old) bot.privileges[channel][new] = value + for channel in bot.channels_.values(): + channel.rename_user(old, new) + if old in bot.users: + bot.users[new] = bot.users.pop(old) + @sopel.module.rule('(.*)') @sopel.module.event('PART') @@ -241,14 +249,9 @@ def track_nicks(bot, trigger): @sopel.module.thread(False) @sopel.module.unblockable def track_part(bot, trigger): - if trigger.nick == bot.nick: - bot.channels.remove(trigger.sender) - del bot.privileges[trigger.sender] - else: - try: - del bot.privileges[trigger.sender][trigger.nick] - except KeyError: - pass + nick = trigger.nick + channel = trigger.sender + _remove_from_channel(bot, nick, channel) @sopel.module.rule('.*') @@ -258,17 +261,58 @@ def track_part(bot, trigger): @sopel.module.unblockable def track_kick(bot, trigger): nick = Identifier(trigger.args[1]) + channel = trigger.sender + _remove_from_channel(bot, nick, channel) + + +def _remove_from_channel(bot, nick, channel): if nick == bot.nick: - bot.channels.remove(trigger.sender) - del bot.privileges[trigger.sender] + del bot.privileges[channel] + bot.channels.remove(channel) + + if channel in bot.channels_: + del bot.channels_[channel] + + lost_users = [] + for nick_, user in bot.users.items(): + if channel in user.channels: + del user.channels[channel] + if not user.channels: + lost_users.append(nick_) + for nick_ in lost_users: + del bot.users[nick_] + else: + if nick in bot.privileges[channel]: + del bot.privileges[channel][nick] + + user = bot.users.get(nick) + if user and channel in user.channels: + bot.channels_[channel].clear_user(nick) + if not user.channels: + del bot.users[nick] + bot.channels_[channel] + + +def _accounts_enabled(bot): + return ('account-notify' in bot.enabled_capabilities and + 'extended-join' in bot.enabled_capabilities) + + +def _send_who(bot, channel): + if _accounts_enabled(bot): + # WHOX syntax, see http://faerion.sourceforge.net/doc/irc/whox.var + # Needed for accounts in who replies. The random integer is a param + # to identify the reply as one from this command, because if someone + # else sent it, we have no fucking way to know what the format is. + rand = str(randint(0, 999)) + while rand in who_reqs: + rand = str(randint(0, 999)) + who_reqs[rand] = channel + bot.write(['WHO', channel, 'a%nuacht,' + rand]) else: - # Temporary fix to stop KeyErrors from being sent to channel - # The privileges dict may not have all nicks stored at all times - # causing KeyErrors - try: - del bot.privileges[trigger.sender][nick] - except KeyError: - pass + # We might be on an old network, but we still care about keeping our + # user list updated + bot.write(['WHO', channel]) @sopel.module.rule('.*') @@ -280,8 +324,19 @@ def track_join(bot, trigger): if trigger.nick == bot.nick and trigger.sender not in bot.channels: bot.channels.append(trigger.sender) bot.privileges[trigger.sender] = dict() + if trigger.nick == bot.nick and trigger.sender not in bot.channels_: + bot.channels_[trigger.sender] = Channel(trigger.sender) + _send_who(bot, trigger.sender) bot.privileges[trigger.sender][trigger.nick] = 0 + user = bot.users.get(trigger.nick) + if user is None: + user = User(trigger.nick, trigger.user, trigger.host) + bot.channels_[trigger.sender].add_user(user) + + if len(trigger.args) > 1 and trigger.args[1] != '*' and _accounts_enabled(bot): + user.account = trigger.args[1] + @sopel.module.rule('.*') @sopel.module.event('QUIT') @@ -292,6 +347,9 @@ def track_quit(bot, trigger): for chanprivs in bot.privileges.values(): if trigger.nick in chanprivs: del chanprivs[trigger.nick] + for channel in bot.channels_.values(): + channel.clear_user(trigger.nick) + bot.users.pop(trigger.nick, None) @sopel.module.rule('.*') @@ -314,10 +372,11 @@ def recieve_cap_list(bot, trigger): if req[0] and req[2]: # Call it. req[2](bot, req[0] + trigger) - # Server is acknowledinge SASL for us. - elif (trigger.args[0] == bot.nick and trigger.args[1] == 'ACK' and - 'sasl' in trigger.args[2]): - recieve_cap_ack_sasl(bot) + # Server is acknowledging SASL for us. + elif trigger.args[1] == 'ACK': + if (trigger.args[0] == bot.nick and 'sasl' in trigger.args[2]): + recieve_cap_ack_sasl(bot) + bot.enabled_capabilities.add(trigger.args[2].strip()) def recieve_cap_ls_reply(bot, trigger): @@ -347,6 +406,15 @@ def recieve_cap_ls_reply(bot, trigger): # parse it, so we don't need to worry if it fails. bot._cap_reqs['multi-prefix'] = (['', 'coretasks', None, None],) + def acct_warn(bot, cap): + LOGGER.info('Server does not support {}, or it conflicts with a custom ' + 'module. User account validation is not available.'.format( + cap)) + if 'account-notify' not in bot._cap_reqs: + bot._cap_reqs['account-notify'] = (['', 'coretasks', None, acct_warn],) + if 'extended-join' not in bot._cap_reqs: + bot._cap_reqs['extended-join'] = (['', 'coretasks', None, acct_warn],) + for cap, reqs in iteritems(bot._cap_reqs): # At this point, we know mandatory and prohibited don't co-exist, but # we need to call back for optionals if they're also prohibited @@ -493,3 +561,53 @@ def blocks(bot, trigger): return else: bot.reply(STRINGS['huh']) + + +@sopel.module.event('ACCOUNT') +@sopel.module.rule('.*') +def account_notify(bot, trigger): + print(trigger.nick) + if trigger.nick not in bot.users: + bot.users[trigger.nick] = User(trigger.nick, trigger.user, trigger.host) + account = trigger.args[0] + if account == '*': + account = None + bot.users[trigger.nick].account = account + + +@sopel.module.event('354') +@sopel.module.rule('.*') +@sopel.module.priority('high') +@sopel.module.unblockable +def recv_whox(bot, trigger): + if (len(trigger.args) < 2 or trigger.args[1] not in who_reqs or + not _accounts_enabled(bot)): + # Ignored, some module probably called WHO + # TODO a separate 352 handler function + return + if len(trigger.args) != 7: + return LOGGER.warning('While populating `bot.accounts` a WHO response was malformed.') + print(trigger.args) + _, _, channel, user, host, nick, account = trigger.args + nick = Identifier(nick) + channel = Identifier(channel) + if nick not in bot.users: + bot.users[nick] = User(nick, user, host) + user = bot.users[nick] + if account == '0': + user.account = None + else: + user.account = account + if channel not in bot.channels_: + bot.channels_[channel] = Channel(channel) + bot.channels_[channel].add_user(user) + + +@sopel.module.event('315') +@sopel.module.rule('.*') +@sopel.module.priority('high') +@sopel.module.unblockable +def end_who(bot, trigger): + if not _accounts_enabled(bot): + return + who_reqs.pop(trigger.args[1], None) diff --git a/sopel/tools/target.py b/sopel/tools/target.py new file mode 100644 index 0000000000..63d14a82d9 --- /dev/null +++ b/sopel/tools/target.py @@ -0,0 +1,64 @@ +# coding=utf-8 +from __future__ import unicode_literals, absolute_import, print_function, division + +import functools +from sopel.tools import Identifier + + +@functools.total_ordering +class User(object): + def __init__(self, nick, user, host): + assert isinstance(nick, Identifier) + self.nick = nick + self.user = user + self.host = host + self.channels = {} + + hostmask = property(lambda self: '{}!{}@{}'.format(self.nick, self.user, + self.host)) + + def __eq__(self, other): + if not isinstance(other, User): + return NotImplemented + return self.name == other.name + + def __lt__(self, other): + if not isinstance(other, User): + return NotImplemented + return self.name < other.name + + +@functools.total_ordering +class Channel(object): + def __init__(self, name): + assert isinstance(name, Identifier) + self.name = name + self.users = {} + self.privileges = {} + + def clear_user(self, nick): + user = self.users[nick] + user.channels.pop(self.name, None) + del self.users[nick] + del self.privileges[nick] + + def add_user(self, user): + assert isinstance(user, User) + self.users[user.nick] = user + self.privileges[user.nick] = 0 + + def rename_user(self, old, new): + if old in self.users: + self.users[new] = self.users.pop(old) + if old in self.privileges: + self.privileges[new] = self.privileges.pop(old) + + def __eq__(self, other): + if not isinstance(other, Channel): + return NotImplemented + return self.name == other.name + + def __lt__(self, other): + if not isinstance(other, Channel): + return NotImplemented + return self.name < other.name From 24a6fe381cdcc51653eaef84a3d4573af9d8b7e6 Mon Sep 17 00:00:00 2001 From: Embolalia Date: Sat, 12 Dec 2015 15:10:41 -0500 Subject: [PATCH 2/8] Address comments on PR #961 This includes being more consistent about using pop rather than del to prevent key errors, and adding some locking around the privilege related things --- sopel/bot.py | 4 ++-- sopel/coretasks.py | 32 +++++++++++++------------------- sopel/tools/target.py | 4 ++-- 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/sopel/bot.py b/sopel/bot.py index 210fbad087..9608fdf7fd 100644 --- a/sopel/bot.py +++ b/sopel/bot.py @@ -99,8 +99,8 @@ def __init__(self, config, daemon=False): bitwise integer value, determined by combining the appropriate constants from `module`.""" - self.channels_ = dict() # name to chan obj - self.users = dict() # name to user obj + self.channels_ = tools.SopelMemory() # name to chan obj + self.users = tools.SopelMemory() # name to user obj self.db = SopelDB(config) """The bot's database.""" diff --git a/sopel/coretasks.py b/sopel/coretasks.py index f33a343d60..5ae1b40698 100644 --- a/sopel/coretasks.py +++ b/sopel/coretasks.py @@ -267,30 +267,25 @@ def track_kick(bot, trigger): def _remove_from_channel(bot, nick, channel): if nick == bot.nick: - del bot.privileges[channel] + bot.privileges.pop(channel, None) bot.channels.remove(channel) - - if channel in bot.channels_: - del bot.channels_[channel] + bot.channels_.pop(channel, None) lost_users = [] for nick_, user in bot.users.items(): - if channel in user.channels: - del user.channels[channel] - if not user.channels: - lost_users.append(nick_) + user.channels.pop(channel, None) + if not user.channels: + lost_users.append(nick_) for nick_ in lost_users: - del bot.users[nick_] + bot.users.pop(nick_, None) else: - if nick in bot.privileges[channel]: - del bot.privileges[channel][nick] + bot.privileges[channel].pop(nick, None) user = bot.users.get(nick) if user and channel in user.channels: bot.channels_[channel].clear_user(nick) if not user.channels: - del bot.users[nick] - bot.channels_[channel] + bot.users.pop(nick, None) def _accounts_enabled(bot): @@ -324,9 +319,10 @@ def track_join(bot, trigger): if trigger.nick == bot.nick and trigger.sender not in bot.channels: bot.channels.append(trigger.sender) bot.privileges[trigger.sender] = dict() - if trigger.nick == bot.nick and trigger.sender not in bot.channels_: + bot.channels_[trigger.sender] = Channel(trigger.sender) _send_who(bot, trigger.sender) + bot.privileges[trigger.sender][trigger.nick] = 0 user = bot.users.get(trigger.nick) @@ -345,8 +341,7 @@ def track_join(bot, trigger): @sopel.module.unblockable def track_quit(bot, trigger): for chanprivs in bot.privileges.values(): - if trigger.nick in chanprivs: - del chanprivs[trigger.nick] + chanprivs.pop(trigger.nick, None) for channel in bot.channels_.values(): channel.clear_user(trigger.nick) bot.users.pop(trigger.nick, None) @@ -608,6 +603,5 @@ def recv_whox(bot, trigger): @sopel.module.priority('high') @sopel.module.unblockable def end_who(bot, trigger): - if not _accounts_enabled(bot): - return - who_reqs.pop(trigger.args[1], None) + if _accounts_enabled(bot): + who_reqs.pop(trigger.args[1], None) diff --git a/sopel/tools/target.py b/sopel/tools/target.py index 63d14a82d9..c9ccb220a0 100644 --- a/sopel/tools/target.py +++ b/sopel/tools/target.py @@ -20,12 +20,12 @@ def __init__(self, nick, user, host): def __eq__(self, other): if not isinstance(other, User): return NotImplemented - return self.name == other.name + return self.nick == other.nick def __lt__(self, other): if not isinstance(other, User): return NotImplemented - return self.name < other.name + return self.nick < other.nick @functools.total_ordering From 54824f05d5521f7131474ea4172242a47d6d0fb4 Mon Sep 17 00:00:00 2001 From: Embolalia Date: Sat, 12 Dec 2015 15:31:19 -0500 Subject: [PATCH 3/8] Add user tracking for RFC WHO replies --- sopel/coretasks.py | 18 ++++++++++++++---- sopel/tools/target.py | 1 + 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/sopel/coretasks.py b/sopel/coretasks.py index 5ae1b40698..c4087cadef 100644 --- a/sopel/coretasks.py +++ b/sopel/coretasks.py @@ -561,7 +561,6 @@ def blocks(bot, trigger): @sopel.module.event('ACCOUNT') @sopel.module.rule('.*') def account_notify(bot, trigger): - print(trigger.nick) if trigger.nick not in bot.users: bot.users[trigger.nick] = User(trigger.nick, trigger.user, trigger.host) account = trigger.args[0] @@ -576,14 +575,16 @@ def account_notify(bot, trigger): @sopel.module.unblockable def recv_whox(bot, trigger): if (len(trigger.args) < 2 or trigger.args[1] not in who_reqs or - not _accounts_enabled(bot)): + not _accounts_enabled(bot)): # Ignored, some module probably called WHO - # TODO a separate 352 handler function return if len(trigger.args) != 7: return LOGGER.warning('While populating `bot.accounts` a WHO response was malformed.') - print(trigger.args) _, _, channel, user, host, nick, account = trigger.args + _record_who(bot, channel, user, host, nick, account) + + +def _record_who(bot, channel, user, host, nick, account=None): nick = Identifier(nick) channel = Identifier(channel) if nick not in bot.users: @@ -598,6 +599,15 @@ def recv_whox(bot, trigger): bot.channels_[channel].add_user(user) +@sopel.module.event('352') +@sopel.module.rule('.*') +@sopel.module.priority('high') +@sopel.module.unblockable +def recv_who(bot, trigger): + channel, user, host, _, nick, = trigger.args[1:6] + _record_who(bot, channel, user, host, nick) + + @sopel.module.event('315') @sopel.module.rule('.*') @sopel.module.priority('high') diff --git a/sopel/tools/target.py b/sopel/tools/target.py index c9ccb220a0..65289a0079 100644 --- a/sopel/tools/target.py +++ b/sopel/tools/target.py @@ -46,6 +46,7 @@ def add_user(self, user): assert isinstance(user, User) self.users[user.nick] = user self.privileges[user.nick] = 0 + user.channels[self.name] = self def rename_user(self, old, new): if old in self.users: From c2a0b177b953ec10e66ec15477c72e2539abadc8 Mon Sep 17 00:00:00 2001 From: Embolalia Date: Sat, 12 Dec 2015 16:28:41 -0500 Subject: [PATCH 4/8] Add away tracking --- sopel/bot.py | 2 +- sopel/coretasks.py | 47 +++++++++++++++++++++++++++++++------------ sopel/tools/target.py | 2 ++ sopel/trigger.py | 4 ++-- 4 files changed, 39 insertions(+), 16 deletions(-) diff --git a/sopel/bot.py b/sopel/bot.py index 9608fdf7fd..64256ec656 100644 --- a/sopel/bot.py +++ b/sopel/bot.py @@ -270,7 +270,7 @@ def call(self, func, sopel, trigger): def dispatch(self, pretrigger): args = pretrigger.args - event, args, text = pretrigger.event, args, args[-1] + event, args, text = pretrigger.event, args, args[-1] if args else '' if self.config.core.nick_blocks or self.config.core.host_blocks: nick_blocked = self._nick_blocked(pretrigger.nick) diff --git a/sopel/coretasks.py b/sopel/coretasks.py index c4087cadef..4ace78d47a 100644 --- a/sopel/coretasks.py +++ b/sopel/coretasks.py @@ -288,13 +288,16 @@ def _remove_from_channel(bot, nick, channel): bot.users.pop(nick, None) -def _accounts_enabled(bot): - return ('account-notify' in bot.enabled_capabilities and - 'extended-join' in bot.enabled_capabilities) +def _whox_enabled(bot): + # Either privilege tracking or away notification. For simplicity, both + # account notify and extended join must be there for account tracking. + return (('account-notify' in bot.enabled_capabilities and + 'extended-join' in bot.enabled_capabilities) or + 'away-notify' in bot.enabled_capabilities) def _send_who(bot, channel): - if _accounts_enabled(bot): + if _whox_enabled(bot): # WHOX syntax, see http://faerion.sourceforge.net/doc/irc/whox.var # Needed for accounts in who replies. The random integer is a param # to identify the reply as one from this command, because if someone @@ -303,7 +306,7 @@ def _send_who(bot, channel): while rand in who_reqs: rand = str(randint(0, 999)) who_reqs[rand] = channel - bot.write(['WHO', channel, 'a%nuacht,' + rand]) + bot.write(['WHO', channel, 'a%nuachtf,' + rand]) else: # We might be on an old network, but we still care about keeping our # user list updated @@ -330,7 +333,9 @@ def track_join(bot, trigger): user = User(trigger.nick, trigger.user, trigger.host) bot.channels_[trigger.sender].add_user(user) - if len(trigger.args) > 1 and trigger.args[1] != '*' and _accounts_enabled(bot): + if len(trigger.args) > 1 and trigger.args[1] != '*' and ( + 'account-notify' in bot.enabled_capabilities and + 'extended-join' in bot.enabled_capabilities): user.account = trigger.args[1] @@ -409,6 +414,8 @@ def acct_warn(bot, cap): bot._cap_reqs['account-notify'] = (['', 'coretasks', None, acct_warn],) if 'extended-join' not in bot._cap_reqs: bot._cap_reqs['extended-join'] = (['', 'coretasks', None, acct_warn],) + if 'away-notify' not in bot._cap_reqs: + bot._cap_reqs['away-notify'] = (['', 'coretasks', None, None],) for cap, reqs in iteritems(bot._cap_reqs): # At this point, we know mandatory and prohibited don't co-exist, but @@ -574,17 +581,18 @@ def account_notify(bot, trigger): @sopel.module.priority('high') @sopel.module.unblockable def recv_whox(bot, trigger): - if (len(trigger.args) < 2 or trigger.args[1] not in who_reqs or - not _accounts_enabled(bot)): + print(trigger.args) + if len(trigger.args) < 2 or trigger.args[1] not in who_reqs: # Ignored, some module probably called WHO return - if len(trigger.args) != 7: + if len(trigger.args) != 8: return LOGGER.warning('While populating `bot.accounts` a WHO response was malformed.') - _, _, channel, user, host, nick, account = trigger.args - _record_who(bot, channel, user, host, nick, account) + _, _, channel, user, host, nick, status, account = trigger.args + away = 'G' in status + _record_who(bot, channel, user, host, nick, account, away) -def _record_who(bot, channel, user, host, nick, account=None): +def _record_who(bot, channel, user, host, nick, account=None, away=None): nick = Identifier(nick) channel = Identifier(channel) if nick not in bot.users: @@ -594,6 +602,7 @@ def _record_who(bot, channel, user, host, nick, account=None): user.account = None else: user.account = account + user.away = away if channel not in bot.channels_: bot.channels_[channel] = Channel(channel) bot.channels_[channel].add_user(user) @@ -613,5 +622,17 @@ def recv_who(bot, trigger): @sopel.module.priority('high') @sopel.module.unblockable def end_who(bot, trigger): - if _accounts_enabled(bot): + if _whox_enabled(bot): who_reqs.pop(trigger.args[1], None) + + +@sopel.module.rule('.*') +@sopel.module.event('AWAY') +@sopel.module.priority('high') +@sopel.module.thread(False) +@sopel.module.unblockable +def track_notify(bot, trigger): + if trigger.nick not in bot.users: + bot.users[trigger.nick] = User(trigger.nick, trigger.user, trigger.host) + user = bot.users[trigger.nick] + user.away = bool(trigger.args) diff --git a/sopel/tools/target.py b/sopel/tools/target.py index 65289a0079..e3626648b5 100644 --- a/sopel/tools/target.py +++ b/sopel/tools/target.py @@ -13,6 +13,8 @@ def __init__(self, nick, user, host): self.user = user self.host = host self.channels = {} + self.account = None + self.away = None hostmask = property(lambda self: '{}!{}@{}'.format(self.nick, self.user, self.host)) diff --git a/sopel/trigger.py b/sopel/trigger.py index ef3049335d..766ef4dd36 100644 --- a/sopel/trigger.py +++ b/sopel/trigger.py @@ -131,10 +131,10 @@ class Trigger(unicode): """True if the nick which triggered the command is the bot's owner.""" def __new__(cls, config, message, match): - self = unicode.__new__(cls, message.args[-1]) + self = unicode.__new__(cls, message.args[-1] if message.args else '') self._pretrigger = message self._match = match - self._is_privmsg = message.sender.is_nick() + self._is_privmsg = message.sender and message.sender.is_nick() def match_host_or_nick(pattern): pattern = sopel.tools.get_hostmask_regex(pattern) From 729ee590504639a4de80c01577f3a63cd4632c15 Mon Sep 17 00:00:00 2001 From: Embolalia Date: Sat, 12 Dec 2015 16:41:43 -0500 Subject: [PATCH 5/8] Add support for account-tag --- sopel/coretasks.py | 16 ++++++++-------- sopel/irc.py | 2 ++ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/sopel/coretasks.py b/sopel/coretasks.py index 4ace78d47a..c3c8724e4e 100644 --- a/sopel/coretasks.py +++ b/sopel/coretasks.py @@ -405,17 +405,17 @@ def recieve_cap_ls_reply(bot, trigger): # Whether or not the server supports multi-prefix doesn't change how we # parse it, so we don't need to worry if it fails. bot._cap_reqs['multi-prefix'] = (['', 'coretasks', None, None],) + if 'away-notify' not in bot._cap_reqs: + bot._cap_reqs['away-notify'] = (['', 'coretasks', None, None],) def acct_warn(bot, cap): LOGGER.info('Server does not support {}, or it conflicts with a custom ' - 'module. User account validation is not available.'.format( - cap)) - if 'account-notify' not in bot._cap_reqs: - bot._cap_reqs['account-notify'] = (['', 'coretasks', None, acct_warn],) - if 'extended-join' not in bot._cap_reqs: - bot._cap_reqs['extended-join'] = (['', 'coretasks', None, acct_warn],) - if 'away-notify' not in bot._cap_reqs: - bot._cap_reqs['away-notify'] = (['', 'coretasks', None, None],) + 'module. User account validation unavailable or limited.' + .format(cap)) + auth_caps = ['account-notify', 'extended-join', 'account-tag'] + for cap in auth_caps: + if cap not in bot._cap_reqs: + bot._cap_reqs[cap] = (['', 'coretasks', None, acct_warn],) for cap, reqs in iteritems(bot._cap_reqs): # At this point, we know mandatory and prohibited don't co-exist, but diff --git a/sopel/irc.py b/sopel/irc.py index f26a1afcd0..f49f0cdb5f 100644 --- a/sopel/irc.py +++ b/sopel/irc.py @@ -366,6 +366,8 @@ def found_terminator(self): self.buffer = '' self.last_ping_time = datetime.now() pretrigger = PreTrigger(self.nick, line) + if 'account-tag' not in self.enabled_capabilities: + pretrigger.tags.pop('account', None) if pretrigger.event == 'PING': self.write(('PONG', pretrigger.args[-1])) From 8f7af2a7f2c706eeee5410bf7c7b2d2548a8dc1c Mon Sep 17 00:00:00 2001 From: Embolalia Date: Sat, 12 Dec 2015 16:53:31 -0500 Subject: [PATCH 6/8] Enable the account-tag capability --- sopel/bot.py | 4 +++- sopel/trigger.py | 9 ++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/sopel/bot.py b/sopel/bot.py index 64256ec656..ddf0cdd46b 100644 --- a/sopel/bot.py +++ b/sopel/bot.py @@ -286,7 +286,9 @@ def dispatch(self, pretrigger): match = regexp.match(text) if not match: continue - trigger = Trigger(self.config, pretrigger, match) + user_obj = self.users.get(pretrigger.nick) + account = user_obj.account if user_obj else None + trigger = Trigger(self.config, pretrigger, match, account) wrapper = self.SopelWrapper(self, trigger) for func in funcs: diff --git a/sopel/trigger.py b/sopel/trigger.py index 766ef4dd36..eedb611bb1 100644 --- a/sopel/trigger.py +++ b/sopel/trigger.py @@ -129,8 +129,15 @@ class Trigger(unicode): """ owner = property(lambda self: self._owner) """True if the nick which triggered the command is the bot's owner.""" + account = property(lambda self: self.tags.get('account', self._account)) + """The account name of the user sending the message. - def __new__(cls, config, message, match): + This is only available if either the account-tag or the account-notify and + extended-join capabilites are available. If this isn't the case, or the user + sending the message isn't logged in, this will be None. + """ + + def __new__(cls, config, message, match, account=None): self = unicode.__new__(cls, message.args[-1] if message.args else '') self._pretrigger = message self._match = match From f17987435bffb8dede92d345fd443bcaee32eb46 Mon Sep 17 00:00:00 2001 From: Embolalia Date: Fri, 18 Dec 2015 20:17:03 -0500 Subject: [PATCH 7/8] Replace channels list with channels dictionary Hopefully, nobody else is taking advantage of channels being a list, rather than a dict. If they are, well, oops. --- sopel/bot.py | 14 +++++++++++++- sopel/coretasks.py | 24 +++++++++--------------- sopel/irc.py | 18 ++++-------------- 3 files changed, 26 insertions(+), 30 deletions(-) diff --git a/sopel/bot.py b/sopel/bot.py index ddf0cdd46b..058e747221 100644 --- a/sopel/bot.py +++ b/sopel/bot.py @@ -95,12 +95,24 @@ def __init__(self, config, daemon=False): self.privileges = dict() """A dictionary of channels to their users and privilege levels + Deprecated from 6.2.0; use bot.channels instead. + The value associated with each channel is a dictionary of Identifiers to a bitwise integer value, determined by combining the appropriate constants from `module`.""" - self.channels_ = tools.SopelMemory() # name to chan obj + self.channels = tools.SopelMemory() # name to chan obj + """A map of the channels that Sopel is in. + + The keys are Identifiers of the channel names, and map to Channel + objects which contain the users in the channel and their permissions. + """ self.users = tools.SopelMemory() # name to user obj + """A map of the users that Sopel is aware of. + + In order for Sopel to be aware of a user, it must be in at least one + channel which they are also in. + """ self.db = SopelDB(config) """The bot's database.""" diff --git a/sopel/coretasks.py b/sopel/coretasks.py index c3c8724e4e..6be3251184 100644 --- a/sopel/coretasks.py +++ b/sopel/coretasks.py @@ -118,8 +118,6 @@ def retry_join(bot, trigger): time.sleep(6) bot.join(channel) -#Functions to maintain a list of chanops in all of sopel's channels. - @sopel.module.rule('(.*)') @sopel.module.event('353') @@ -237,7 +235,7 @@ def track_nicks(bot, trigger): value = bot.privileges[channel].pop(old) bot.privileges[channel][new] = value - for channel in bot.channels_.values(): + for channel in bot.channels.values(): channel.rename_user(old, new) if old in bot.users: bot.users[new] = bot.users.pop(old) @@ -268,8 +266,7 @@ def track_kick(bot, trigger): def _remove_from_channel(bot, nick, channel): if nick == bot.nick: bot.privileges.pop(channel, None) - bot.channels.remove(channel) - bot.channels_.pop(channel, None) + bot.channels.pop(channel, None) lost_users = [] for nick_, user in bot.users.items(): @@ -283,7 +280,7 @@ def _remove_from_channel(bot, nick, channel): user = bot.users.get(nick) if user and channel in user.channels: - bot.channels_[channel].clear_user(nick) + bot.channels[channel].clear_user(nick) if not user.channels: bot.users.pop(nick, None) @@ -320,10 +317,8 @@ def _send_who(bot, channel): @sopel.module.unblockable def track_join(bot, trigger): if trigger.nick == bot.nick and trigger.sender not in bot.channels: - bot.channels.append(trigger.sender) bot.privileges[trigger.sender] = dict() - - bot.channels_[trigger.sender] = Channel(trigger.sender) + bot.channels[trigger.sender] = Channel(trigger.sender) _send_who(bot, trigger.sender) bot.privileges[trigger.sender][trigger.nick] = 0 @@ -331,7 +326,7 @@ def track_join(bot, trigger): user = bot.users.get(trigger.nick) if user is None: user = User(trigger.nick, trigger.user, trigger.host) - bot.channels_[trigger.sender].add_user(user) + bot.channels[trigger.sender].add_user(user) if len(trigger.args) > 1 and trigger.args[1] != '*' and ( 'account-notify' in bot.enabled_capabilities and @@ -347,7 +342,7 @@ def track_join(bot, trigger): def track_quit(bot, trigger): for chanprivs in bot.privileges.values(): chanprivs.pop(trigger.nick, None) - for channel in bot.channels_.values(): + for channel in bot.channels.values(): channel.clear_user(trigger.nick) bot.users.pop(trigger.nick, None) @@ -581,7 +576,6 @@ def account_notify(bot, trigger): @sopel.module.priority('high') @sopel.module.unblockable def recv_whox(bot, trigger): - print(trigger.args) if len(trigger.args) < 2 or trigger.args[1] not in who_reqs: # Ignored, some module probably called WHO return @@ -603,9 +597,9 @@ def _record_who(bot, channel, user, host, nick, account=None, away=None): else: user.account = account user.away = away - if channel not in bot.channels_: - bot.channels_[channel] = Channel(channel) - bot.channels_[channel].add_user(user) + if channel not in bot.channels: + bot.channels[channel] = Channel(channel) + bot.channels[channel].add_user(user) @sopel.module.event('352') diff --git a/sopel/irc.py b/sopel/irc.py index f49f0cdb5f..e213754363 100644 --- a/sopel/irc.py +++ b/sopel/irc.py @@ -64,9 +64,6 @@ def __init__(self, config): self.name = config.core.name """Sopel's "real name", as used for whois.""" - self.channels = [] - """The list of channels Sopel is currently in.""" - self.stack = {} self.ca_certs = ca_certs self.hasquit = False @@ -78,20 +75,13 @@ def __init__(self, config): # Right now, only accounting for two op levels. # This might be expanded later. # These lists are filled in startup.py, as of right now. + # Are these even touched at all anymore? Remove in 7.0. self.ops = dict() - """ - A dictionary mapping channels to a ``Identifier`` list of their operators. - """ + """Deprecated. Use bot.channels instead.""" self.halfplus = dict() - """ - A dictionary mapping channels to a ``Identifier`` list of their half-ops and - ops. - """ + """Deprecated. Use bot.channels instead.""" self.voices = dict() - """ - A dictionary mapping channels to a ``Identifier`` list of their voices, - half-ops and ops. - """ + """Deprecated. Use bot.channels instead.""" # We need this to prevent error loops in handle_error self.error_count = 0 From 941ec2b59e44a8cd21e299d2119e21dbb71225a0 Mon Sep 17 00:00:00 2001 From: Embolalia Date: Sat, 19 Dec 2015 18:20:30 -0500 Subject: [PATCH 8/8] Add cap-notify support See #971 --- sopel/bot.py | 41 +++++++++++++++++--------- sopel/coretasks.py | 72 ++++++++++++++++++++++++++++++++-------------- 2 files changed, 78 insertions(+), 35 deletions(-) diff --git a/sopel/bot.py b/sopel/bot.py index 058e747221..3563f9250b 100644 --- a/sopel/bot.py +++ b/sopel/bot.py @@ -39,6 +39,18 @@ py3 = False +class _CapReq(object): + def __init__(self, prefix, module, failure=None, arg=None, success=None): + def nop(bot, cap): + pass + # TODO at some point, reorder those args to be sane + self.prefix = prefix + self.module = module + self.arg = arg + self.failure = failure or nop + self.success = success or nop + + class Sopel(irc.Bot): def __init__(self, config, daemon=False): irc.Bot.__init__(self, config) @@ -85,12 +97,7 @@ def __init__(self, config, daemon=False): self.enabled_capabilities = set() """A set containing the IRCv3 capabilities that the bot has enabled.""" self._cap_reqs = dict() - """A dictionary of capability requests - - Maps the capability name to a list of tuples of the prefix ('-', '=', - or ''), the name of the requesting module, the function to call if the - the request is rejected, and the argument to the capability (or None). - """ + """A dictionary of capability names to a list of requests""" self.privileges = dict() """A dictionary of channels to their users and privilege levels @@ -381,7 +388,8 @@ def _shutdown(self): ) ) - def cap_req(self, module_name, capability, arg=None, failure_callback=None): + def cap_req(self, module_name, capability, arg=None, failure_callback=None, + success_callback=None): """Tell Sopel to request a capability when it starts. By prefixing the capability with `-`, it will be ensured that the @@ -404,7 +412,12 @@ def cap_req(self, module_name, capability, arg=None, failure_callback=None): request, the `failure_callback` function will be called, if provided. The arguments will be a `Sopel` object, and the capability which was rejected. This can be used to disable callables which rely on the - capability. In future versions + capability. It will be be called either if the server NAKs the request, + or if the server enabled it and later DELs it. + + The `success_callback` function will be called upon acknowledgement of + the capability from the server, whether during the initial capability + negotiation, or later. If ``arg`` is given, and does not exactly match what the server provides or what other modules have requested for that capability, it is @@ -415,16 +428,17 @@ def cap_req(self, module_name, capability, arg=None, failure_callback=None): prefix = capability[0] entry = self._cap_reqs.get(cap, []) - if any((ent[3] != arg for ent in entry)): + if any((ent.arg != arg for ent in entry)): raise Exception('Capability conflict') if prefix == '-': if self.connection_registered and cap in self.enabled_capabilities: raise Exception('Can not change capabilities after server ' 'connection has been completed.') - if any((ent[0] != '-' for ent in entry)): + if any((ent.prefix != '-' for ent in entry)): raise Exception('Capability conflict') - entry.append((prefix, module_name, failure_callback, arg)) + entry.append(_CapReq(prefix, module_name, failure_callback, arg, + success_callback)) self._cap_reqs[cap] = entry else: if prefix != '=': @@ -436,7 +450,8 @@ def cap_req(self, module_name, capability, arg=None, failure_callback=None): 'connection has been completed.') # Non-mandatory will callback at the same time as if the server # rejected it. - if any((ent[0] == '-' for ent in entry)) and prefix == '=': + if any((ent.prefix == '-' for ent in entry)) and prefix == '=': raise Exception('Capability conflict') - entry.append((prefix, module_name, failure_callback, arg)) + entry.append(_CapReq(prefix, module_name, failure_callback, arg, + success_callback)) self._cap_reqs[cap] = entry diff --git a/sopel/coretasks.py b/sopel/coretasks.py index 6be3251184..cd18d7afbe 100644 --- a/sopel/coretasks.py +++ b/sopel/coretasks.py @@ -22,6 +22,7 @@ import time import sopel import sopel.module +from sopel.bot import _CapReq from sopel.tools import Identifier, iteritems from sopel.tools.target import User, Channel import base64 @@ -353,25 +354,54 @@ def track_quit(bot, trigger): @sopel.module.priority('high') @sopel.module.unblockable def recieve_cap_list(bot, trigger): + cap = trigger.strip('-=~') # Server is listing capabilites if trigger.args[1] == 'LS': recieve_cap_ls_reply(bot, trigger) # Server denied CAP REQ elif trigger.args[1] == 'NAK': - entry = bot._cap_reqs.get(trigger, None) + entry = bot._cap_reqs.get(cap, None) # If it was requested with bot.cap_req if entry: for req in entry: # And that request was mandatory/prohibit, and a callback was # provided - if req[0] and req[2]: + if req.prefix and req.failure: # Call it. - req[2](bot, req[0] + trigger) - # Server is acknowledging SASL for us. + req.failure(bot, req.prefix + cap) + # Server is removing a capability + elif trigger.args[1] == 'DEL': + entry = bot._cap_reqs.get(cap, None) + # If it was requested with bot.cap_req + if entry: + for req in entry: + # And that request wasn't prohibit, and a callback was + # provided + if req.prefix != '-' and req.failure: + # Call it. + req.failure(bot, req.prefix + cap) + # Server is adding new capability + elif trigger.args[1] == 'NEW': + entry = bot._cap_reqs.get(cap, None) + # If it was requested with bot.cap_req + if entry: + for req in entry: + # And that request wasn't prohibit + if req.prefix != '-': + # Request it + bot.write(('CAP', 'REQ', req.prefix + cap)) + # Server is acknowledging a capability elif trigger.args[1] == 'ACK': - if (trigger.args[0] == bot.nick and 'sasl' in trigger.args[2]): - recieve_cap_ack_sasl(bot) - bot.enabled_capabilities.add(trigger.args[2].strip()) + caps = trigger.args[2].split() + for cap in caps: + cap.strip('-~= ') + bot.enabled_capabilities.add(cap) + entry = bot._cap_reqs.get(cap, []) + for req in entry: + if req.success: + req.success(bot, req.prefix + trigger) + if cap == 'sasl': # TODO why is this not done with bot.cap_req? + recieve_cap_ack_sasl(bot) def recieve_cap_ls_reply(bot, trigger): @@ -396,43 +426,41 @@ def recieve_cap_ls_reply(bot, trigger): # If some other module requests it, we don't need to add another request. # If some other module prohibits it, we shouldn't request it. - if 'multi-prefix' not in bot._cap_reqs: - # Whether or not the server supports multi-prefix doesn't change how we - # parse it, so we don't need to worry if it fails. - bot._cap_reqs['multi-prefix'] = (['', 'coretasks', None, None],) - if 'away-notify' not in bot._cap_reqs: - bot._cap_reqs['away-notify'] = (['', 'coretasks', None, None],) + core_caps = ['multi-prefix', 'away-notify', 'cap-notify'] + for cap in core_caps: + if cap not in bot._cap_reqs: + bot._cap_reqs[cap] = [_CapReq('', 'coretasks')] def acct_warn(bot, cap): LOGGER.info('Server does not support {}, or it conflicts with a custom ' 'module. User account validation unavailable or limited.' - .format(cap)) + .format(cap[1:])) auth_caps = ['account-notify', 'extended-join', 'account-tag'] for cap in auth_caps: if cap not in bot._cap_reqs: - bot._cap_reqs[cap] = (['', 'coretasks', None, acct_warn],) + bot._cap_reqs[cap] = [_CapReq('=', 'coretasks', acct_warn)] for cap, reqs in iteritems(bot._cap_reqs): # At this point, we know mandatory and prohibited don't co-exist, but # we need to call back for optionals if they're also prohibited prefix = '' for entry in reqs: - if prefix == '-' and entry[0] != '-': - entry[2](bot, entry[0] + cap) + if prefix == '-' and entry.prefix != '-': + entry.failure(bot, entry.prefix + cap) continue - if entry[0]: - prefix = entry[0] + if entry.prefix: + prefix = entry.prefix # It's not required, or it's supported, so we can request it if prefix != '=' or cap in bot.server_capabilities: # REQs fail as a whole, so we send them one capability at a time - bot.write(('CAP', 'REQ', entry[0] + cap)) + bot.write(('CAP', 'REQ', entry.prefix + cap)) # If it's required but not in server caps, we need to call all the # callbacks else: for entry in reqs: - if entry[2] and entry[0] == '=': - entry[2](bot, entry[0] + cap) + if entry.failure and entry.prefix == '=': + entry.failure(bot, entry.prefix + cap) # If we want to do SASL, we have to wait before we can send CAP END. So if # we are, wait on 903 (SASL successful) to send it.