Merge pull request #1546 from progval/account-ban

Add support for account-based channel bans
This commit is contained in:
Val Lorentz 2024-07-24 21:12:01 +02:00 committed by GitHub
commit 10a341c70c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 512 additions and 38 deletions

View File

@ -163,8 +163,9 @@ class AutoMode(callbacks.Plugin):
# We're not in the channel anymore. # We're not in the channel anymore.
pass pass
schedule.addEvent(unban, time.time()+period) schedule.addEvent(unban, time.time()+period)
banmask =conf.supybot.protocols.irc.banmask.makeBanmask(msg.prefix) banmasks = conf.supybot.protocols.irc.banmask.makeExtBanmasks(
irc.queueMsg(ircmsgs.ban(channel, banmask)) msg.prefix, channel=channel, network=irc.network)
irc.queueMsg(ircmsgs.bans(channel, banmasks))
irc.queueMsg(ircmsgs.kick(channel, msg.nick)) irc.queueMsg(ircmsgs.kick(channel, msg.nick))
try: try:

View File

@ -321,13 +321,16 @@ class Channel(callbacks.Plugin):
--exact bans only the exact hostmask; --nick bans just the nick; --exact bans only the exact hostmask; --nick bans just the nick;
--user bans just the user, and --host bans just the host --user bans just the user, and --host bans just the host
You can combine the --nick, --user, and --host options as you choose. 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.
<channel> is only necessary if the message isn't sent in the channel itself. <channel> is only necessary if the message isn't sent in the channel itself.
""" """
self._ban(irc, msg, args, self._ban(irc, msg, args,
channel, optlist, bannedNick, expiry, reason, True) channel, optlist, bannedNick, expiry, reason, True)
kban = wrap(kban, kban = wrap(kban,
['op', ['op',
getopts({'exact':'', 'nick':'', 'user':'', 'host':''}), getopts({'exact':'', 'nick':'', 'user':'', 'host':'',
'account': ''}),
('haveHalfop+', _('kick or ban someone')), ('haveHalfop+', _('kick or ban someone')),
'nickInChannel', 'nickInChannel',
optional('expiry', 0), optional('expiry', 0),
@ -343,13 +346,16 @@ class Channel(callbacks.Plugin):
don't specify a number of seconds) it will ban the person indefinitely. don't specify a number of seconds) it will ban the person indefinitely.
--exact can be used to specify an exact hostmask. --exact can be used to specify an exact hostmask.
You can combine the --nick, --user, and --host options as you choose. 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.
<channel> is only necessary if the message isn't sent in the channel itself. <channel> is only necessary if the message isn't sent in the channel itself.
""" """
self._ban(irc, msg, args, self._ban(irc, msg, args,
channel, optlist, bannedNick, expiry, None, False) channel, optlist, bannedNick, expiry, None, False)
iban = wrap(iban, iban = wrap(iban,
['op', ['op',
getopts({'exact':'', 'nick':'', 'user':'', 'host':''}), getopts({'exact':'', 'nick':'', 'user':'', 'host':'',
'account': ''}),
('haveHalfop+', _('ban someone')), ('haveHalfop+', _('ban someone')),
first('nick', 'hostmask'), first('nick', 'hostmask'),
optional('expiry', 0)]) optional('expiry', 0)])
@ -362,18 +368,22 @@ class Channel(callbacks.Plugin):
try: try:
bannedHostmask = irc.state.nickToHostmask(target) bannedHostmask = irc.state.nickToHostmask(target)
banmaskstyle = conf.supybot.protocols.irc.banmask 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: except KeyError:
if not conf.supybot.protocols.irc.strictRfc() and \ if not conf.supybot.protocols.irc.strictRfc() and \
target.startswith('$'): target.startswith('$'):
# Select the last part, or the whole target: # Select the last part, or the whole target:
bannedNick = target.split(':')[-1] bannedNick = target.split(':')[-1]
banmask = bannedHostmask = target bannedHostmask = target
banmasks = [bannedHostmask]
else: else:
irc.error(format(_('I haven\'t seen %s.'), bannedNick), Raise=True) irc.error(format(_('I haven\'t seen %s.'), bannedNick), Raise=True)
else: else:
bannedNick = ircutils.nickFromHostmask(target) bannedNick = ircutils.nickFromHostmask(target)
banmask = bannedHostmask = target bannedHostmask = target
banmasks = [bannedHostmask]
if not irc.isNick(bannedNick): if not irc.isNick(bannedNick):
self.log.warning('%q tried to kban a non nick: %q', self.log.warning('%q tried to kban a non nick: %q',
msg.prefix, bannedNick) msg.prefix, bannedNick)
@ -389,30 +399,47 @@ class Channel(callbacks.Plugin):
if not reason: if not reason:
reason = msg.nick reason = msg.nick
capability = ircdb.makeChannelCapability(channel, 'op') capability = ircdb.makeChannelCapability(channel, 'op')
# Check (again) that they're not trying to make us kickban ourself. # Check (again) that they're not trying to make us kickban ourself.
if ircutils.hostmaskPatternEqual(banmask, irc.prefix): self_account_extban = ircutils.accountExtban(irc, irc.nick)
if ircutils.hostmaskPatternEqual(bannedHostmask, irc.prefix): 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) self.log.warning('%q tried to make me kban myself.',msg.prefix)
irc.error(_('I cowardly refuse to ban myself.')) irc.error(_('I cowardly refuse to ban myself.'))
return 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 # 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 # #channel,op and the bannee doesn't have #channel,op; or that the
# bannee and the banner are both the same person. # bannee and the banner are both the same person.
def doBan(): def doBan():
if irc.state.channels[channel].isOp(bannedNick): if irc.state.channels[channel].isOp(bannedNick):
irc.queueMsg(ircmsgs.deop(channel, bannedNick)) irc.queueMsg(ircmsgs.deop(channel, bannedNick))
irc.queueMsg(ircmsgs.ban(channel, banmask)) irc.queueMsg(ircmsgs.bans(channel, banmasks))
if kick: if kick:
irc.queueMsg(ircmsgs.kick(channel, bannedNick, reason)) irc.queueMsg(ircmsgs.kick(channel, bannedNick, reason))
if expiry > 0: if expiry > 0:
def f(): def f():
if channel in irc.state.channels and \ if channel not in irc.state.channels:
banmask in irc.state.channels[channel].bans: return
irc.queueMsg(ircmsgs.unban(channel, banmask)) 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) schedule.addEvent(f, expiry)
if bannedNick == msg.nick: if bannedNick == msg.nick:
doBan() doBan()
@ -583,7 +610,7 @@ class Channel(callbacks.Plugin):
hostmask = wrap(hostmask, ['op', ('haveHalfop+', _('ban someone')), 'text']) hostmask = wrap(hostmask, ['op', ('haveHalfop+', _('ban someone')), 'text'])
@internationalizeDocstring @internationalizeDocstring
def add(self, irc, msg, args, channel, banmask, expires): def add(self, irc, msg, args, channel, banmasks, expires):
"""[<channel>] <nick|hostmask> [<expires>] """[<channel>] <nick|hostmask> [<expires>]
If you have the #channel,op capability, this will effect a If you have the #channel,op capability, this will effect a
@ -597,10 +624,15 @@ class Channel(callbacks.Plugin):
channel itself. channel itself.
""" """
c = ircdb.channels.getChannel(channel) 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) ircdb.channels.setChannel(channel, c)
irc.replySuccess() irc.replySuccess()
add = wrap(add, ['op', first('hostmask', 'banmask'), additional('expiry', 0)]) add = wrap(add, ['op',
first('hostmask', 'extbanmasks'),
additional('expiry', 0)])
@internationalizeDocstring @internationalizeDocstring
def remove(self, irc, msg, args, channel, banmask): def remove(self, irc, msg, args, channel, banmask):

