|
2 | 2 | import io |
3 | 3 | import json |
4 | 4 | import re |
| 5 | +import time |
5 | 6 | import unicodedata |
6 | 7 | from collections import defaultdict |
7 | 8 | from collections.abc import Iterable, Mapping |
|
72 | 73 | OFFENSIVE_MSG_DELETE_TIME = datetime.timedelta(days=7) |
73 | 74 | WEEKLY_REPORT_ISO_DAY = 3 # 1=Monday, 7=Sunday |
74 | 75 | MAX_IMAGE_HASH_SIZE = 5_000_000 |
| 76 | +ACTION_DEDUPE_WINDOW_SECONDS = 3 |
75 | 77 |
|
76 | 78 |
|
77 | 79 | def _clean_ban_mentions(mentions: set[str]) -> set[str]: |
@@ -113,6 +115,7 @@ def __init__(self, bot: Bot): |
113 | 115 | self.bot = bot |
114 | 116 | self.filter_lists: dict[str, FilterList] = {} |
115 | 117 | self._subscriptions = defaultdict[Event, list[FilterList]](list) |
| 118 | + self._recent_actions: dict[tuple, float] = {} |
116 | 119 | self.delete_scheduler = scheduling.Scheduler(self.__class__.__name__) |
117 | 120 | self.webhook: discord.Webhook | None = None |
118 | 121 |
|
@@ -267,8 +270,7 @@ async def on_message(self, msg: Message) -> None: |
267 | 270 |
|
268 | 271 | result_actions, list_messages, triggers = await self._resolve_action(ctx) |
269 | 272 | 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) |
272 | 274 | if ctx.send_alert: |
273 | 275 | await self._send_alert(ctx, list_messages) |
274 | 276 |
|
@@ -298,8 +300,7 @@ async def on_message_edit(self, before: discord.Message, after: discord.Message) |
298 | 300 | self.message_cache.update(after) |
299 | 301 | ctx = FilterContext.from_message(Event.MESSAGE_EDIT, after, before, self.message_cache) |
300 | 302 | 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) |
303 | 304 | if ctx.send_alert: |
304 | 305 | await self._send_alert(ctx, list_messages) |
305 | 306 | await self._maybe_schedule_msg_delete(ctx, result_actions) |
@@ -334,8 +335,7 @@ async def filter_snekbox_output( |
334 | 335 | ctx = FilterContext.from_message(Event.SNEKBOX, msg).replace(content=content, attachments=files) |
335 | 336 |
|
336 | 337 | 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) |
339 | 339 | if ctx.send_alert: |
340 | 340 | await self._send_alert(ctx, list_messages) |
341 | 341 |
|
@@ -1076,6 +1076,35 @@ async def _send_alert(self, ctx: FilterContext, triggered_filters: dict[FilterLi |
1076 | 1076 | username=name, content=ctx.alert_content, embeds=[embed, *ctx.alert_embeds][:10], view=AlertView(ctx) |
1077 | 1077 | ) |
1078 | 1078 |
|
| 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 | + |
1079 | 1108 | def _increment_stats(self, triggered_filters: dict[AtomicList, list[Filter]]) -> None: |
1080 | 1109 | """Increment the stats for every filter triggered.""" |
1081 | 1110 | for filters in triggered_filters.values(): |
@@ -1116,8 +1145,7 @@ async def _check_bad_name(self, ctx: FilterContext) -> FilterContext: |
1116 | 1145 | new_ctx = ctx.replace(content=" ".join(names_to_check)) |
1117 | 1146 | result_actions, list_messages, triggers = await self._resolve_action(new_ctx) |
1118 | 1147 | 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) |
1121 | 1149 | if new_ctx.send_alert: |
1122 | 1150 | await self._send_alert(new_ctx, list_messages) |
1123 | 1151 | self._increment_stats(triggers) |
|
0 commit comments