From ee70224aa391f7e5cbecaac07152a3b9657d1039 Mon Sep 17 00:00:00 2001 From: Jeremy Fincher Date: Wed, 8 Sep 2004 23:34:48 +0000 Subject: [PATCH] Changed callCommand to give a name rather than a method; added invalidCommand throttling, ctcp throttling, and whole bunch of other crap. --- plugins/Amazon.py | 4 +- plugins/Anonymous.py | 7 +- plugins/Ctcp.py | 61 +++++++++-- plugins/Weather.py | 4 +- plugins/Words.py | 4 +- src/Admin.py | 38 ++++--- src/Channel.py | 66 ++++++++---- src/Config.py | 2 +- src/Misc.py | 54 +++++++--- src/Owner.py | 54 +++++----- src/User.py | 26 ++--- src/__init__.py | 30 ++++-- src/callbacks.py | 224 +++++++++++++++++++++++------------------ src/conf.py | 61 +++++++++-- src/ircdb.py | 144 +++++++++++++------------- src/socketDrivers.py | 3 +- test/test_callbacks.py | 7 ++ 17 files changed, 500 insertions(+), 289 deletions(-) diff --git a/plugins/Amazon.py b/plugins/Amazon.py index e2e775800..ec89458d2 100644 --- a/plugins/Amazon.py +++ b/plugins/Amazon.py @@ -89,9 +89,9 @@ class Amazon(callbacks.PrivmsgCommandAndRegexp): threaded = True regexps = ['amzSnarfer'] - def callCommand(self, method, irc, msg, *L, **kwargs): + def callCommand(self, name, irc, msg, *L, **kwargs): try: - super(Amazon, self).callCommand(method, irc, msg, *L, **kwargs) + super(Amazon, self).callCommand(name, irc, msg, *L, **kwargs) except amazon.NoLicenseKey, e: irc.error('You must have a free Amazon web services license key ' 'in order to use this command. You can get one at ' diff --git a/plugins/Anonymous.py b/plugins/Anonymous.py index 6acac5047..7f045156f 100644 --- a/plugins/Anonymous.py +++ b/plugins/Anonymous.py @@ -65,6 +65,11 @@ conf.registerGlobalValue(conf.supybot.plugins.Anonymous, 'requireRegistration', conf.registerGlobalValue(conf.supybot.plugins.Anonymous, 'requireCapability', registry.String('', """Determines what capability (if any) the bot should require people trying to use this plugin to have.""")) +conf.registerGlobalValue(conf.supybot.plugins.Anonymous, 'allowPrivateTarget', + registry.Boolean(False, """Determines whether the bot will require targets + of the "say" command to be public (i.e., channels). If this is True, the + bot will allow people to use the "say" command to send private messages to + other users.""")) class Anonymous(callbacks.Privmsg): @@ -89,7 +94,7 @@ class Anonymous(callbacks.Privmsg): c = ircdb.channels.getChannel(channel) if c.lobotomized: irc.error('I\'m lobotomized in %s.' % channel, Raise=True) - if not c.checkCapability(self.__class__.__name__): + if not c.checkCapability(self.name()): irc.error('That channel has set its capabilities so as to ' 'disallow the use of this plugin.', Raise=True) diff --git a/plugins/Ctcp.py b/plugins/Ctcp.py index a71aa1d40..cf46bbd09 100644 --- a/plugins/Ctcp.py +++ b/plugins/Ctcp.py @@ -46,44 +46,85 @@ sys.path.append(os.pardir) import supybot.conf as conf import supybot.ircmsgs as ircmsgs +import supybot.ircutils as ircutils +import supybot.registry as registry import supybot.callbacks as callbacks -notice = ircmsgs.notice +conf.registerPlugin('Ctcp') +conf.registerGlobalValue(conf.supybot.abuse.flood, 'ctcp', + registry.Boolean(True, """Determines whether the bot will defend itself + against CTCP flooding.""")) +conf.registerGlobalValue(conf.supybot.abuse.flood.ctcp, 'maximum', + registry.PositiveInteger(5, """Determines how many CTCP messages (not + including actions) the bot will reply to from a given user in a minute. + If a user sends more than this many CTCP messages in a 60 second period, + the bot will ignore CTCP messages from this user for + supybot.abuse.flood.ctcp.punishment seconds.""")) +conf.registerGlobalValue(conf.supybot.abuse.flood.ctcp, 'punishment', + registry.PositiveInteger(300, """Determines how many seconds the bot will + ignore CTCP messages from users who flood it with CTCP messages.""")) class Ctcp(callbacks.PrivmsgRegexp): public = False + def __init__(self): + self.__parent = super(Ctcp, self) + self.__parent.__init__() + self.ignores = ircutils.IrcDict() + self.floods = ircutils.FloodQueue(60) + + def callCommand(self, name, irc, msg, *L, **kwargs): + if conf.supybot.abuse.flood.ctcp(): + now = time.time() + for (ignore, expiration) in self.ignores.items(): + if expiration < now: + del self.ignores[ignore] + elif ircutils.hostmaskPatternEqual(ignore, msg.prefix): + return + self.floods.enqueue(msg) + max = conf.supybot.abuse.flood.ctcp.maximum() + if self.floods.len(msg) > max: + expires = conf.supybot.abuse.flood.ctcp.punishment() + self.log.warning('Apparent CTCP flood from %s, ' + 'ignoring CTCP messages for %s seconds.', + msg.prefix, expires) + ignoreMask = '*!%s@%s' % (msg.user, msg.host) + self.ignores[ignoreMask] = now + expires + return + self.__parent.callCommand(name, irc, msg, *L, **kwargs) + + def _reply(self, irc, msg, s): + s = '\x01%s\x01' % s + irc.reply(s, notice=True, private=True, to=msg.nick) + def ping(self, irc, msg, match): "\x01PING (.*)\x01" self.log.info('Received CTCP PING from %s', msg.prefix) - irc.queueMsg(notice(msg.nick, '\x01PING %s\x01' % match.group(1))) + self._reply(irc, msg, 'PING %s' % match.group(1)) def version(self, irc, msg, match): "\x01VERSION\x01" self.log.info('Received CTCP VERSION from %s', msg.prefix) - s = '\x01VERSION Supybot %s\x01' % conf.version - irc.queueMsg(notice(msg.nick, s)) + self._reply(irc, msg, 'VERSION Supybot %s' % conf.version) def userinfo(self, irc, msg, match): "\x01USERINFO\x01" self.log.info('Received CTCP USERINFO from %s', msg.prefix) - irc.queueMsg(notice(msg.nick, '\x01USERINFO\x01')) + self._reply(irc, msg, 'USERINFO') def time(self, irc, msg, match): "\x01TIME\x01" self.log.info('Received CTCP TIME from %s' % msg.prefix) - irc.queueMsg(notice(msg.nick, '\x01%s\x01' % time.ctime())) + self._reply(irc, msg, time.ctime()) def finger(self, irc, msg, match): "\x01FINGER\x01" self.log.info('Received CTCP FINGER from %s' % msg.prefix) - s = '\x01Supybot, the best Python bot in existence!\x01' - irc.queueMsg(notice(msg.nick, s)) + irc._reply(irc, msg, 'Supybot, the best Python IRC bot in existence!') def source(self, irc, msg, match): "\x01SOURCE\x01" self.log.info('Received CTCP SOURCE from %s' % msg.prefix) - s = 'http://www.sourceforge.net/projects/supybot/' - irc.queueMsg(notice(msg.nick, s)) + self._reply(irc, msg, 'http://www.sourceforge.net/projects/supybot/') Class = Ctcp # vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/plugins/Weather.py b/plugins/Weather.py index a4fcc5a29..716f706a4 100644 --- a/plugins/Weather.py +++ b/plugins/Weather.py @@ -84,9 +84,9 @@ class Weather(callbacks.Privmsg): the name of 'weather' which should override this help.""" weatherCommands = ['ham', 'cnn', 'wunder'] threaded = True - def callCommand(self, method, irc, msg, *L, **kwargs): + def callCommand(self, name, irc, msg, *L, **kwargs): try: - super(Weather, self).callCommand(method, irc, msg, *L, **kwargs) + super(Weather, self).callCommand(name, irc, msg, *L, **kwargs) except webutils.WebError, e: irc.error(str(e)) diff --git a/plugins/Words.py b/plugins/Words.py index 06b8eabf6..f74d881a1 100644 --- a/plugins/Words.py +++ b/plugins/Words.py @@ -133,10 +133,10 @@ class HangmanGame: class Words(callbacks.Privmsg): - def callCommand(self, command, irc, msg, args, *L, **kw): + def callCommand(self, name, irc, msg, args, *L, **kw): # We'll catch the IOError here. try: - super(Words, self).callCommand(command, irc, msg, args, *L, **kw) + super(Words, self).callCommand(name, irc, msg, args, *L, **kw) except EnvironmentError, e: irc.error('I couldn\'t open the words file. This plugin expects ' 'a words file (i.e., a file with one word per line, in ' diff --git a/src/Admin.py b/src/Admin.py index 3f753239e..d31462fb0 100755 --- a/src/Admin.py +++ b/src/Admin.py @@ -60,7 +60,8 @@ import supybot.callbacks as callbacks class Admin(privmsgs.CapabilityCheckingPrivmsg): capability = 'admin' def __init__(self): - privmsgs.CapabilityCheckingPrivmsg.__init__(self) + self.__parent = super(Admin, self) + self.__parent.__init__() self.joins = {} self.pendingNickChanges = {} @@ -157,7 +158,7 @@ class Admin(privmsgs.CapabilityCheckingPrivmsg): else: channels.append(channel) if not ircutils.isChannel(channel): - irc.error('%r is not a valid channel.' % channel) + irc.errorInvalid('channel', channel) return conf.supybot.channels().add(original) maxchannels = irc.state.supported.get('maxchannels', sys.maxint) @@ -237,7 +238,7 @@ class Admin(privmsgs.CapabilityCheckingPrivmsg): irc.queueMsg(ircmsgs.nick(nick)) self.pendingNickChanges[irc.getRealIrc()] = irc else: - irc.error('That\'s not a valid nick.') + irc.errorInvalid('nick', nick) else: irc.reply(irc.nick) @@ -351,21 +352,33 @@ class Admin(privmsgs.CapabilityCheckingPrivmsg): irc.error(s) def ignore(self, irc, msg, args): - """ + """ [] Ignores or, if a nick is given, ignores whatever hostmask - that nick is currently using. + that nick is currently using. is a "seconds from now" value + that determines when the ignore will expire; if, for instance, you wish + for the ignore to expire in an hour, you could give an of + 3600. If no is given, the ignore will never automatically + expire. """ - arg = privmsgs.getArgs(args) - if ircutils.isUserHostmask(arg): - hostmask = arg + (nickOrHostmask, expires) = privmsgs.getArgs(args, optional=1) + if ircutils.isUserHostmask(nickOrHostmask): + hostmask = nickOrHostmask else: try: - hostmask = irc.state.nickToHostmask(arg) + hostmask = irc.state.nickToHostmask(nickOrHostmask) except KeyError: - irc.error('I can\'t find a hostmask for %s' % arg) + irc.error('I can\'t find a hostmask for %s.' % nickOrHostmask) return - ircdb.ignores.addHostmask(hostmask) + if expires: + try: + expires = int(float(expires)) + expires += int(time.time()) + except ValueError: + irc.errorInvalid('number of seconds', expires, Raise=True) + else: + expires = 0 + ircdb.ignores.add(hostmask, expires) irc.replySuccess() def unignore(self, irc, msg, args): @@ -384,7 +397,7 @@ class Admin(privmsgs.CapabilityCheckingPrivmsg): irc.error('I can\'t find a hostmask for %s' % arg) return try: - ircdb.ignores.removeHostmask(hostmask) + ircdb.ignores.remove(hostmask) irc.replySuccess() except KeyError: irc.error('%s wasn\'t in the ignores database.' % hostmask) @@ -394,6 +407,7 @@ class Admin(privmsgs.CapabilityCheckingPrivmsg): Returns the hostmasks currently being globally ignored. """ + # XXX Add the expirations. if ircdb.ignores.hostmasks: irc.reply(utils.commaAndify(imap(repr, ircdb.ignores.hostmasks))) else: diff --git a/src/Channel.py b/src/Channel.py index cff29be54..ad9d4dc2c 100755 --- a/src/Channel.py +++ b/src/Channel.py @@ -441,23 +441,34 @@ class Channel(callbacks.Privmsg): unlobotomize = privmsgs.checkChannelCapability(unlobotomize, 'op') def permban(self, irc, msg, args, channel): - """[] + """[] [] If you have the #channel,op capability, this will effect a permanent (persistent) ban from interacting with the bot on the given - (or the current hostmask associated with . is only - necessary if the message isn't sent in the channel itself. + (or the current hostmask associated with . Other plugins may + enforce this ban by actually banning users with matching hostmasks when + they join. is an optional argument specifying when (in + "seconds from now") the ban should expire; if none is given, the ban + will never automatically expire. is only necessary if the + message isn't sent in the channel itself. """ - arg = privmsgs.getArgs(args) - if ircutils.isNick(arg): - banmask = ircutils.banmask(irc.state.nickToHostmask(arg)) - elif ircutils.isUserHostmask(arg): - banmask = arg + (nickOrHostmask, expires) = privmsgs.getArgs(args, optional=1) + if ircutils.isNick(nickOrHostmask): + banmask = ircutils.banmask(irc.state.nickToHostmask(nickOrHostmask)) + elif ircutils.isUserHostmask(nickOrHostmask): + banmask = nickOrHostmask else: - irc.error('That\'s not a valid nick or hostmask.') - return + irc.errorInvalid('nick or hostmask', nickOrHostmask, Raise=True) + if expires: + try: + expires = int(float(expires)) + expires += int(time.time()) + except ValueError: + irc.errorInvalid('number of seconds',nickOrHostmask,Raise=True) + else: + expires = 0 c = ircdb.channels.getChannel(channel) - c.addBan(banmask) + c.addBan(banmask, expires) ircdb.channels.setChannel(channel, c) irc.replySuccess() permban = privmsgs.checkChannelCapability(permban, 'op') @@ -483,6 +494,7 @@ class Channel(callbacks.Privmsg): If you have the #channel,op capability, this will show you the current bans on #channel. """ + # XXX Add the expirations. c = ircdb.channels.getChannel(channel) if c.bans: irc.reply(utils.commaAndify(map(utils.dqrepr, c.bans))) @@ -491,23 +503,32 @@ class Channel(callbacks.Privmsg): permbans = privmsgs.checkChannelCapability(permbans, 'op') def ignore(self, irc, msg, args, channel): - """[] + """[] [] If you have the #channel,op capability, this will set a permanent (persistent) ignore on or the hostmask currently associated - with . is only necessary if the message isn't sent in - the channel itself. + with . is an optional argument specifying when (in + "seconds from now") the ignore will expire; if it isn't given, the + ignore will never automatically expire. is only necessary + if the message isn't sent in the channel itself. """ - arg = privmsgs.getArgs(args) - if ircutils.isNick(arg): - banmask = ircutils.banmask(irc.state.nickToHostmask(arg)) - elif ircutils.isUserHostmask(arg): - banmask = arg + (nickOrHostmask, expires) = privmsgs.getArgs(args, optional=1) + if ircutils.isNick(nickOrHostmask): + banmask = ircutils.banmask(irc.state.nickToHostmask(nickOrHostmask)) + elif ircutils.isUserHostmask(nickOrHostmask): + banmask = nickOrHostmask else: - irc.error('That\'s not a valid nick or hostmask.') - return + irc.errorInvalid('nick or hostmask', nickOrHostmask, Raise=True) + if expires: + try: + expires = int(float(expires)) + expires += int(time.time()) + except ValueError: + irc.errorInvalid('number of seconds',nickOrHostmask,Raise=True) + else: + expires = 0 c = ircdb.channels.getChannel(channel) - c.addIgnore(banmask) + c.addIgnore(banmask, expires) ircdb.channels.setChannel(channel, c) irc.replySuccess() ignore = privmsgs.checkChannelCapability(ignore, 'op') @@ -533,6 +554,7 @@ class Channel(callbacks.Privmsg): is only necessary if the message isn't sent in the channel itself. """ + # XXX Add the expirations. channelarg = privmsgs.getArgs(args, required=0, optional=1) channel = channelarg or channel c = ircdb.channels.getChannel(channel) diff --git a/src/Config.py b/src/Config.py index a9a6aeb38..e96c13ff2 100644 --- a/src/Config.py +++ b/src/Config.py @@ -101,7 +101,7 @@ class Config(callbacks.Privmsg): try: super(Config, self).callCommand(name, irc, msg, *L, **kwargs) except InvalidRegistryName, e: - irc.error('%r is not a valid configuration variable.' % e.args[0]) + irc.errorInvalid('configuration variable', e.args[0]) except registry.InvalidRegistryValue, e: irc.error(str(e)) diff --git a/src/Misc.py b/src/Misc.py index f5727b0f7..a47a77177 100755 --- a/src/Misc.py +++ b/src/Misc.py @@ -66,19 +66,50 @@ conf.registerGlobalValue(conf.supybot.plugins.Misc, 'listPrivatePlugins', class Misc(callbacks.Privmsg): priority = sys.maxint + def __init__(self): + super(Misc, self).__init__() + timeout = conf.supybot.abuse.flood.command.invalid + self.invalidCommands = ircutils.FloodQueue(timeout) + def invalidCommand(self, irc, msg, tokens): self.log.debug('Misc.invalidCommand called (tokens %s)', tokens) - if conf.supybot.reply.whenNotCommand(): + # First, we check for invalidCommand floods. This is rightfully done + # here since this will be the last invalidCommand called, and thus it + # will only be called if this is *truly* an invalid command. + maximum = conf.supybot.abuse.flood.command.invalid.maximum() + self.invalidCommands.enqueue(msg) + if self.invalidCommands.len(msg) > maximum and \ + not ircdb.checkCapability(msg.prefix, 'owner'): + punishment = conf.supybot.abuse.flood.command.invalid.punishment() + banmask = '*!%s@%s' % (msg.user, msg.host) + self.log.info('Ignoring %s for %s seconds due to an apparent ' + 'invalid command flood.', banmask, punishment) + if tokens and tokens[0] == 'Error:': + self.log.warning('Apparent error loop with another Supybot ' + 'observed at %s. Consider ignoring this bot ' + 'permanently.', log.timestamp()) + ircdb.ignores.add(banmask, time.time() + punishment) + irc.reply('You\'ve given me %s invalid commands within the last ' + 'minute; I\'m now ignoring you for %s.' % + (maximum, utils.timeElapsed(punishment))) + return + # Now, for normal handling. + channel = msg.args[0] + if conf.get(conf.supybot.reply.whenNotCommand, channel): command = tokens and tokens[0] or '' - irc.error('%r is not a valid command.' % command) + irc.errorInvalid('command', command) else: if tokens: # echo [] will get us an empty token set, but there's no need # to log this in that case anyway, it being a nested command. self.log.info('Not replying to %s, not a command.' % tokens[0]) if not isinstance(irc.irc, irclib.Irc): - brackets = conf.supybot.reply.brackets.get(msg.args[0])() - irc.reply(''.join([brackets[0],' '.join(tokens), brackets[1]])) + brackets = conf.get(conf.supybot.reply.brackets, channel) + if brackets: + (left, right) = brackets + irc.reply(left + ' '.join(tokens) + right) + else: + pass # Let's just do nothing, I can't think of better. def list(self, irc, msg, args): """[--private] [] @@ -505,8 +536,7 @@ class Misc(callbacks.Privmsg): try: i = int(s) except ValueError: - irc.error('Invalid argument: %s' % arg) - return + irc.errorInvalid('argument', arg, Raise=True) if kind == 'y': seconds += i*31536000 elif kind == 'w': @@ -522,10 +552,10 @@ class Misc(callbacks.Privmsg): irc.reply(str(seconds)) def tell(self, irc, msg, args): - """ + """ - Tells the whatever is. Use nested commands to - your benefit here. + Tells the whatever is. Use nested commands to your + benefit here. """ (target, text) = privmsgs.getArgs(args, required=2) if target.lower() == 'me': @@ -534,11 +564,9 @@ class Misc(callbacks.Privmsg): irc.error('Dude, just give the command. No need for the tell.') return elif not ircutils.isNick(target): - irc.error('%s is not a valid nick.' % target) - return + irc.errorInvalid('nick', target, Raise=True) elif ircutils.nickEqual(target, irc.nick): - irc.error('You just told me, why should I tell myself?') - return + irc.error('You just told me, why should I tell myself?',Raise=True) elif target not in irc.state.nicksToHostmasks and \ not ircdb.checkCapability(msg.prefix, 'owner'): # We'll let owners do this. diff --git a/src/Owner.py b/src/Owner.py index d32697b24..acf0a28ad 100644 --- a/src/Owner.py +++ b/src/Owner.py @@ -63,6 +63,7 @@ import supybot.ircutils as ircutils import supybot.privmsgs as privmsgs import supybot.registry as registry import supybot.callbacks as callbacks +import supybot.structures as structures class Deprecated(ImportError): pass @@ -131,7 +132,8 @@ conf.supybot.plugins.Owner.register('public', registry.Boolean(True, # supybot.commands. ### -conf.registerGroup(conf.supybot.commands, 'defaultPlugins') +conf.registerGroup(conf.supybot.commands, 'defaultPlugins', + orderAlphabetically=True) conf.supybot.commands.defaultPlugins.help = utils.normalizeWhitespace(""" Determines what commands have default plugins set, and which plugins are set to be the default for each of those commands.""".strip()) @@ -185,10 +187,13 @@ class Owner(privmsgs.CapabilityCheckingPrivmsg): capability = 'owner' _srcPlugins = ircutils.IrcSet(('Admin', 'Channel', 'Config', 'Misc', 'Owner', 'User')) - def __init__(self): - callbacks.Privmsg.__init__(self) + def __init__(self, *args, **kwargs): + self.__parent = super(Owner, self) + self.__parent.__init__() # Setup log object/command. self.log = LogProxy(self.log) + # Setup command flood detection. + self.commands = ircutils.FloodQueue(60) # Setup exec command. setattr(self.__class__, 'exec', self.__class__._exec) # Setup Irc objects, connected to networks. If world.ircs is already @@ -236,14 +241,14 @@ class Owner(privmsgs.CapabilityCheckingPrivmsg): return None return msg - def isCommand(self, methodName): - return methodName == 'log' or \ - privmsgs.CapabilityCheckingPrivmsg.isCommand(self, methodName) + def isCommand(self, name): + return name == 'log' or \ + self.__parent.isCommand(name) def reset(self): # This has to be done somewhere, I figure here is as good place as any. callbacks.Privmsg._mores.clear() - privmsgs.CapabilityCheckingPrivmsg.reset(self) + self.__parent.reset() def do001(self, irc, msg): self.log.info('Loading plugins.') @@ -416,8 +421,8 @@ class Owner(privmsgs.CapabilityCheckingPrivmsg): except Exception, e: irc.reply(utils.exnToString(e)) else: - # This should never happen, so I haven't bothered updating - # this error string to say --allow-eval. + # There's a potential that allowEval got changed after we were + # loaded. Let's be extra-special-safe. irc.error('You must run Supybot with the --allow-eval ' 'option for this command to be enabled.') @@ -434,7 +439,8 @@ class Owner(privmsgs.CapabilityCheckingPrivmsg): except Exception, e: irc.reply(utils.exnToString(e)) else: - # This should never happen. + # There's a potential that allowEval got changed after we were + # loaded. Let's be extra-special-safe. irc.error('You must run Supybot with the --allow-eval ' 'option for this command to be enabled.') else: @@ -481,13 +487,11 @@ class Owner(privmsgs.CapabilityCheckingPrivmsg): s = 'I don\'t have a default plugin set for that command.' irc.error(s) elif not cbs: - irc.error('That\'s not a valid command.') - return + irc.errorInvalid('command', command, Raise=True) elif plugin: cb = irc.getCallback(plugin) if cb is None: - irc.error('That\'s not a valid plugin.') - return + irc.errorInvalid('plugin', plugin, Raise=True) registerDefaultPlugin(command, plugin) irc.replySuccess() else: @@ -568,7 +572,7 @@ class Owner(privmsgs.CapabilityCheckingPrivmsg): utils.nItems('line', len(linecache.cache))) linecache.clearcache() sys.exc_clear() - collected = world.upkeep(scheduleNext=False) + collected = world.upkeep() if gc.garbage: L.append('Garbage! %r.' % gc.garbage) L.append('%s collected.' % utils.nItems('object', collected)) @@ -686,7 +690,7 @@ class Owner(privmsgs.CapabilityCheckingPrivmsg): irc.error('I couldn\'t reconnect. You should restart me instead.') def defaultcapability(self, irc, msg, args): - """ + """{add|remove} Adds or removes (according to the first argument) from the default capabilities given to users (the configuration variable @@ -709,8 +713,8 @@ class Owner(privmsgs.CapabilityCheckingPrivmsg): conf.supybot.capabilities().add(anticap) irc.replySuccess() else: - irc.error('That\'s not a valid action to take. Valid actions ' - 'are "add" and "remove"') + irc.errorInvalid('action to take', action, + 'Valid actions include "add" and "remove".') def disable(self, irc, msg, args): """[] @@ -751,7 +755,6 @@ class Owner(privmsgs.CapabilityCheckingPrivmsg): self._disabled.remove(command, plugin) irc.replySuccess() except KeyError: - raise irc.error('That command wasn\'t disabled.') def rename(self, irc, msg, args): @@ -762,17 +765,14 @@ class Owner(privmsgs.CapabilityCheckingPrivmsg): (plugin, command, newName) = privmsgs.getArgs(args, required=3) name = callbacks.canonicalName(newName) if name != newName: - irc.error('%s is a not a valid new command name. ' - 'Try making it lowercase and removing - and _.' %newName) - return + irc.errorInvalid('command name', name, + 'Try making it lowercase and removing dashes ' + 'and underscores.', Raise=True) cb = irc.getCallback(plugin) if cb is None: - irc.error('%s is not a valid plugin.' % plugin) - return + irc.errorInvalid('plugin', plugin, Raise=True) if not cb.isCommand(command): - s = '%s is not a valid command in the %s plugin.' % (name, plugin) - irc.error(s) - return + irc.errorInvalid('command in the %s plugin'%plugin,name,Raise=True) if hasattr(cb, name): irc.error('The %s plugin already has an attribute named %s.' % (plugin, name)) diff --git a/src/User.py b/src/User.py index cdc3e3522..a6402ce27 100755 --- a/src/User.py +++ b/src/User.py @@ -116,13 +116,12 @@ class User(callbacks.Privmsg): self._checkNotChannel(irc, msg, password) try: ircdb.users.getUserId(name) - irc.error('That name is already assigned to someone.') - return + irc.error('That name is already assigned to someone.', Raise=True) except KeyError: pass if ircutils.isUserHostmask(name): - irc.error('Hostmasks aren\'t valid usernames.') - return + irc.errorInvalid('username', name, + 'Hostmasks are not valid usernames.', Raise=True) try: u = ircdb.users.getUser(msg.prefix) if u.checkCapability('owner'): @@ -208,7 +207,7 @@ class User(callbacks.Privmsg): if not name: name = msg.prefix if not ircutils.isUserHostmask(hostmask): - irc.error('That\'s not a valid hostmask. Make sure your hostmask ' + irc.errorInvalid('hostmask', hostmask, 'Make sure your hostmask ' 'includes a nick, then an exclamation point (!), then ' 'a user, then an at symbol (@), then a host. Feel ' 'free to use wildcards (* and ?, which work just like ' @@ -404,7 +403,10 @@ class User(callbacks.Privmsg): def unidentify(self, irc, msg, args): """takes no arguments - Un-identifies the user. + Un-identifies you. Note that this may not result in the desired + effect of causing the bot not to recognize you anymore, since you may + have added hostmasks to your user that can cause the bot to continue to + recognize you. """ try: id = ircdb.users.getUserId(msg.prefix) @@ -450,11 +452,12 @@ class User(callbacks.Privmsg): irc.errorNotRegistered() if value == '': value = not user.secure - elif value.lower() in ('true', 'false'): - value = eval(value.capitalize()) + elif value.lower() in ('true', 'on', 'enable'): + value = True + elif value.lower() in ('false', 'off', 'disable'): + value = False else: - irc.error('%s is not a valid boolean value.' % value) - return + irc.errorInvalid('boolean value', value, Raise=True) if user.checkPassword(password) and \ user.checkHostmask(msg.prefix, useAuth=False): user.secure = value @@ -518,8 +521,7 @@ class User(callbacks.Privmsg): ## wrapper = Config.getWrapper(name) ## wrapper = wrapper.get(str(id)) ## except InvalidRegistryValue, e: -## irc.error('%r is not a valid configuration variable.' % name) -## return +## irc.errorInvalid('configuration variable', name, Raise=True) ## if list: ## pass ## else: diff --git a/src/__init__.py b/src/__init__.py index 9937743fe..9153a722a 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -29,6 +29,8 @@ # POSSIBILITY OF SUCH DAMAGE. ### +__revision__ = "$Id$" + import sys import os.path @@ -40,13 +42,27 @@ othersDir = os.path.join(installDir, 'others') sys.path.insert(0, srcDir) sys.path.insert(0, othersDir) -class authors: # This is basically a bag. - jemfinch = 'Jeremy Fincher (jemfinch) ' - jamessan = 'James Vega (jamessan) ' - strike = 'Daniel DiPaolo (Strike) ' - baggins = 'William Robinson (baggins) ' - skorobeus = 'Kevin Murphy (Skorobeus) ' - inkedmn = 'Brett Kelly (inkedmn) ' +class Author(object): + def __init__(self, name=None, nick=None, email=None, **kwargs): + self.__dict__.update(kwargs) + self.name = name + self.nick = nick + self.email = email + + def __str__(self): + return '%s (%s) <%s>' % (self.name, self.nick, self.email) +class authors(object): # This is basically a bag. + jemfinch = Author('Jeremy Fincher', 'jemfinch', 'jemfinch@users.sf.net') + jamessan = Author('James Vega', 'jamessan', 'jamessan@users.sf.net') + strike = Author('Daniel DiPaolo', 'Strike', 'ddipaolo@users.sf.net') + baggins = Author('William Robinson', 'baggins', 'airbaggins@users.sf.net') + skorobeus = Author('Kevin Murphy', 'Skorobeus', 'skoro@skoroworld.com') + inkedmn = Author('Brett Kelly', 'inkedmn', 'inkedmn@users.sf.net') + bwp = Author('Brett Phipps', 'bwp', 'phippsb@gmail.com') + + # Let's be somewhat safe about this. + def __getattr__(self, attr): + return Author('Unknown author', 'unknown', 'unknown@supybot.org') # vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/src/callbacks.py b/src/callbacks.py index 409f53f6f..902679d62 100644 --- a/src/callbacks.py +++ b/src/callbacks.py @@ -65,7 +65,7 @@ import supybot.ircmsgs as ircmsgs import supybot.ircutils as ircutils import supybot.registry as registry -def addressed(nick, msg, prefixChars=None, +def addressed(nick, msg, prefixChars=None, nicks=None, prefixStrings=None, whenAddressedByNick=None): """If msg is addressed to 'name', returns the portion after the address. Otherwise returns the empty string. @@ -88,7 +88,12 @@ def addressed(nick, msg, prefixChars=None, whenAddressedByNick = get(conf.supybot.reply.whenAddressedBy.nick) if prefixStrings is None: prefixStrings = get(conf.supybot.reply.whenAddressedBy.strings) - nick = ircutils.toLower(nick) + if nicks is None: + nicks = get(conf.supybot.reply.whenAddressedBy.nicks) + nicks = map(ircutils.toLower, nicks) + else: + nicks = list(nicks) # Just in case. + nicks.insert(0, ircutils.toLower(nick)) # Ok, let's see if it's a private message. if ircutils.nickEqual(target, nick): payload = stripPrefixStrings(payload) @@ -96,21 +101,22 @@ def addressed(nick, msg, prefixChars=None, payload = payload[1:].lstrip() return payload # Ok, not private. Does it start with our nick? - elif whenAddressedByNick and \ - ircutils.toLower(payload).startswith(nick): - try: - (maybeNick, rest) = payload.split(None, 1) - while not ircutils.isNick(maybeNick, strictRfc=True): - if maybeNick[-1].isalnum(): - return '' - maybeNick = maybeNick[:-1] - if ircutils.nickEqual(maybeNick, nick): - return rest - else: - return '' - except ValueError: # split didn't work. - return '' - elif payload and any(payload.startswith, prefixStrings): + elif whenAddressedByNick: + for nick in nicks: + if ircutils.toLower(payload).startswith(nick): + try: + (maybeNick, rest) = payload.split(None, 1) + while not ircutils.isNick(maybeNick, strictRfc=True): + if maybeNick[-1].isalnum(): + continue + maybeNick = maybeNick[:-1] + if ircutils.nickEqual(maybeNick, nick): + return rest + else: + continue + except ValueError: # split didn't work. + continue + if payload and any(payload.startswith, prefixStrings): return stripPrefixStrings(payload) elif payload and payload[0] in prefixChars: return payload[1:].strip() @@ -213,10 +219,6 @@ class ArgumentError(Error): """The bot replies with a help message when this is raised.""" pass -class CannotNest(Error): - """Exception to be raised by commands that cannot be nested.""" - pass - class Tokenizer: # This will be used as a global environment to evaluate strings in. # Evaluation is, of course, necessary in order to allowed escaped @@ -310,10 +312,10 @@ def tokenize(s, brackets=None, channel=None): start = time.time() try: if brackets is None: - tokens = conf.channelValue(conf.supybot.reply.brackets, channel) + tokens = conf.get(conf.supybot.reply.brackets, channel) else: tokens = brackets - if conf.channelValue(conf.supybot.reply.pipeSyntax, channel): + if conf.get(conf.supybot.reply.pipeSyntax, channel): tokens = '%s|' % tokens log.stat('tokenize took %s seconds.' % (time.time() - start)) return Tokenizer(tokens).tokenize(s) @@ -329,14 +331,15 @@ def getCommands(tokens): L.extend(getCommands(elt)) return L -def findCallbackForCommand(irc, commandName): +def findCallbackForCommand(irc, name): """Given a command name and an Irc object, returns a list of callbacks that commandName is in.""" L = [] + name = canonicalName(name) for callback in irc.callbacks: if not isinstance(callback, PrivmsgRegexp): if hasattr(callback, 'isCommand'): - if callback.isCommand(commandName): + if callback.isCommand(name): L.append(callback) return L @@ -344,16 +347,17 @@ def formatArgumentError(method, name=None, channel=None): if name is None: name = method.__name__ if hasattr(method, '__doc__') and method.__doc__: - if conf.channelValue(conf.supybot.reply.showSimpleSyntax, channel): + if conf.get(conf.supybot.reply.showSimpleSyntax, channel): return getSyntax(method, name=name) else: return getHelp(method, name=name) else: return 'Invalid arguments for %s.' % method.__name__ -def checkCommandCapability(msg, cb, command): +def checkCommandCapability(msg, cb, commandName): + assert isinstance(commandName, basestring), commandName plugin = cb.name().lower() - pluginCommand = '%s.%s' % (plugin, command) + pluginCommand = '%s.%s' % (plugin, commandName) def checkCapability(capability): assert ircdb.isAntiCapability(capability) if ircdb.checkCapability(msg.prefix, capability): @@ -362,12 +366,12 @@ def checkCommandCapability(msg, cb, command): raise RuntimeError, capability try: antiPlugin = ircdb.makeAntiCapability(plugin) - antiCommand = ircdb.makeAntiCapability(command) + antiCommand = ircdb.makeAntiCapability(commandName) antiPluginCommand = ircdb.makeAntiCapability(pluginCommand) checkCapability(antiPlugin) checkCapability(antiCommand) checkCapability(antiPluginCommand) - checkAtEnd = [command, pluginCommand] + checkAtEnd = [commandName, pluginCommand] default = conf.supybot.capabilities.default() if ircutils.isChannel(msg.args[0]): channel = msg.args[0] @@ -376,7 +380,7 @@ def checkCommandCapability(msg, cb, command): checkCapability(ircdb.makeChannelCapability(channel, antiPluginCommand)) chanPlugin = ircdb.makeChannelCapability(channel, plugin) - chanCommand = ircdb.makeChannelCapability(channel, command) + chanCommand = ircdb.makeChannelCapability(channel, commandName) chanPluginCommand = ircdb.makeChannelCapability(channel, pluginCommand) checkAtEnd += [chanCommand, chanPlugin, chanPluginCommand] @@ -437,49 +441,56 @@ class RichReplyMethods(object): else: self.reply(prefixer(s), **kwargs) - def _error(self, s, Raise, **kwargs): + def _error(self, s, Raise=False, **kwargs): if Raise: raise Error, s else: self.error(s, **kwargs) - def errorNoCapability(self, capability, s='', Raise=False, **kwargs): + def errorNoCapability(self, capability, s='', **kwargs): if isinstance(capability, basestring): # checkCommandCapability! log.warning('Denying %s for lacking %r capability.', self.msg.prefix, capability) if not self._getConfig(conf.supybot.reply.noCapabilityError): v = self._getConfig(conf.supybot.replies.noCapability) s = self.__makeReply(v % capability, s) - self._error(s, Raise, **kwargs) + self._error(s, **kwargs) else: log.warning('Denying %s for some unspecified capability ' '(or a default).', self.msg.prefix) v = self._getConfig(conf.supybot.replies.genericNoCapability) - self._error(self.__makeReply(v, s), Raise, **kwargs) + self._error(self.__makeReply(v, s), **kwargs) - def errorPossibleBug(self, s='', Raise=False, **kwargs): + def errorPossibleBug(self, s='', **kwargs): v = self._getConfig(conf.supybot.replies.possibleBug) if s: s += ' (%s)' % v else: s = v - self._error(s, Raise, **kwargs) + self._error(s, **kwargs) - def errorNotRegistered(self, s='', Raise=False, **kwargs): + def errorNotRegistered(self, s='', **kwargs): v = self._getConfig(conf.supybot.replies.notRegistered) - self._error(self.__makeReply(v, s), Raise, **kwargs) + self._error(self.__makeReply(v, s), **kwargs) - def errorNoUser(self, s='', name='that user', Raise=False, **kwargs): + def errorNoUser(self, s='', name='that user', **kwargs): v = self._getConfig(conf.supybot.replies.noUser) try: v = v % name except TypeError: log.warning('supybot.replies.noUser should have one "%s" in it.') - self._error(self.__makeReply(v, s), Raise, **kwargs) + self._error(self.__makeReply(v, s), **kwargs) - def errorRequiresPrivacy(self, s='', Raise=False, **kwargs): + def errorRequiresPrivacy(self, s='', **kwargs): v = self._getConfig(conf.supybot.replies.requiresPrivacy) - self._error(self.__makeReply(v, s), Raise, **kwargs) + self._error(self.__makeReply(v, s), **kwargs) + + def errorInvalid(self, what, given=None, s='', **kwargs): + if given is not None: + v = '%r is not a valid %s.' % (given, what) + else: + v = 'That\'s not a valid %s.' % what + self._error(self.__makeReply(v, s), **kwargs) class IrcObjectProxy(RichReplyMethods): @@ -542,21 +553,14 @@ class IrcObjectProxy(RichReplyMethods): log.exception('Uncaught exception in %s.invalidCommand', cb.name()) - def _callCommand(self, name, command, cb): + def _callCommand(self, name, cb): try: - self.commandMethod = command + self.commandMethod = cb.getCommand(name) try: - cb.callCommand(command, self, self.msg, self.args) - except (getopt.GetoptError, ArgumentError): - self.reply(formatArgumentError(command, name=name)) - except CannotNest, e: - if not isinstance(self.irc, irclib.Irc): - self.error('Command %r cannot be nested.' % name) - except (SyntaxError, Error), e: - cb.log.info('Error return: %s', e) - self.error(str(e)) + cb.callCommand(name, self, self.msg, self.args) except Exception, e: - cb.log.exception('Uncaught exception:') + cb.log.exception('Uncaught exception in %s.%s:', + cb.name(), name) if conf.supybot.reply.detailedErrors(): self.error(utils.exnToString(e)) else: @@ -567,27 +571,27 @@ class IrcObjectProxy(RichReplyMethods): def finalEval(self): assert not self.finalEvaled, 'finalEval called twice.' self.finalEvaled = True - name = canonicalName(self.args[0]) + name = self.args[0] cbs = findCallbackForCommand(self, name) if len(cbs) == 0: for cb in self.irc.callbacks: if isinstance(cb, PrivmsgRegexp): - for (r, m) in cb.res: + for (r, name) in cb.res: if r.search(self.msg.args[1]): log.debug('Skipping invalidCommand: %s.%s', - m.im_class.__name__,m.im_func.func_name) + cb.name(), name) return elif isinstance(cb, PrivmsgCommandAndRegexp): - for (r, m) in cb.res: + for (r, name) in cb.res: if r.search(self.msg.args[1]): log.debug('Skipping invalidCommand: %s.%s', - m.im_class.__name__,m.im_func.func_name) + cb.name(), name) return payload = addressed(self.irc.nick, self.msg) - for (r, m) in cb.addressedRes: + for (r, name) in cb.addressedRes: if r.search(payload): log.debug('Skipping invalidCommand: %s.%s', - m.im_class.__name__,m.im_func.func_name) + cb.name(), name) return # Ok, no regexp-based things matched. self._callInvalidCommands() @@ -604,18 +608,13 @@ class IrcObjectProxy(RichReplyMethods): else: del self.args[0] cb = cbs[0] - cap = checkCommandCapability(self.msg, cb, name) - if cap: - self.errorNoCapability(cap) - return - command = getattr(cb, name) Privmsg.handled = True if cb.threaded or conf.supybot.debug.threadAllCommands(): t = CommandThread(target=self._callCommand, - args=(name, command, cb)) + args=(name, cb)) t.start() else: - self._callCommand(name, command, cb) + self._callCommand(name, cb) def reply(self, s, noLengthCheck=False, prefixName=True, action=None, private=None, notice=None, to=None, msg=None): @@ -768,7 +767,8 @@ class CommandThread(threading.Thread): to run in threads. """ def __init__(self, target=None, args=(), kwargs={}): - (self.name, self.command, self.cb) = args + (self.name, self.cb) = args + self.command = self.cb.getCommand(self.name) world.threadsSpawned += 1 threadName = 'Thread #%s (for %s.%s)' % (world.threadsSpawned, self.cb.name(), self.name) @@ -925,17 +925,17 @@ class Privmsg(irclib.IrcCallback): else: self.__parent.__call__(irc, msg) - def isCommand(self, methodName): + def isCommand(self, name): """Returns whether a given method name is a command in this plugin.""" # This function is ugly, but I don't want users to call methods like # doPrivmsg or __init__ or whatever, and this is good to stop them. # Don't canonicalize this name: consider outFilter(self, irc, msg). - # methodName = canonicalName(methodName) - if self._disabled.disabled(methodName, plugin=self.name()): + # name = canonicalName(name) + if self._disabled.disabled(name, plugin=self.name()): return False - if hasattr(self, methodName): - method = getattr(self, methodName) + if hasattr(self, name): + method = getattr(self, name) if inspect.ismethod(method): code = method.im_func.func_code return inspect.getargs(code)[0] == self.commandArgs @@ -944,19 +944,32 @@ class Privmsg(irclib.IrcCallback): else: return False - def getCommand(self, methodName): + def getCommand(self, name): """Gets the given command from this plugin.""" - assert self.isCommand(methodName) - methodName = canonicalName(methodName) - return getattr(self, methodName) + name = canonicalName(name) + assert self.isCommand(name), '%r is not a command.' % name + return getattr(self, name) - def callCommand(self, method, irc, msg, *L): - name = method.im_func.func_name + def callCommand(self, name, irc, msg, *L, **kwargs): + #print '*', name, utils.stackTrace() + checkCapabilities = kwargs.pop('checkCapabilities', True) + if checkCapabilities: + cap = checkCommandCapability(msg, self, name) + if cap: + irc.errorNoCapability(cap) + return + method = self.getCommand(name) assert L, 'Odd, nothing in L. This can\'t happen.' - self.log.info('%r called by %s', name, msg.prefix) + self.log.info('%s.%s called by %s.', self.name(), name, msg.prefix) self.log.debug('args: %s', L[0]) start = time.time() - method(irc, msg, *L) + try: + method(irc, msg, *L) + except (getopt.GetoptError, ArgumentError): + irc.reply(formatArgumentError(method, name=name)) + except (SyntaxError, Error), e: + self.log.debug('Error return: %s', utils.exnToString(e)) + irc.error(str(e)) elapsed = time.time() - start log.stat('%s took %s seconds', name, elapsed) @@ -1080,13 +1093,14 @@ class PrivmsgRegexp(Privmsg): self.log.warning('Invalid regexp: %r (%s)',value.__doc__,e) self.res.sort(lambda (r1, m1), (r2, m2): cmp(m1.__name__, m2.__name__)) - def callCommand(self, method, irc, msg, *L): + def callCommand(self, name, irc, msg, *L, **kwargs): try: - self.__parent.callCommand(method, irc, msg, *L) + self.__parent.callCommand(name, irc, msg, *L, **kwargs) except Exception, e: # We catch exceptions here because IrcObjectProxy isn't doing our # dirty work for us anymore. - self.log.exception('Uncaught exception from callCommand:') + self.log.exception('Uncaught exception in %s.%s:', + self.name(), name) if conf.supybot.reply.detailedErrors(): irc.error(utils.exnToString(e)) else: @@ -1126,17 +1140,31 @@ class PrivmsgCommandAndRegexp(Privmsg): for name in self.regexps: method = getattr(self, name) r = re.compile(method.__doc__, self.flags) - self.res.append((r, method)) + self.res.append((r, name)) for name in self.addressedRegexps: method = getattr(self, name) r = re.compile(method.__doc__, self.flags) - self.addressedRes.append((r, method)) + self.addressedRes.append((r, name)) - def callCommand(self, f, irc, msg, *L, **kwargs): + def isCommand(self, name): + return self.__parent.isCommand(name) or \ + name in self.regexps or \ + name in self.addressedRegexps + + def getCommand(self, name): try: - self.__parent.callCommand(f, irc, msg, *L) + return getattr(self, name) # Regexp stuff. + except AttributeError: + return self.__parent.getCommand(name) + + def callCommand(self, name, irc, msg, *L, **kwargs): + try: + self.__parent.callCommand(name, irc, msg, *L, **kwargs) except Exception, e: - if 'catchErrors' in kwargs and kwargs['catchErrors']: + # As annoying as it is, Python doesn't allow *L in addition to + # well-defined keyword arguments. So we have to do this trick. + catchErrors = kwargs.pop('catchErrors', False) + if catchErrors: self.log.exception('Uncaught exception in callCommand:') if conf.supybot.reply.detailedErrors(): irc.error(utils.exnToString(e)) @@ -1147,24 +1175,22 @@ class PrivmsgCommandAndRegexp(Privmsg): def doPrivmsg(self, irc, msg): if Privmsg.errored: - self.log.info('%s not running due to Privmsg.errored.', - self.name()) + self.log.debug('%s not running due to Privmsg.errored.', + self.name()) return - for (r, method) in self.res: - name = method.__name__ + for (r, name) in self.res: for m in r.finditer(msg.args[1]): proxy = self.Proxy(irc, msg) - self.callCommand(method, proxy, msg, m, catchErrors=True) + self.callCommand(name, proxy, msg, m, catchErrors=True) if not Privmsg.handled: s = addressed(irc.nick, msg) if s: - for (r, method) in self.addressedRes: - name = method.__name__ + for (r, name) in self.addressedRes: if Privmsg.handled and name not in self.alwaysCall: continue for m in r.finditer(s): proxy = self.Proxy(irc, msg) - self.callCommand(method,proxy,msg,m,catchErrors=True) + self.callCommand(name, proxy, msg, m, catchErrors=True) Privmsg.handled = True diff --git a/src/conf.py b/src/conf.py index fa1502461..802634a18 100644 --- a/src/conf.py +++ b/src/conf.py @@ -86,7 +86,9 @@ allowDefaultOwner = False supybot = registry.Group() supybot.setName('supybot') -def registerGroup(Group, name, group=None): +def registerGroup(Group, name, group=None, **kwargs): + if kwargs: + group = registry.Group(**kwargs) return Group.register(name, group) def registerGlobalValue(group, name, value): @@ -109,8 +111,8 @@ def registerPlugin(name, currentValue=None, public=True): supybot.plugins.get(name).setValue(currentValue) return registerGroup(users.plugins, name) -def channelValue(group, channel=None): - if channel is None: +def get(group, channel=None): + if channel is None or not group.channelValue: return group() else: return group.get(channel)() @@ -120,7 +122,7 @@ def channelValue(group, channel=None): ### users = registry.Group() users.setName('users') -registerGroup(users, 'plugins') +registerGroup(users, 'plugins', orderAlphabetically=True) def registerUserValue(group, name, value): assert group._name.startswith('users') @@ -188,7 +190,8 @@ class Networks(registry.SpaceSeparatedSetOfStrings): List = ircutils.IrcSet registerGlobalValue(supybot, 'networks', - Networks([], """Determines what networks the bot will connect to.""")) + Networks([], """Determines what networks the bot will connect to.""", + orderAlphabetically=True)) class Servers(registry.SpaceSeparatedListOfStrings): def normalize(self, s): @@ -278,8 +281,8 @@ registerChannelValue(supybot.reply.mores, 'instant', registerGlobalValue(supybot.reply, 'oneToOne', registry.Boolean(True, """Determines whether the bot will send multi-message replies in a single message or in multiple messages. For - safety purposes (so the bot can't possibly flood) it will normally send - everything in a single message.""")) + safety purposes (so the bot is less likely to flood) it will normally send + everything in a single message, using mores if necessary.""")) class ValidBrackets(registry.OnlySomeStrings): validStrings = ('', '[]', '<>', '{}', '()') @@ -409,6 +412,11 @@ registerChannelValue(supybot.reply.whenAddressedBy, 'nick', registry.Boolean(True, """Determines whether the bot will reply when people address it by its nick, rather than with a prefix character.""")) +registerChannelValue(supybot.reply.whenAddressedBy, 'nicks', + registry.SpaceSeparatedSetOfStrings([], """Determines what extra nicks the + bot will always respond to when addressed by, even if its current nick is + something else.""")) + ### # Replies ### @@ -501,6 +509,7 @@ registerGlobalValue(supybot, 'flush', inside the bot, your changes won't be flushed. To make this change permanent, you must edit the registry yourself.""")) + ### # supybot.commands. For stuff relating to commands. ### @@ -508,6 +517,42 @@ registerGroup(supybot, 'commands') # supybot.commands.disabled moved to callbacks for canonicalName. +### +# supybot.abuse. For stuff relating to abuse of the bot. +### +registerGroup(supybot, 'abuse') +registerGroup(supybot.abuse, 'flood') +registerGlobalValue(supybot.abuse.flood, 'command', + registry.Boolean(True, """Determines whether the bot will defend itself + against command-flooding.""")) +registerGlobalValue(supybot.abuse.flood.command, 'maximum', + registry.PositiveInteger(12, """Determines how many commands users are + allowed per minute. If a user sends more than this many commands in any + 60 second period, he or she will be ignored for + supybot.abuse.flood.command.punishment seconds.""")) +registerGlobalValue(supybot.abuse.flood.command, 'punishment', + registry.PositiveInteger(300, """Determines how many seconds the bot + will ignore users who flood it with commands.""")) + +registerGlobalValue(supybot.abuse.flood.command, 'invalid', + registry.Boolean(True, """Determines whether the bot will defend itself + against invalid command-flooding.""")) +registerGlobalValue(supybot.abuse.flood.command.invalid, 'maximum', + registry.PositiveInteger(5, """Determines how many invalid commands users + are allowed per minute. If a user sends more than this many invalid + commands in any 60 second period, he or she will be ignored for + supybot.abuse.flood.command.invalid.punishment seconds. Typically, this + value is lower than supybot.abuse.flood.command.maximum, since it's far + less likely (and far more annoying) for users to flood with invalid + commands than for them to flood with valid commands.""")) +registerGlobalValue(supybot.abuse.flood.command.invalid, 'punishment', + registry.PositiveInteger(600, """Determines how many seconds the bot + will ignore users who flood it with invalid commands. Typically, this + value is higher than supybot.abuse.flood.command.punishment, since it's far + less likely (and far more annoying) for users to flood witih invalid + commands than for them to flood with valid commands.""")) + + ### # supybot.drivers. For stuff relating to Supybot's drivers (duh!) ### @@ -601,7 +646,7 @@ registerGlobalValue(supybot.directories, 'plugins', a new one. E.g. you can say: bot: 'config supybot.directories.plugins [config supybot.directories.plugins], newPluginDirectory'.""")) -registerGroup(supybot, 'plugins') # This will be used by plugins, but not here. +registerGroup(supybot, 'plugins', orderAlphabetically=True) registerGlobalValue(supybot.plugins, 'alwaysLoadDefault', registry.Boolean(True, """Determines whether the bot will always load the default plugins (Admin, Channel, Config, Misc, Owner, and User) diff --git a/src/ircdb.py b/src/ircdb.py index 4c986f693..80b8bac8e 100644 --- a/src/ircdb.py +++ b/src/ircdb.py @@ -39,6 +39,7 @@ import os import sets import time import string +import operator from itertools import imap, ilen, ifilter import supybot.log as log @@ -310,32 +311,17 @@ class IrcUser(object): class IrcChannel(object): - """This class holds the capabilities, bans, and ignores of a channel. - """ + """This class holds the capabilities, bans, and ignores of a channel.""" defaultOff = ('op', 'halfop', 'voice', 'protected') def __init__(self, bans=None, silences=None, exceptions=None, ignores=None, capabilities=None, lobotomized=False, defaultAllow=True): self.defaultAllow = defaultAllow - if bans is None: - self.bans = [] - else: - self.bans = bans - if exceptions is None: - self.exceptions = [] - else: - self.exceptions = exceptions - if silences is None: - self.silences = [] - else: - self.silences = silences - if ignores is None: - self.ignores = [] - else: - self.ignores = ignores - if capabilities is None: - self.capabilities = CapabilitySet() - else: - self.capabilities = capabilities + self.expiredBans = [] + self.bans = bans or {} + self.ignores = ignores or {} + self.silences = silences or [] + self.exceptions = exceptions or [] + self.capabilities = capabilities or CapabilitySet() for capability in self.defaultOff: if capability not in self.capabilities: self.capabilities.add(makeAntiCapability(capability)) @@ -349,28 +335,33 @@ class IrcChannel(object): self.capabilities, self.lobotomized, self.defaultAllow, self.silences, self.exceptions) - def addBan(self, hostmask): + def addBan(self, hostmask, expiration=0): """Adds a ban to the channel banlist.""" - self.bans.append(hostmask) + self.bans[hostmask] = int(expiration) def removeBan(self, hostmask): """Removes a ban from the channel banlist.""" - self.bans = [s for s in self.bans if s != hostmask] + return self.bans.pop(hostmask) def checkBan(self, hostmask): """Checks whether a given hostmask is banned by the channel banlist.""" - for pat in self.bans: - if ircutils.hostmaskPatternEqual(pat, hostmask): - return True + now = time.time() + for (pattern, expiration) in self.bans.items(): + if now < expiration or not expiration: + if ircutils.hostmaskPatternEqual(pattern, hostmask): + return True + else: + self.expiredBans.append((pattern, expiration)) + del self.bans[pattern] return False - def addIgnore(self, hostmask): + def addIgnore(self, hostmask, expiration=0): """Adds an ignore to the channel ignore list.""" - self.ignores.append(hostmask) + self.ignores[hostmask] = int(expiration) def removeIgnore(self, hostmask): """Removes an ignore from the channel ignore list.""" - self.ignores = [s for s in self.ignores if s != hostmask] + return self.ignores.pop(hostmask) def addCapability(self, capability): """Adds a capability to the channel's default capabilities.""" @@ -398,12 +389,16 @@ class IrcChannel(object): """Checks whether a given hostmask is to be ignored by the channel.""" if self.lobotomized: return True - for mask in self.bans: - if ircutils.hostmaskPatternEqual(mask, hostmask): - return True - for mask in self.ignores: - if ircutils.hostmaskPatternEqual(mask, hostmask): - return True + if self.checkBan(hostmask): + return True + now = time.time() + for (pattern, expiration) in self.ignores.items(): + if now < expiration or not expiration: + if ircutils.hostmaskPatternEqual(pattern, hostmask): + return True + else: + del self.ignores[pattern] + # Later we may wish to keep expiredIgnores, but not now. return False def preserve(self, fd, indent=''): @@ -415,14 +410,14 @@ class IrcChannel(object): write('defaultAllow %s' % self.defaultAllow) for capability in self.capabilities: write('capability ' + capability) - for ban in self.bans: - write('ban ' + ban) - for silence in self.silences: - write('silence ' + silence) - for exception in self.exceptions: - write('exception ' + exception) - for ignore in self.ignores: - write('ignore ' + ignore) + bans = self.bans.items() + utils.sortBy(operator.itemgetter(1), bans) + for (ban, expiration) in bans: + write('ban %s %d' % (ban, expiration)) + ignores = self.ignores.items() + utils.sortBy(operator.itemgetter(1), ignores) + for (ignore, expiration) in ignores: + write('ignore %s %d' % (ignore, expiration)) fd.write(os.linesep) @@ -511,22 +506,14 @@ class IrcChannelCreator(Creator): def ban(self, rest, lineno): if self.name is None: raise ValueError, 'Unexpected channel description without channel.' - self.c.bans.append(rest) + (pattern, expiration) = rest + self.c.bans[pattern] = int(float(expiration)) def ignore(self, rest, lineno): if self.name is None: raise ValueError, 'Unexpected channel description without channel.' - self.c.ignores.append(rest) - - def silence(self, rest, lineno): - if self.name is None: - raise ValueError, 'Unexpected channel description without channel.' - self.c.silences.append(rest) - - def exception(self, rest, lineno): - if self.name is None: - raise ValueError, 'Unexpected channel description without channel.' - self.c.exceptions.append(rest) + (pattern, expiration) = rest + self.c.ignores[pattern] = int(float(expiration)) def finish(self): if self.hadChannel: @@ -782,21 +769,33 @@ class ChannelsDictionary(utils.IterableMap): class IgnoresDB(object): def __init__(self): self.filename = None - self.hostmasks = sets.Set() + self.hostmasks = {} def open(self, filename): self.filename = filename fd = file(self.filename) for line in utils.nonCommentNonEmptyLines(fd): - self.hostmasks.add(line.rstrip('\r\n')) + try: + line = line.rstrip('\r\n') + L = line.split() + hostmask = L.pop(0) + if L: + expiration = int(float(L.pop(0))) + else: + expiration = 0 + self.add(hostmask, expiration) + except Exception, e: + log.error('Invalid line in ignores database: %r', line) fd.close() def flush(self): if self.filename is not None: fd = utils.transactionalFile(self.filename) - for hostmask in self.hostmasks: - fd.write(hostmask) - fd.write(os.linesep) + now = time.time() + for (hostmask, expiration) in self.hostmasks.items(): + if now < expiration or not expiration: + fd.write('%s %s' % (hostmask, expiration)) + fd.write(os.linesep) fd.close() else: log.warning('IgnoresDB.flush called without self.filename.') @@ -809,26 +808,33 @@ class IgnoresDB(object): def reload(self): if self.filename is not None: + oldhostmasks = self.hostmasks.copy() self.hostmasks.clear() try: self.open(self.filename) except EnvironmentError, e: log.warning('IgnoresDB.reload failed: %s', e) + # Let's be somewhat transactional. + self.hostmasks.update(oldhostmasks) else: log.warning('IgnoresDB.reload called without self.filename.') def checkIgnored(self, prefix): - for hostmask in self.hostmasks: - if ircutils.hostmaskPatternEqual(hostmask, prefix): - return True + now = time.time() + for (hostmask, expiration) in self.hostmasks.items(): + if expiration and now > expiration: + del self.hostmasks[hostmask] + else: + if ircutils.hostmaskPatternEqual(hostmask, prefix): + return True return False - def addHostmask(self, hostmask): + def add(self, hostmask, expiration=0): assert ircutils.isUserHostmask(hostmask) - self.hostmasks.add(hostmask) + self.hostmasks[hostmask] = expiration - def removeHostmask(self, hostmask): - self.hostmasks.remove(hostmask) + def remove(self, hostmask): + del self.hostmasks[hostmask] confDir = conf.supybot.directories.conf() diff --git a/src/socketDrivers.py b/src/socketDrivers.py index e5a3ed9f1..a5b3952a5 100644 --- a/src/socketDrivers.py +++ b/src/socketDrivers.py @@ -55,8 +55,7 @@ reconnectWaits = (0, 60, 300) class SocketDriver(drivers.IrcDriver, drivers.ServersMixin): def __init__(self, irc): self.irc = irc - drivers.ServersMixin.__init__(self, irc) - drivers.IrcDriver.__init__(self) # Must come after setting irc. + super(SocketDriver, self).__init__(irc) self.conn = None self.servers = () self.eagains = 0 diff --git a/test/test_callbacks.py b/test/test_callbacks.py index 97d8cbb5f..b45e23740 100644 --- a/test/test_callbacks.py +++ b/test/test_callbacks.py @@ -182,6 +182,11 @@ class FunctionsTestCase(SupyTestCase): finally: conf.supybot.reply.whenNotAddressed.setValue(original) + def testAddressedWithMultipleNicks(self): + msg = ircmsgs.privmsg('#foo', 'bar: baz') + self.failUnless(callbacks.addressed('bar', msg)) + self.failUnless(callbacks.addressed('biff', msg, nicks=['bar'])) + def testReply(self): prefix = 'foo!bar@baz' channelMsg = ircmsgs.privmsg('#foo', 'bar baz', prefix=prefix) @@ -259,6 +264,7 @@ class PrivmsgTestCase(ChannelPluginTestCase): original = conf.supybot.reply.errorInPrivate() conf.supybot.reply.errorInPrivate.setValue(False) m = self.getMsg("eval irc.error('foo', private=True)") + self.failUnless(m, 'No message returned.') self.failIf(ircutils.isChannel(m.args[0])) finally: conf.supybot.reply.errorInPrivate.setValue(original) @@ -271,6 +277,7 @@ class PrivmsgTestCase(ChannelPluginTestCase): original = conf.supybot.reply.errorWithNotice() conf.supybot.reply.errorWithNotice.setValue(True) m = self.getMsg("eval irc.error('foo')") + self.failUnless(m, 'No message returned.') self.failUnless(m.command == 'NOTICE') finally: conf.supybot.reply.errorWithNotice.setValue(original)