Repository: sopel-irc/sopel

Issue:
Allow the disabling of repetition detection
It would be useful for bot owners to be able to disable the repetition detection feature ("...").

We use Sopel primarily for devops and moderation, with modules which will often return repetitive answers (i.e. checking IPs or e-mails against blocklists), and thus far have had to fork Sopel to remove the "..." responses.

Other rate limits throughout Sopel are configurable (for instance, `@rate`); it would make sense to bring this in line with them, be it through a config option or a per-callable option to disable the feature.

Additionally, this would be very helpful for debugging modules.


Discussion:
This is something I've been doing for the past few weeks with my dev bot. This would be a good addition to the new flood controls in #1518 ,,, `config.core.message_loop_detection` (Name TBD) seems like a good idea to me.

I do agree heavily with #1559 that the current state of the bot does NOT uniformly rate limit, and that these flood tweaks should filter through `irc.bot.write` and not Sopel.say.

I think #1518 and this should be added, but moreso in the form that @HumorBaby proposes in #1559 .
This is a "nice to have" that we can work on for 7.1. It won't add anything backward-incompatible, and so we don't need to find time to build and ship it in 7.0.
Just checking in on this - now that the custom flood protection patches have landed, I'd be happy to submit a PR for this (if welcome).

Would "repetition_filter" be an acceptable name for such a setting?
For a simple toggle, I'd name it something like `limit_repetition` (edit: or `anti_looping`). But if you're feeling ambitious _and_ you think more knobs might be useful to override the hard-coded limits [here](https://github.com/sopel-irc/sopel/blob/005a53452fdd96dde1dd266b1587d67170665317/sopel/irc/__init__.py#L625-L631), have at that. (I won't even _start_ bikeshedding names for such settings unless you actually want to go that way, though! 😁)

Resolving patch:
diff --git a/sopel/config/core_section.py b/sopel/config/core_section.py
--- a/sopel/config/core_section.py
+++ b/sopel/config/core_section.py
@@ -133,6 +133,92 @@ class CoreSection(StaticSection):
     :func:`~sopel.plugin.nickname_command`.
     """
 
+    antiloop_repeat_text = ValidatedAttribute(
+        'antiloop_repeat_text', default='...')
+    """The replacement text sent when detecting a repeated message.
+
+    :default: ``...``
+
+    This is equivalent to the default value:
+
+    .. code-block:: ini
+
+        antiloop_repeat_text = ...
+
+    .. seealso::
+
+        The :ref:`Loop Prevention` chapter to learn what each antiloop-related
+        setting does.
+
+    .. versionadded:: 8.0
+    """
+
+    antiloop_silent_after = ValidatedAttribute(
+        'antiloop_silent_after', int, default=3)
+    """How many times the anti-looping message will be sent before stopping.
+
+    :default: ``3``
+
+    This is equivalent to the default value:
+
+    .. code-block:: ini
+
+        antiloop_silent_after = 3
+
+    .. seealso::
+
+        The :ref:`Loop Prevention` chapter to learn what each antiloop-related
+        setting does.
+
+    .. versionadded:: 8.0
+    """
+
+    antiloop_threshold = ValidatedAttribute(
+        'antiloop_threshold', int, default=5)
+    """How many times a message can be repeated without anti-looping action.
+
+    :default: ``5``
+
+    This is equivalent to the default value:
+
+    .. code-block:: ini
+
+        antiloop_threshold = 5
+
+    You can deactivate the anti-looping feature (not recommended) by setting
+    this to ``0``:
+
+    .. code-block:: ini
+
+        antiloop_threshold = 0
+
+    .. seealso::
+
+        The :ref:`Loop Prevention` chapter to learn what each antiloop-related
+        setting does.
+
+    .. versionadded:: 8.0
+    """
+
+    antiloop_window = ValidatedAttribute('antiloop_window', int, default=120)
+    """The time period (in seconds) checked when detecting repeated messages.
+
+    :default: ``120``
+
+    This is equivalent to the default value:
+
+    .. code-block:: ini
+
+        antiloop_window = 120
+
+    .. seealso::
+
+        The :ref:`Loop Prevention` chapter to learn what each antiloop-related
+        setting does.
+
+    .. versionadded:: 8.0
+    """
+
     auth_method = ChoiceAttribute('auth_method', choices=[
         'nickserv', 'authserv', 'Q', 'sasl', 'server', 'userserv'])
     """Simple method to authenticate with the server.
diff --git a/sopel/irc/__init__.py b/sopel/irc/__init__.py
--- a/sopel/irc/__init__.py
+++ b/sopel/irc/__init__.py
@@ -790,6 +790,11 @@ def say(
         flood_text_length = self.settings.core.flood_text_length
         flood_penalty_ratio = self.settings.core.flood_penalty_ratio
 
+        antiloop_threshold = min(10, self.settings.core.antiloop_threshold)
+        antiloop_window = self.settings.core.antiloop_window
+        antiloop_repeat_text = self.settings.core.antiloop_repeat_text
+        antiloop_silent_after = self.settings.core.antiloop_silent_after
+
         with self.sending:
             recipient_id = self.make_identifier(recipient)
             recipient_stack = self.stack.setdefault(recipient_id, {
@@ -840,18 +845,22 @@ def say(
                     time.sleep(sleep_time)
 
             # Loop detection
-            messages = [m[1] for m in recipient_stack['messages'][-8:]]
+            if antiloop_threshold > 0 and elapsed < antiloop_window:
+                messages = [m[1] for m in recipient_stack['messages'][-10:]]
 
-            # If what we're about to send repeated at least 5 times in the last
-            # two minutes, replace it with '...'
-            if messages.count(text) >= 5 and elapsed < 120:
-                text = '...'
-                if messages.count('...') >= 3:
-                    # If we've already said '...' 3 times, discard message
-                    return
+                # If what we're about to send repeated at least N times
+                # in the anti-looping window, replace it
+                if messages.count(text) >= antiloop_threshold:
+                    text = antiloop_repeat_text
+                    if messages.count(text) >= antiloop_silent_after:
+                        # If we've already said that N times, discard message
+                        return
 
             self.backend.send_privmsg(recipient, text)
-            recipient_stack['flood_left'] = max(0, recipient_stack['flood_left'] - 1)
+
+            # update recipient meta-data
+            flood_left = recipient_stack['flood_left'] - 1
+            recipient_stack['flood_left'] = max(0, flood_left)
             recipient_stack['messages'].append((time.time(), safe(text)))
             recipient_stack['messages'] = recipient_stack['messages'][-10:]
 


Test patch:
diff --git a/test/test_irc.py b/test/test_irc.py
--- a/test/test_irc.py
+++ b/test/test_irc.py
@@ -353,7 +353,7 @@ def test_say_long_truncation_trailing(bot):
     )
 
 
-def test_say_no_repeat_protection(bot):
+def test_say_antiloop(bot):
     # five is fine
     bot.say('hello', '#sopel')
     bot.say('hello', '#sopel')
@@ -407,8 +407,73 @@ def test_say_no_repeat_protection(bot):
         'PRIVMSG #sopel :hello',
         'PRIVMSG #sopel :hello',
         'PRIVMSG #sopel :hello',
-        #  three time, then stop
+        #  three times, then stop
         'PRIVMSG #sopel :...',
         'PRIVMSG #sopel :...',
         'PRIVMSG #sopel :...',
     )
+
+
+def test_say_antiloop_configuration(bot):
+    bot.settings.core.antiloop_threshold = 3
+    bot.settings.core.antiloop_silent_after = 2
+    bot.settings.core.antiloop_repeat_text = '???'
+
+    # three is fine now
+    bot.say('hello', '#sopel')
+    bot.say('hello', '#sopel')
+    bot.say('hello', '#sopel')
+
+    assert bot.backend.message_sent == rawlist(
+        'PRIVMSG #sopel :hello',
+        'PRIVMSG #sopel :hello',
+        'PRIVMSG #sopel :hello',
+    )
+
+    # fourth: replaced by '???'
+    bot.say('hello', '#sopel')
+
+    assert bot.backend.message_sent == rawlist(
+        'PRIVMSG #sopel :hello',
+        'PRIVMSG #sopel :hello',
+        'PRIVMSG #sopel :hello',
+        # the extra hello is replaced by '???'
+        'PRIVMSG #sopel :???',
+    )
+
+    # this one will add one more '???'
+    bot.say('hello', '#sopel')
+
+    assert bot.backend.message_sent == rawlist(
+        'PRIVMSG #sopel :hello',
+        'PRIVMSG #sopel :hello',
+        'PRIVMSG #sopel :hello',
+        'PRIVMSG #sopel :???',
+        # the new one is also replaced by '???'
+        'PRIVMSG #sopel :???',
+    )
+
+    # but at some point it just stops talking
+    bot.say('hello', '#sopel')
+
+    assert bot.backend.message_sent == rawlist(
+        'PRIVMSG #sopel :hello',
+        'PRIVMSG #sopel :hello',
+        'PRIVMSG #sopel :hello',
+        #  two times, then stop
+        'PRIVMSG #sopel :???',
+        'PRIVMSG #sopel :???',
+    )
+
+
+def test_say_antiloop_deactivated(bot):
+    bot.settings.core.antiloop_threshold = 0
+
+    # no more loop prevention
+    for _ in range(10):
+        bot.say('hello', '#sopel')
+
+    expected = ['PRIVMSG #sopel :hello'] * 10
+    assert bot.backend.message_sent == rawlist(*expected), (
+        'When antiloop is deactivated, messages must not be replaced.'
+    )