Skip to content

Commit

Permalink
bot, loader, plugin, rules: add NOTICE message to rate limits
Browse files Browse the repository at this point in the history
Co-authored-by: dgw <dgw@technobabbl.es>
  • Loading branch information
Exirel and dgw committed Jul 13, 2022
1 parent 5c4f56e commit 605b586
Show file tree
Hide file tree
Showing 10 changed files with 941 additions and 78 deletions.
12 changes: 12 additions & 0 deletions docs/source/plugin/anatomy.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------------------

Expand Down
32 changes: 24 additions & 8 deletions sopel/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -587,32 +587,48 @@ 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:
disabled_plugins = channel_config.disable_plugins.split(',')

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

Expand Down Expand Up @@ -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]:
Expand Down
6 changes: 5 additions & 1 deletion sopel/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
207 changes: 195 additions & 12 deletions sopel/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@
'output_prefix',
'priority',
'rate',
'rate_user',
'rate_channel',
'rate_global',
'require_account',
'require_admin',
'require_bot_privilege',
Expand Down Expand Up @@ -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

Expand Down
Loading

0 comments on commit 605b586

Please sign in to comment.