Skip to content

Commit 6101e3a

Browse files
authored
Merge pull request #2320 from Exirel/anti-loop-configuration
irc: configure anti-looping system
2 parents 0479737 + f565f61 commit 6101e3a

File tree

4 files changed

+246
-13
lines changed

4 files changed

+246
-13
lines changed

docs/source/configuration.rst

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -336,8 +336,8 @@ policy.
336336

337337
.. versionadded:: 7.0
338338

339-
Additional configuration options: ``flood_burst_lines``, ``flood_empty_wait``,
340-
and ``flood_refill_rate``.
339+
Additional configuration options: ``flood_burst_lines``,
340+
``flood_empty_wait``, and ``flood_refill_rate``.
341341

342342
.. versionadded:: 7.1
343343

@@ -350,6 +350,79 @@ policy.
350350

351351
*"It's some arcane magic from AT LEAST a decade ago."*
352352

353+
Loop prevention
354+
---------------
355+
356+
In order to prevent the bot from entering a loop (for example when there is
357+
another bot in the same channel, or if a user spams a command), it'll try to
358+
see if the next message to send is repeating too often in a short time period.
359+
If that happens, the bot will send ``...`` a few times before remaining silent::
360+
361+
<bot> I repeat myself!
362+
<bot> I repeat myself!
363+
<bot> I repeat myself!
364+
<bot> I repeat myself!
365+
<bot> I repeat myself!
366+
# wanted to say: "I repeat myself"
367+
<bot> ...
368+
# wanted to say: "I repeat myself"
369+
<bot> ...
370+
# wanted to say: "I repeat myself"
371+
<bot> ...
372+
# silence, wanted to say: "..." instead of "I repeat myself"
373+
374+
This doesn't affect non-repeating messages, and if enough time has passed
375+
between now and the last message sent, the loop prevention won't be triggered.
376+
377+
This behavior can be configured with:
378+
379+
* :attr:`~CoreSection.antiloop_threshold`: the number of repeating messages
380+
before triggering the loop prevention.
381+
* :attr:`~CoreSection.antiloop_silent_after`: how many times the bot will send
382+
the repeat text until it remains silent.
383+
* :attr:`~CoreSection.antiloop_window`: how much time (in seconds) since the
384+
last message must pass before ignoring the loop prevention.
385+
* :attr:`~CoreSection.antiloop_repeat_text`: the text used to replace repeating
386+
messages (default to ``...``).
387+
388+
For example this configuration::
389+
390+
[core]
391+
antiloop_threshold = 2
392+
antiloop_silent_after = 1
393+
antiloop_window = 60
394+
antiloop_repeat_text = Ditto.
395+
396+
will activate the loop prevention feature if there are at least 2 messages
397+
in the last 60 seconds, **and** exactly 2 of those messages are the same.
398+
After sending ``...`` *once* (a third message), the bot will remain silent.
399+
400+
This doesn't affect other messages, i.e. messages that don't repeat::
401+
402+
<bot> I repeat myself!
403+
<bot> No I don't!
404+
<bot> I can talk.
405+
<bot> I repeat myself!
406+
<bot> No I don't!
407+
# wanted to say: "I repeat myself"
408+
<bot> Ditto.
409+
# silence, wanted to say: "Ditto." instead of "No I don't!"
410+
<bot> This message is unique.
411+
412+
You can **deactivate** the loop prevention by setting
413+
:attr:`~CoreSection.antiloop_threshold` to 0.
414+
415+
.. versionadded:: 8.0
416+
417+
The loop prevention feature wasn't configurable before Sopel 8.0. The
418+
new configuration options are: ``antiloop_threshold``,
419+
``antiloop_silent_after``, and ``antiloop_window``.
420+
421+
.. note::
422+
423+
Since Sopel remembers only the last ten messages, it will use the minimum
424+
value between ``antiloop_threshold`` and ten.
425+
353426
Perform commands on connect
354427
---------------------------
355428