View File

@ -161,9 +161,13 @@ class ChannelTestCase(ChannelPluginTestCase):
self.assertTrue(m.command == 'MODE' and self.assertTrue(m.command == 'MODE' and
m.args == (self.channel, '+v', 'bar')) m.args == (self.channel, '+v', 'bar'))
def assertKban(self, query, hostmask, **kwargs): def assertKban(self, query, *hostmasks, **kwargs):
m = self.getMsg(query, **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(' ') m = self.getMsg(' ')
self.assertEqual(m.command, 'KICK') self.assertEqual(m.command, 'KICK')
def assertBan(self, query, hostmask, **kwargs): def assertBan(self, query, hostmask, **kwargs):
@ -185,6 +189,30 @@ class ChannelTestCase(ChannelPluginTestCase):
self.assertBan('iban $a:nyuszika7h', '$a:nyuszika7h') self.assertBan('iban $a:nyuszika7h', '$a:nyuszika7h')
self.assertNotError('unban $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): def testKban(self):
self.irc.prefix = 'something!else@somehwere.else' self.irc.prefix = 'something!else@somehwere.else'
self.irc.nick = 'something' self.irc.nick = 'something'
@ -219,11 +247,108 @@ class ChannelTestCase(ChannelPluginTestCase):
self.assertRegexp('kban adlkfajsdlfkjsd', 'adlkfajsdlfkjsd is not in') 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): def testBan(self):
with conf.supybot.protocols.irc.banmask.context(['exact']): with conf.supybot.protocols.irc.banmask.context(['exact']):
self.assertNotError('ban add foo!bar@baz') self.assertNotError('ban add foo!bar@baz')
self.assertNotError('ban remove foo!bar@baz') self.assertNotError('ban remove foo!bar@baz')
orig = conf.supybot.protocols.irc.strictRfc()
with conf.supybot.protocols.irc.strictRfc.context(True): with conf.supybot.protocols.irc.strictRfc.context(True):
# something wonky is going on here. irc.error (src/Channel.py|449) # something wonky is going on here. irc.error (src/Channel.py|449)
# is being called but the assert is failing # is being called but the assert is failing
@ -249,7 +374,6 @@ class ChannelTestCase(ChannelPluginTestCase):
'"foobar!*@baz" (never expires)') '"foobar!*@baz" (never expires)')
def testIgnore(self): def testIgnore(self):
orig = conf.supybot.protocols.irc.banmask()
def ignore(given, expect=None): def ignore(given, expect=None):
if expect is None: if expect is None:
expect = given expect = given
@ -257,8 +381,9 @@ class ChannelTestCase(ChannelPluginTestCase):
self.assertResponse('channel ignore list', "'%s'" % expect) self.assertResponse('channel ignore list', "'%s'" % expect)
self.assertNotError('channel ignore remove %s' % expect) self.assertNotError('channel ignore remove %s' % expect)
self.assertRegexp('channel ignore list', 'not currently') self.assertRegexp('channel ignore list', 'not currently')
ignore('foo!bar@baz', '*!*@baz') with conf.supybot.protocols.irc.banmask.context(['host']):
ignore('foo!*@*') ignore('foo!bar@baz', '*!*@baz')
ignore('foo!*@*')
with conf.supybot.protocols.irc.banmask.context(['exact']): with conf.supybot.protocols.irc.banmask.context(['exact']):
ignore('foo!bar@baz') ignore('foo!bar@baz')
ignore('foo!*@*') ignore('foo!*@*')

