Skip to content

Commit

Permalink
Clear command official release (#125)
Browse files Browse the repository at this point in the history
  • Loading branch information
AiroPi authored Dec 17, 2023
2 parents c4b6423 + 17e6b5a commit 0dd2c3f
Show file tree
Hide file tree
Showing 7 changed files with 71 additions and 97 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
data/
config.toml
draft/

# editors
.vscode/
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ two048 # a personal library
topggpy==2.0.0a0
lingua-language-detector
aiohttp
python-dateutil
77 changes: 42 additions & 35 deletions src/cogs/clear/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,28 @@
import asyncio
import logging
import time
from datetime import datetime
from typing import TYPE_CHECKING, AsyncGenerator, Awaitable, Callable, Self, cast

import discord
from discord import app_commands, ui
from discord.app_commands import Transform, locale_str as __
from discord.app_commands import Range, Transform, locale_str as __

from core import ExtendedCog, Menu, MessageDisplay, ResponseType, response_constructor
from core.checkers import MaxConcurrency
from core import ExtendedCog, Menu, MessageDisplay, ResponseType, checkers, response_constructor
from core.errors import BadArgument, MaxConcurrencyReached, NonSpecificError, UnexpectedError
from core.i18n import _
from core.transformers import DateTransformer
from core.utils import async_all

from .clear_transformers import (
AfterTransformer,
BeforeTransformer,
HasTransformer,
LengthTransformer,
PinnedTransformer,
RegexTransformer,
RoleTransformer,
UserTransformer,
)
from .filters import DateFilter, Filter, HasFilter, LengthFilter, PinnedFilter, RegexFilter, RoleFilter, UserFilter
from .filters import Filter, HasFilter, LengthFilter, PinnedFilter, RegexFilter, RoleFilter, UserFilter

if TYPE_CHECKING:
from discord import TextChannel, Thread, VoiceChannel
Expand All @@ -46,53 +45,52 @@ class Clear(ExtendedCog):
def __init__(self, bot: MyBot):
super().__init__(bot)

self.clear_max_concurrency = MaxConcurrency(1, key=channel_bucket, wait=False)
self.clear_max_concurrency = checkers.MaxConcurrency(1, key=channel_bucket, wait=False)

@checkers.app.bot_required_permissions(
manage_messages=True, read_message_history=True, read_messages=True, connect=True
)
@app_commands.command(
description=__("Delete multiple messages with some filters."),
extras={"beta": True},
description=__("Delete multiple messages with filters."),
)
@app_commands.default_permissions(manage_messages=True)
@app_commands.guild_only()
@app_commands.describe(
amount=__("The amount of messages to delete."),
user=__("Delete only messages from the specified user."),
role=__("Delete only messages whose user has the specified role."),
pattern=__(
"Delete only messages that match the specified search (can be regex)."
), # e.g. regex:hello will delete the message "hello world".
has=__(
"Delete only messages that contains the selected entry. #TODO"
), # e.g. attachement:image will delete messages that has an image attached.
length=__("Delete only messages where length match the specified entry. (e.g. '<=100', '5', '>10') #TODO"),
before=__("Delete only messages sent before the specified message or date. (yyyy-mm-dd) #TODO"),
after=__("Delete only messages sent after the specified message or date. (yyyy-mm-dd) #TODO"),
pinned=__(
'Include/exclude pinned messages in deletion, or deletes "only" pinned messages. (default to exclude)'
),
amount=__("{{}} messages (max 250)"),
user=__("messages from the user {{}}"),
role=__("messages whose user has the role {{}}"),
pattern=__("messages that match {{}} (regex, multiline, case sensitive, not anchored)"),
has=__("messages that has {{}}"), # e.g. attachement:image will delete messages that has an image attached.
max_length=__("messages longer or equal to {{}} (blank spaces included) (empty messages included)"),
min_length=__("messages shorter or equal to {{}} (blank spaces included)"),
before=__("messages sent before {{}} (yyyy-mm-dd or message ID)"),
after=__("messages sent after {{}} (yyyy-mm-dd or message ID)"),
pinned=__("only pinned message or exclude them from the deletion. (default to exclude)"),
)
@app_commands.rename(
amount=__("amount"),
user=__("user"),
role=__("role"),
pattern=__("search"),
has=__("has"),
length=__("length"),
max_length=__("max_length"),
min_length=__("min_length"),
before=__("before"),
after=__("after"),
pinned=__("pinned"),
)
async def clear(
self,
inter: discord.Interaction,
amount: int,
amount: Range[int, 1, 250],
user: Transform[UserFilter, UserTransformer] | None = None,
role: Transform[RoleFilter, RoleTransformer] | None = None,
pattern: Transform[RegexFilter, RegexTransformer] | None = None,
has: Transform[HasFilter, HasTransformer] | None = None,
length: Transform[LengthFilter, LengthTransformer] | None = None,
before: Transform[DateFilter, BeforeTransformer] | None = None,
after: Transform[DateFilter, AfterTransformer] | None = None,
max_length: Transform[LengthFilter, LengthTransformer("max")] | None = None,
min_length: Transform[LengthFilter, LengthTransformer("min")] | None = None,
before: Transform[datetime, DateTransformer] | None = None,
after: Transform[datetime, DateTransformer] | None = None,
pinned: Transform[PinnedFilter, PinnedTransformer] = PinnedFilter.default(),
):
await self.clear_max_concurrency.acquire(inter)
Expand All @@ -103,13 +101,18 @@ async def clear(
if not 0 < amount < 251:
raise BadArgument(_("You must supply a number between 1 and 250. (0 < {amount} < 251)", amount=amount))

# Because of @guild_only, we can assume that the channel is a guild channel
# Also, the channel should not be able to be a ForumChannel or StageChannel or CategoryChannel

available_filters: list[Filter | None] = [pinned, user, role, pattern, has, length, before, after]
available_filters: list[Filter | None] = [
pinned,
user,
role,
pattern,
has,
max_length,
min_length,
]
active_filters: list[Filter] = [f for f in available_filters if f is not None]

job = ClearWorker(self.bot, inter, amount, active_filters)
job = ClearWorker(self.bot, inter, amount, active_filters, before, after)
await job.start()
await self.clear_max_concurrency.release(inter)

Expand All @@ -126,12 +129,16 @@ def __init__(
inter: discord.Interaction,
amount: int,
filters: list[Filter],
before: datetime | None,
after: datetime | None,
):
self.deleted_messages: int = 0
self.analyzed_messages: int = 0
self.deletion_planned: int = 0
self.deletion_goal: int = amount
self.filters = filters
self.before = before
self.after = after
self.inter = inter
self.bot = bot
self.channel = cast("AllowPurgeChannel", inter.channel)
Expand Down Expand Up @@ -241,7 +248,7 @@ async def _bulk_delete_strategy(messages: list[discord.Message]) -> None:
async def filtered_history(self) -> AsyncGenerator[discord.Message, None]:
limit = self.deletion_goal if not any(self.filters) else None

async for msg in self.channel.history(limit=limit):
async for msg in self.channel.history(limit=limit, before=self.before, after=self.after):
# if not all the filters tests are compliant, continue
self.analyzed_messages += 1
if not await async_all(await filter.test(msg) for filter in self.filters):
Expand Down
65 changes: 20 additions & 45 deletions src/cogs/clear/clear_transformers.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
from __future__ import annotations

import re
from operator import eq, ge, gt, le, lt
from typing import Callable
from operator import ge, le
from typing import Literal

from discord import AppCommandOptionType, Interaction
from discord.app_commands import Choice, Transformer, locale_str as __

from core.errors import NonSpecificError
from core.transformers import DateTransformer, SimpleTransformer
from core.transformers import SimpleTransformer

from .enums import Pinned
from .filters import DateFilter, Has, HasFilter, LengthFilter, PinnedFilter, RegexFilter, RoleFilter, UserFilter
from .filters import Has, HasFilter, LengthFilter, PinnedFilter, RegexFilter, RoleFilter, UserFilter

RegexTransformer = SimpleTransformer(RegexFilter.from_string)
UserTransformer = SimpleTransformer(UserFilter.from_user, AppCommandOptionType.user)
Expand Down Expand Up @@ -61,52 +60,28 @@ async def transform(self, inter: Interaction, value: int) -> HasFilter:


class LengthTransformer(Transformer):
identifiers: dict[str, Callable[[int, int], bool]] = {
"<": lt,
"<=": le,
">": gt,
">=": ge,
"=": eq,
"": eq,
}
regex = re.compile(r"^(<|>|<=|>=|=|)(\d+)$")
def __init__(self, mode: Literal["min", "max"]):
self.mode = mode

@property
def type(self) -> AppCommandOptionType:
return AppCommandOptionType.string

async def transform(self, inter: Interaction, value: str) -> LengthFilter:
del inter # unused

result = self.regex.match(value)

if not result:
raise NonSpecificError("Invalid length filter")

length = int(result.group(2))

if not 0 <= length <= 4000:
raise NonSpecificError("A length filter must be between 0 and 4000 characters")

test = self.identifiers[result.group(1)]
return LengthFilter(test, length)

return AppCommandOptionType.integer

class BeforeTransformer(Transformer):
@property
def type(self) -> AppCommandOptionType:
return AppCommandOptionType.string
def min_value(self) -> int:
return 1

async def transform(self, inter: Interaction, value: str) -> DateFilter:
inner_transformer = DateTransformer()
return DateFilter(lt, await inner_transformer.transform(inter, value))
@property
def max_value(self) -> int:
return 4000

async def transform(self, inter: Interaction, value: int) -> LengthFilter:
del inter # unused

class AfterTransformer(Transformer):
@property
def type(self) -> AppCommandOptionType:
return AppCommandOptionType.string
if not 0 <= value <= 4000:
raise NonSpecificError("A length filter must be between 0 and 4000 characters")

async def transform(self, inter: Interaction, value: str) -> DateFilter:
inner_transformer = DateTransformer()
return DateFilter(gt, await inner_transformer.transform(inter, value))
if self.mode == "min":
return LengthFilter(ge, value)
else:
return LengthFilter(le, value)
10 changes: 0 additions & 10 deletions src/cogs/clear/filters.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import datetime
import re
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Callable, cast
Expand Down Expand Up @@ -147,12 +146,3 @@ def __init__(self, length_test: Callable[[int, int], bool], length: int):

async def test(self, message: discord.Message) -> bool:
return self.length_test(len(message.content), self.length)


class DateFilter(Filter):
def __init__(self, date_test: Callable[[datetime.datetime, datetime.datetime], bool], date: datetime.datetime):
self.date_test = date_test
self.date = date

async def test(self, message: discord.Message) -> bool:
return self.date_test(message.created_at, self.date)
1 change: 1 addition & 0 deletions src/core/checkers/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from . import app as app
from .max_concurrency import MaxConcurrency as MaxConcurrency
13 changes: 6 additions & 7 deletions src/core/error_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from typing import TYPE_CHECKING, Literal, TypeVar

from discord import ButtonStyle, Interaction, ui
from discord.app_commands.errors import AppCommandError, CheckFailure, CommandNotFound, NoPrivateMessage
from discord.app_commands.errors import AppCommandError, CommandNotFound, NoPrivateMessage
from discord.ext import commands

from . import ResponseType, response_constructor
Expand All @@ -14,7 +14,6 @@
BotMissingPermissions,
BotUserNotPresent,
MaxConcurrencyReached,
MiscCheckFailure,
MiscNoPrivateMessage,
NonSpecificError,
)
Expand Down Expand Up @@ -63,21 +62,21 @@ async def handle(self, ctx: Interaction | MiscCommandContext[MyBot], error: Exce
ctx,
_("This command is already executed the max amount of times. (Max: {error.rate})", error=error),
)
case CheckFailure() | MiscCheckFailure():
return await self.send_error(ctx, _("This command needs some conditions you don't meet."))
case BadArgument(): # Interactions only
# TODO(airo.pi_): improve this ?
return await self.send_error(ctx, _("You provided a bad argument."))
case BotMissingPermissions():
return await self.send_error(
ctx, _("The bot is missing some permissions.\n`{}`", "`, `".join(error.missing_perms))
)
case BadArgument(): # Interactions only
# TODO(airo.pi_): improve this ?
return await self.send_error(ctx, _("You provided a bad argument."))
case BotUserNotPresent():
return await self.send_error(
ctx, _("It looks like the bot has been added incorrectly. Please ask an admin to re-add the bot.")
)
case MiscNoPrivateMessage() | NoPrivateMessage():
return await self.send_error(ctx, _("This command cannot be used in DMs."))
# case CheckFailure() | MiscCheckFailure():
# return await self.send_error(ctx, _("This command needs some conditions you don't meet."))
case _:
await self.send_error(
ctx, _("An unhandled error happened.\nPlease ask on the support server!", error=error)
Expand Down

0 comments on commit 0dd2c3f

Please sign in to comment.