sopel/config/core_section.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,92 @@ class CoreSection(StaticSection):
133133
:func:`~sopel.plugin.nickname_command`.
134134
"""
135135

136+
antiloop_repeat_text = ValidatedAttribute(
137+
'antiloop_repeat_text', default='...')
138+
"""The replacement text sent when detecting a repeated message.
139+
140+
:default: ``...``
141+
142+
This is equivalent to the default value:
143+
144+
.. code-block:: ini
145+
146+
antiloop_repeat_text = ...
147+
148+
.. seealso::
149+
150+
The :ref:`Loop Prevention` chapter to learn what each antiloop-related
151+
setting does.
152+
153+
.. versionadded:: 8.0
154+
"""
155+
156+
antiloop_silent_after = ValidatedAttribute(
157+
'antiloop_silent_after', int, default=3)
158+
"""How many times the anti-looping message will be sent before stopping.
159+
160+
:default: ``3``
161+
162+
This is equivalent to the default value:
163+
164+
.. code-block:: ini
165+
166+
antiloop_silent_after = 3
167+
168+
.. seealso::
169+
170+
The :ref:`Loop Prevention` chapter to learn what each antiloop-related
171+
setting does.
172+
173+
.. versionadded:: 8.0
174+
"""
175+
176+
antiloop_threshold = ValidatedAttribute(
177+
'antiloop_threshold', int, default=5)
178+
"""How many times a message can be repeated without anti-looping action.
179+
180+
:default: ``5``
181+
182+
This is equivalent to the default value:
183+
184+
.. code-block:: ini
185+
186+
antiloop_threshold = 5
187+
188+
You can deactivate the anti-looping feature (not recommended) by setting
189+
this to ``0``:
190+
191+
.. code-block:: ini
192+
193+
antiloop_threshold = 0
194+
195+
.. seealso::
196+
197+
The :ref:`Loop Prevention` chapter to learn what each antiloop-related
198+
setting does.
199+
200+
.. versionadded:: 8.0
201+
"""
202+
203+
antiloop_window = ValidatedAttribute('antiloop_window', int, default=120)
204+
"""The time period (in seconds) checked when detecting repeated messages.
205+
206+
:default: ``120``
207+
208+
This is equivalent to the default value:
209+
210+
.. code-block:: ini
211+
212+
antiloop_window = 120
213+
214+
.. seealso::
215+
216+
The :ref:`Loop Prevention` chapter to learn what each antiloop-related
217+
setting does.
218+
219+
.. versionadded:: 8.0
220+
"""
221+
136222
auth_method = ChoiceAttribute('auth_method', choices=[
137223
'nickserv', 'authserv', 'Q', 'sasl', 'server', 'userserv'])
138224
"""Simple method to authenticate with the server.

