diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index dcecd4101e..9a8ad39229 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -336,8 +336,8 @@ policy. .. versionadded:: 7.0 - Additional configuration options: ``flood_burst_lines``, ``flood_empty_wait``, - and ``flood_refill_rate``. + Additional configuration options: ``flood_burst_lines``, + ``flood_empty_wait``, and ``flood_refill_rate``. .. versionadded:: 7.1 @@ -350,6 +350,79 @@ policy. *"It's some arcane magic from AT LEAST a decade ago."* +Loop prevention +--------------- + +In order to prevent the bot from entering a loop (for example when there is +another bot in the same channel, or if a user spams a command), it'll try to +see if the next message to send is repeating too often in a short time period. +If that happens, the bot will send ``...`` a few times before remaining silent:: + + I repeat myself! + I repeat myself! + I repeat myself! + I repeat myself! + I repeat myself! + # wanted to say: "I repeat myself" + ... + # wanted to say: "I repeat myself" + ... + # wanted to say: "I repeat myself" + ... + # silence, wanted to say: "..." instead of "I repeat myself" + +This doesn't affect non-repeating messages, and if enough time has passed +between now and the last message sent, the loop prevention won't be triggered. + +This behavior can be configured with: + +* :attr:`~CoreSection.antiloop_threshold`: the number of repeating messages + before triggering the loop prevention. +* :attr:`~CoreSection.antiloop_silent_after`: how many times the bot will send + the repeat text until it remains silent. +* :attr:`~CoreSection.antiloop_window`: how much time (in seconds) since the + last message must pass before ignoring the loop prevention. +* :attr:`~CoreSection.antiloop_repeat_text`: the text used to replace repeating + messages (default to ``...``). + +For example this configuration:: + + [core] + antiloop_threshold = 2 + antiloop_silent_after = 1 + antiloop_window = 60 + antiloop_repeat_text = Ditto. + +will activate the loop prevention feature if there are at least 2 messages +in the last 60 seconds, **and** exactly 2 of those messages are the same. +After sending ``...`` *once* (a third message), the bot will remain silent. + +This doesn't affect other messages, i.e. messages that don't repeat:: + + I repeat myself! + No I don't! + I can talk. + I repeat myself! + No I don't! + # wanted to say: "I repeat myself" + Ditto. + # silence, wanted to say: "Ditto." instead of "No I don't!" + This message is unique. + +You can **deactivate** the loop prevention by setting +:attr:`~CoreSection.antiloop_threshold` to 0. + +.. versionadded:: 8.0 + + The loop prevention feature wasn't configurable before Sopel 8.0. The + new configuration options are: ``antiloop_threshold``, + ``antiloop_silent_after``, and ``antiloop_window``. + +.. note:: + + Since Sopel remembers only the last ten messages, it will use the minimum + value between ``antiloop_threshold`` and ten. + Perform commands on connect --------------------------- diff --git a/sopel/config/core_section.py b/sopel/config/core_section.py index 63e0710fac..3895b19249 100644 --- 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 index 8fc71e168f..fc9aca5e11 100644 --- 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:] diff --git a/test/test_irc.py b/test/test_irc.py index b1f9021b62..5766f5de71 100644 --- 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.' + )