Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

irc: configure anti-looping system #2320

Merged
merged 1 commit into from
Aug 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 a short time period.
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 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::

<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 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.
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:]]
dgw marked this conversation as resolved.
Show resolved Hide resolved

# 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)
Exirel marked this conversation as resolved.
Show resolved Hide resolved
recipient_stack['messages'].append((time.time(), safe(text)))
recipient_stack['messages'] = recipient_stack['messages'][-10:]
dgw marked this conversation as resolved.
Show resolved Hide resolved

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.'
)