Skip to content

Commit 54a72d9

Browse files
committed
Bulk delete messages as soon as possible
1 parent 0c020ff commit 54a72d9

2 files changed

Lines changed: 58 additions & 35 deletions

File tree

bot/exts/moderation/clean.py

Lines changed: 54 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import asyncio
12
import contextlib
23
import itertools
34
import re
45
import time
56
from collections import defaultdict
6-
from collections.abc import Callable, Iterable
7+
from collections.abc import Callable, Collection
78
from datetime import datetime
89
from itertools import takewhile
910
from typing import Literal, TYPE_CHECKING
@@ -37,6 +38,8 @@
3738
# How many ongoing API requests a clean operation can have at the same time.
3839
CONCURRENT_REQUESTS_POOL = 10
3940

41+
BULK_DELETE_LIMIT = 100
42+
4043

4144
class CleanChannels(Converter):
4245
"""A converter to turn the string into a list of channels to clean, or the literal `*` for all public channels."""
@@ -239,48 +242,66 @@ def _get_messages_from_cache(
239242
channels: set[TextChannel],
240243
to_delete: Predicate,
241244
lower_limit: datetime
242-
) -> tuple[defaultdict[TextChannel, list], list[int]]:
245+
) -> defaultdict[TextChannel, list]:
243246
"""Helper function for getting messages from the cache."""
244247
message_mappings = defaultdict(list)
245-
message_ids = []
246248
for message in takewhile(lambda m: m.created_at > lower_limit, reversed(self.bot.cached_messages)):
247249
if not self.cleaning:
248250
# Cleaning was canceled
249-
return message_mappings, message_ids
251+
return message_mappings
250252

251253
if message.channel in channels and to_delete(message):
252254
message_mappings[message.channel].append(message)
253-
message_ids.append(message.id)
254255

255-
return message_mappings, message_ids
256+
return message_mappings
256257

257-
async def _get_messages_from_channels(
258+
async def _get_messages_from_channels_and_delete(
258259
self,
259-
channels: Iterable[TextChannel],
260+
executor: AsyncExecutor,
261+
*,
262+
channels: Collection[TextChannel],
260263
to_delete: Predicate,
261264
after: datetime,
262265
before: datetime | None = None
263-
) -> tuple[defaultdict[TextChannel, list], list]:
266+
) -> tuple[list[Message], dict[TextChannel, list[Message]]]:
264267
"""
265-
Collect the messages for deletion by iterating over the histories of the appropriate channels.
268+
Collect and delete the messages for deletion by iterating over the histories of the appropriate channels.
266269
267270
The clean cog enforces an upper limit on message age through `_validate_input`.
268271
"""
269-
message_mappings = defaultdict(list)
270-
message_ids = []
272+
deleted = []
273+
old_messages = {channel: [] for channel in channels}
271274

272275
for channel in channels:
276+
messages_to_delete = []
277+
message_ids = []
273278
async for message in channel.history(limit=CleanMessages.message_limit, before=before, after=after):
274279

275280
if not self.cleaning:
276-
# Cleaning was canceled, return empty containers.
277-
return defaultdict(list), []
281+
# Cleaning was canceled, return any messages already sent for deletion.
282+
return deleted, {}
278283

279284
if to_delete(message):
280-
message_mappings[message.channel].append(message)
285+
if self.is_older_than_14d(message):
286+
old_messages[channel].append(message)
287+
continue
288+
289+
messages_to_delete.append(message)
281290
message_ids.append(message.id)
282291

283-
return message_mappings, message_ids
292+
if len(message_ids) == BULK_DELETE_LIMIT:
293+
self.mod_log.ignore(Event.message_delete, *message_ids)
294+
executor.submit(channel.delete_messages(messages_to_delete))
295+
deleted.extend(messages_to_delete)
296+
messages_to_delete = []
297+
message_ids = []
298+
299+
if message_ids: # Remaining messages not deleted.
300+
self.mod_log.ignore(Event.message_delete, *message_ids)
301+
executor.submit(channel.delete_messages(messages_to_delete))
302+
deleted.extend(messages_to_delete)
303+
304+
return deleted, old_messages
284305

285306
@staticmethod
286307
def is_older_than_14d(message: Message) -> bool:
@@ -296,6 +317,9 @@ def is_older_than_14d(message: Message) -> bool:
296317