sopel/irc/__init__.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,11 @@ def say(
790790
flood_text_length = self.settings.core.flood_text_length
791791
flood_penalty_ratio = self.settings.core.flood_penalty_ratio
792792

793+
antiloop_threshold = min(10, self.settings.core.antiloop_threshold)
794+
antiloop_window = self.settings.core.antiloop_window
795+
antiloop_repeat_text = self.settings.core.antiloop_repeat_text
796+
antiloop_silent_after = self.settings.core.antiloop_silent_after
797+
793798
with self.sending:
794799
recipient_id = self.make_identifier(recipient)
795800
recipient_stack = self.stack.setdefault(recipient_id, {
@@ -840,18 +845,22 @@ def say(
840845
time.sleep(sleep_time)
841846

842847
# Loop detection
843-
messages = [m[1] for m in recipient_stack['messages'][-8:]]
848+
if antiloop_threshold > 0 and elapsed < antiloop_window:
849+
messages = [m[1] for m in recipient_stack['messages'][-10:]]
844850

845-
# If what we're about to send repeated at least 5 times in the last
846-
# two minutes, replace it with '...'
847-
if messages.count(text) >= 5 and elapsed < 120:
848-
text = '...'
849-
if messages.count('...') >= 3:
850-
# If we've already said '...' 3 times, discard message
851-
return
851+
# If what we're about to send repeated at least N times
852+
# in the anti-looping window, replace it
853+
if messages.count(text) >= antiloop_threshold:
854+
text = antiloop_repeat_text
855+
if messages.count(text) >= antiloop_silent_after:
856+
# If we've already said that N times, discard message
857+
return
852858

853859
self.backend.send_privmsg(recipient, text)
854-
recipient_stack['flood_left'] = max(0, recipient_stack['flood_left'] - 1)
860+
861+
# update recipient meta-data
862+
flood_left = recipient_stack['flood_left'] - 1
863+
recipient_stack['flood_left'] = max(0, flood_left)
855864
recipient_stack['messages'].append((time.time(), safe(text)))
856865
recipient_stack['messages'] = recipient_stack['messages'][-10:]
857866

test/test_irc.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ def test_say_long_truncation_trailing(bot):
353353
)
354354

355355

356-
def test_say_no_repeat_protection(bot):
356+
def test_say_antiloop(bot):
357357
# five is fine
358358
bot.say('hello', '#sopel')
359359
bot.say('hello', '#sopel')
@@ -407,8 +407,73 @@ def test_say_no_repeat_protection(bot):
407407
'PRIVMSG #sopel :hello',
408408
'PRIVMSG #sopel :hello',
409409
'PRIVMSG #sopel :hello',
410-
# three time, then stop
410+
# three times, then stop
411411
'PRIVMSG #sopel :...',
412412
'PRIVMSG #sopel :...',
413413
'PRIVMSG #sopel :...',
414414
)
415+
416+
417+
def test_say_antiloop_configuration(bot):
418+
bot.settings.core.antiloop_threshold = 3
419+
bot.settings.core.antiloop_silent_after = 2
420+
bot.settings.core.antiloop_repeat_text = '???'
421+
422+
# three is fine now
423+
bot.say('hello', '#sopel')
424+
bot.say('hello', '#sopel')
425+
bot.say('hello', '#sopel')
426+
427+
assert bot.backend.message_sent == rawlist(
428+
'PRIVMSG #sopel :hello',
429+
'PRIVMSG #sopel :hello',
430+
'PRIVMSG #sopel :hello',
431+
)
432+
433+
# fourth: replaced by '???'
434+
bot.say('hello', '#sopel')
435+
436+
assert bot.backend.message_sent == rawlist(
437+
'PRIVMSG #sopel :hello',
438+
'PRIVMSG #sopel :hello',
439+
'PRIVMSG #sopel :hello',
440+
# the extra hello is replaced by '???'
441+
'PRIVMSG #sopel :???',
442+
)
443+
444+
# this one will add one more '???'
445+
bot.say('hello', '#sopel')
446+
447+
assert bot.backend.message_sent == rawlist(
448+
'PRIVMSG #sopel :hello',
449+
'PRIVMSG #sopel :hello',
450+
'PRIVMSG #sopel :hello',
451+
'PRIVMSG #sopel :???',
452+
# the new one is also replaced by '???'
453+
'PRIVMSG #sopel :???',
454+
)
455+
456+
# but at some point it just stops talking
457+
bot.say('hello', '#sopel')
458+
459+
assert bot.backend.message_sent == rawlist(
460+
'PRIVMSG #sopel :hello',
461+
'PRIVMSG #sopel :hello',
462+
'PRIVMSG #sopel :hello',
463+
# two times, then stop
464+
'PRIVMSG #sopel :???',
465+
'PRIVMSG #sopel :???',
466+
)
467+
468+
469+
def test_say_antiloop_deactivated(bot):
470+
bot.settings.core.antiloop_threshold = 0
471+
472+
# no more loop prevention
473+
for _ in range(10):
474+
bot.say('hello', '#sopel')
475+
476+
expected = ['PRIVMSG #sopel :hello'] * 10
477+
assert bot.backend.message_sent == rawlist(*expected), (
478+
'When antiloop is deactivated, messages must not be replaced.'
479+
)

0 commit comments

Comments
 (0)