View File

@ -435,7 +435,14 @@ def getBanmask(irc, msg, args, state):
getChannel(irc, msg, args, state) getChannel(irc, msg, args, state)
banmaskstyle = conf.supybot.protocols.irc.banmask banmaskstyle = conf.supybot.protocols.irc.banmask
state.args[-1] = banmaskstyle.makeBanmask(state.args[-1], 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): def getUser(irc, msg, args, state):
try: try:
@ -806,6 +813,7 @@ wrappers = ircutils.IrcDict({
'commandName': getCommandName, 'commandName': getCommandName,
'email': getEmail, 'email': getEmail,
'expiry': getExpiry, 'expiry': getExpiry,
'extbanmasks': getExtBanmasks,
'filename': getSomething, # XXX Check for validity. 'filename': getSomething, # XXX Check for validity.
'float': getFloat, 'float': getFloat,
'glob': getGlob, 'glob': getGlob,

View File

@ -1191,7 +1191,7 @@ registerGroup(supybot.protocols, 'irc')
class Banmask(registry.SpaceSeparatedSetOfStrings): class Banmask(registry.SpaceSeparatedSetOfStrings):
__slots__ = ('__parent', '__dict__') # __dict__ is needed to set __doc__ __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): def __init__(self, *args, **kwargs):
assert self.validStrings, 'There must be some valid strings. ' \ assert self.validStrings, 'There must be some valid strings. ' \
'This is a bug.' 'This is a bug.'
@ -1225,13 +1225,43 @@ class Banmask(registry.SpaceSeparatedSetOfStrings):
isn't specified via options, the value of isn't specified via options, the value of
conf.supybot.protocols.irc.banmask is used. 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 options - A list specifying which parts of the hostmask should
explicitly be matched: nick, user, host. If 'exact' is given, then explicitly be matched: nick, user, host. If 'exact' is given, then
only the exact hostmask will be used.""" only the exact hostmask will be used.
if not channel: """
channel = dynamic.channel
if not network: if not network:
network = dynamic.irc.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) (nick, user, host) = ircutils.splitHostmask(hostmask)
bnick = '*' bnick = '*'
buser = '*' buser = '*'
@ -1239,19 +1269,62 @@ class Banmask(registry.SpaceSeparatedSetOfStrings):
if not options: if not options:
options = supybot.protocols.irc.banmask.getSpecific( options = supybot.protocols.irc.banmask.getSpecific(
network, channel)() network, channel)()
add_star_mask = False
masks = []
for option in options: for option in options:
if option == 'nick': if option == 'nick':
bnick = nick bnick = nick
add_star_mask = True
elif option == 'user': elif option == 'user':
buser = user buser = user
add_star_mask = True
elif option == 'host': elif option == 'host':
bhost = host bhost = host
add_star_mask = True
elif option == 'exact': 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 \ if (bnick, buser, bhost) == ('*', '*', '*') and \
ircutils.isUserHostmask(hostmask): options == ['account'] and \
return hostmask not masks:
return ircutils.joinHostmask(bnick, buser, bhost) # 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', registerChannelValue(supybot.protocols.irc, 'banmask',
Banmask(['host'], _("""Determines what will be used as the Banmask(['host'], _("""Determines what will be used as the

View File

@ -681,6 +681,13 @@ class IrcState(IrcCommandDispatcher, log.Firewalled):
Stores the last hostmask of a seen nick. Stores the last hostmask of a seen nick.
:type: ircutils.IrcDict[str, str] :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} __firewalled__ = {'addMsg': None}
@ -689,7 +696,8 @@ class IrcState(IrcCommandDispatcher, log.Firewalled):
nicksToHostmasks=None, channels=None, nicksToHostmasks=None, channels=None,
capabilities_req=None, capabilities_req=None,
capabilities_ack=None, capabilities_nak=None, capabilities_ack=None, capabilities_nak=None,
capabilities_ls=None): capabilities_ls=None,
nicksToAccounts=None):
self.fsm = IrcStateFsm() self.fsm = IrcStateFsm()
if history is None: if history is None:
history = RingBuffer(conf.supybot.protocols.irc.maxHistoryLength()) history = RingBuffer(conf.supybot.protocols.irc.maxHistoryLength())
@ -697,6 +705,8 @@ class IrcState(IrcCommandDispatcher, log.Firewalled):
supported = utils.InsensitivePreservingDict() supported = utils.InsensitivePreservingDict()
if nicksToHostmasks is None: if nicksToHostmasks is None:
nicksToHostmasks = ircutils.IrcDict() nicksToHostmasks = ircutils.IrcDict()
if nicksToAccounts is None:
nicksToAccounts = ircutils.IrcDict()
if channels is None: if channels is None:
channels = ircutils.IrcDict() channels = ircutils.IrcDict()
self.capabilities_req = capabilities_req or set() self.capabilities_req = capabilities_req or set()
@ -708,6 +718,7 @@ class IrcState(IrcCommandDispatcher, log.Firewalled):
self.history = history self.history = history
self.channels = channels self.channels = channels
self.nicksToHostmasks = nicksToHostmasks self.nicksToHostmasks = nicksToHostmasks
self.nicksToAccounts = nicksToAccounts
# Batches usually finish and are way shorter than 3600s, but # Batches usually finish and are way shorter than 3600s, but
# we need to: # we need to:
@ -725,6 +736,7 @@ class IrcState(IrcCommandDispatcher, log.Firewalled):
self.channels.clear() self.channels.clear()
self.supported.clear() self.supported.clear()
self.nicksToHostmasks.clear() self.nicksToHostmasks.clear()
self.nicksToAccounts.clear()
self.capabilities_req = set() self.capabilities_req = set()
self.capabilities_ack = set() self.capabilities_ack = set()
self.capabilities_nak = set() self.capabilities_nak = set()
@ -745,13 +757,16 @@ class IrcState(IrcCommandDispatcher, log.Firewalled):
def __reduce__(self): def __reduce__(self):
return (self.__class__, (self.history, self.supported, return (self.__class__, (self.history, self.supported,
self.nicksToHostmasks, self.channels)) self.nicksToHostmasks,
self.nicksToAccounts,
self.channels))
def __eq__(self, other): def __eq__(self, other):
return self.history == other.history and \ return self.history == other.history and \
self.channels == other.channels and \ self.channels == other.channels and \
self.supported == other.supported and \ self.supported == other.supported and \
self.nicksToHostmasks == other.nicksToHostmasks and \ self.nicksToHostmasks == other.nicksToHostmasks and \
self.nicksToAccounts == other.nicksToAccounts and \
self.batches == other.batches self.batches == other.batches
def __ne__(self, other): def __ne__(self, other):
@ -761,6 +776,7 @@ class IrcState(IrcCommandDispatcher, log.Firewalled):
ret = self.__class__() ret = self.__class__()
ret.history = copy.deepcopy(self.history) ret.history = copy.deepcopy(self.history)
ret.nicksToHostmasks = copy.deepcopy(self.nicksToHostmasks) ret.nicksToHostmasks = copy.deepcopy(self.nicksToHostmasks)
ret.nicksToAccounts = copy.deepcopy(self.nicksToAccounts)
ret.channels = copy.deepcopy(self.channels) ret.channels = copy.deepcopy(self.channels)
ret.batches = copy.deepcopy(self.batches) ret.batches = copy.deepcopy(self.batches)
return ret return ret
@ -770,6 +786,8 @@ class IrcState(IrcCommandDispatcher, log.Firewalled):
self.history.append(msg) self.history.append(msg)
if ircutils.isUserHostmask(msg.prefix) and not msg.command == 'NICK': if ircutils.isUserHostmask(msg.prefix) and not msg.command == 'NICK':
self.nicksToHostmasks[msg.nick] = msg.prefix 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: if 'batch' in msg.server_tags:
batch_name = msg.server_tags['batch'] batch_name = msg.server_tags['batch']
assert batch_name in self.batches, \ assert batch_name in self.batches, \
@ -788,6 +806,12 @@ class IrcState(IrcCommandDispatcher, log.Firewalled):
"""Returns the hostmask for a given nick.""" """Returns the hostmask for a given nick."""
return self.nicksToHostmasks[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): def getParentBatches(self, msg):
"""Given an IrcMsg, returns a list of all batches that contain it, """Given an IrcMsg, returns a list of all batches that contain it,
innermost first. innermost first.
@ -957,6 +981,11 @@ class IrcState(IrcCommandDispatcher, log.Firewalled):
(n, t, user, ip, host, nick, status, account, gecos) = msg.args (n, t, user, ip, host, nick, status, account, gecos) = msg.args
hostmask = '%s!%s@%s' % (nick, user, host) hostmask = '%s!%s@%s' % (nick, user, host)
self.nicksToHostmasks[nick] = hostmask self.nicksToHostmasks[nick] = hostmask
if account == '0':
# logged out
self.nicksToAccounts[nick] = None
else:
self.nicksToAccounts[nick] = account
def do353(self, irc, msg): def do353(self, irc, msg):
# NAMES reply. # NAMES reply.
@ -978,6 +1007,7 @@ class IrcState(IrcCommandDispatcher, log.Firewalled):
stripped_item = item.lstrip(prefix_chars) stripped_item = item.lstrip(prefix_chars)
item_prefix = item[0:-len(stripped_item)] item_prefix = item[0:-len(stripped_item)]
if ircutils.isUserHostmask(stripped_item): if ircutils.isUserHostmask(stripped_item):
# https://ircv3.net/specs/extensions/userhost-in-names
nick = ircutils.nickFromHostmask(stripped_item) nick = ircutils.nickFromHostmask(stripped_item)
self.nicksToHostmasks[nick] = stripped_item self.nicksToHostmasks[nick] = stripped_item
name = item_prefix + nick name = item_prefix + nick
@ -989,11 +1019,20 @@ class IrcState(IrcCommandDispatcher, log.Firewalled):
c.modes['s'] = None c.modes['s'] = None
def doChghost(self, irc, msg): def doChghost(self, irc, msg):
# https://ircv3.net/specs/extensions/chghost
(user, host) = msg.args (user, host) = msg.args
nick = msg.nick nick = msg.nick
hostmask = '%s!%s@%s' % (nick, user, host) hostmask = '%s!%s@%s' % (nick, user, host)
self.nicksToHostmasks[nick] = hostmask 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): def doJoin(self, irc, msg):
for channel in msg.args[0].split(','): for channel in msg.args[0].split(','):
if channel in self.channels: if channel in self.channels:
@ -1004,6 +1043,12 @@ class IrcState(IrcCommandDispatcher, log.Firewalled):
self.channels[channel] = chan self.channels[channel] = chan
# I don't know why this assert was here. # I don't know why this assert was here.
#assert msg.nick == irc.nick, msg #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): def do367(self, irc, msg):
# Example: # Example:
@ -1083,6 +1128,8 @@ class IrcState(IrcCommandDispatcher, log.Firewalled):
if msg.nick in self.nicksToHostmasks: if msg.nick in self.nicksToHostmasks:
# If we're quitting, it may not be. # If we're quitting, it may not be.
del self.nicksToHostmasks[msg.nick] del self.nicksToHostmasks[msg.nick]
if msg.nick in self.nicksToAccounts:
del self.nicksToAccounts[msg.nick]
def doTopic(self, irc, msg): def doTopic(self, irc, msg):
if len(msg.args) == 1: if len(msg.args) == 1:
@ -1100,6 +1147,7 @@ class IrcState(IrcCommandDispatcher, log.Firewalled):
def doNick(self, irc, msg): def doNick(self, irc, msg):
newNick = msg.args[0] newNick = msg.args[0]
oldNick = msg.nick oldNick = msg.nick
try: try:
if msg.user and msg.host: if msg.user and msg.host:
# Nick messages being handed out from the bot itself won't # Nick messages being handed out from the bot itself won't
@ -1109,6 +1157,13 @@ class IrcState(IrcCommandDispatcher, log.Firewalled):
del self.nicksToHostmasks[oldNick] del self.nicksToHostmasks[oldNick]
except KeyError: except KeyError:
pass pass
try:
self.nicksToAccounts[newNick] = self.nicksToAccounts[oldNick]
del self.nicksToAccounts[oldNick]
except KeyError:
pass
channel_names = ircutils.IrcSet() channel_names = ircutils.IrcSet()
for (name, channel) in self.channels.items(): for (name, channel) in self.channels.items():
if msg.nick in channel.users: if msg.nick in channel.users:

