Skip to content

Commit

Permalink
irc: configure anti-looping system
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 22, 2022
1 parent 55c78a4 commit 7371b73
Show file tree
Hide file tree
Showing 4 changed files with 246 additions and 13 deletions.
77 changes: 75 additions & 2 deletions docs/source/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -333,8 +333,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

Expand All @@ -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 the last ten messages
in the last few minutes. If that happens, the bot will send ``...`` a few times
before remaining silent::

<bot> I repeat myself!
<bot> I repeat myself!
<bot> I repeat myself!
<bot> I repeat myself!
<bot> I repeat myself!
# wanted to say: "I repeat myself"
<bot> ...
# wanted to say: "I repeat myself"
<bot> ...
# wanted to say: "I repeat myself"
<bot> ...
# 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 is at least one message in
the last 60s, from the last 10 messages, 2 are already the same. In that case
the bot will send ``...``, but only *once*. After that, the bot will remain
silent. This doesn't affect other messages, i.e. messages that don't repeat::

<bot> I repeat myself!
<bot> No I don't!
<bot> I can talk.
<bot> I repeat myself!
<bot> No I don't!
# wanted to say: "I repeat myself"
<bot> Ditto.
# silence, wanted to say: "Ditto." instead of "No I don't!"
<bot> 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
---------------------------

Expand Down
86 changes: 86 additions & 0 deletions sopel/config/core_section.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,92 @@ class CoreSection(StaticSection):
:func:`~sopel.plugin.nickname_command`.
"""

antiloop_repeat_text = ValidatedAttribute(
'antiloop_repeat_text', default='...')
"""The replacement text when detecting a repeating 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 window (in seconds) used to activate anti-looping.
: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.
Expand Down
27 changes: 18 additions & 9 deletions sopel/irc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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:]

Expand Down
69 changes: 67 additions & 2 deletions test/test_irc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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.'
)

0 comments on commit 7371b73

Please sign in to comment.