diff --git a/RateLimit/README.txt b/RateLimit/README.txt new file mode 100644 index 0000000..d60b47a --- /dev/null +++ b/RateLimit/README.txt @@ -0,0 +1 @@ +Insert a description of your plugin here, with any notes, etc. about using it. diff --git a/RateLimit/__init__.py b/RateLimit/__init__.py new file mode 100644 index 0000000..c6ae06c --- /dev/null +++ b/RateLimit/__init__.py @@ -0,0 +1,69 @@ +### +# Copyright (c) 2013, Valentin Lorentz +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +""" +Add a description of the plugin (to be presented to the user inside the wizard) +here. This should describe *what* the plugin does. +""" + +import supybot +import supybot.world as world + +# Use this for the version of this plugin. You may wish to put a CVS keyword +# in here if you're keeping the plugin in CVS or some similar system. +__version__ = "" + +# XXX Replace this with an appropriate author or supybot.Author instance. +__author__ = supybot.authors.unknown + +# This is a dictionary mapping supybot.Author instances to lists of +# contributions. +__contributors__ = {} + +# This is a url where the most recent plugin package can be downloaded. +__url__ = '' # 'http://supybot.com/Members/yourname/RateLimit/download' + +from . import config +from . import plugin +from imp import reload +# In case we're being reloaded. +reload(config) +reload(plugin) +# Add more reloads here if you add third-party modules and want them to be +# reloaded when this plugin is reloaded. Don't forget to import them as well! + +if world.testing: + from . import test + +Class = plugin.Class +configure = config.configure + + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/RateLimit/config.py b/RateLimit/config.py new file mode 100644 index 0000000..ebf1cc4 --- /dev/null +++ b/RateLimit/config.py @@ -0,0 +1,56 @@ +### +# Copyright (c) 2013, Valentin Lorentz +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +import supybot.conf as conf +import supybot.registry as registry +try: + from supybot.i18n import PluginInternationalization + _ = PluginInternationalization('RateLimit') +except: + # Placeholder that allows to run the plugin on a bot + # without the i18n module + _ = lambda x:x + +def configure(advanced): + # This will be called by supybot to configure this module. advanced is + # a bool that specifies whether the user identified himself as an advanced + # user or not. You should effect your configuration by manipulating the + # registry as appropriate. + from supybot.questions import expect, anything, something, yn + conf.registerPlugin('RateLimit', True) + + +RateLimit = conf.registerPlugin('RateLimit') +# This is where your configuration variables (if any) should go. For example: +# conf.registerGlobalValue(RateLimit, 'someConfigVariableName', +# registry.Boolean(False, _("""Help for someConfigVariableName."""))) + + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/RateLimit/local/__init__.py b/RateLimit/local/__init__.py new file mode 100644 index 0000000..e86e97b --- /dev/null +++ b/RateLimit/local/__init__.py @@ -0,0 +1 @@ +# Stub so local is a module, used for third-party modules diff --git a/RateLimit/plugin.py b/RateLimit/plugin.py new file mode 100644 index 0000000..e1bb03d --- /dev/null +++ b/RateLimit/plugin.py @@ -0,0 +1,177 @@ +### +# Copyright (c) 2013, Valentin Lorentz +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +import time + +import supybot.dbi as dbi +import supybot.conf as conf +import supybot.utils as utils +import supybot.ircdb as ircdb +from supybot.commands import * +import supybot.plugins as plugins +import supybot.ircutils as ircutils +import supybot.callbacks as callbacks +try: + from supybot.i18n import PluginInternationalization + _ = PluginInternationalization('RateLimit') +except: + # Placeholder that allows to run the plugin on a bot + # without the i18n module + _ = lambda x:x + +if not hasattr(callbacks.Commands, 'pre_command_callbacks'): + raise callbacks.Error( + 'Your version of Supybot is not compatible with ' + 'this plugin (it does not have support for ' + 'pre-command-call callbacks).') + +class RateLimitRecord(dbi.Record): + __fields__ = ('channel', 'user', 'count', 'interval', 'command') +class RateLimitDB(dbi.DB): + Record = RateLimitRecord + def set_user_limit(self, channel, user, count, interval, command): + record = RateLimitRecord(channel=channel, user=user, count=count, + interval=interval, command=command) + try: + previous_record = list(filter( + lambda x:x.user == user and + x.command == command and + x.channel == channel, + self))[0] + except IndexError: + self.add(record) + else: + self.set(previous_record.id, record) + + def get_limits(self, command): + return filter(lambda x:x.command == command, self) + def get_user_limit(self, user, command): + records = list(filter(lambda x:x.user in (user, '*', 'global'), + self.get_limits(command))) + # TODO: Add channel support. + try: + return list(filter(lambda x:x.user == user, records))[0] + except IndexError: + return list(records)[0] # May raise IndexError too + +filename = conf.supybot.directories.data.dirize('RateLimit.db') + +def format_ratelimit(record): + return _('%(count)s per %(interval)s sec') % { + 'count': record.count, + 'interval': record.interval + } + +class RateLimit(callbacks.Plugin): + """Add the help for "@plugin help RateLimit" here + This should describe *how* to use this plugin.""" + + def __init__(self, irc): + super(RateLimit, self).__init__(irc) + self.db = RateLimitDB(filename) + callbacks.Commands.pre_command_callbacks.append( + self._pre_command_callback) + self._history = {} # {command: [(user, timestamp)]} + + def die(self): + callbacks.Commands.pre_command_callbacks.remove( + self._pre_command_callback) + + def _pre_command_callback(self, command, irc, msg, *args, **kwargs): + command = ' '.join(command) + try: + user = ircdb.users.getUserId(msg.prefix) + except KeyError: + user = None + try: + record = self.db.get_user_limit(user, command) + except IndexError: + return False + else: + if command not in self._history: + list_ = [] + self._history[command] = list_ + else: + list_ = self._history[command] + timestamp = time.time() - record.interval + list_ = list(filter(lambda x:x[1] > timestamp and + (x[0]==user or record.user=='global'), + list_)) + if len(list_) >= record.count: + self.log.info('Throttling command %r call (rate limited).', + command) + return True + list_.append((user, time.time())) + self._history[command] = list_ + return False + + @wrap([optional(first('otherUser', ('literal', '*'))), + 'nonNegativeInt', 'nonNegativeInt', 'commandName', 'admin']) + def set(self, irc, msg, args, user, count, interval, command): + """[] + + Sets the rate limit of the for the . + If is not given, the rate limit will be enforced globally, + and if * is given as the , the rate limit will be enforced + for everyone.""" + if user is None: + user = 'global' + elif user != '*': + user = user.id + self.db.set_user_limit(None, user, count, interval, command) + irc.replySuccess() + + @wrap(['commandName']) + def get(self, irc, msg, args, command): + """ + + Return rate limits set for the given .""" + records = self.db.get_limits(command) + global_ = 'none' + star = 'none' + users = [] + for record in records: + if record.user == 'global': + global_ = format_ratelimit(record) + elif record.user == '*': + star = format_ratelimit(record) + else: + users.append('%s: %s' % (ircdb.users.getUser(record.user).name, + format_ratelimit(record))) + irc.reply(', '.join([_('global: %s') % global_, + _('*: %s') % star] + + users)) + + + +Class = RateLimit + + +# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/RateLimit/test.py b/RateLimit/test.py new file mode 100644 index 0000000..39d93f6 --- /dev/null +++ b/RateLimit/test.py @@ -0,0 +1,80 @@ +### +# Copyright (c) 2013, Valentin Lorentz +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +from supybot.test import * + +class RateLimitTestCase(PluginTestCase): + plugins = ('RateLimit', 'User', 'Utilities') + + def setUp(self): + super(RateLimitTestCase, self).setUp() + for name in ('foo', 'bar', 'baz'): + self.assertNotError('register %s passwd' % name, + frm='%s!a@a' % name) + + def testSingleUser(self): + self.assertResponse('ratelimit get echo', + 'global: none, *: none') + self.assertNotError('ratelimit set foo 3 1 echo') + self.assertResponse('ratelimit get echo', + 'global: none, *: none, foo: 3 per 1 sec') + self.assertResponse('echo spam', 'spam', frm='foo!a@a') + time.sleep(1.1) + self.assertResponse('echo spam', 'spam', frm='foo!a@a') + self.assertResponse('echo spam', 'spam', frm='foo!a@a') + self.assertResponse('echo spam', 'spam', frm='foo!a@a') + self.assertNoResponse('echo spam', frm='foo!a@a') + self.assertResponse('echo spam', 'spam', frm='bar!a@a') + + def testStar(self): + self.assertResponse('ratelimit get echo', + 'global: none, *: none') + self.assertNotError('ratelimit set * 3 1 echo') + self.assertResponse('ratelimit get echo', + 'global: none, *: 3 per 1 sec') + self.assertResponse('echo spam', 'spam', frm='foo!a@a') + self.assertResponse('echo spam', 'spam', frm='foo!a@a') + self.assertResponse('echo spam', 'spam', frm='foo!a@a') + self.assertNoResponse('echo spam', frm='foo!a@a') + self.assertResponse('echo spam', 'spam', frm='bar!a@a') + + def testGlobal(self): + self.assertResponse('ratelimit get echo', + 'global: none, *: none') + self.assertNotError('ratelimit set 3 1 echo') + self.assertResponse('ratelimit get echo', + 'global: 3 per 1 sec, *: none') + self.assertResponse('echo spam', 'spam', frm='foo!a@a') + self.assertResponse('echo spam', 'spam', frm='bar!a@a') + self.assertResponse('echo spam', 'spam', frm='baz!a@a') + self.assertNoResponse('echo spam', frm='foo!a@a') + + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: