Repository: sopel-irc/sopel

Issue:
More placeholders in rate-limit messages
### Requested Feature

We discussed a bit in #2290 what other things might be useful to have as placeholders in the optional message sent when a command is blocked by rate limiting. Some ideas from that, plus a bit of reflection just now:

- `{time_left}` — a `tools.time.seconds_to_human()` of the time left until the rate limit expires
- `{seconds_left}` — the raw version of `{time_left}`
- `{limit}` — what the actual rate limit is, probably in `seconds_to_human()` format
- `{limit_type}` — especially for the default message, useful to differentiate why a command was blocked
- `{command}` — the name of the command that was rate-limited
  - Potentially tricky because [almost any kind of callable](https://github.com/sopel-irc/sopel/blob/3a5ba8ec47c97e4de247473cec4c1badb546ae48/sopel/loader.py#L114-L128) can be rate-limited.
- `{plugin}` — the name of the plugin in which the limited feature lives

Names subject to change, as always, if better ideas come up.

### Problems Solved

The time-related placeholders are good for games and such, where players can only perform a given action every _n_ seconds, and would want to know how long until they can use it again.

Command and plugin names could be useful if the message needs to include some usage info, and would reduce code duplication (and later, l10n workload) by allowing a given plugin the use of just one templatized rate-limit string in place of many.

### Alternatives

There are no alternatives to the remaining-time placeholders. Plugin code can't otherwise insert those values. (Same for the existing nick and channel placeholders defined in the current code. The values are only available at runtime to the dispatcher.)

The plugin author obviously knows what the rate limit is, in simple cases, but if multiple types of rate limit are in effect on a single callable a hard-coded value in the message isn't always going to work.

### Notes

_No response_


Discussion:
So, I've been looking at this issue and discussed a few things on IRC, and we seem to be in agreement that implementing this is going to require some plumbing changes.

In particular, implementing the time-related fields suggested is going to require some changes to [the way data flows between `Sopel` and `Rule`](https://github.com/sopel-irc/sopel/blob/f1002382c12159b277f57922f2ed3b8b444bc580/sopel/bot.py#L607-L625), because currently `Sopel` calls boolean functions on `Rule` to determine if something should be rate-limited and _then_ gets the associated message from another `Rule` function before calling `notice()`

@Exirel suggested the possibility of a `RateLimitInfo` object that could be passed across the class boundary, and I'm inclined to think that encapsulation of some sort may make sense here. Will keep thinking about it…
At the risk of sounding a little insane, because what I'm about to suggest can be seen as breaking the separation of concerns that the existing interface appears designed to enforce:
Why not have the `get_*_rate_message()` methods take advantage of the fact that they live in the object (`Rule`) that is also charged with hanging onto its own `RuleMetrics` for each scope (user, channel, global)?

The `_metrics_nick`, `_metrics_sender`, and `_metrics_global` fields live inside the `Rule`, as do the `_user_rate_limit`, `_channel_rate_limit`, and `_global_rate_limit` fields. The remaining time can be calculated from those, inside the message formatting method, without having to pass it back to the bot (forcing the bot to pass yet another value to the next method call).

Possible sticking point: The logic isn't super clear-cut because of checks like this one in `RuleMetrics.is_limited()`:

https://github.com/sopel-irc/sopel/blob/f1002382c12159b277f57922f2ed3b8b444bc580/sopel/plugins/rules.py#L489

Nonetheless, it's certainly _possible_ to implement the time-related placeholders without modifying the data flow, if you'd like to explore that route instead.

Resolving patch:
diff --git a/sopel/bot.py b/sopel/bot.py
--- a/sopel/bot.py
+++ b/sopel/bot.py
@@ -8,7 +8,7 @@
 from __future__ import annotations
 
 from ast import literal_eval
-from datetime import datetime
+import datetime
 import inspect
 import itertools
 import logging
@@ -592,6 +592,62 @@ def register_urls(self, urls: Iterable) -> None:
                 except plugins.exceptions.PluginError as err:
                     LOGGER.error("Cannot register URL callback: %s", err)
 
+    def rate_limit_info(
+        self,
+        rule: plugin_rules.Rule,
+        trigger: Trigger,
+    ) -> Tuple[bool, Optional[str]]:
+        if trigger.admin or rule.is_unblockable():
+            return False, None
+
+        is_channel = trigger.sender and not trigger.sender.is_nick()
+        channel = trigger.sender if is_channel else None
+
+        at_time = trigger.time
+
+        user_metrics = rule.get_user_metrics(trigger.nick)
+        channel_metrics = rule.get_channel_metrics(channel)
+        global_metrics = rule.get_global_metrics()
+
+        if user_metrics.is_limited(at_time - rule.user_rate_limit):
+            template = rule.user_rate_template
+            rate_limit_type = "user"
+            rate_limit = rule.user_rate_limit
+            metrics = user_metrics
+        elif is_channel and channel_metrics.is_limited(at_time - rule.channel_rate_limit):
+            template = rule.channel_rate_template
+            rate_limit_type = "channel"
+            rate_limit = rule.channel_rate_limit
+            metrics = channel_metrics
+        elif global_metrics.is_limited(at_time - rule.global_rate_limit):
+            template = rule.global_rate_template
+            rate_limit_type = "global"
+            rate_limit = rule.global_rate_limit
+            metrics = global_metrics
+        else:
+            return False, None
+
+        next_time = metrics.last_time + rate_limit
+        time_left = next_time - at_time
+
+        message: Optional[str] = None
+
+        if template:
+            message = template.format(
+                nick=trigger.nick,
+                channel=channel or 'private message',
+                sender=trigger.sender,
+                plugin=rule.get_plugin_name(),
+                label=rule.get_rule_label(),
+                time_left=time_left,
+                time_left_sec=time_left.total_seconds(),
+                rate_limit=rate_limit,
+                rate_limit_sec=rate_limit.total_seconds(),
+                rate_limit_type=rate_limit_type,
+            )
+
+        return True, message
+
     # message dispatch
 
     def call_rule(
@@ -604,25 +660,11 @@ def call_rule(
         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_user_rate_limited(nick):
-                message = rule.get_user_rate_message(nick)
-                if message:
-                    sopel.notice(message, destination=nick)
-                return
-
-            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
+        limited, limit_msg = self.rate_limit_info(rule, trigger)
+        if limit_msg:
+            sopel.notice(limit_msg, destination=nick)
+        if limited:
+            return
 
         # channel config
         if is_channel and context in self.config:
@@ -1003,7 +1045,7 @@ def error(
 
         if trigger:
             message = '{} from {} at {}. Message was: {}'.format(
-                message, trigger.nick, str(datetime.utcnow()), trigger.group(0)
+                message, trigger.nick, str(datetime.datetime.utcnow()), trigger.group(0)
             )
 
         LOGGER.exception(message)
diff --git a/sopel/plugin.py b/sopel/plugin.py
--- a/sopel/plugin.py
+++ b/sopel/plugin.py
@@ -1112,10 +1112,27 @@ def rate(
         @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!')
+    The message can contain placeholders which will be filled in:
+
+    * ``nick``: the nick that hit the rate limit
+    * ``channel``: the channel in which the rate limit was hit (will be
+      ``'private-message'`` for private messages)
+    * ``sender``: the sender (nick or channel) of the message which hit
+      the rate limit
+    * ``plugin``: the name of the plugin that hit the rate limit
+    * ``label``: the label of the plugin handler that hit the rate limit
+    * ``time_left``: the time remaining before the rate limit expires, as
+      a string
+    * ``time_left_sec``: the time remaining before the rate limit expires,
+      expressed in number of seconds
+    * ``rate_limit``: the rate limit, as a string
+    * ``rate_limit_sec``: the rate limit, expressed in number of seconds
+    * ``rate_limit_type``: the type of rate limit that was hit (one of
+      ``user, group, global``)
+
+    For example::
+
+        @rate(10, 10, 2, message='Sorry {nick}, you hit the {rate_limit_type} rate limit!')
 
     Rate-limited functions that use scheduled future commands should import
     :class:`threading.Timer` instead of :mod:`sched`, or rate limiting will
@@ -1168,10 +1185,9 @@ def rate_user(
             # 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::
+    The message can contain the same placeholders supported by :func:`rate`::
 
-        @rate_user(5, 'Sorry {nick}, you hit your 5s limit!')
+        @rate_user(5, 'Sorry {nick}, you hit your {rate_limit_sec}s limit!')
 
     If you don't provide a message, the default message set by :func:`rate`
     (if any) will be used instead.
@@ -1213,13 +1229,11 @@ def rate_channel(
     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``::
+    The message can contain the same placeholders supported by :func:`rate`::
 
         @rate_channel(
             5,
-            'Sorry {nick}, you hit the 5s limit for the {channel} channel!',
+            'Sorry {nick}, you hit the {rate_limit_sec}s limit for the {channel} channel!',
         )
 
     .. versionadded:: 8.0
@@ -1248,7 +1262,9 @@ def rate_global(
     :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::
+    will always take precedence.
+
+    For example::
 
         @rate(10, 10, 10)
         @rate_global(5, 'You hit the global rate limit for this function.')
@@ -1259,8 +1275,7 @@ def rate_global(
     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::
+    The message can contain the same placeholders supported by :func:`rate`::
 
         @rate_global(5, 'Sorry {nick}, you hit the 5s limit!')
 
diff --git a/sopel/plugins/rules.py b/sopel/plugins/rules.py
--- a/sopel/plugins/rules.py
+++ b/sopel/plugins/rules.py
@@ -36,6 +36,8 @@
 )
 from urllib.parse import urlparse
 
+import pytz
+
 from sopel import tools
 from sopel.config.core_section import (
     COMMAND_DEFAULT_HELP_PREFIX, COMMAND_DEFAULT_PREFIX, URL_DEFAULT_SCHEMES)
@@ -465,16 +467,26 @@ def __init__(self) -> None:
 
     def start(self) -> None:
         """Record a starting time (before execution)."""
-        self.started_at = datetime.datetime.utcnow()
+        self.started_at = pytz.utc.localize(datetime.datetime.utcnow())
 
     def end(self) -> None:
         """Record a ending time (after execution)."""
-        self.ended_at = datetime.datetime.utcnow()
+        self.ended_at = pytz.utc.localize(datetime.datetime.utcnow())
 
     def set_return_value(self, value: Any) -> None:
         """Set the last return value of a rule."""
         self.last_return_value = value
 
+    @property
+    def last_time(self) -> Optional[datetime.datetime]:
+        """Last recorded start/end time for the associated rule"""
+        # detect if we just started something or if it ended
+        last_time = self.started_at
+        if self.ended_at and self.started_at < self.ended_at:
+            last_time = self.ended_at
+
+        return last_time
+
     def is_limited(
         self,
         time_limit: datetime.datetime,
@@ -484,15 +496,12 @@ def is_limited(
             # not even started, so not limited
             return False
 
-        # detect if we just started something or if it ended
-        last_time = self.started_at
         if self.ended_at and self.started_at < self.ended_at:
-            last_time = self.ended_at
             # since it ended, check the return value
             if self.last_return_value == IGNORE_RATE_LIMIT:
                 return False
 
-        return last_time > time_limit
+        return self.last_time > time_limit
 
     def __enter__(self) -> RuleMetrics:
         self.start()
@@ -732,58 +741,72 @@ def is_unblockable(self) -> bool:
         """
 
     @abc.abstractmethod
-    def is_user_rate_limited(self, nick: Identifier) -> bool:
+    def is_user_rate_limited(
+        self,
+        nick: Identifier,
+        at_time: Optional[datetime.datetime] = None,
+    ) -> bool:
         """Tell when the rule reached the ``nick``'s rate limit.
 
-        :return: ``True`` when the rule reached the limit, ``False`` otherwise
+        :param nick: the nick associated with this check
+        :param at_time: optional aware datetime for the rate limit check;
+                        if not given, ``utcnow`` will be used
+        :return: ``True`` when the rule reached the limit, ``False`` otherwise.
         """
 
     @abc.abstractmethod
-    def is_channel_rate_limited(self, channel: Identifier) -> bool:
+    def is_channel_rate_limited(
+        self,
+        channel: Identifier,
+        at_time: Optional[datetime.datetime] = None,
+    ) -> bool:
         """Tell when the rule reached the ``channel``'s rate limit.
 
-        :return: ``True`` when the rule reached the limit, ``False`` otherwise
+        :param channel: the channel associated with this check
+        :param at_time: optional aware datetime for the rate limit check;
+                        if not given, ``utcnow`` will be used
+        :return: ``True`` when the rule reached the limit, ``False`` otherwise.
         """
 
     @abc.abstractmethod
-    def is_global_rate_limited(self) -> bool:
+    def is_global_rate_limited(
+        self,
+        at_time: Optional[datetime.datetime] = None,
+    ) -> bool:
         """Tell when the rule reached the global rate limit.
 
-        :return: ``True`` when the rule reached the limit, ``False`` otherwise
+        :param at_time: optional aware datetime for the rate limit check;
+                        if not given, ``utcnow`` will be used
+        :return: ``True`` when the rule reached the limit, ``False`` otherwise.
         """
 
+    @property
     @abc.abstractmethod
-    def get_user_rate_message(self, nick: Identifier) -> Optional[str]:
-        """Give the message to send with a NOTICE to ``nick``.
+    def user_rate_template(self) -> Optional[str]:
+        """Give the message template 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
+        This property is accessed by the bot when a trigger hits the user rate
         limit (i.e. for the specificed ``nick``).
         """
 
+    @property
     @abc.abstractmethod
-    def get_channel_rate_message(
-        self,
-        nick: Identifier,
-        channel: Identifier,
-    ) -> Optional[str]:
-        """Give the message to send with a NOTICE to ``nick``.
+    def channel_rate_template(self) -> Optional[str]:
+        """Give the message template 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``).
         """
 
+    @property
     @abc.abstractmethod
-    def get_global_rate_message(self, nick: Identifier) -> Optional[str]:
+    def global_rate_template(self) -> 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
@@ -1141,53 +1164,70 @@ def is_threaded(self):
     def is_unblockable(self):
         return self._unblockable
 
-    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._user_rate_limit)
-        return metrics.is_limited(now - rate_limit)
+    def get_user_metrics(self, nick: Identifier) -> RuleMetrics:
+        return self._metrics_nick.get(nick, RuleMetrics())
 
-    def is_channel_rate_limited(self, channel):
-        metrics: RuleMetrics = self._metrics_sender.get(channel, RuleMetrics())
-        now = datetime.datetime.utcnow()
-        rate_limit = datetime.timedelta(seconds=self._channel_rate_limit)
-        return metrics.is_limited(now - rate_limit)
+    def get_channel_metrics(self, channel: Identifier) -> RuleMetrics:
+        return self._metrics_sender.get(channel, RuleMetrics())
 
-    def is_global_rate_limited(self):
-        now = datetime.datetime.utcnow()
-        rate_limit = datetime.timedelta(seconds=self._global_rate_limit)
-        return self._metrics_global.is_limited(now - rate_limit)
+    def get_global_metrics(self) -> RuleMetrics:
+        return self._metrics_global
 
-    def get_user_rate_message(self, nick):
-        template = self._user_rate_message or self._default_rate_message
+    @property
+    def user_rate_limit(self) -> datetime.timedelta:
+        return datetime.timedelta(seconds=self._user_rate_limit)
 
-        if not template:
-            return None
+    @property
+    def channel_rate_limit(self) -> datetime.timedelta:
+        return datetime.timedelta(seconds=self._channel_rate_limit)
 
-        return template.format(
-            nick=nick,
-        )
+    @property
+    def global_rate_limit(self) -> datetime.timedelta:
+        return datetime.timedelta(seconds=self._global_rate_limit)
 
-    def get_channel_rate_message(self, nick, channel):
-        template = self._channel_rate_message or self._default_rate_message
+    def is_user_rate_limited(
+        self,
+        nick: Identifier,
+        at_time: Optional[datetime.datetime] = None,
+    ) -> bool:
+        if at_time is None:
+            at_time = pytz.utc.localize(datetime.datetime.utcnow())
+        metrics = self.get_user_metrics(nick)
+        return metrics.is_limited(at_time - self.user_rate_limit)
 
-        if not template:
-            return None
+    def is_channel_rate_limited(
+        self,
+        channel: Identifier,
+        at_time: Optional[datetime.datetime] = None,
+    ) -> bool:
+        if at_time is None:
+            at_time = pytz.utc.localize(datetime.datetime.utcnow())
+        metrics = self.get_channel_metrics(channel)
+        return metrics.is_limited(at_time - self.channel_rate_limit)
 
-        return template.format(
-            nick=nick,
-            channel=channel,
-        )
+    def is_global_rate_limited(
+        self,
+        at_time: Optional[datetime.datetime] = None,
+    ) -> bool:
+        if at_time is None:
+            at_time = pytz.utc.localize(datetime.datetime.utcnow())
+        metrics = self.get_global_metrics()
+        return metrics.is_limited(at_time - self.global_rate_limit)
 
-    def get_global_rate_message(self, nick):
-        template = self._global_rate_message or self._default_rate_message
+    @property
+    def user_rate_template(self) -> Optional[str]:
+        template = self._user_rate_message or self._default_rate_message
+        return template
 
-        if not template:
-            return None
+    @property
+    def channel_rate_template(self) -> Optional[str]:
+        template = self._channel_rate_message or self._default_rate_message
+        return template
 
-        return template.format(
-            nick=nick,
-        )
+    @property
+    def global_rate_template(self) -> Optional[str]:
+        template = self._global_rate_message or self._default_rate_message
+        return template
 
     def execute(self, bot, trigger):
         if not self._handler:


Test patch:
diff --git a/test/plugins/test_plugins_rules.py b/test/plugins/test_plugins_rules.py
--- a/test/plugins/test_plugins_rules.py
+++ b/test/plugins/test_plugins_rules.py
@@ -5,6 +5,7 @@
 import re
 
 import pytest
+import pytz
 
 from sopel import bot, loader, plugin, trigger
 from sopel.plugins import rules
@@ -468,7 +469,7 @@ def test_manager_has_action_command_aliases():
 # tests for :class:`Manager`
 
 def test_rulemetrics():
-    now = datetime.datetime.utcnow()
+    now = pytz.utc.localize(datetime.datetime.utcnow())
     time_window = datetime.timedelta(seconds=3600)
     metrics = rules.RuleMetrics()
 
@@ -1633,10 +1634,6 @@ 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],
@@ -1649,23 +1646,15 @@ def handler(bot, trigger):
         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'
+    assert rule.user_rate_template == 'User message: {nick}'
+    assert rule.channel_rate_template == 'Channel message: {nick}/{channel}'
+    assert rule.global_rate_template == 'Server message: {nick}'
 
 
 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],
@@ -1675,20 +1664,15 @@ def handler(bot, trigger):
         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'
+    assert rule.user_rate_template == 'Default message'
+    assert rule.channel_rate_template == 'Default message'
+    assert rule.global_rate_template == '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],
@@ -1699,10 +1683,9 @@ def handler(bot, trigger):
         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.'
+    assert rule.user_rate_template == 'User message.'
+    assert rule.channel_rate_template == 'The default.'
+    assert rule.global_rate_template == 'The default.'
 
     rule = rules.Rule(
         [regex],
@@ -1713,10 +1696,9 @@ def handler(bot, trigger):
         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.'
+    assert rule.user_rate_template == 'The default.'
+    assert rule.channel_rate_template == 'Channel message.'
+    assert rule.global_rate_template == 'The default.'
 
     rule = rules.Rule(
         [regex],
@@ -1727,10 +1709,9 @@ def handler(bot, trigger):
         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.'
+    assert rule.user_rate_template == 'The default.'
+    assert rule.channel_rate_template == 'The default.'
+    assert rule.global_rate_template == 'Server message.'
 
 
 # -----------------------------------------------------------------------------
diff --git a/test/test_bot.py b/test/test_bot.py
--- a/test/test_bot.py
+++ b/test/test_bot.py
@@ -675,7 +675,26 @@ def url_callback_http(bot, trigger, match):
 # -----------------------------------------------------------------------------
 # call_rule
 
-def test_call_rule(mockbot):
+@pytest.fixture
+def match_hello_rule(mockbot, triggerfactory):
+    """Helper for generating matches to each `Rule` in the following tests"""
+    def _factory(rule_hello):
+        # trigger
+        line = ':Test!test@example.com PRIVMSG #channel :hello'
+
+        trigger = triggerfactory(mockbot, line)
+        pretrigger = trigger._pretrigger
+
+        matches = list(rule_hello.match(mockbot, pretrigger))
+        match = matches[0]
+
+        wrapper = bot.SopelWrapper(mockbot, trigger)
+
+        return match, trigger, wrapper
+    return _factory
+
+
+def test_call_rule(mockbot, match_hello_rule):
     # setup
     items = []
 
@@ -690,19 +709,7 @@ def testrule(bot, trigger):
         label='testrule',
         handler=testrule)
 
-    # trigger
-    line = ':Test!test@example.com PRIVMSG #channel :hello'
-    pretrigger = trigger.PreTrigger(mockbot.nick, line)
-
-    # match
-    matches = list(rule_hello.match(mockbot, pretrigger))
-    assert len(matches) == 1
-    match = matches[0]
-
-    # trigger and wrapper
-    rule_trigger = trigger.Trigger(
-        mockbot.settings, pretrigger, match, account=None)
-    wrapper = bot.SopelWrapper(mockbot, rule_trigger)
+    match, rule_trigger, wrapper = match_hello_rule(rule_hello)
 
     # call rule
     mockbot.call_rule(rule_hello, wrapper, rule_trigger)
@@ -718,6 +725,8 @@ def testrule(bot, trigger):
     assert not rule_hello.is_channel_rate_limited('#channel')
     assert not rule_hello.is_global_rate_limited()
 
+    match, rule_trigger, wrapper = match_hello_rule(rule_hello)
+
     # call rule again
     mockbot.call_rule(rule_hello, wrapper, rule_trigger)
 
@@ -729,7 +738,7 @@ def testrule(bot, trigger):
     assert items == [1, 1]
 
 
-def test_call_rule_rate_limited_user(mockbot):
+def test_call_rule_rate_limited_user(mockbot, match_hello_rule):
     items = []
 
     # setup
@@ -746,18 +755,7 @@ def testrule(bot, trigger):
         user_rate_limit=100,
     )
 
-    # 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)
+    match, rule_trigger, wrapper = match_hello_rule(rule_hello)
 
     # call rule
     mockbot.call_rule(rule_hello, wrapper, rule_trigger)
@@ -773,6 +771,8 @@ def testrule(bot, trigger):
     assert not rule_hello.is_channel_rate_limited('#channel')
     assert not rule_hello.is_global_rate_limited()
 
+    match, rule_trigger, wrapper = match_hello_rule(rule_hello)
+
     # call rule again
     mockbot.call_rule(rule_hello, wrapper, rule_trigger)
 
@@ -783,7 +783,7 @@ def testrule(bot, trigger):
     assert items == [1], 'There must not be any new item'
 
 
-def test_call_rule_rate_limited_user_with_message(mockbot):
+def test_call_rule_rate_limited_user_with_message(mockbot, match_hello_rule):
     items = []
 
     # setup
@@ -800,18 +800,7 @@ def testrule(bot, trigger):
         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)
+    match, rule_trigger, wrapper = match_hello_rule(rule_hello)
 
     # call rule
     mockbot.call_rule(rule_hello, wrapper, rule_trigger)
@@ -822,6 +811,8 @@ def testrule(bot, trigger):
     )
     assert items == [1]
 
+    match, rule_trigger, wrapper = match_hello_rule(rule_hello)
+
     # call rule again
     mockbot.call_rule(rule_hello, wrapper, rule_trigger)
 
@@ -833,7 +824,7 @@ def testrule(bot, trigger):
     assert items == [1], 'There must not be any new item'
 
 
-def test_call_rule_rate_limited_channel(mockbot):
+def test_call_rule_rate_limited_channel(mockbot, match_hello_rule):
     items = []
 
     # setup
@@ -849,18 +840,7 @@ def testrule(bot, trigger):
         handler=testrule,
         channel_rate_limit=100)
 
-    # 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)
+    match, rule_trigger, wrapper = match_hello_rule(rule_hello)
 
     # call rule
     mockbot.call_rule(rule_hello, wrapper, rule_trigger)
@@ -876,6 +856,8 @@ def testrule(bot, trigger):
     assert rule_hello.is_channel_rate_limited('#channel')
     assert not rule_hello.is_global_rate_limited()
 
+    match, rule_trigger, wrapper = match_hello_rule(rule_hello)
+
     # call rule again
     mockbot.call_rule(rule_hello, wrapper, rule_trigger)
 
@@ -886,7 +868,7 @@ def testrule(bot, trigger):
     assert items == [1], 'There must not be any new item'
 
 
-def test_call_rule_rate_limited_channel_with_message(mockbot):
+def test_call_rule_rate_limited_channel_with_message(mockbot, match_hello_rule):
     items = []
 
     # setup
@@ -903,18 +885,7 @@ def testrule(bot, trigger):
         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)
+    match, rule_trigger, wrapper = match_hello_rule(rule_hello)
 
     # call rule
     mockbot.call_rule(rule_hello, wrapper, rule_trigger)
@@ -930,6 +901,8 @@ def testrule(bot, trigger):
     assert rule_hello.is_channel_rate_limited('#channel')
     assert not rule_hello.is_global_rate_limited()
 
+    match, rule_trigger, wrapper = match_hello_rule(rule_hello)
+
     # call rule again
     mockbot.call_rule(rule_hello, wrapper, rule_trigger)
 
@@ -941,7 +914,7 @@ def testrule(bot, trigger):
     assert items == [1], 'There must not be any new item'
 
 
-def test_call_rule_rate_limited_global(mockbot):
+def test_call_rule_rate_limited_global(mockbot, match_hello_rule):
     items = []
 
     # setup
@@ -957,18 +930,7 @@ def testrule(bot, trigger):
         handler=testrule,
         global_rate_limit=100)
 
-    # 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)
+    match, rule_trigger, wrapper = match_hello_rule(rule_hello)
 
     # call rule
     mockbot.call_rule(rule_hello, wrapper, rule_trigger)
@@ -984,6 +946,8 @@ def testrule(bot, trigger):
     assert not rule_hello.is_channel_rate_limited('#channel')
     assert rule_hello.is_global_rate_limited()
 
+    match, rule_trigger, wrapper = match_hello_rule(rule_hello)
+
     # call rule again
     mockbot.call_rule(rule_hello, wrapper, rule_trigger)
 
@@ -994,7 +958,7 @@ def testrule(bot, trigger):
     assert items == [1], 'There must not be any new item'
 
 
-def test_call_rule_rate_limited_global_with_message(mockbot):
+def test_call_rule_rate_limited_global_with_message(mockbot, match_hello_rule):
     items = []
 
     # setup
@@ -1011,18 +975,7 @@ def testrule(bot, trigger):
         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)
+    match, rule_trigger, wrapper = match_hello_rule(rule_hello)
 
     # call rule
     mockbot.call_rule(rule_hello, wrapper, rule_trigger)
@@ -1038,6 +991,8 @@ def testrule(bot, trigger):
     assert not rule_hello.is_channel_rate_limited('#channel')
     assert rule_hello.is_global_rate_limited()
 
+    match, rule_trigger, wrapper = match_hello_rule(rule_hello)
+
     # call rule again
     mockbot.call_rule(rule_hello, wrapper, rule_trigger)
 
@@ -1049,6 +1004,125 @@ def testrule(bot, trigger):
     assert items == [1], 'There must not be any new item'
 
 
+# -----------------------------------------------------------------------------
+# rate limit response templating
+
+@pytest.mark.parametrize("limit_type", ("user", "channel", "global"))
+@pytest.mark.parametrize("msg_fmt, expected_notice", (
+    ("{nick}", "NOTICE Test :Test"),
+    ("{channel}", "NOTICE Test :#channel"),
+    ("{sender}", "NOTICE Test :#channel"),
+    ("{plugin}", "NOTICE Test :testplugin"),
+    ("{label}", "NOTICE Test :testrule"),
+    ("{rate_limit}", "NOTICE Test :0:01:40"),
+    ("{rate_limit_sec}", "NOTICE Test :100.0"),
+))
+def test_rate_limit_fixed_fields(
+    mockbot,
+    match_hello_rule,
+    limit_type,
+    msg_fmt,
+    expected_notice,
+):
+    def testrule(bot, trigger):
+        return "Return Value"
+
+    limit_type_params = {
+        "{limit_type}_rate_limit".format(limit_type=limit_type): 100,
+        "{limit_type}_rate_message".format(limit_type=limit_type): msg_fmt,
+    }
+
+    rule_hello = rules.Rule(
+        [re.compile(r'(hi|hello|hey|sup)')],
+        plugin='testplugin',
+        label='testrule',
+        handler=testrule,
+        **limit_type_params,
+    )
+
+    # call rule
+    match, rule_trigger, wrapper = match_hello_rule(rule_hello)
+    mockbot.call_rule(rule_hello, wrapper, rule_trigger)
+
+    # call rule again
+    match, rule_trigger, wrapper = match_hello_rule(rule_hello)
+    mockbot.call_rule(rule_hello, wrapper, rule_trigger)
+
+    # assert there is now a NOTICE which contains templated rate-limit information
+    assert mockbot.backend.message_sent == rawlist(
+        expected_notice,
+    )
+
+
+@pytest.mark.parametrize("limit_type", ("user", "channel", "global"))
+def test_rate_limit_type_field(mockbot, match_hello_rule, limit_type):
+    def testrule(bot, trigger):
+        return "Return Value"
+
+    msg_fmt = "{rate_limit_type}"
+
+    limit_type_params = {
+        "{limit_type}_rate_limit".format(limit_type=limit_type): 100,
+        "{limit_type}_rate_message".format(limit_type=limit_type): msg_fmt,
+    }
+
+    rule_hello = rules.Rule(
+        [re.compile(r'(hi|hello|hey|sup)')],
+        plugin='testplugin',
+        label='testrule',
+        handler=testrule,
+        **limit_type_params,
+    )
+
+    # call rule
+    match, rule_trigger, wrapper = match_hello_rule(rule_hello)
+    mockbot.call_rule(rule_hello, wrapper, rule_trigger)
+
+    # call rule again
+    match, rule_trigger, wrapper = match_hello_rule(rule_hello)
+    mockbot.call_rule(rule_hello, wrapper, rule_trigger)
+
+    # assert there is now a NOTICE which contains templated rate-limit information
+    assert mockbot.backend.message_sent == rawlist(
+        "NOTICE Test :{limit_type}".format(limit_type=limit_type),
+    )
+
+
+@pytest.mark.parametrize("limit_type", ("user", "channel", "global"))
+def test_rate_limit_time_left_field(mockbot, match_hello_rule, limit_type):
+    def testrule(bot, trigger):
+        return "Return Value"
+
+    msg_fmt = "time_left={time_left} time_left_sec={time_left_sec}"
+    limit_type_params = {
+        "{limit_type}_rate_limit".format(limit_type=limit_type): 100,
+        "{limit_type}_rate_message".format(limit_type=limit_type): msg_fmt,
+    }
+
+    rule_hello = rules.Rule(
+        [re.compile(r'(hi|hello|hey|sup)')],
+        plugin='testplugin',
+        label='testrule',
+        handler=testrule,
+        **limit_type_params,
+    )
+
+    # call rule
+    match, rule_trigger, wrapper = match_hello_rule(rule_hello)
+    mockbot.call_rule(rule_hello, wrapper, rule_trigger)
+
+    # call rule again
+    match, rule_trigger, wrapper = match_hello_rule(rule_hello)
+    mockbot.call_rule(rule_hello, wrapper, rule_trigger)
+
+    # assert there is now a NOTICE which contains templated time left information
+    assert mockbot.backend.message_sent
+    patt = (br"NOTICE Test :"
+            br"time_left=\d+:\d+:\d+(\.\d+)? "
+            br"time_left_sec=\d+(\.\d+)?")
+    assert re.match(patt, mockbot.backend.message_sent[0])
+
+
 # -----------------------------------------------------------------------------
 # Channel privileges