diff --git a/docs/source/plugin/anatomy.rst b/docs/source/plugin/anatomy.rst index b3459fab07..aedb7eb1db 100644 --- a/docs/source/plugin/anatomy.rst +++ b/docs/source/plugin/anatomy.rst @@ -124,6 +124,18 @@ Example:: A rule with rate-limiting can return :const:`sopel.plugin.NOLIMIT` to let the user try again after a failed command, e.g. if a required argument is missing. +.. seealso:: + + There are three other decorators to use: + + * :func:`sopel.plugin.rate_user` + * :func:`sopel.plugin.rate_channel` + * :func:`sopel.plugin.rate_global` + + These decorators can be used independently and will always override the + value set by ``rate()``. They also accept a dedicated message. See their + respective documentation for more information. + Bypassing restrictions ---------------------- diff --git a/sopel/bot.py b/sopel/bot.py index 87d6ba325f..14b5e47da0 100644 --- a/sopel/bot.py +++ b/sopel/bot.py @@ -587,18 +587,34 @@ def call_rule( sopel: 'SopelWrapper', trigger: Trigger, ) -> None: + nick = trigger.nick + context = trigger.sender + is_channel = context and not context.is_nick() + # rate limiting if not trigger.admin and not rule.is_unblockable(): - if rule.is_rate_limited(trigger.nick): + if rule.is_user_rate_limited(nick): + message = rule.get_user_rate_message(nick) + if message: + sopel.notice(message, destination=nick) return - if not trigger.is_privmsg and rule.is_channel_rate_limited(trigger.sender): + + if is_channel and rule.is_channel_rate_limited(context): + message = rule.get_channel_rate_message(nick, context) + if message: + sopel.notice(message, destination=nick) return + if rule.is_global_rate_limited(): + message = rule.get_global_rate_message(nick) + if message: + sopel.notice(message, destination=nick) return # channel config - if trigger.sender in self.config: - channel_config = self.config[trigger.sender] + if is_channel and context in self.config: + channel_config = self.config[context] + plugin_name = rule.get_plugin_name() # disable listed plugins completely on provided channel if 'disable_plugins' in channel_config: @@ -606,13 +622,13 @@ def call_rule( if '*' in disabled_plugins: return - elif rule.get_plugin_name() in disabled_plugins: + elif plugin_name in disabled_plugins: return # disable chosen methods from plugins if 'disable_commands' in channel_config: disabled_commands = literal_eval(channel_config.disable_commands) - disabled_commands = disabled_commands.get(rule.get_plugin_name(), []) + disabled_commands = disabled_commands.get(plugin_name, []) if rule.get_rule_label() in disabled_commands: return @@ -650,11 +666,11 @@ def call( if not trigger.admin and not func.unblockable: if func in self._times[nick]: usertimediff = current_time - self._times[nick][func] - if func.rate > 0 and usertimediff < func.rate: + if func.user_rate > 0 and usertimediff < func.user_rate: LOGGER.info( "%s prevented from using %s in %s due to user limit: %d < %d", trigger.nick, func.__name__, trigger.sender, usertimediff, - func.rate + func.user_rate ) return if func in self._times[self.nick]: diff --git a/sopel/loader.py b/sopel/loader.py index ec2b6c8535..77304c6752 100644 --- a/sopel/loader.py +++ b/sopel/loader.py @@ -48,9 +48,13 @@ def clean_callable(func, config): if is_limitable(func): # These attributes are a waste of memory on callables that don't pass # through Sopel's rate-limiting machinery - func.rate = getattr(func, 'rate', 0) + func.user_rate = getattr(func, 'user_rate', 0) func.channel_rate = getattr(func, 'channel_rate', 0) func.global_rate = getattr(func, 'global_rate', 0) + func.user_rate_message = getattr(func, 'user_rate_message', None) + func.channel_rate_message = getattr(func, 'channel_rate_message', None) + func.global_rate_message = getattr(func, 'global_rate_message', None) + func.default_rate_message = getattr(func, 'default_rate_message', None) func.unblockable = getattr(func, 'unblockable', False) if not is_triggerable(func) and not is_url_callback(func): diff --git a/sopel/plugin.py b/sopel/plugin.py index 9b65de9973..6e00925998 100644 --- a/sopel/plugin.py +++ b/sopel/plugin.py @@ -40,6 +40,9 @@ 'output_prefix', 'priority', 'rate', + 'rate_user', + 'rate_channel', + 'rate_global', 'require_account', 'require_admin', 'require_bot_privilege', @@ -830,31 +833,211 @@ def add_attribute(function): return add_attribute -def rate(user: int = 0, channel: int = 0, server: int = 0) -> typing.Callable: +def rate( + user: int = 0, + channel: int = 0, + server: int = 0, + *, + message: typing.Optional[str] = None, +) -> typing.Callable: """Decorate a function to be rate-limited. - :param int user: seconds between permitted calls of this function by the - same user - :param int channel: seconds between permitted calls of this function in - the same channel, regardless of triggering user - :param int server: seconds between permitted calls of this function no - matter who triggered it or where + :param user: seconds between permitted calls of this function by the same + user + :param channel: seconds between permitted calls of this function in the + same channel, regardless of triggering user + :param server: seconds between permitted calls of this function no matter + who triggered it or where + :param message: optional keyword argument; default message sent as NOTICE + when a rate limit is reached How often a function can be triggered on a per-user basis, in a channel, or across the server (bot) can be controlled with this decorator. A value of ``0`` means no limit. If a function is given a rate of 20, that function may only be used once every 20 seconds in the scope corresponding - to the parameter. Users on the admin list in Sopel’s configuration are - exempted from rate limits. + to the parameter:: + + from sopel import plugin + + @plugin.rate(10) + # won't trigger if used more than once per 10s by a user + + @plugin.rate(10, 10) + # won't trigger if used more than once per 10s by a user/channel + + @plugin.rate(10, 10, 2) + # won't trigger if used more than once per 10s by a user/channel + # and never more than once every 2s + + If a ``message`` is provided, it will be used as the default message sent + as a ``NOTICE`` to the user who hit the rate limit:: + + @rate(10, 10, 10, message='Hit the rate limit for this function.') + # will send a NOTICE + + The message can contain a placeholder for ``nick``: the message will be + formatted with the nick that hit rate limit:: + + @rate(10, 10, 2, message='Sorry {nick}, you hit the rate limit!') Rate-limited functions that use scheduled future commands should import :class:`threading.Timer` instead of :mod:`sched`, or rate limiting will not work properly. + + .. versionchanged:: 8.0 + + Optional keyword argument ``message`` was added in Sopel 8. + + .. note:: + + Users on the admin list in Sopel's configuration are exempted from rate + limits. + + .. seealso:: + + You can control each rate limit separately, with their own custom + message using :func:`rate_user`, :func:`rate_channel`, or + :func:`rate_global`. + + """ + def add_attribute(function): + if not hasattr(function, 'user_rate'): + function.user_rate = user + if not hasattr(function, 'channel_rate'): + function.channel_rate = channel + if not hasattr(function, 'global_rate'): + function.global_rate = server + function.default_rate_message = message + return function + return add_attribute + + +def rate_user( + rate: int, + message: typing.Optional[str] = None, +) -> typing.Callable: + """Decorate a function to be rate-limited for a user. + + :param rate: seconds between permitted calls of this function by the same + user + :param message: optional; message sent as NOTICE when a user hits the limit + + This decorator can be used alone or with the :func:`rate` decorator, as it + will always take precedence:: + + @rate(10, 10, 10) + @rate_user(20, 'You hit your rate limit for this function.') + # user limit will be set to 20, other to 10 + # will send a NOTICE only when a user hits their own limit + # as other rate limits don't have any message set + + The message can contain a placeholder for ``nick``: the message will be + formatted with the nick that hit rate limit:: + + @rate_user(5, 'Sorry {nick}, you hit your 5s limit!') + + If you don't provide a message, the default message set by :func:`rate` + (if any) will be used instead. + + .. versionadded:: 8.0 + + .. note:: + + Users on the admin list in Sopel's configuration are exempted from rate + limits. + + """ + def add_attribute(function): + function.user_rate = rate + function.user_rate_message = message + return function + return add_attribute + + +def rate_channel( + rate: int, + message: typing.Optional[str] = None, +) -> typing.Callable: + """Decorate a function to be rate-limited for a channel. + + :param rate: seconds between permitted calls of this function in the same + channel, regardless of triggering user + :param message: optional; message sent as NOTICE when a user hits the limit + + This decorator can be used alone or with the :func:`rate` decorator, as it + will always take precedence:: + + @rate(10, 10, 10) + @rate_channel(5, 'You hit the channel rate limit for this function.') + # channel limit will be set to 5, other to 10 + # will send a NOTICE only when a user hits the channel limit + # as other rate limits don't have any message set + + If you don't provide a message, the default message set by :func:`rate` + (if any) will be used instead. + + The message can contain placeholders for ``nick`` and ``channel``: the + message will be formatted with the nick that hit rate limit for said + ``channel``:: + + @rate_channel( + 5, + 'Sorry {nick}, you hit the 5s limit for the {channel} channel!', + ) + + .. versionadded:: 8.0 + + .. note:: + + Users on the admin list in Sopel's configuration are exempted from rate + limits. + + """ + def add_attribute(function): + function.channel_rate = rate + function.channel_rate_message = message + return function + return add_attribute + + +def rate_global( + rate: int, + message: typing.Optional[str] = None, +) -> typing.Callable: + """Decorate a function to be rate-limited for the whole server. + + :param rate: seconds between permitted calls of this function no matter who + triggered it or where + :param message: optional; message sent as NOTICE when a user hits the limit + + This decorator can be used alone or with the :func:`rate` decorator, as it + will always take precedence:: + + @rate(10, 10, 10) + @rate_global(5, 'You hit the global rate limit for this function.') + # global limit will be set to 5, other to 10 + # will send a NOTICE only when a user hits the global limit + # as other rate limits don't have any message set + + If you don't provide a message, the default message set by :func:`rate` + (if any) will be used instead. + + The message can contain a placeholder for ``nick``: the message will be + formatted with the nick that hit rate limit:: + + @rate_global(5, 'Sorry {nick}, you hit the 5s limit!') + + .. versionadded:: 8.0 + + .. note:: + + Users on the admin list in Sopel's configuration are exempted from rate + limits. + """ def add_attribute(function): - function.rate = user - function.channel_rate = channel - function.global_rate = server + function.global_rate = rate + function.global_rate_message = message return function return add_attribute diff --git a/sopel/plugins/rules.py b/sopel/plugins/rules.py index f250679241..7501b4e0de 100644 --- a/sopel/plugins/rules.py +++ b/sopel/plugins/rules.py @@ -724,31 +724,65 @@ def is_unblockable(self) -> bool: """Tell if the rule is unblockable. :return: ``True`` when the rule is unblockable, ``False`` otherwise - :rtype: bool """ @abc.abstractmethod - def is_rate_limited(self, nick) -> bool: + def is_user_rate_limited(self, nick: Identifier) -> bool: """Tell when the rule reached the ``nick``'s rate limit. :return: ``True`` when the rule reached the limit, ``False`` otherwise - :rtype: bool """ @abc.abstractmethod - def is_channel_rate_limited(self, channel) -> bool: + def is_channel_rate_limited(self, channel: Identifier) -> bool: """Tell when the rule reached the ``channel``'s rate limit. :return: ``True`` when the rule reached the limit, ``False`` otherwise - :rtype: bool """ @abc.abstractmethod def is_global_rate_limited(self) -> bool: - """Tell when the rule reached the server's rate limit. + """Tell when the rule reached the global rate limit. :return: ``True`` when the rule reached the limit, ``False`` otherwise - :rtype: bool + """ + + @abc.abstractmethod + def get_user_rate_message(self, nick: Identifier) -> Optional[str]: + """Give the message to send with a NOTICE to ``nick``. + + :param nick: the nick that is rate limited + :return: A formatted string, or ``None`` if no message is set. + + This method is called by the bot when a trigger hits the user rate + limit (i.e. for the specificed ``nick``). + """ + + @abc.abstractmethod + def get_channel_rate_message( + self, + nick: Identifier, + channel: Identifier, + ) -> Optional[str]: + """Give the message to send with a NOTICE to ``nick``. + + :param nick: the nick that reached the channel's rate limit + :param channel: the channel that is rate limited + :return: A formatted string, or ``None`` if no message is set. + + This method is called by the bot when a trigger hits the channel rate + limit (i.e. for the specificed ``channel``). + """ + + @abc.abstractmethod + def get_global_rate_message(self, nick: Identifier) -> Optional[str]: + """Give the message to send with a NOTICE to ``nick``. + + :param nick: the nick that reached the global rate limit + :return: A formatted string, or ``None`` if no message is set. + + This method is called by the bot when a trigger hits the global rate + limit (i.e. for any nick/channel). """ @abc.abstractmethod @@ -837,9 +871,17 @@ def kwargs_from_callable(cls, handler): 'threaded': getattr(handler, 'thread', True), 'output_prefix': getattr(handler, 'output_prefix', ''), 'unblockable': getattr(handler, 'unblockable', False), - 'rate_limit': getattr(handler, 'rate', 0), + 'user_rate_limit': getattr(handler, 'user_rate', 0), 'channel_rate_limit': getattr(handler, 'channel_rate', 0), 'global_rate_limit': getattr(handler, 'global_rate', 0), + 'user_rate_message': getattr( + handler, 'user_rate_message', None), + 'channel_rate_message': getattr( + handler, 'channel_rate_message', None), + 'global_rate_message': getattr( + handler, 'global_rate_message', None), + 'default_rate_message': getattr( + handler, 'default_rate_message', None), 'usages': usages or tuple(), 'tests': tests, 'doc': inspect.getdoc(handler), @@ -927,9 +969,13 @@ def __init__(self, threaded=True, output_prefix=None, unblockable=False, - rate_limit=0, + user_rate_limit=0, channel_rate_limit=0, global_rate_limit=0, + user_rate_message=None, + channel_rate_message=None, + global_rate_message=None, + default_rate_message=None, usages=None, tests=None, doc=None): @@ -952,9 +998,13 @@ def __init__(self, # rate limiting self._unblockable = bool(unblockable) - self._rate_limit = rate_limit + self._user_rate_limit = user_rate_limit self._channel_rate_limit = channel_rate_limit self._global_rate_limit = global_rate_limit + self._user_rate_message = user_rate_message + self._channel_rate_message = channel_rate_message + self._global_rate_message = global_rate_message + self._default_rate_message = default_rate_message # metrics self._metrics_nick: Dict[Identifier, RuleMetrics] = {} @@ -1062,7 +1112,7 @@ def parse(self, text): if result: yield result - def match_event(self, event): + def match_event(self, event) -> bool: return bool(event and event in self._events) def match_ctcp(self, command: Optional[str]) -> bool: @@ -1086,10 +1136,10 @@ def is_threaded(self): def is_unblockable(self): return self._unblockable - def is_rate_limited(self, nick): + def is_user_rate_limited(self, nick): metrics: RuleMetrics = self._metrics_nick.get(nick, RuleMetrics()) now = datetime.datetime.utcnow() - rate_limit = datetime.timedelta(seconds=self._rate_limit) + rate_limit = datetime.timedelta(seconds=self._user_rate_limit) return metrics.is_limited(now - rate_limit) def is_channel_rate_limited(self, channel): @@ -1103,6 +1153,37 @@ def is_global_rate_limited(self): rate_limit = datetime.timedelta(seconds=self._global_rate_limit) return self._metrics_global.is_limited(now - rate_limit) + def get_user_rate_message(self, nick): + template = self._user_rate_message or self._default_rate_message + + if not template: + return None + + return template.format( + nick=nick, + ) + + def get_channel_rate_message(self, nick, channel): + template = self._channel_rate_message or self._default_rate_message + + if not template: + return None + + return template.format( + nick=nick, + channel=channel, + ) + + def get_global_rate_message(self, nick): + template = self._global_rate_message or self._default_rate_message + + if not template: + return None + + return template.format( + nick=nick, + ) + def execute(self, bot, trigger): if not self._handler: raise RuntimeError('Improperly configured rule: no handler') diff --git a/test/plugins/test_plugins_rules.py b/test/plugins/test_plugins_rules.py index 388be6be32..28d89273d1 100644 --- a/test/plugins/test_plugins_rules.py +++ b/test/plugins/test_plugins_rules.py @@ -1109,6 +1109,13 @@ def handler(wrapped, trigger): assert 'threaded' in kwargs assert 'output_prefix' in kwargs assert 'unblockable' in kwargs + assert 'user_rate_limit' in kwargs + assert 'channel_rate_limit' in kwargs + assert 'global_rate_limit' in kwargs + assert 'user_rate_message' in kwargs + assert 'channel_rate_message' in kwargs + assert 'global_rate_message' in kwargs + assert 'default_rate_message' in kwargs assert 'usages' in kwargs assert 'tests' in kwargs assert 'doc' in kwargs @@ -1122,6 +1129,13 @@ def handler(wrapped, trigger): assert kwargs['threaded'] is True assert kwargs['output_prefix'] == '' assert kwargs['unblockable'] is False + assert kwargs['user_rate_limit'] == 0 + assert kwargs['channel_rate_limit'] == 0 + assert kwargs['global_rate_limit'] == 0 + assert kwargs['user_rate_message'] is None + assert kwargs['channel_rate_message'] is None + assert kwargs['global_rate_message'] is None + assert kwargs['default_rate_message'] is None assert kwargs['usages'] == tuple() assert kwargs['tests'] == tuple() assert kwargs['doc'] is None @@ -1235,7 +1249,7 @@ def handler(wrapped, trigger): def test_kwargs_from_callable_rate_limit(mockbot): # prepare callable @plugin.rule(r'hello', r'hi', r'hey', r'hello|hi') - @plugin.rate(user=20, channel=30, server=40) + @plugin.rate(user=20, channel=30, server=40, message='Default message.') def handler(wrapped, trigger): wrapped.reply('Hi!') @@ -1243,12 +1257,101 @@ def handler(wrapped, trigger): # get kwargs kwargs = rules.Rule.kwargs_from_callable(handler) - assert 'rate_limit' in kwargs + assert 'user_rate_limit' in kwargs assert 'channel_rate_limit' in kwargs assert 'global_rate_limit' in kwargs - assert kwargs['rate_limit'] == 20 + assert 'user_rate_message' in kwargs + assert 'channel_rate_message' in kwargs + assert 'global_rate_message' in kwargs + assert 'default_rate_message' in kwargs + assert kwargs['user_rate_limit'] == 20 assert kwargs['channel_rate_limit'] == 30 assert kwargs['global_rate_limit'] == 40 + assert kwargs['user_rate_message'] is None + assert kwargs['channel_rate_message'] is None + assert kwargs['global_rate_message'] is None + assert kwargs['default_rate_message'] == 'Default message.' + + +def test_kwargs_from_callable_rate_limit_user(mockbot): + # prepare callable + @plugin.rule(r'hello', r'hi', r'hey', r'hello|hi') + @plugin.rate_user(20, 'User message.') + def handler(wrapped, trigger): + wrapped.reply('Hi!') + + loader.clean_callable(handler, mockbot.settings) + + # get kwargs + kwargs = rules.Rule.kwargs_from_callable(handler) + assert 'user_rate_limit' in kwargs + assert 'channel_rate_limit' in kwargs + assert 'global_rate_limit' in kwargs + assert 'user_rate_message' in kwargs + assert 'channel_rate_message' in kwargs + assert 'global_rate_message' in kwargs + assert 'default_rate_message' in kwargs + assert kwargs['user_rate_limit'] == 20 + assert kwargs['channel_rate_limit'] == 0 + assert kwargs['global_rate_limit'] == 0 + assert kwargs['user_rate_message'] == 'User message.' + assert kwargs['channel_rate_message'] is None + assert kwargs['global_rate_message'] is None + assert kwargs['default_rate_message'] is None + + +def test_kwargs_from_callable_rate_limit_channel(mockbot): + # prepare callable + @plugin.rule(r'hello', r'hi', r'hey', r'hello|hi') + @plugin.rate_channel(20, 'Channel message.') + def handler(wrapped, trigger): + wrapped.reply('Hi!') + + loader.clean_callable(handler, mockbot.settings) + + # get kwargs + kwargs = rules.Rule.kwargs_from_callable(handler) + assert 'user_rate_limit' in kwargs + assert 'channel_rate_limit' in kwargs + assert 'global_rate_limit' in kwargs + assert 'user_rate_message' in kwargs + assert 'channel_rate_message' in kwargs + assert 'global_rate_message' in kwargs + assert 'default_rate_message' in kwargs + assert kwargs['user_rate_limit'] == 0 + assert kwargs['channel_rate_limit'] == 20 + assert kwargs['global_rate_limit'] == 0 + assert kwargs['user_rate_message'] is None + assert kwargs['channel_rate_message'] == 'Channel message.' + assert kwargs['global_rate_message'] is None + assert kwargs['default_rate_message'] is None + + +def test_kwargs_from_callable_rate_limit_server(mockbot): + # prepare callable + @plugin.rule(r'hello', r'hi', r'hey', r'hello|hi') + @plugin.rate_global(20, 'Server message.') + def handler(wrapped, trigger): + wrapped.reply('Hi!') + + loader.clean_callable(handler, mockbot.settings) + + # get kwargs + kwargs = rules.Rule.kwargs_from_callable(handler) + assert 'user_rate_limit' in kwargs + assert 'channel_rate_limit' in kwargs + assert 'global_rate_limit' in kwargs + assert 'user_rate_message' in kwargs + assert 'channel_rate_message' in kwargs + assert 'global_rate_message' in kwargs + assert 'default_rate_message' in kwargs + assert kwargs['user_rate_limit'] == 0 + assert kwargs['channel_rate_limit'] == 0 + assert kwargs['global_rate_limit'] == 20 + assert kwargs['user_rate_message'] is None + assert kwargs['channel_rate_message'] is None + assert kwargs['global_rate_message'] == 'Server message.' + assert kwargs['default_rate_message'] is None def test_kwargs_from_callable_examples(mockbot): @@ -1459,16 +1562,16 @@ def handler(bot, trigger): rule = rules.Rule( [regex], handler=handler, - rate_limit=20, + user_rate_limit=20, global_rate_limit=20, channel_rate_limit=20, ) - assert rule.is_rate_limited(mocktrigger.nick) is False + assert rule.is_user_rate_limited(mocktrigger.nick) is False assert rule.is_channel_rate_limited(mocktrigger.sender) is False assert rule.is_global_rate_limited() is False rule.execute(mockbot, mocktrigger) - assert rule.is_rate_limited(mocktrigger.nick) is True + assert rule.is_user_rate_limited(mocktrigger.nick) is True assert rule.is_channel_rate_limited(mocktrigger.sender) is True assert rule.is_global_rate_limited() is True @@ -1485,16 +1588,16 @@ def handler(bot, trigger): rule = rules.Rule( [regex], handler=handler, - rate_limit=0, + user_rate_limit=0, global_rate_limit=0, channel_rate_limit=0, ) - assert rule.is_rate_limited(mocktrigger.nick) is False + assert rule.is_user_rate_limited(mocktrigger.nick) is False assert rule.is_channel_rate_limited(mocktrigger.sender) is False assert rule.is_global_rate_limited() is False rule.execute(mockbot, mocktrigger) - assert rule.is_rate_limited(mocktrigger.nick) is False + assert rule.is_user_rate_limited(mocktrigger.nick) is False assert rule.is_channel_rate_limited(mocktrigger.sender) is False assert rule.is_global_rate_limited() is False @@ -1511,21 +1614,125 @@ def handler(bot, trigger): rule = rules.Rule( [regex], handler=handler, - rate_limit=20, + user_rate_limit=20, global_rate_limit=20, channel_rate_limit=20, threaded=False, # make sure there is no race-condition here ) - assert rule.is_rate_limited(mocktrigger.nick) is False + assert rule.is_user_rate_limited(mocktrigger.nick) is False assert rule.is_channel_rate_limited(mocktrigger.sender) is False assert rule.is_global_rate_limited() is False rule.execute(mockbot, mocktrigger) - assert rule.is_rate_limited(mocktrigger.nick) is False + assert rule.is_user_rate_limited(mocktrigger.nick) is False assert rule.is_channel_rate_limited(mocktrigger.sender) is False assert rule.is_global_rate_limited() is False +def test_rule_rate_limit_messages(mockbot, triggerfactory): + def handler(bot, trigger): + return 'hello' + + wrapper = triggerfactory.wrapper( + mockbot, ':Foo!foo@example.com PRIVMSG #channel :test message') + mocktrigger = wrapper._trigger + + regex = re.compile(r'.*') + rule = rules.Rule( + [regex], + handler=handler, + user_rate_limit=20, + global_rate_limit=20, + channel_rate_limit=20, + user_rate_message='User message: {nick}', + channel_rate_message='Channel message: {nick}/{channel}', + global_rate_message='Server message: {nick}', + default_rate_message='Default message: {nick}', + ) + assert rule.get_user_rate_message(mocktrigger.nick) == 'User message: Foo' + assert rule.get_channel_rate_message( + mocktrigger.nick, mocktrigger.sender + ) == 'Channel message: Foo/#channel' + assert rule.get_global_rate_message( + mocktrigger.nick + ) == 'Server message: Foo' + + +def test_rule_rate_limit_messages_default(mockbot, triggerfactory): + def handler(bot, trigger): + return 'hello' + + wrapper = triggerfactory.wrapper( + mockbot, ':Foo!foo@example.com PRIVMSG #channel :test message') + mocktrigger = wrapper._trigger + + regex = re.compile(r'.*') + rule = rules.Rule( + [regex], + handler=handler, + user_rate_limit=20, + global_rate_limit=20, + channel_rate_limit=20, + default_rate_message='Default message', + ) + assert rule.get_user_rate_message(mocktrigger.nick) == 'Default message' + assert rule.get_channel_rate_message( + mocktrigger.nick, mocktrigger.sender) == 'Default message' + assert rule.get_global_rate_message(mocktrigger.nick) == 'Default message' + + +def test_rule_rate_limit_messages_default_mixed(mockbot, triggerfactory): + def handler(bot, trigger): + return 'hello' + + wrapper = triggerfactory.wrapper( + mockbot, ':Foo!foo@example.com PRIVMSG #channel :test message') + mocktrigger = wrapper._trigger + + regex = re.compile(r'.*') + rule = rules.Rule( + [regex], + handler=handler, + user_rate_limit=20, + global_rate_limit=20, + channel_rate_limit=20, + user_rate_message='User message.', + default_rate_message='The default.', + ) + assert rule.get_user_rate_message(mocktrigger.nick) == 'User message.' + assert rule.get_channel_rate_message( + mocktrigger.nick, mocktrigger.sender) == 'The default.' + assert rule.get_global_rate_message(mocktrigger.nick) == 'The default.' + + rule = rules.Rule( + [regex], + handler=handler, + user_rate_limit=20, + global_rate_limit=20, + channel_rate_limit=20, + channel_rate_message='Channel message.', + default_rate_message='The default.', + ) + assert rule.get_user_rate_message(mocktrigger.nick) == 'The default.' + assert rule.get_channel_rate_message( + mocktrigger.nick, mocktrigger.sender) == 'Channel message.' + assert rule.get_global_rate_message(mocktrigger.nick) == 'The default.' + + rule = rules.Rule( + [regex], + handler=handler, + user_rate_limit=20, + global_rate_limit=20, + channel_rate_limit=20, + global_rate_message='Server message.', + default_rate_message='The default.', + ) + assert rule.get_user_rate_message(mocktrigger.nick) == 'The default.' + assert rule.get_channel_rate_message( + mocktrigger.nick, mocktrigger.sender) == 'The default.' + assert rule.get_global_rate_message(mocktrigger.nick) == 'Server message.' + + # ----------------------------------------------------------------------------- # tests for :class:`sopel.plugins.rules.Command` diff --git a/test/test_bot.py b/test/test_bot.py index ffd487da2a..8530bd82a7 100644 --- a/test/test_bot.py +++ b/test/test_bot.py @@ -632,7 +632,7 @@ def testrule(bot, trigger): assert items == [1] # assert the rule is not rate limited - assert not rule_hello.is_rate_limited(Identifier('Test')) + assert not rule_hello.is_user_rate_limited(Identifier('Test')) assert not rule_hello.is_channel_rate_limited('#channel') assert not rule_hello.is_global_rate_limited() @@ -661,8 +661,7 @@ def testrule(bot, trigger): plugin='testplugin', label='testrule', handler=testrule, - rate_limit=100, - threaded=False, + user_rate_limit=100, ) # trigger @@ -688,7 +687,7 @@ def testrule(bot, trigger): assert items == [1] # assert the rule is now rate limited - assert rule_hello.is_rate_limited(Identifier('Test')) + assert rule_hello.is_user_rate_limited(Identifier('Test')) assert not rule_hello.is_channel_rate_limited('#channel') assert not rule_hello.is_global_rate_limited() @@ -702,6 +701,56 @@ def testrule(bot, trigger): assert items == [1], 'There must not be any new item' +def test_call_rule_rate_limited_user_with_message(mockbot): + items = [] + + # setup + def testrule(bot, trigger): + bot.say('hi') + items.append(1) + return "Return Value" + + rule_hello = rules.Rule( + [re.compile(r'(hi|hello|hey|sup)')], + plugin='testplugin', + label='testrule', + handler=testrule, + user_rate_limit=100, + user_rate_message='You reached the rate limit.') + + # trigger + line = ':Test!test@example.com PRIVMSG #channel :hello' + pretrigger = trigger.PreTrigger(mockbot.nick, line) + + # match + matches = list(rule_hello.match(mockbot, pretrigger)) + match = matches[0] + + # trigger and wrapper + rule_trigger = trigger.Trigger( + mockbot.settings, pretrigger, match, account=None) + wrapper = bot.SopelWrapper(mockbot, rule_trigger) + + # call rule + mockbot.call_rule(rule_hello, wrapper, rule_trigger) + + # assert the rule has been executed + assert mockbot.backend.message_sent == rawlist( + 'PRIVMSG #channel :hi' + ) + assert items == [1] + + # call rule again + mockbot.call_rule(rule_hello, wrapper, rule_trigger) + + # assert there is now a NOTICE + assert mockbot.backend.message_sent == rawlist( + 'PRIVMSG #channel :hi', + 'NOTICE Test :You reached the rate limit.', + ), 'A NOTICE should appear here.' + assert items == [1], 'There must not be any new item' + + def test_call_rule_rate_limited_channel(mockbot): items = [] @@ -734,8 +783,14 @@ def testrule(bot, trigger): # call rule mockbot.call_rule(rule_hello, wrapper, rule_trigger) + # assert the rule has been executed + assert mockbot.backend.message_sent == rawlist( + 'PRIVMSG #channel :hi' + ) + assert items == [1] + # assert the rule is now rate limited - assert not rule_hello.is_rate_limited(Identifier('Test')) + assert not rule_hello.is_user_rate_limited(Identifier('Test')) assert rule_hello.is_channel_rate_limited('#channel') assert not rule_hello.is_global_rate_limited() @@ -749,6 +804,61 @@ def testrule(bot, trigger): assert items == [1], 'There must not be any new item' +def test_call_rule_rate_limited_channel_with_message(mockbot): + items = [] + + # setup + def testrule(bot, trigger): + bot.say('hi') + items.append(1) + return "Return Value" + + rule_hello = rules.Rule( + [re.compile(r'(hi|hello|hey|sup)')], + plugin='testplugin', + label='testrule', + handler=testrule, + channel_rate_limit=100, + channel_rate_message='You reached the channel rate limit.') + + # trigger + line = ':Test!test@example.com PRIVMSG #channel :hello' + pretrigger = trigger.PreTrigger(mockbot.nick, line) + + # match + matches = list(rule_hello.match(mockbot, pretrigger)) + match = matches[0] + + # trigger and wrapper + rule_trigger = trigger.Trigger( + mockbot.settings, pretrigger, match, account=None) + wrapper = bot.SopelWrapper(mockbot, rule_trigger) + + # call rule + mockbot.call_rule(rule_hello, wrapper, rule_trigger) + + # assert the rule has been executed + assert mockbot.backend.message_sent == rawlist( + 'PRIVMSG #channel :hi' + ) + assert items == [1] + + # assert the rule is now rate limited + assert not rule_hello.is_user_rate_limited(Identifier('Test')) + assert rule_hello.is_channel_rate_limited('#channel') + assert not rule_hello.is_global_rate_limited() + + # call rule again + mockbot.call_rule(rule_hello, wrapper, rule_trigger) + + # assert there is now a NOTICE + assert mockbot.backend.message_sent == rawlist( + 'PRIVMSG #channel :hi', + 'NOTICE Test :You reached the channel rate limit.', + ), 'A NOTICE should appear here.' + assert items == [1], 'There must not be any new item' + + def test_call_rule_rate_limited_global(mockbot): items = [] @@ -781,8 +891,14 @@ def testrule(bot, trigger): # call rule mockbot.call_rule(rule_hello, wrapper, rule_trigger) + # assert the rule has been executed + assert mockbot.backend.message_sent == rawlist( + 'PRIVMSG #channel :hi' + ) + assert items == [1] + # assert the rule is now rate limited - assert not rule_hello.is_rate_limited(Identifier('Test')) + assert not rule_hello.is_user_rate_limited(Identifier('Test')) assert not rule_hello.is_channel_rate_limited('#channel') assert rule_hello.is_global_rate_limited() @@ -796,6 +912,61 @@ def testrule(bot, trigger): assert items == [1], 'There must not be any new item' +def test_call_rule_rate_limited_global_with_message(mockbot): + items = [] + + # setup + def testrule(bot, trigger): + bot.say('hi') + items.append(1) + return "Return Value" + + rule_hello = rules.Rule( + [re.compile(r'(hi|hello|hey|sup)')], + plugin='testplugin', + label='testrule', + handler=testrule, + global_rate_limit=100, + global_rate_message='You reached the server rate limit.') + + # trigger + line = ':Test!test@example.com PRIVMSG #channel :hello' + pretrigger = trigger.PreTrigger(mockbot.nick, line) + + # match + matches = list(rule_hello.match(mockbot, pretrigger)) + match = matches[0] + + # trigger and wrapper + rule_trigger = trigger.Trigger( + mockbot.settings, pretrigger, match, account=None) + wrapper = bot.SopelWrapper(mockbot, rule_trigger) + + # call rule + mockbot.call_rule(rule_hello, wrapper, rule_trigger) + + # assert the rule has been executed + assert mockbot.backend.message_sent == rawlist( + 'PRIVMSG #channel :hi' + ) + assert items == [1] + + # assert the rule is now rate limited + assert not rule_hello.is_user_rate_limited(Identifier('Test')) + assert not rule_hello.is_channel_rate_limited('#channel') + assert rule_hello.is_global_rate_limited() + + # call rule again + mockbot.call_rule(rule_hello, wrapper, rule_trigger) + + # assert there is now a NOTICE + assert mockbot.backend.message_sent == rawlist( + 'PRIVMSG #channel :hi', + 'NOTICE Test :You reached the server rate limit.', + ), 'A NOTICE should appear here.' + assert items == [1], 'There must not be any new item' + + # ----------------------------------------------------------------------------- # Channel privileges diff --git a/test/test_loader.py b/test/test_loader.py index 1124c8c0f1..f493c0ce7a 100644 --- a/test/test_loader.py +++ b/test/test_loader.py @@ -301,9 +301,13 @@ def test_clean_callable_default(tmpconfig, func): # Not added by default assert not hasattr(func, 'unblockable') assert not hasattr(func, 'priority') - assert not hasattr(func, 'rate') + assert not hasattr(func, 'user_rate') assert not hasattr(func, 'channel_rate') assert not hasattr(func, 'global_rate') + assert not hasattr(func, 'user_rate_message') + assert not hasattr(func, 'channel_rate_message') + assert not hasattr(func, 'global_rate_message') + assert not hasattr(func, 'default_rate_message') assert not hasattr(func, 'echo') assert not hasattr(func, 'allow_bots') assert not hasattr(func, 'output_prefix') @@ -334,12 +338,20 @@ def test_clean_callable_command(tmpconfig, func): assert func.priority == 'medium' assert hasattr(func, 'thread') assert func.thread is True - assert hasattr(func, 'rate') - assert func.rate == 0 + assert hasattr(func, 'user_rate') + assert func.user_rate == 0 assert hasattr(func, 'channel_rate') assert func.channel_rate == 0 assert hasattr(func, 'global_rate') assert func.global_rate == 0 + assert hasattr(func, 'user_rate_message') + assert func.user_rate_message is None + assert hasattr(func, 'channel_rate_message') + assert func.channel_rate_message is None + assert hasattr(func, 'global_rate_message') + assert func.global_rate_message is None + assert hasattr(func, 'default_rate_message') + assert func.default_rate_message is None assert hasattr(func, 'echo') assert func.echo is False assert hasattr(func, 'allow_bots') @@ -357,9 +369,13 @@ def test_clean_callable_command(tmpconfig, func): assert func.unblockable is False assert func.priority == 'medium' assert func.thread is True - assert func.rate == 0 + assert func.user_rate == 0 assert func.channel_rate == 0 assert func.global_rate == 0 + assert func.user_rate_message is None + assert func.channel_rate_message is None + assert func.global_rate_message is None + assert func.default_rate_message is None assert func.echo is False assert func.allow_bots is False assert func.output_prefix == '' @@ -380,12 +396,20 @@ def test_clean_callable_event(tmpconfig, func): assert func.priority == 'medium' assert hasattr(func, 'thread') assert func.thread is True - assert hasattr(func, 'rate') - assert func.rate == 0 + assert hasattr(func, 'user_rate') + assert func.user_rate == 0 assert hasattr(func, 'channel_rate') assert func.channel_rate == 0 assert hasattr(func, 'global_rate') assert func.global_rate == 0 + assert hasattr(func, 'user_rate_message') + assert func.user_rate_message is None + assert hasattr(func, 'channel_rate_message') + assert func.channel_rate_message is None + assert hasattr(func, 'global_rate_message') + assert func.global_rate_message is None + assert hasattr(func, 'default_rate_message') + assert func.default_rate_message is None assert hasattr(func, 'echo') assert func.echo is False assert hasattr(func, 'allow_bots') @@ -400,9 +424,13 @@ def test_clean_callable_event(tmpconfig, func): assert func.unblockable is False assert func.priority == 'medium' assert func.thread is True - assert func.rate == 0 + assert func.user_rate == 0 assert func.channel_rate == 0 assert func.global_rate == 0 + assert func.user_rate_message is None + assert func.channel_rate_message is None + assert func.global_rate_message is None + assert func.default_rate_message is None assert func.echo is False assert func.allow_bots is False assert func.output_prefix == '' @@ -428,12 +456,20 @@ def test_clean_callable_rule(tmpconfig, func): assert func.priority == 'medium' assert hasattr(func, 'thread') assert func.thread is True - assert hasattr(func, 'rate') - assert func.rate == 0 + assert hasattr(func, 'user_rate') + assert func.user_rate == 0 assert hasattr(func, 'channel_rate') assert func.channel_rate == 0 assert hasattr(func, 'global_rate') assert func.global_rate == 0 + assert hasattr(func, 'user_rate_message') + assert func.user_rate_message is None + assert hasattr(func, 'channel_rate_message') + assert func.channel_rate_message is None + assert hasattr(func, 'global_rate_message') + assert func.global_rate_message is None + assert hasattr(func, 'default_rate_message') + assert func.default_rate_message is None assert hasattr(func, 'echo') assert func.echo is False assert hasattr(func, 'allow_bots') @@ -450,9 +486,13 @@ def test_clean_callable_rule(tmpconfig, func): assert func.unblockable is False assert func.priority == 'medium' assert func.thread is True - assert func.rate == 0 + assert func.user_rate == 0 assert func.channel_rate == 0 assert func.global_rate == 0 + assert func.user_rate_message is None + assert func.channel_rate_message is None + assert func.global_rate_message is None + assert func.default_rate_message is None assert func.echo is False assert func.allow_bots is False assert func.output_prefix == '' @@ -513,12 +553,20 @@ def test_clean_callable_find_rules(tmpconfig, func): assert func.priority == 'medium' assert hasattr(func, 'thread') assert func.thread is True - assert hasattr(func, 'rate') - assert func.rate == 0 + assert hasattr(func, 'user_rate') + assert func.user_rate == 0 assert hasattr(func, 'channel_rate') assert func.channel_rate == 0 assert hasattr(func, 'global_rate') assert func.global_rate == 0 + assert hasattr(func, 'user_rate_message') + assert func.user_rate_message is None + assert hasattr(func, 'channel_rate_message') + assert func.channel_rate_message is None + assert hasattr(func, 'global_rate_message') + assert func.global_rate_message is None + assert hasattr(func, 'default_rate_message') + assert func.default_rate_message is None assert hasattr(func, 'echo') assert func.echo is False assert hasattr(func, 'allow_bots') @@ -537,9 +585,13 @@ def test_clean_callable_find_rules(tmpconfig, func): assert func.unblockable is False assert func.priority == 'medium' assert func.thread is True - assert func.rate == 0 + assert func.user_rate == 0 assert func.channel_rate == 0 assert func.global_rate == 0 + assert func.user_rate_message is None + assert func.channel_rate_message is None + assert func.global_rate_message is None + assert func.default_rate_message is None assert func.echo is False assert func.allow_bots is False assert func.output_prefix == '' @@ -567,12 +619,20 @@ def test_clean_callable_search_rules(tmpconfig, func): assert func.priority == 'medium' assert hasattr(func, 'thread') assert func.thread is True - assert hasattr(func, 'rate') - assert func.rate == 0 + assert hasattr(func, 'user_rate') + assert func.user_rate == 0 assert hasattr(func, 'channel_rate') assert func.channel_rate == 0 assert hasattr(func, 'global_rate') assert func.global_rate == 0 + assert hasattr(func, 'user_rate_message') + assert func.user_rate_message is None + assert hasattr(func, 'channel_rate_message') + assert func.channel_rate_message is None + assert hasattr(func, 'global_rate_message') + assert func.global_rate_message is None + assert hasattr(func, 'default_rate_message') + assert func.default_rate_message is None assert hasattr(func, 'echo') assert func.echo is False assert hasattr(func, 'allow_bots') @@ -591,9 +651,13 @@ def test_clean_callable_search_rules(tmpconfig, func): assert func.unblockable is False assert func.priority == 'medium' assert func.thread is True - assert func.rate == 0 + assert func.user_rate == 0 assert func.channel_rate == 0 assert func.global_rate == 0 + assert func.user_rate_message is None + assert func.channel_rate_message is None + assert func.global_rate_message is None + assert func.default_rate_message is None assert func.echo is False assert func.allow_bots is False assert func.output_prefix == '' @@ -615,12 +679,20 @@ def test_clean_callable_nickname_command(tmpconfig, func): assert func.priority == 'medium' assert hasattr(func, 'thread') assert func.thread is True - assert hasattr(func, 'rate') - assert func.rate == 0 + assert hasattr(func, 'user_rate') + assert func.user_rate == 0 assert hasattr(func, 'channel_rate') assert func.channel_rate == 0 assert hasattr(func, 'global_rate') assert func.global_rate == 0 + assert hasattr(func, 'user_rate_message') + assert func.user_rate_message is None + assert hasattr(func, 'channel_rate_message') + assert func.channel_rate_message is None + assert hasattr(func, 'global_rate_message') + assert func.global_rate_message is None + assert hasattr(func, 'default_rate_message') + assert func.default_rate_message is None assert hasattr(func, 'echo') assert func.echo is False assert hasattr(func, 'allow_bots') @@ -634,9 +706,13 @@ def test_clean_callable_nickname_command(tmpconfig, func): assert func.unblockable is False assert func.priority == 'medium' assert func.thread is True - assert func.rate == 0 + assert func.user_rate == 0 assert func.channel_rate == 0 assert func.global_rate == 0 + assert func.user_rate_message is None + assert func.channel_rate_message is None + assert func.global_rate_message is None + assert func.default_rate_message is None assert func.echo is False assert func.allow_bots is False assert func.output_prefix == '' @@ -883,12 +959,20 @@ def test_clean_callable_ctcp(tmpconfig, func): assert func.priority == 'medium' assert hasattr(func, 'thread') assert func.thread is True - assert hasattr(func, 'rate') - assert func.rate == 0 + assert hasattr(func, 'user_rate') + assert func.user_rate == 0 assert hasattr(func, 'channel_rate') assert func.channel_rate == 0 assert hasattr(func, 'global_rate') assert func.global_rate == 0 + assert hasattr(func, 'user_rate_message') + assert func.user_rate_message is None + assert hasattr(func, 'channel_rate_message') + assert func.channel_rate_message is None + assert hasattr(func, 'global_rate_message') + assert func.global_rate_message is None + assert hasattr(func, 'default_rate_message') + assert func.default_rate_message is None assert hasattr(func, 'echo') assert func.echo is False assert hasattr(func, 'allow_bots') @@ -904,9 +988,13 @@ def test_clean_callable_ctcp(tmpconfig, func): assert func.unblockable is False assert func.priority == 'medium' assert func.thread is True - assert func.rate == 0 + assert func.user_rate == 0 assert func.channel_rate == 0 assert func.global_rate == 0 + assert func.user_rate_message is None + assert func.channel_rate_message is None + assert func.global_rate_message is None + assert func.default_rate_message is None assert func.echo is False assert func.allow_bots is False assert func.output_prefix == '' @@ -925,12 +1013,20 @@ def test_clean_callable_url(tmpconfig, func): assert func.unblockable is False assert hasattr(func, 'thread') assert func.thread is True - assert hasattr(func, 'rate') - assert func.rate == 0 + assert hasattr(func, 'user_rate') + assert func.user_rate == 0 assert hasattr(func, 'channel_rate') assert func.channel_rate == 0 assert hasattr(func, 'global_rate') assert func.global_rate == 0 + assert hasattr(func, 'user_rate_message') + assert func.user_rate_message is None + assert hasattr(func, 'channel_rate_message') + assert func.channel_rate_message is None + assert hasattr(func, 'global_rate_message') + assert func.global_rate_message is None + assert hasattr(func, 'default_rate_message') + assert func.default_rate_message is None assert hasattr(func, 'echo') assert func.echo is False assert hasattr(func, 'allow_bots') @@ -943,9 +1039,13 @@ def test_clean_callable_url(tmpconfig, func): assert len(func.url_regex) == 1 assert func.unblockable is False assert func.thread is True - assert func.rate == 0 + assert func.user_rate == 0 assert func.channel_rate == 0 assert func.global_rate == 0 + assert func.user_rate_message is None + assert func.channel_rate_message is None + assert func.global_rate_message is None + assert func.default_rate_message is None assert func.echo is False assert func.allow_bots is False assert func.output_prefix == '' diff --git a/test/test_module.py b/test/test_module.py index 3cd495af73..dc5b048188 100644 --- a/test/test_module.py +++ b/test/test_module.py @@ -343,7 +343,19 @@ def test_rate(): @module.rate(5) def mock(bot, trigger, match): return True - assert mock.rate == 5 + assert mock.user_rate == 5 + assert mock.channel_rate == 0, 'Default channel_rate should be 0' + assert mock.global_rate == 0, 'Default global_rate should be 0' + + +def test_rate_only_once(): + @module.rate(10) + @module.rate(5) + def mock(bot, trigger, match): + return True + assert mock.user_rate == 5, 'Only the first decorator has effect.' + assert mock.channel_rate == 0, 'Only the first decorator has effect.' + assert mock.global_rate == 0, 'Only the first decorator has effect.' def test_require_privmsg(bot, trigger, trigger_pm): diff --git a/test/test_plugin.py b/test/test_plugin.py index 57fc83b826..325c60e7b3 100644 --- a/test/test_plugin.py +++ b/test/test_plugin.py @@ -286,6 +286,83 @@ def mock(bot, trigger, match): assert mock.ctcp == ['ACTION'] +def test_rate_user(): + @plugin.rate_user(10) + def mock(bot, trigger): + return True + assert mock.user_rate == 10 + assert mock.user_rate_message is None + assert not hasattr(mock, 'channel_rate') + assert not hasattr(mock, 'global_rate') + assert not hasattr(mock, 'default_rate_message') + + @plugin.rate_user(20, 'User rate message.') + def mock(bot, trigger): + return True + assert mock.user_rate == 20 + assert mock.user_rate_message == 'User rate message.' + assert not hasattr(mock, 'channel_rate') + assert not hasattr(mock, 'global_rate') + assert not hasattr(mock, 'default_rate_message') + + +def test_rate_channel(): + @plugin.rate_channel(10) + def mock(bot, trigger): + return True + assert mock.channel_rate == 10 + assert mock.channel_rate_message is None + assert not hasattr(mock, 'user_rate') + assert not hasattr(mock, 'global_rate') + assert not hasattr(mock, 'default_rate_message') + + @plugin.rate_channel(20, 'Channel rate message.') + def mock(bot, trigger): + return True + assert mock.channel_rate == 20 + assert mock.channel_rate_message == 'Channel rate message.' + assert not hasattr(mock, 'user_rate') + assert not hasattr(mock, 'global_rate') + assert not hasattr(mock, 'default_rate_message') + + +def test_rate_global(): + @plugin.rate_global(10) + def mock(bot, trigger): + return True + assert mock.global_rate == 10 + assert mock.global_rate_message is None + assert not hasattr(mock, 'user_rate') + assert not hasattr(mock, 'channel_rate') + assert not hasattr(mock, 'default_rate_message') + + @plugin.rate_global(20, 'Server rate message.') + def mock(bot, trigger): + return True + assert mock.global_rate == 20 + assert mock.global_rate_message == 'Server rate message.' + assert not hasattr(mock, 'user_rate') + assert not hasattr(mock, 'channel_rate') + assert not hasattr(mock, 'default_rate_message') + + +def test_rate_combine_rate_decorators(): + @plugin.rate(400, 500, 600, message='Last default rate message') + @plugin.rate_global(2, 'Server rate message') + @plugin.rate_channel(5, 'Channel rate message') + @plugin.rate_user(10, 'User rate message') + @plugin.rate(40, 50, 60, message='Initial default rate message') + def mock(bot, trigger): + return True + assert mock.user_rate == 10 + assert mock.user_rate_message == 'User rate message' + assert mock.channel_rate == 5 + assert mock.channel_rate_message == 'Channel rate message' + assert mock.global_rate == 2 + assert mock.global_rate_message == 'Server rate message' + assert mock.default_rate_message == 'Last default rate message' + + BAN_MESSAGE = ':Foo!foo@example.com PRIVMSG #chan :.ban ExiClone' BAN_PRIVATE_MESSAGE = ':Foo!foo@example.com PRIVMSG Sopel :.ban #chan ExiClone'