297318
async def _delete_messages_individually(self, channel_messages: dict[TextChannel, list[Message]]) -> list[Message]:
298319
"""Delete each message unless cleaning is cancelled. Return the deleted messages."""
320+
message_ids = [m.id for messages in channel_messages.values() for m in messages]
321+
self.mod_log.ignore(Event.message_delete, *message_ids)
322+
299323
deleted = []
300324
for messages in channel_messages.values():
301325
for message in messages:
@@ -308,7 +332,7 @@ async def _delete_messages_individually(self, channel_messages: dict[TextChannel
308332
return deleted
309333

310334
async def _delete_bulk(
311-
self, message_mappings: dict[TextChannel, list[Message]], executor:AsyncExecutor
335+
self, messages_per_channel: dict[TextChannel, list[Message]], executor: AsyncExecutor
312336
) -> tuple[list[Message], dict[TextChannel, list[Message]]]:
313337
"""
314338
Delete the detected messages.
@@ -320,11 +344,13 @@ async def _delete_bulk(
320344
If cleaning was cancelled in the middle, return messages already deleted.
321345
"""
322346
deleted = []
323-
old_messages = {}
324-
for channel, messages in message_mappings.items():
347+
old_messages = {channel: [] for channel in messages_per_channel}
348+
349+
for channel, messages in messages_per_channel.items():
325350
to_delete = []
326351

327352
for current_index, message in enumerate(messages):
353+
await asyncio.sleep(0) # Avoid hogging the event loop, and allow the clean to be cancelled.
328354
if not self.cleaning:
329355
# Means that the cleaning was canceled
330356
return deleted, {}
@@ -336,8 +362,8 @@ async def _delete_bulk(
336362

337363
to_delete.append(message)
338364

339-
if len(to_delete) == 100:
340-
# Only up to 100 messages can be deleted in a bulk
365+
if len(to_delete) == BULK_DELETE_LIMIT:
366+
self.mod_log.ignore(Event.message_delete, *(message.id for message in to_delete))
341367
executor.submit(channel.delete_messages(to_delete))
342368
deleted.extend(to_delete)
343369
to_delete = []
@@ -346,11 +372,10 @@ async def _delete_bulk(
346372
return deleted, {}
347373
if len(to_delete) > 0:
348374
# Deleting any leftover messages if there are any
375+
self.mod_log.ignore(Event.message_delete, *(message.id for message in to_delete))
349376
executor.submit(channel.delete_messages(to_delete))
350377
deleted.extend(to_delete)
351378

352-
await executor.gather(return_exceptions=True)
353-
354379
return deleted, old_messages
355380

356381
async def _modlog_cleaned_messages(
@@ -440,27 +465,23 @@ async def _clean_messages(
440465

441466
if self._use_cache(second_limit):
442467
log.trace(f"Messages for cleaning by {ctx.author.id} will be searched in the cache.")
443-
message_mappings, message_ids = self._get_messages_from_cache(
468+
messages_per_channel = self._get_messages_from_cache(
444469
channels=deletion_channels, to_delete=predicate, lower_limit=first_limit
445470
)
446-
self.mod_log.ignore(Event.message_delete, *message_ids)
447-
deleted_messages, old_messages = await self._delete_bulk(message_mappings, executor)
471+
deleted_messages, old_messages = await self._delete_bulk(messages_per_channel, executor)
448472
second_limit = self._earliest_cache_datetime()
449473

450474
if self._use_api(first_limit):
451475
log.trace(f"Messages for cleaning by {ctx.author.id} will be searched in channel histories.")
452-
message_mappings, message_ids = await self._get_messages_from_channels(
453-
channels=deletion_channels,
454-
to_delete=predicate,
455-
after=first_limit, # Remember first is the earlier datetime (the "older" time).
456-
before=second_limit
476+
api_deleted_messages, api_old_messages = await self._get_messages_from_channels_and_delete(
477+
executor, channels=deletion_channels, to_delete=predicate, after=first_limit, before=second_limit
457478
)
458-
self.mod_log.ignore(Event.message_delete, *message_ids)
459-
api_deleted_messages, api_old_messages = await self._delete_bulk(message_mappings, executor)
460479
deleted_messages.extend(api_deleted_messages)
461480
for channel, messages in api_old_messages.items():
462481
old_messages[channel].extend(messages)
463482

483+
await executor.gather(return_exceptions=True) # Ignore NotFound errors etc.
484+
464485
if not self.cleaning:
465486
# Means that the cleaning was canceled
466487
return None

bot/utils/async_utils.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ def submit[T](self, coro: Awaitable[T]) -> asyncio.Task[T]:
6060
"""Wraps the coroutine with _semaphore logic, schedules it on the event loop, and ensures cleanup."""
6161
task = asyncio.create_task(self.execute(coro))
6262
self._running_tasks.add(task)
63-
task.add_done_callback(self._running_tasks.discard)
6463
return task
6564

6665
async def execute[T](self, coro: Awaitable[T]) -> T:
@@ -71,7 +70,10 @@ async def execute[T](self, coro: Awaitable[T]) -> T:
7170
async def gather(self, return_exceptions: bool = False) -> list[Any]:
7271
"""Waits for all submitted coroutines to finish execution."""
7372
if self._running_tasks:
74-
return await asyncio.gather(*self._running_tasks, return_exceptions=return_exceptions)
73+
result = await asyncio.gather(*self._running_tasks, return_exceptions=return_exceptions)
74+
self._running_tasks.clear()
75+
return result
76+
7577
return []
7678

7779

0 commit comments

Comments
 (0)