diff --git a/plugins/AutoMode/plugin.py b/plugins/AutoMode/plugin.py index 738aae428..f11ed3238 100644 --- a/plugins/AutoMode/plugin.py +++ b/plugins/AutoMode/plugin.py @@ -163,8 +163,9 @@ def unban(): # We're not in the channel anymore. pass schedule.addEvent(unban, time.time()+period) - banmask =conf.supybot.protocols.irc.banmask.makeBanmask(msg.prefix) - irc.queueMsg(ircmsgs.ban(channel, banmask)) + banmasks = conf.supybot.protocols.irc.banmask.makeExtBanmasks( + msg.prefix, channel=channel, network=irc.network) + irc.queueMsg(ircmsgs.bans(channel, banmasks)) irc.queueMsg(ircmsgs.kick(channel, msg.nick)) try: diff --git a/plugins/Channel/plugin.py b/plugins/Channel/plugin.py index 1f3f97fed..3589e8bd5 100644 --- a/plugins/Channel/plugin.py +++ b/plugins/Channel/plugin.py @@ -321,13 +321,16 @@ def kban(self, irc, msg, args, --exact bans only the exact hostmask; --nick bans just the nick; --user bans just the user, and --host bans just the host You can combine the --nick, --user, and --host options as you choose. + If --account is provided and the user is logged in and the network + supports account bans, this will ban the user's account instead. is only necessary if the message isn't sent in the channel itself. """ self._ban(irc, msg, args, channel, optlist, bannedNick, expiry, reason, True) kban = wrap(kban, ['op', - getopts({'exact':'', 'nick':'', 'user':'', 'host':''}), + getopts({'exact':'', 'nick':'', 'user':'', 'host':'', + 'account': ''}), ('haveHalfop+', _('kick or ban someone')), 'nickInChannel', optional('expiry', 0), @@ -343,13 +346,16 @@ def iban(self, irc, msg, args, don't specify a number of seconds) it will ban the person indefinitely. --exact can be used to specify an exact hostmask. You can combine the --nick, --user, and --host options as you choose. + If --account is provided and the user is logged in and the network + supports account bans, this will ban the user's account instead. is only necessary if the message isn't sent in the channel itself. """ self._ban(irc, msg, args, channel, optlist, bannedNick, expiry, None, False) iban = wrap(iban, ['op', - getopts({'exact':'', 'nick':'', 'user':'', 'host':''}), + getopts({'exact':'', 'nick':'', 'user':'', 'host':'', + 'account': ''}), ('haveHalfop+', _('ban someone')), first('nick', 'hostmask'), optional('expiry', 0)]) @@ -362,18 +368,22 @@ def _ban(self, irc, msg, args, try: bannedHostmask = irc.state.nickToHostmask(target) banmaskstyle = conf.supybot.protocols.irc.banmask - banmask = banmaskstyle.makeBanmask(bannedHostmask, [o[0] for o in optlist]) + banmasks = banmaskstyle.makeExtBanmasks( + bannedHostmask, [o[0] for o in optlist], + channel=channel, network=irc.network) except KeyError: if not conf.supybot.protocols.irc.strictRfc() and \ target.startswith('$'): # Select the last part, or the whole target: bannedNick = target.split(':')[-1] - banmask = bannedHostmask = target + bannedHostmask = target + banmasks = [bannedHostmask] else: irc.error(format(_('I haven\'t seen %s.'), bannedNick), Raise=True) else: bannedNick = ircutils.nickFromHostmask(target) - banmask = bannedHostmask = target + bannedHostmask = target + banmasks = [bannedHostmask] if not irc.isNick(bannedNick): self.log.warning('%q tried to kban a non nick: %q', msg.prefix, bannedNick) @@ -389,30 +399,47 @@ def _ban(self, irc, msg, args, if not reason: reason = msg.nick capability = ircdb.makeChannelCapability(channel, 'op') + # Check (again) that they're not trying to make us kickban ourself. - if ircutils.hostmaskPatternEqual(banmask, irc.prefix): - if ircutils.hostmaskPatternEqual(bannedHostmask, irc.prefix): + self_account_extban = ircutils.accountExtban(irc, irc.nick) + for banmask in banmasks: + if ircutils.hostmaskPatternEqual(banmask, irc.prefix): + if ircutils.hostmaskPatternEqual(bannedHostmask, irc.prefix): + self.log.warning('%q tried to make me kban myself.',msg.prefix) + irc.error(_('I cowardly refuse to ban myself.')) + return + else: + self.log.warning('Using exact hostmask since banmask would ' + 'ban myself.') + banmasks = [bannedHostmask] + elif self_account_extban is not None \ + and banmask.lower() == self_account_extban.lower(): self.log.warning('%q tried to make me kban myself.',msg.prefix) irc.error(_('I cowardly refuse to ban myself.')) return - else: - self.log.warning('Using exact hostmask since banmask would ' - 'ban myself.') - banmask = bannedHostmask + + # Now, let's actually get to it. Check to make sure they have # #channel,op and the bannee doesn't have #channel,op; or that the # bannee and the banner are both the same person. def doBan(): if irc.state.channels[channel].isOp(bannedNick): irc.queueMsg(ircmsgs.deop(channel, bannedNick)) - irc.queueMsg(ircmsgs.ban(channel, banmask)) + irc.queueMsg(ircmsgs.bans(channel, banmasks)) if kick: irc.queueMsg(ircmsgs.kick(channel, bannedNick, reason)) if expiry > 0: def f(): - if channel in irc.state.channels and \ - banmask in irc.state.channels[channel].bans: - irc.queueMsg(ircmsgs.unban(channel, banmask)) + if channel not in irc.state.channels: + return + remaining_banmasks = [ + banmask + for banmask in banmasks + if banmask in irc.state.channels[channel].bans + ] + if remaining_banmasks: + irc.queueMsg(ircmsgs.unbans( + channel, remaining_banmasks)) schedule.addEvent(f, expiry) if bannedNick == msg.nick: doBan() @@ -583,7 +610,7 @@ def hostmask(self, irc, msg, args, channel, banmask): hostmask = wrap(hostmask, ['op', ('haveHalfop+', _('ban someone')), 'text']) @internationalizeDocstring - def add(self, irc, msg, args, channel, banmask, expires): + def add(self, irc, msg, args, channel, banmasks, expires): """[] [] If you have the #channel,op capability, this will effect a @@ -597,10 +624,15 @@ def add(self, irc, msg, args, channel, banmask, expires): channel itself. """ c = ircdb.channels.getChannel(channel) - c.addBan(banmask, expires) + if isinstance(banmasks, str): + banmasks = [banmasks] + for banmask in banmasks: + c.addBan(banmask, expires) ircdb.channels.setChannel(channel, c) irc.replySuccess() - add = wrap(add, ['op', first('hostmask', 'banmask'), additional('expiry', 0)]) + add = wrap(add, ['op', + first('hostmask', 'extbanmasks'), + additional('expiry', 0)]) @internationalizeDocstring def remove(self, irc, msg, args, channel, banmask): diff --git a/plugins/Channel/test.py b/plugins/Channel/test.py index 4295ef081..bcfc52786 100644 --- a/plugins/Channel/test.py +++ b/plugins/Channel/test.py @@ -161,9 +161,13 @@ def testVoice(self): self.assertTrue(m.command == 'MODE' and m.args == (self.channel, '+v', 'bar')) - def assertKban(self, query, hostmask, **kwargs): + def assertKban(self, query, *hostmasks, **kwargs): m = self.getMsg(query, **kwargs) - self.assertEqual(m, ircmsgs.ban(self.channel, hostmask)) + self.assertEqual(m.command, "MODE", m) + self.assertEqual(m.args[0], self.channel, m) + self.assertEqual(m.args[1], "+" + "b" * len(hostmasks), m) + self.assertCountEqual(m.args[2:], hostmasks, m) + m = self.getMsg(' ') self.assertEqual(m.command, 'KICK') def assertBan(self, query, hostmask, **kwargs): @@ -185,6 +189,30 @@ def testIban(self): self.assertBan('iban $a:nyuszika7h', '$a:nyuszika7h') self.assertNotError('unban $a:nyuszika7h') + def testWontIbanItself(self): + self.irc.state.supported['ACCOUNTEXTBAN'] = 'a,account' + self.irc.state.supported['EXTBAN'] = '~,abc' + + self.irc.feedMsg(ircmsgs.join(self.channel, + prefix='foobar!user@host.domain.tld')) + self.irc.feedMsg(ircmsgs.op(self.channel, self.irc.nick)) + + # not authenticated, falls back to hostname and notices the match + self.assertError('iban --account ' + self.nick) + + self.irc.feedMsg(ircmsgs.IrcMsg(prefix=self.prefix, command='ACCOUNT', + args=['botaccount'])) + + # notices the matching account + self.assertError('iban --account ' + self.nick) + + self.irc.feedMsg(ircmsgs.IrcMsg(prefix='othernick!otheruser@otherhost', + command='ACCOUNT', + args=['botaccount'])) + + # ditto + self.assertError('iban --account othernick') + def testKban(self): self.irc.prefix = 'something!else@somehwere.else' self.irc.nick = 'something' @@ -219,11 +247,108 @@ def join(): self.assertRegexp('kban adlkfajsdlfkjsd', 'adlkfajsdlfkjsd is not in') + def testAccountKbanNoAccount(self): + self.irc.prefix = 'something!else@somehwere.else' + self.irc.nick = 'something' + self.irc.state.supported['ACCOUNTEXTBAN'] = 'a,account' + self.irc.state.supported['EXTBAN'] = '~,abc' + def join(): + self.irc.feedMsg(ircmsgs.join( + self.channel, prefix='foobar!user@host.domain.tld')) + join() + self.irc.feedMsg(ircmsgs.op(self.channel, self.irc.nick)) + self.assertKban('kban --account --exact foobar', + 'foobar!user@host.domain.tld') + join() + self.assertKban('kban --account foobar', + '*!*@host.domain.tld') + join() + with conf.supybot.protocols.irc.banmask.context(['user', 'host']): + # falls back from --account to config + self.assertKban('kban --account foobar', + '*!user@host.domain.tld') + join() + with conf.supybot.protocols.irc.banmask.context(['account']): + # falls back from --account to config, then to only the host + self.assertKban('kban --account foobar', + '*!*@host.domain.tld') + join() + self.assertKban('kban --account --host foobar', + '*!*@host.domain.tld') + + def testAccountKbanLoggedOut(self): + self.irc.prefix = 'something!else@somehwere.else' + self.irc.nick = 'something' + self.irc.state.supported['ACCOUNTEXTBAN'] = 'a,account' + self.irc.state.supported['EXTBAN'] = '~,abc' + self.irc.feedMsg(ircmsgs.IrcMsg( + prefix='foobar!user@host.domain.tld', + command='ACCOUNT', args=['*'])) + def join(): + self.irc.feedMsg(ircmsgs.join( + self.channel, prefix='foobar!user@host.domain.tld')) + join() + self.irc.feedMsg(ircmsgs.op(self.channel, self.irc.nick)) + self.assertKban('kban --account --exact foobar', + 'foobar!user@host.domain.tld') + join() + self.assertKban('kban --account foobar', + '*!*@host.domain.tld') + join() + with conf.supybot.protocols.irc.banmask.context(['user', 'host']): + # falls back from --account to config + self.assertKban('kban --account foobar', + '*!user@host.domain.tld') + join() + with conf.supybot.protocols.irc.banmask.context(['account']): + # falls back from --account to config, then to only the host + self.assertKban('kban --account foobar', + '*!*@host.domain.tld') + join() + self.assertKban('kban --account --host foobar', + '*!*@host.domain.tld') + + def testAccountKbanLoggedIn(self): + self.irc.prefix = 'something!else@somehwere.else' + self.irc.nick = 'something' + self.irc.state.supported['ACCOUNTEXTBAN'] = 'a,account' + self.irc.state.supported['EXTBAN'] = '~,abc' + self.irc.feedMsg(ircmsgs.IrcMsg( + prefix='foobar!user@host.domain.tld', + command='ACCOUNT', args=['account1'])) + def join(): + self.irc.feedMsg(ircmsgs.join( + self.channel, prefix='foobar!user@host.domain.tld')) + join() + self.irc.feedMsg(ircmsgs.op(self.channel, self.irc.nick)) + + + for style in (['exact'], ['account', 'exact']): + with conf.supybot.protocols.irc.banmask.context(style): + self.assertKban('kban --account --exact foobar', + '~a:account1', 'foobar!user@host.domain.tld') + join() + self.assertKban('kban --account foobar', + '~a:account1') + join() + self.assertKban('kban --account --host foobar', + '~a:account1', '*!*@host.domain.tld') + join() + + with conf.supybot.protocols.irc.banmask.context(['account', 'exact']): + self.assertKban('kban foobar', + '~a:account1', 'foobar!user@host.domain.tld') + join() + + with conf.supybot.protocols.irc.banmask.context(['account', 'host']): + self.assertKban('kban foobar', + '~a:account1', '*!*@host.domain.tld') + join() + def testBan(self): with conf.supybot.protocols.irc.banmask.context(['exact']): self.assertNotError('ban add foo!bar@baz') self.assertNotError('ban remove foo!bar@baz') - orig = conf.supybot.protocols.irc.strictRfc() with conf.supybot.protocols.irc.strictRfc.context(True): # something wonky is going on here. irc.error (src/Channel.py|449) # is being called but the assert is failing @@ -249,7 +374,6 @@ def testBanList(self): '"foobar!*@baz" (never expires)') def testIgnore(self): - orig = conf.supybot.protocols.irc.banmask() def ignore(given, expect=None): if expect is None: expect = given @@ -257,8 +381,9 @@ def ignore(given, expect=None): self.assertResponse('channel ignore list', "'%s'" % expect) self.assertNotError('channel ignore remove %s' % expect) self.assertRegexp('channel ignore list', 'not currently') - ignore('foo!bar@baz', '*!*@baz') - ignore('foo!*@*') + with conf.supybot.protocols.irc.banmask.context(['host']): + ignore('foo!bar@baz', '*!*@baz') + ignore('foo!*@*') with conf.supybot.protocols.irc.banmask.context(['exact']): ignore('foo!bar@baz') ignore('foo!*@*') diff --git a/src/commands.py b/src/commands.py index f93e7ce47..7da905fa4 100644 --- a/src/commands.py +++ b/src/commands.py @@ -435,7 +435,14 @@ def getBanmask(irc, msg, args, state): getChannel(irc, msg, args, state) banmaskstyle = conf.supybot.protocols.irc.banmask state.args[-1] = banmaskstyle.makeBanmask(state.args[-1], - channel=state.channel) + channel=state.channel, network=irc.network) + +def getExtBanmasks(irc, msg, args, state): + getHostmask(irc, msg, args, state) + getChannel(irc, msg, args, state) + banmaskstyle = conf.supybot.protocols.irc.extbanmask + state.args[-1] = banmaskstyle.makeExtBanmasks(state.args[-1], + channel=state.channel, network=irc.network) def getUser(irc, msg, args, state): try: @@ -806,6 +813,7 @@ def getText(irc, msg, args, state): 'commandName': getCommandName, 'email': getEmail, 'expiry': getExpiry, + 'extbanmasks': getExtBanmasks, 'filename': getSomething, # XXX Check for validity. 'float': getFloat, 'glob': getGlob, diff --git a/src/conf.py b/src/conf.py index 0544613ab..f090816b3 100644 --- a/src/conf.py +++ b/src/conf.py @@ -1191,7 +1191,7 @@ def isNick(s, strictRfc=None, **kw): class Banmask(registry.SpaceSeparatedSetOfStrings): __slots__ = ('__parent', '__dict__') # __dict__ is needed to set __doc__ - validStrings = ('exact', 'nick', 'user', 'host') + validStrings = ('exact', 'nick', 'user', 'host', 'account') def __init__(self, *args, **kwargs): assert self.validStrings, 'There must be some valid strings. ' \ 'This is a bug.' @@ -1225,13 +1225,43 @@ def makeBanmask(self, hostmask, options=None, channel=None, network=None): isn't specified via options, the value of conf.supybot.protocols.irc.banmask is used. + Unlike :meth:`makeExtBanmasks`, this is guaranteed to return an + RFC1459-like mask, suitable for ircdb's ignore lists. + options - A list specifying which parts of the hostmask should explicitly be matched: nick, user, host. If 'exact' is given, then - only the exact hostmask will be used.""" - if not channel: - channel = dynamic.channel + only the exact hostmask will be used. + """ if not network: network = dynamic.irc.network + if not options: + options = supybot.protocols.irc.banmask.getSpecific( + network, channel)() + options = [option for option in options if option != 'account'] + masks = self.makeExtBanmasks( + hostmask, options, channel, network=network) + assert len(masks) == 1, 'Unexpected number of banmasks: %r' % masks + return masks[0] + + def makeExtBanmasks(self, hostmask, options=None, channel=None, *, network): + """Create banmasks from the given hostmask. If a style of banmask + isn't specified via options, the value of + conf.supybot.protocols.irc.banmask is used. + + Depending on the options and configuration, this may return a mask + in the format of an extban (eg. "~account:foobar" on UnrealIRCd). + If this is unwanted (eg. to pass to ircdb's ignore lists), use + :meth:`makeBanmask` instead. + + options - A list specifying which parts of the hostmask should + explicitly be matched: nick, user, host. If 'exact' is given, then + only the exact hostmask will be used. + If 'account' is given (and not after 'exact') and the user is + logged in and the server supports account extbans, then an account + extban is returned instead. + """ + if not channel: + channel = dynamic.channel (nick, user, host) = ircutils.splitHostmask(hostmask) bnick = '*' buser = '*' @@ -1239,19 +1269,62 @@ def makeBanmask(self, hostmask, options=None, channel=None, network=None): if not options: options = supybot.protocols.irc.banmask.getSpecific( network, channel)() + + add_star_mask = False + masks = [] + for option in options: if option == 'nick': bnick = nick + add_star_mask = True elif option == 'user': buser = user + add_star_mask = True elif option == 'host': bhost = host + add_star_mask = True elif option == 'exact': - return hostmask + masks.append(hostmask) + elif option == 'account': + import supybot.world as world + irc = world.getIrc(network) + if irc is None: + continue + extban = ircutils.accountExtban(irc, nick) + if extban is not None: + masks.append(extban) + else: + from . import log + log.warning( + "Unknown mask option passed to makeExtBanmasks: %r", + option) + + if add_star_mask and (bnick, buser, bhost) != ('*', '*', '*'): + masks.append(ircutils.joinHostmask(bnick, buser, bhost)) + if (bnick, buser, bhost) == ('*', '*', '*') and \ - ircutils.isUserHostmask(hostmask): - return hostmask - return ircutils.joinHostmask(bnick, buser, bhost) + options == ['account'] and \ + not masks: + # found no ban mask to set (because options == ['account'] and user + # is logged out?), try again with the default ban mask + options = supybot.protocols.irc.banmask.getSpecific( + network, channel)() + options = [option for option in options if option != 'account'] + return self.makeExtBanmasks( + hostmask, options=options, channel=channel, network=network) + + if (bnick, buser, bhost) == ('*', '*', '*') and \ + ircutils.isUserHostmask(hostmask) and \ + not masks: + # still no ban mask found, fallback to the host, if any + if host != '*': + masks.append(ircutils.joinHostmask('*', '*', host)) + else: + # if no host, fall back to the exact mask provided + masks.append(hostmask) + + return masks + registerChannelValue(supybot.protocols.irc, 'banmask', Banmask(['host'], _("""Determines what will be used as the diff --git a/src/irclib.py b/src/irclib.py index 233f86f97..b4cfe677e 100644 --- a/src/irclib.py +++ b/src/irclib.py @@ -681,6 +681,13 @@ class IrcState(IrcCommandDispatcher, log.Firewalled): Stores the last hostmask of a seen nick. :type: ircutils.IrcDict[str, str] + + .. attribute:: nicksToAccounts + + Stores the current services account name of a seen nick (or + :const:`None` for un-identified nicks) + + :type: ircutils.IrcDict[str, Optional[str]] """ __firewalled__ = {'addMsg': None} @@ -689,7 +696,8 @@ def __init__(self, history=None, supported=None, nicksToHostmasks=None, channels=None, capabilities_req=None, capabilities_ack=None, capabilities_nak=None, - capabilities_ls=None): + capabilities_ls=None, + nicksToAccounts=None): self.fsm = IrcStateFsm() if history is None: history = RingBuffer(conf.supybot.protocols.irc.maxHistoryLength()) @@ -697,6 +705,8 @@ def __init__(self, history=None, supported=None, supported = utils.InsensitivePreservingDict() if nicksToHostmasks is None: nicksToHostmasks = ircutils.IrcDict() + if nicksToAccounts is None: + nicksToAccounts = ircutils.IrcDict() if channels is None: channels = ircutils.IrcDict() self.capabilities_req = capabilities_req or set() @@ -708,6 +718,7 @@ def __init__(self, history=None, supported=None, self.history = history self.channels = channels self.nicksToHostmasks = nicksToHostmasks + self.nicksToAccounts = nicksToAccounts # Batches usually finish and are way shorter than 3600s, but # we need to: @@ -725,6 +736,7 @@ def reset(self): self.channels.clear() self.supported.clear() self.nicksToHostmasks.clear() + self.nicksToAccounts.clear() self.capabilities_req = set() self.capabilities_ack = set() self.capabilities_nak = set() @@ -745,13 +757,16 @@ def reset(self): def __reduce__(self): return (self.__class__, (self.history, self.supported, - self.nicksToHostmasks, self.channels)) + self.nicksToHostmasks, + self.nicksToAccounts, + self.channels)) def __eq__(self, other): return self.history == other.history and \ self.channels == other.channels and \ self.supported == other.supported and \ self.nicksToHostmasks == other.nicksToHostmasks and \ + self.nicksToAccounts == other.nicksToAccounts and \ self.batches == other.batches def __ne__(self, other): @@ -761,6 +776,7 @@ def copy(self): ret = self.__class__() ret.history = copy.deepcopy(self.history) ret.nicksToHostmasks = copy.deepcopy(self.nicksToHostmasks) + ret.nicksToAccounts = copy.deepcopy(self.nicksToAccounts) ret.channels = copy.deepcopy(self.channels) ret.batches = copy.deepcopy(self.batches) return ret @@ -770,6 +786,8 @@ def addMsg(self, irc, msg): self.history.append(msg) if ircutils.isUserHostmask(msg.prefix) and not msg.command == 'NICK': self.nicksToHostmasks[msg.nick] = msg.prefix + if 'account' in msg.server_tags: + self.nicksToAccounts[msg.nick] = msg.server_tags['account'] if 'batch' in msg.server_tags: batch_name = msg.server_tags['batch'] assert batch_name in self.batches, \ @@ -788,6 +806,12 @@ def nickToHostmask(self, nick): """Returns the hostmask for a given nick.""" return self.nicksToHostmasks[nick] + def nickToAccount(self, nick): + """Returns the account for a given nick, or None if the nick is logged + out. Raises :exc:`KeyError` if the nick was not seen or its account is + not known yet.""" + return self.nicksToAccounts[nick] + def getParentBatches(self, msg): """Given an IrcMsg, returns a list of all batches that contain it, innermost first. @@ -957,6 +981,11 @@ def do354(self, irc, msg): (n, t, user, ip, host, nick, status, account, gecos) = msg.args hostmask = '%s!%s@%s' % (nick, user, host) self.nicksToHostmasks[nick] = hostmask + if account == '0': + # logged out + self.nicksToAccounts[nick] = None + else: + self.nicksToAccounts[nick] = account def do353(self, irc, msg): # NAMES reply. @@ -978,6 +1007,7 @@ def do353(self, irc, msg): stripped_item = item.lstrip(prefix_chars) item_prefix = item[0:-len(stripped_item)] if ircutils.isUserHostmask(stripped_item): + # https://ircv3.net/specs/extensions/userhost-in-names nick = ircutils.nickFromHostmask(stripped_item) self.nicksToHostmasks[nick] = stripped_item name = item_prefix + nick @@ -989,11 +1019,20 @@ def do353(self, irc, msg): c.modes['s'] = None def doChghost(self, irc, msg): + # https://ircv3.net/specs/extensions/chghost (user, host) = msg.args nick = msg.nick hostmask = '%s!%s@%s' % (nick, user, host) self.nicksToHostmasks[nick] = hostmask + def doAccount(self, irc, msg): + # https://ircv3.net/specs/extensions/account-notify + account = msg.args[0] + if account == '*': + self.nicksToAccounts[msg.nick] = None + else: + self.nicksToAccounts[msg.nick] = account + def doJoin(self, irc, msg): for channel in msg.args[0].split(','): if channel in self.channels: @@ -1004,6 +1043,12 @@ def doJoin(self, irc, msg): self.channels[channel] = chan # I don't know why this assert was here. #assert msg.nick == irc.nick, msg + if 'extended-join' in self.capabilities_ack: + account = msg.args[1] + if account == '*': + self.nicksToAccounts[msg.nick] = None + else: + self.nicksToAccounts[msg.nick] = account def do367(self, irc, msg): # Example: @@ -1083,6 +1128,8 @@ def doQuit(self, irc, msg): if msg.nick in self.nicksToHostmasks: # If we're quitting, it may not be. del self.nicksToHostmasks[msg.nick] + if msg.nick in self.nicksToAccounts: + del self.nicksToAccounts[msg.nick] def doTopic(self, irc, msg): if len(msg.args) == 1: @@ -1100,6 +1147,7 @@ def do332(self, irc, msg): def doNick(self, irc, msg): newNick = msg.args[0] oldNick = msg.nick + try: if msg.user and msg.host: # Nick messages being handed out from the bot itself won't @@ -1109,6 +1157,13 @@ def doNick(self, irc, msg): del self.nicksToHostmasks[oldNick] except KeyError: pass + + try: + self.nicksToAccounts[newNick] = self.nicksToAccounts[oldNick] + del self.nicksToAccounts[oldNick] + except KeyError: + pass + channel_names = ircutils.IrcSet() for (name, channel) in self.channels.items(): if msg.nick in channel.users: diff --git a/src/ircutils.py b/src/ircutils.py index 035ba9a13..4f3a93bea 100644 --- a/src/ircutils.py +++ b/src/ircutils.py @@ -345,6 +345,25 @@ def banmask(hostmask): else: return '*!*@' + host + +def accountExtban(irc, nick): + """If 'nick' is logged in and the network supports account extbans, + returns a ban mask for it. If not, returns None.""" + if 'ACCOUNTEXTBAN' not in irc.state.supported: + return None + if 'EXTBAN' not in irc.state.supported: + return None + try: + account = irc.state.nickToAccount(nick) + except KeyError: + account = None + if account is None: + return None + account_extban = irc.state.supported['ACCOUNTEXTBAN'].split(',')[0] + extban_prefix = irc.state.supported['EXTBAN'].split(',', 1)[0] + return '%s%s:%s'% (extban_prefix, account_extban, account) + + _plusRequireArguments = 'ovhblkqeI' _minusRequireArguments = 'ovhbkqeI' def separateModes(args): diff --git a/src/test.py b/src/test.py index 08e43ba07..5fa48028b 100644 --- a/src/test.py +++ b/src/test.py @@ -318,7 +318,8 @@ def assertError(self, query, **kwargs): raise TimeoutError(query) if lastGetHelp not in m.args[1]: self.assertTrue(m.args[1].startswith('Error:'), - '%r did not error: %s' % (query, m.args[1])) + '%r did not error: %s' % + (query, ' '.join(m.args[1:]))) return m def assertSnarfError(self, query, **kwargs): diff --git a/test/test_irclib.py b/test/test_irclib.py index 91cc5faf6..6f4f560f7 100644 --- a/test/test_irclib.py +++ b/test/test_irclib.py @@ -579,6 +579,112 @@ def testChghost(self): command='CHGHOST', args=['bar2', 'baz2'])) self.assertEqual(st.nickToHostmask('foo'), 'foo!bar2@baz2') + def testNickToAccountBaseJoin(self): + st = irclib.IrcState() + + st.addMsg(self.irc, ircmsgs.join('#foo', prefix='foo!bar@baz')) + st.addMsg(self.irc, ircmsgs.join('#foo', prefix='bar!baz@qux')) + with self.assertRaises(KeyError): + st.nickToAccount('foo') + with self.assertRaises(KeyError): + st.nickToAccount('bar') + + + def testNickToAccountExtendedJoin(self): + st = irclib.IrcState() + st.capabilities_ack.add('extended-join') + + st.addMsg(self.irc, ircmsgs.IrcMsg( + command='JOIN', prefix='foo!bar@baz', + args=['#foo', 'account1', 'real name1'])) + st.addMsg(self.irc, ircmsgs.IrcMsg( + command='JOIN', prefix='bar!baz@qux', + args=['#foo', 'account2', 'real name2'])) + st.addMsg(self.irc, ircmsgs.IrcMsg( + command='JOIN', prefix='baz!qux@quux', + args=['#foo', '*', 'real name3'])) + self.assertEqual(st.nickToAccount('foo'), 'account1') + self.assertEqual(st.nickToAccount('bar'), 'account2') + self.assertIsNone(st.nickToAccount('baz')) + with self.assertRaises(KeyError): + st.nickToAccount('qux') + + # QUIT erases the entry + with self.subTest("QUIT"): + st2 = st.copy() + st2.addMsg(self.irc, ircmsgs.quit(prefix='foo!bar@baz')) + with self.assertRaises(KeyError): + st2.nickToAccount('foo') + self.assertEqual(st2.nickToAccount('bar'), 'account2') + + # check st isn't affected by changes to st2 + self.assertEqual(st.nickToAccount('foo'), 'account1') + self.assertEqual(st.nickToAccount('bar'), 'account2') + + # NICK moves the entry + with self.subTest("NICK"): + st2 = st.copy() + st2.addMsg(self.irc, ircmsgs.IrcMsg(prefix='foo!bar@baz', + command='NICK', args=['foo2'])) + with self.assertRaises(KeyError): + st2.nickToAccount('foo') + self.assertEqual(st2.nickToAccount('foo2'), 'account1') + self.assertEqual(st2.nickToAccount('bar'), 'account2') + + # check st isn't affected by changes to st2 + self.assertEqual(st.nickToAccount('foo'), 'account1') + self.assertEqual(st.nickToAccount('bar'), 'account2') + + # NICK moves the entry (and overwrites if needed) + with self.subTest("NICK with overwrite"): + st2 = st.copy() + st2.addMsg(self.irc, ircmsgs.IrcMsg(prefix='foo!bar@baz', + command='NICK', args=['bar'])) + with self.assertRaises(KeyError): + st2.nickToAccount('foo') + self.assertEqual(st2.nickToAccount('bar'), 'account1') + + # check st isn't affected by changes to st2 + self.assertEqual(st.nickToAccount('foo'), 'account1') + self.assertEqual(st.nickToAccount('bar'), 'account2') + + def testNickToAccountWho(self): + st = irclib.IrcState() + + st.addMsg(self.irc, ircmsgs.IrcMsg(command='352', # RPL_WHOREPLY + args=[self.irc.nick, '#chan', 'bar', 'baz', 'server.example', + 'foo', 'H', '0 real name'])) + with self.assertRaises(KeyError): + st.nickToAccount('foo') + + def testNickToAccountWhox(self): + st = irclib.IrcState() + + st.addMsg(self.irc, ircmsgs.IrcMsg(command='354', # RPL_WHOSPCRPL + args=[self.irc.nick, '1', 'bar', '127.0.0.1', 'baz', + 'foo', 'H', 'account1', 'real name'])) + self.assertEqual(st.nickToAccount('foo'), 'account1') + + st.addMsg(self.irc, ircmsgs.IrcMsg(command='354', # RPL_WHOSPCRPL + args=[self.irc.nick, '1', 'bar', '127.0.0.1', 'baz', + 'foo', 'H', '0', 'real name'])) + self.assertIsNone(st.nickToAccount('foo')) + + def testAccountNotify(self): + st = irclib.IrcState() + + st.addMsg(self.irc, ircmsgs.IrcMsg(prefix='foo!bar@baz', + command='ACCOUNT', args=['account1'])) + self.assertEqual(st.nickToAccount('foo'), 'account1') + + st.addMsg(self.irc, ircmsgs.IrcMsg(prefix='foo!bar@baz', + command='ACCOUNT', args=['account2'])) + self.assertEqual(st.nickToAccount('foo'), 'account2') + + st.addMsg(self.irc, ircmsgs.IrcMsg(prefix='foo!bar@baz', + command='ACCOUNT', args=['*'])) + self.assertIsNone(st.nickToAccount('foo')) + def testEq(self): state1 = irclib.IrcState() state2 = irclib.IrcState() diff --git a/test/test_ircutils.py b/test/test_ircutils.py index cb62358c8..59363e73f 100644 --- a/test/test_ircutils.py +++ b/test/test_ircutils.py @@ -367,6 +367,60 @@ def testBanmask(self): '*!*@*.host.tld') self.assertEqual(ircutils.banmask('foo!bar@2001::'), '*!*@2001::*') + def testAccountExtban(self): + irc = getTestIrc() + irc.state.addMsg(irc, ircmsgs.IrcMsg( + prefix='foo!bar@baz', command='ACCOUNT', args=['account1'])) + irc.state.addMsg(irc, ircmsgs.IrcMsg( + prefix='bar!baz@qux', command='ACCOUNT', args=['*'])) + + with self.subTest('spec example'): + irc.state.supported['ACCOUNTEXTBAN'] = 'a,account' + irc.state.supported['EXTBAN'] = '~,abc' + self.assertEqual(ircutils.accountExtban(irc, 'foo'), + '~a:account1') + self.assertIsNone(ircutils.accountExtban(irc, 'bar')) + self.assertIsNone(ircutils.accountExtban(irc, 'baz')) + + with self.subTest('InspIRCd'): + irc.state.supported['ACCOUNTEXTBAN'] = 'account,R' + irc.state.supported['EXTBAN'] = ',abcR' + self.assertEqual(ircutils.accountExtban(irc, 'foo'), + 'account:account1') + self.assertIsNone(ircutils.accountExtban(irc, 'bar')) + self.assertIsNone(ircutils.accountExtban(irc, 'baz')) + + with self.subTest('Solanum'): + irc.state.supported['ACCOUNTEXTBAN'] = 'a' + irc.state.supported['EXTBAN'] = '$,abc' + self.assertEqual(ircutils.accountExtban(irc, 'foo'), + '$a:account1') + self.assertIsNone(ircutils.accountExtban(irc, 'bar')) + self.assertIsNone(ircutils.accountExtban(irc, 'baz')) + + with self.subTest('UnrealIRCd'): + irc.state.supported['ACCOUNTEXTBAN'] = 'account,a' + irc.state.supported['EXTBAN'] = '~,abc' + self.assertEqual(ircutils.accountExtban(irc, 'foo'), + '~account:account1') + self.assertIsNone(ircutils.accountExtban(irc, 'bar')) + self.assertIsNone(ircutils.accountExtban(irc, 'baz')) + + with self.subTest('no ACCOUNTEXTBAN'): + irc.state.supported.pop('ACCOUNTEXTBAN') + irc.state.supported['EXTBAN'] = '~,abc' + self.assertIsNone(ircutils.accountExtban(irc, 'foo')) + self.assertIsNone(ircutils.accountExtban(irc, 'bar')) + self.assertIsNone(ircutils.accountExtban(irc, 'baz')) + + with self.subTest('no EXTBAN'): + irc.state.supported['ACCOUNTEXTBAN'] = 'account,a' + irc.state.supported.pop('EXTBAN') + self.assertIsNone(ircutils.accountExtban(irc, 'foo')) + self.assertIsNone(ircutils.accountExtban(irc, 'bar')) + self.assertIsNone(ircutils.accountExtban(irc, 'baz')) + + def testSeparateModes(self): self.assertEqual(ircutils.separateModes(['+ooo', 'x', 'y', 'z']), [('+o', 'x'), ('+o', 'y'), ('+o', 'z')])