View File

@ -345,6 +345,25 @@ def banmask(hostmask):
else: else:
return '*!*@' + host 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' _plusRequireArguments = 'ovhblkqeI'
_minusRequireArguments = 'ovhbkqeI' _minusRequireArguments = 'ovhbkqeI'
def separateModes(args): def separateModes(args):

View File

@ -318,7 +318,8 @@ class PluginTestCase(SupyTestCase):
raise TimeoutError(query) raise TimeoutError(query)
if lastGetHelp not in m.args[1]: if lastGetHelp not in m.args[1]:
self.assertTrue(m.args[1].startswith('Error:'), 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 return m
def assertSnarfError(self, query, **kwargs): def assertSnarfError(self, query, **kwargs):

View File

@ -579,6 +579,112 @@ class IrcStateTestCase(SupyTestCase):
command='CHGHOST', args=['bar2', 'baz2'])) command='CHGHOST', args=['bar2', 'baz2']))
self.assertEqual(st.nickToHostmask('foo'), 'foo!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): def testEq(self):
state1 = irclib.IrcState() state1 = irclib.IrcState()
state2 = irclib.IrcState() state2 = irclib.IrcState()

View File

@ -367,6 +367,60 @@ class FunctionsTestCase(SupyTestCase):
'*!*@*.host.tld') '*!*@*.host.tld')
self.assertEqual(ircutils.banmask('foo!bar@2001::'), '*!*@2001::*') 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): def testSeparateModes(self):
self.assertEqual(ircutils.separateModes(['+ooo', 'x', 'y', 'z']), self.assertEqual(ircutils.separateModes(['+ooo', 'x', 'y', 'z']),
[('+o', 'x'), ('+o', 'y'), ('+o', 'z')]) [('+o', 'x'), ('+o', 'y'), ('+o', 'z')])