Skip to content

Commit c1c51e4

Browse files
Action deduplication in filtering system (#3533)
1 parent 35d9e45 commit c1c51e4

2 files changed

Lines changed: 87 additions & 8 deletions

File tree

bot/exts/filtering/filtering.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import io
33
import json
44
import re
5+
import time
56
import unicodedata
67
from collections import defaultdict
78
from collections.abc import Iterable, Mapping
@@ -72,6 +73,7 @@
7273
OFFENSIVE_MSG_DELETE_TIME = datetime.timedelta(days=7)
7374
WEEKLY_REPORT_ISO_DAY = 3 # 1=Monday, 7=Sunday
7475
MAX_IMAGE_HASH_SIZE = 5_000_000
76+
ACTION_DEDUPE_WINDOW_SECONDS = 3
7577

7678

7779
def _clean_ban_mentions(mentions: set[str]) -> set[str]:
@@ -113,6 +115,7 @@ def __init__(self, bot: Bot):
113115
self.bot = bot
114116
self.filter_lists: dict[str, FilterList] = {}
115117
self._subscriptions = defaultdict[Event, list[FilterList]](list)
118+
self._recent_actions: dict[tuple, float] = {}
116119
self.delete_scheduler = scheduling.Scheduler(self.__class__.__name__)
117120
self.webhook: discord.Webhook | None = None
118121

@@ -267,8 +270,7 @@ async def on_message(self, msg: Message) -> None:
267270

268271
result_actions, list_messages, triggers = await self._resolve_action(ctx)
269272
self.message_cache.update(msg, metadata=triggers)
270-
if result_actions:
271-
await result_actions.action(ctx)
273+
await self._run_actions(ctx, result_actions)
272274
if ctx.send_alert:
273275
await self._send_alert(ctx, list_messages)
274276

@@ -298,8 +300,7 @@ async def on_message_edit(self, before: discord.Message, after: discord.Message)
298300
self.message_cache.update(after)
299301
ctx = FilterContext.from_message(Event.MESSAGE_EDIT, after, before, self.message_cache)
300302
result_actions, list_messages, triggers = await self._resolve_action(ctx)
301-
if result_actions:
302-
await result_actions.action(ctx)
303+
await self._run_actions(ctx, result_actions)
303304
if ctx.send_alert:
304305
await self._send_alert(ctx, list_messages)
305306
await self._maybe_schedule_msg_delete(ctx, result_actions)
@@ -334,8 +335,7 @@ async def filter_snekbox_output(
334335
ctx = FilterContext.from_message(Event.SNEKBOX, msg).replace(content=content, attachments=files)
335336

336337
result_actions, list_messages, triggers = await self._resolve_action(ctx)
337-
if result_actions:
338-
await result_actions.action(ctx)
338+
await self._run_actions(ctx, result_actions)
339339
if ctx.send_alert:
340340
await self._send_alert(ctx, list_messages)
341341

@@ -1076,6 +1076,35 @@ async def _send_alert(self, ctx: FilterContext, triggered_filters: dict[FilterLi
10761076
username=name, content=ctx.alert_content, embeds=[embed, *ctx.alert_embeds][:10], view=AlertView(ctx)
10771077
)
10781078

1079+
async def _run_actions(self, ctx: FilterContext, actions: ActionSettings | None) -> None:
1080+
"""Execute actions unless this exact action payload was run very recently for the same context source."""
1081+
if actions and self._should_run_actions(ctx, actions):
1082+
await actions.action(ctx)
1083+
1084+
def _should_run_actions(self, ctx: FilterContext, actions: ActionSettings) -> bool:
1085+
"""Return whether actions should run, suppressing identical actions for the same source within a time window."""
1086+
now = time.monotonic()
1087+
recent = self._recent_actions
1088+
# Evict stale cache keys from the front.
1089+
while recent:
1090+
oldest_key = next(iter(recent))
1091+
if now - recent[oldest_key] < ACTION_DEDUPE_WINDOW_SECONDS:
1092+
break
1093+
del recent[oldest_key]
1094+
1095+
key = (
1096+
ctx.event.name,
1097+
getattr(ctx.author, "id", None),
1098+
getattr(ctx.channel, "id", None),
1099+
json.dumps(to_serializable(actions), sort_keys=True, default=str),
1100+
# So that each callable in additional_actions also forms the cache key
1101+
tuple(sorted(getattr(a, "__qualname__", repr(a)) for a in ctx.additional_actions)),
1102+
)
1103+
if key in recent:
1104+
return False
1105+
recent[key] = now
1106+
return True
1107+
10791108
def _increment_stats(self, triggered_filters: dict[AtomicList, list[Filter]]) -> None:
10801109
"""Increment the stats for every filter triggered."""
10811110
for filters in triggered_filters.values():
@@ -1116,8 +1145,7 @@ async def _check_bad_name(self, ctx: FilterContext) -> FilterContext:
11161145
new_ctx = ctx.replace(content=" ".join(names_to_check))
11171146
result_actions, list_messages, triggers = await self._resolve_action(new_ctx)
11181147
new_ctx = new_ctx.replace(content=ctx.content) # Alert with the original content.
1119-
if result_actions:
1120-
await result_actions.action(new_ctx)
1148+
await self._run_actions(new_ctx, result_actions)
11211149
if new_ctx.send_alert:
11221150
await self._send_alert(new_ctx, list_messages)
11231151
self._increment_stats(triggers)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import unittest
2+
3+
from bot.exts.filtering._filter_context import Event, FilterContext
4+
from bot.exts.filtering._settings import ActionSettings
5+
from bot.exts.filtering._settings_types.actions.send_alert import SendAlert
6+
from bot.exts.filtering.filtering import Filtering
7+
from tests.helpers import MockBot, MockMember, MockTextChannel
8+
9+
10+
class ActionDeduplicationTests(unittest.TestCase):
11+
def setUp(self):
12+
self.cog = Filtering(MockBot())
13+
14+
@staticmethod
15+
def _make_actions(send_alert: bool) -> ActionSettings:
16+
actions = ActionSettings({})
17+
actions[SendAlert.name] = SendAlert(send_alert=send_alert)
18+
return actions
19+
20+
@staticmethod
21+
def _make_context(*, author_id: int = 1, channel_id: int = 10) -> FilterContext:
22+
return FilterContext(
23+
event=Event.MESSAGE,
24+
author=MockMember(id=author_id),
25+
channel=MockTextChannel(id=channel_id),
26+
content="hello",
27+
message=None,
28+
)
29+
30+
def test_identical_actions_in_window_are_suppressed(self):
31+
ctx = self._make_context()
32+
actions = self._make_actions(send_alert=True)
33+
34+
self.assertTrue(self.cog._should_run_actions(ctx, actions))
35+
self.assertFalse(self.cog._should_run_actions(ctx, actions))
36+
37+
def test_different_action_values_are_not_suppressed(self):
38+
ctx = self._make_context()
39+
first_actions = self._make_actions(send_alert=True)
40+
second_actions = self._make_actions(send_alert=False)
41+
42+
self.assertTrue(self.cog._should_run_actions(ctx, first_actions))
43+
self.assertTrue(self.cog._should_run_actions(ctx, second_actions))
44+
45+
def test_same_action_payload_for_different_authors_is_not_suppressed(self):
46+
first_ctx = self._make_context(author_id=1)
47+
second_ctx = self._make_context(author_id=2)
48+
actions = self._make_actions(send_alert=True)
49+
50+
self.assertTrue(self.cog._should_run_actions(first_ctx, actions))
51+
self.assertTrue(self.cog._should_run_actions(second_ctx, actions))

0 commit comments

Comments
 (0)