aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Joe Banks <[email protected]>2021-12-25 23:55:10 +0000
committerGravatar GitHub <[email protected]>2021-12-25 23:55:10 +0000
commit7aaee4915d8d22a0bc6f94bc7679e4c4d034c854 (patch)
tree4941f068fad3772fc327c0d68fa5fefac4db2de5
parentRemove debug `maxDiff` assignment. (diff)
parentMerge pull request #2025 from onerandomusername/fix-issue-2024 (diff)
Merge branch 'main' into add-dm_sent-field
-rw-r--r--.github/CODEOWNERS20
-rw-r--r--bot/__init__.py5
-rw-r--r--bot/constants.py16
-rw-r--r--bot/converters.py44
-rw-r--r--bot/exts/backend/error_handler.py26
-rw-r--r--bot/exts/filters/filtering.py33
-rw-r--r--bot/exts/help_channels/_cog.py36
-rw-r--r--bot/exts/info/doc/_cog.py2
-rw-r--r--bot/exts/info/help.py147
-rw-r--r--bot/exts/info/information.py51
-rw-r--r--bot/exts/info/pep.py7
-rw-r--r--bot/exts/info/site.py142
-rw-r--r--bot/exts/info/source.py13
-rw-r--r--bot/exts/info/subscribe.py201
-rw-r--r--bot/exts/info/tags.py484
-rw-r--r--bot/exts/moderation/clean.py210
-rw-r--r--bot/exts/moderation/incidents.py263
-rw-r--r--bot/exts/moderation/infraction/_utils.py4
-rw-r--r--bot/exts/moderation/modlog.py12
-rw-r--r--bot/exts/moderation/modpings.py119
-rw-r--r--bot/exts/moderation/slowmode.py2
-rw-r--r--bot/exts/moderation/verification.py71
-rw-r--r--bot/exts/moderation/voice_gate.py6
-rw-r--r--bot/exts/recruitment/talentpool/_cog.py14
-rw-r--r--bot/exts/recruitment/talentpool/_review.py55
-rw-r--r--bot/exts/utils/reminders.py18
-rw-r--r--bot/exts/utils/utils.py2
-rw-r--r--bot/log.py15
-rw-r--r--bot/monkey_patches.py23
-rw-r--r--bot/resources/tags/faq.md6
-rw-r--r--bot/resources/tags/off-topic-names.md10
-rw-r--r--bot/resources/tags/off-topic.md10
-rw-r--r--bot/resources/tags/ot.md3
-rw-r--r--bot/resources/tags/resources.md6
-rw-r--r--bot/resources/tags/site.md6
-rw-r--r--bot/resources/tags/tools.md6
-rw-r--r--bot/utils/members.py23
-rw-r--r--bot/utils/regex.py1
-rw-r--r--config-default.yml13
-rw-r--r--docker-compose.yml1
-rw-r--r--poetry.lock113
-rw-r--r--pyproject.toml2
-rw-r--r--tests/bot/exts/backend/test_error_handler.py62
-rw-r--r--tests/bot/exts/moderation/test_incidents.py94
-rw-r--r--tests/bot/test_converters.py17
45 files changed, 1575 insertions, 839 deletions
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 6dfe7e859..ea69f7677 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -4,10 +4,10 @@
**/bot/exts/moderation/*silence.py @MarkKoz
bot/exts/info/codeblock/** @MarkKoz
bot/exts/utils/extensions.py @MarkKoz
-bot/exts/utils/snekbox.py @MarkKoz @Akarys42 @jb3
-bot/exts/help_channels/** @MarkKoz @Akarys42
-bot/exts/moderation/** @Akarys42 @mbaruh @Den4200 @ks129 @jb3
-bot/exts/info/** @Akarys42 @Den4200 @jb3
+bot/exts/utils/snekbox.py @MarkKoz @jb3
+bot/exts/help_channels/** @MarkKoz
+bot/exts/moderation/** @mbaruh @Den4200 @ks129 @jb3
+bot/exts/info/** @Den4200 @jb3
bot/exts/info/information.py @mbaruh @jb3
bot/exts/filters/** @mbaruh @jb3
bot/exts/fun/** @ks129
@@ -21,22 +21,16 @@ bot/rules/** @mbaruh
bot/utils/extensions.py @MarkKoz
bot/utils/function.py @MarkKoz
bot/utils/lock.py @MarkKoz
-bot/utils/regex.py @Akarys42
bot/utils/scheduling.py @MarkKoz
# Tests
tests/_autospec.py @MarkKoz
tests/bot/exts/test_cogs.py @MarkKoz
-tests/** @Akarys42
# CI & Docker
-.github/workflows/** @MarkKoz @Akarys42 @SebastiaanZ @Den4200 @jb3
-Dockerfile @MarkKoz @Akarys42 @Den4200 @jb3
-docker-compose.yml @MarkKoz @Akarys42 @Den4200 @jb3
-
-# Tools
-poetry.lock @Akarys42
-pyproject.toml @Akarys42
+.github/workflows/** @MarkKoz @SebastiaanZ @Den4200 @jb3
+Dockerfile @MarkKoz @Den4200 @jb3
+docker-compose.yml @MarkKoz @Den4200 @jb3
# Statistics
bot/async_stats.py @jb3
diff --git a/bot/__init__.py b/bot/__init__.py
index a1c4466f1..17d99105a 100644
--- a/bot/__init__.py
+++ b/bot/__init__.py
@@ -18,6 +18,11 @@ if os.name == "nt":
monkey_patches.patch_typing()
+# This patches any convertors that use PartialMessage, but not the PartialMessageConverter itself
+# as library objects are made by this mapping.
+# https://github.com/Rapptz/discord.py/blob/1a4e73d59932cdbe7bf2c281f25e32529fc7ae1f/discord/ext/commands/converter.py#L984-L1004
+commands.converter.PartialMessageConverter = monkey_patches.FixedPartialMessageConverter
+
# Monkey-patch discord.py decorators to use the Command subclass which supports root aliases.
# Must be patched before any cogs are added.
commands.command = partial(commands.command, cls=monkey_patches.Command)
diff --git a/bot/constants.py b/bot/constants.py
index e3846fb3d..1b713a7e3 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -432,7 +432,7 @@ class Channels(metaclass=YAMLGetter):
black_formatter: int
bot_commands: int
- discord_py: int
+ discord_bots: int
esoteric: int
voice_gate: int
code_jam_planning: int
@@ -476,6 +476,7 @@ class Webhooks(metaclass=YAMLGetter):
big_brother: int
dev_log: int
duck_pond: int
+ incidents: int
incidents_archive: int
@@ -483,7 +484,12 @@ class Roles(metaclass=YAMLGetter):
section = "guild"
subsection = "roles"
+ # Self-assignable roles, see the Subscribe cog
+ advent_of_code: int
announcements: int
+ lovefest: int
+ pyweek_announcements: int
+
contributors: int
help_cooldown: int
muted: int
@@ -682,8 +688,16 @@ class VideoPermission(metaclass=YAMLGetter):
default_permission_duration: int
+class ThreadArchiveTimes(Enum):
+ HOUR = 60
+ DAY = 1440
+ THREE_DAY = 4320
+ WEEK = 10080
+
+
# Debug mode
DEBUG_MODE: bool = _CONFIG_YAML["debug"] == "true"
+FILE_LOGS: bool = _CONFIG_YAML["file_logs"].lower() == "true"
# Paths
BOT_DIR = os.path.dirname(__file__)
diff --git a/bot/converters.py b/bot/converters.py
index 0984fa0a3..559e759e1 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -18,6 +18,7 @@ from bot.api import ResponseCodeError
from bot.constants import URLs
from bot.errors import InvalidInfraction
from bot.exts.info.doc import _inventory_parser
+from bot.exts.info.tags import TagIdentifier
from bot.log import get_logger
from bot.utils.extensions import EXTENSIONS, unqualify
from bot.utils.regex import INVITE_RE
@@ -286,41 +287,6 @@ class Snowflake(IDConverter):
return snowflake
-class TagNameConverter(Converter):
- """
- Ensure that a proposed tag name is valid.
-
- Valid tag names meet the following conditions:
- * All ASCII characters
- * Has at least one non-whitespace character
- * Not solely numeric
- * Shorter than 127 characters
- """
-
- @staticmethod
- async def convert(ctx: Context, tag_name: str) -> str:
- """Lowercase & strip whitespace from proposed tag_name & ensure it's valid."""
- tag_name = tag_name.lower().strip()
-
- # The tag name has at least one invalid character.
- if ascii(tag_name)[1:-1] != tag_name:
- raise BadArgument("Don't be ridiculous, you can't use that character!")
-
- # The tag name is either empty, or consists of nothing but whitespace.
- elif not tag_name:
- raise BadArgument("Tag names should not be empty, or filled with whitespace.")
-
- # The tag name is longer than 127 characters.
- elif len(tag_name) > 127:
- raise BadArgument("Are you insane? That's way too long!")
-
- # The tag name is ascii but does not contain any letters.
- elif not any(character.isalpha() for character in tag_name):
- raise BadArgument("Tag names must contain at least one letter.")
-
- return tag_name
-
-
class SourceConverter(Converter):
"""Convert an argument into a help command, tag, command, or cog."""
@@ -343,9 +309,10 @@ class SourceConverter(Converter):
if not tags_cog:
show_tag = False
- elif argument.lower() in tags_cog._cache:
- return argument.lower()
-
+ else:
+ identifier = TagIdentifier.from_string(argument.lower())
+ if identifier in tags_cog.tags:
+ return identifier
escaped_arg = escape_markdown(argument)
raise BadArgument(
@@ -615,7 +582,6 @@ if t.TYPE_CHECKING:
ValidURL = str # noqa: F811
Inventory = t.Tuple[str, _inventory_parser.InventoryDict] # noqa: F811
Snowflake = int # noqa: F811
- TagNameConverter = str # noqa: F811
SourceConverter = SourceType # noqa: F811
DurationDelta = relativedelta # noqa: F811
Duration = datetime # noqa: F811
diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py
index 6ab6634a6..c79c7b2a7 100644
--- a/bot/exts/backend/error_handler.py
+++ b/bot/exts/backend/error_handler.py
@@ -1,5 +1,4 @@
import difflib
-import typing as t
from discord import Embed
from discord.ext.commands import ChannelNotFound, Cog, Context, TextChannelConverter, VoiceChannelConverter, errors
@@ -8,7 +7,6 @@ from sentry_sdk import push_scope
from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import Colours, Icons, MODERATION_ROLES
-from bot.converters import TagNameConverter
from bot.errors import InvalidInfractedUserError, LockedResourceError
from bot.log import get_logger
from bot.utils.checks import ContextCheckFailure
@@ -97,13 +95,14 @@ class ErrorHandler(Cog):
# MaxConcurrencyReached, ExtensionError
await self.handle_unexpected_error(ctx, e)
- @staticmethod
- def get_help_command(ctx: Context) -> t.Coroutine:
+ async def send_command_help(self, ctx: Context) -> None:
"""Return a prepared `help` command invocation coroutine."""
if ctx.command:
- return ctx.send_help(ctx.command)
+ self.bot.help_command.context = ctx
+ await ctx.send_help(ctx.command)
+ return
- return ctx.send_help()
+ await ctx.send_help()
async def try_silence(self, ctx: Context) -> bool:
"""
@@ -174,16 +173,8 @@ class ErrorHandler(Cog):
await self.on_command_error(ctx, tag_error)
return
- try:
- tag_name = await TagNameConverter.convert(ctx, ctx.invoked_with)
- except errors.BadArgument:
- log.debug(
- f"{ctx.author} tried to use an invalid command "
- f"and the fallback tag failed validation in TagNameConverter."
- )
- else:
- if await ctx.invoke(tags_get_command, tag_name=tag_name):
- return
+ if await ctx.invoke(tags_get_command, argument_string=ctx.message.content):
+ return
if not any(role.id in MODERATION_ROLES for role in ctx.author.roles):
await self.send_command_suggestion(ctx, ctx.invoked_with)
@@ -245,7 +236,6 @@ class ErrorHandler(Cog):
elif isinstance(e, errors.ArgumentParsingError):
embed = self._get_error_embed("Argument parsing error", str(e))
await ctx.send(embed=embed)
- self.get_help_command(ctx).close()
self.bot.stats.incr("errors.argument_parsing_error")
return
else:
@@ -256,7 +246,7 @@ class ErrorHandler(Cog):
self.bot.stats.incr("errors.other_user_input_error")
await ctx.send(embed=embed)
- await self.get_help_command(ctx)
+ await self.send_command_help(ctx)
@staticmethod
async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None:
diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py
index 79b7abe9f..8accc61f8 100644
--- a/bot/exts/filters/filtering.py
+++ b/bot/exts/filters/filtering.py
@@ -1,5 +1,6 @@
import asyncio
import re
+import unicodedata
from datetime import timedelta
from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple, Union
@@ -205,15 +206,21 @@ class Filtering(Cog):
delta = relativedelta(after.edited_at, before.edited_at).microseconds
await self._filter_message(after, delta)
- def get_name_matches(self, name: str) -> List[re.Match]:
- """Check bad words from passed string (name). Return list of matches."""
- name = self.clean_input(name)
- matches = []
+ def get_name_match(self, name: str) -> Optional[re.Match]:
+ """Check bad words from passed string (name). Return the first match found."""
+ normalised_name = unicodedata.normalize("NFKC", name)
+ cleaned_normalised_name = "".join([c for c in normalised_name if not unicodedata.combining(c)])
+
+ # Run filters against normalised, cleaned normalised and the original name,
+ # in case we have filters for one but not the other.
+ names_to_check = (name, normalised_name, cleaned_normalised_name)
+
watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False)
for pattern in watchlist_patterns:
- if match := re.search(pattern, name, flags=re.IGNORECASE):
- matches.append(match)
- return matches
+ for name in names_to_check:
+ if match := re.search(pattern, name, flags=re.IGNORECASE):
+ return match
+ return None
async def check_send_alert(self, member: Member) -> bool:
"""When there is less than 3 days after last alert, return `False`, otherwise `True`."""
@@ -229,10 +236,14 @@ class Filtering(Cog):
"""Send a mod alert every 3 days if a username still matches a watchlist pattern."""
# Use lock to avoid race conditions
async with self.name_lock:
- # Check whether the users display name contains any words in our blacklist
- matches = self.get_name_matches(member.display_name)
+ # Check if we recently alerted about this user first,
+ # to avoid running all the filter tokens against their name again.
+ if not await self.check_send_alert(member):
+ return
- if not matches or not await self.check_send_alert(member):
+ # Check whether the users display name contains any words in our blacklist
+ match = self.get_name_match(member.display_name)
+ if not match:
return
log.info(f"Sending bad nickname alert for '{member.display_name}' ({member.id}).")
@@ -240,7 +251,7 @@ class Filtering(Cog):
log_string = (
f"**User:** {format_user(member)}\n"
f"**Display Name:** {escape_markdown(member.display_name)}\n"
- f"**Bad Matches:** {', '.join(match.group() for match in matches)}"
+ f"**Bad Match:** {match.group()}"
)
await self.mod_log.send_log_message(
diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py
index 0905cb23d..60209ba6e 100644
--- a/bot/exts/help_channels/_cog.py
+++ b/bot/exts/help_channels/_cog.py
@@ -66,6 +66,9 @@ class HelpChannels(commands.Cog):
self.bot = bot
self.scheduler = scheduling.Scheduler(self.__class__.__name__)
+ self.guild: discord.Guild = None
+ self.cooldown_role: discord.Role = None
+
# Categories
self.available_category: discord.CategoryChannel = None
self.in_use_category: discord.CategoryChannel = None
@@ -95,24 +98,6 @@ class HelpChannels(commands.Cog):
self.scheduler.cancel_all()
- async def _handle_role_change(self, member: discord.Member, coro: t.Callable[..., t.Coroutine]) -> None:
- """
- Change `member`'s cooldown role via awaiting `coro` and handle errors.
-
- `coro` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`.
- """
- try:
- await coro(self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.help_cooldown))
- except discord.NotFound:
- log.debug(f"Failed to change role for {member} ({member.id}): member not found")
- except discord.Forbidden:
- log.debug(
- f"Forbidden to change role for {member} ({member.id}); "
- f"possibly due to role hierarchy"
- )
- except discord.HTTPException as e:
- log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}")
-
@lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id"))
@lock.lock_arg(NAMESPACE, "message", attrgetter("author.id"))
@lock.lock_arg(f"{NAMESPACE}.unclaim", "message", attrgetter("author.id"), wait=True)
@@ -128,11 +113,9 @@ class HelpChannels(commands.Cog):
# Handle odd edge case of `message.author` not being a `discord.Member` (see bot#1839)
if not isinstance(message.author, discord.Member):
- log.warning(
- f"{message.author} ({message.author.id}) isn't a member. Not giving cooldown role or sending DM."
- )
+ log.debug(f"{message.author} ({message.author.id}) isn't a member. Not giving cooldown role or sending DM.")
else:
- await self._handle_role_change(message.author, message.author.add_roles)
+ await members.handle_role_change(message.author, message.author.add_roles, self.cooldown_role)
try:
await _message.dm_on_open(message)
@@ -304,6 +287,9 @@ class HelpChannels(commands.Cog):
await self.bot.wait_until_guild_available()
log.trace("Initialising the cog.")
+ self.guild = self.bot.get_guild(constants.Guild.id)
+ self.cooldown_role = self.guild.get_role(constants.Roles.help_cooldown)
+
await self.init_categories()
self.channel_queue = self.create_channel_queue()
@@ -447,11 +433,11 @@ class HelpChannels(commands.Cog):
await _caches.claimants.delete(channel.id)
await _caches.session_participants.delete(channel.id)
- claimant = await members.get_or_fetch_member(self.bot.get_guild(constants.Guild.id), claimant_id)
+ claimant = await members.get_or_fetch_member(self.guild, claimant_id)
if claimant is None:
log.info(f"{claimant_id} left the guild during their help session; the cooldown role won't be removed")
else:
- await self._handle_role_change(claimant, claimant.remove_roles)
+ await members.handle_role_change(claimant, claimant.remove_roles, self.cooldown_role)
await _message.unpin(channel)
await _stats.report_complete_session(channel.id, closed_on)
@@ -592,7 +578,7 @@ class HelpChannels(commands.Cog):
embed = discord.Embed(
title="Currently Helping",
description=f"You're currently helping in {message.channel.mention}",
- color=constants.Colours.soft_green,
+ color=constants.Colours.bright_green,
timestamp=message.created_at
)
embed.add_field(name="Conversation", value=f"[Jump to message]({message.jump_url})")
diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py
index ebf5f5932..4dc5276d9 100644
--- a/bot/exts/info/doc/_cog.py
+++ b/bot/exts/info/doc/_cog.py
@@ -464,7 +464,7 @@ class DocCog(commands.Cog):
) -> None:
"""Clear the persistent redis cache for `package`."""
if await doc_cache.delete(package_name):
- await self.item_fetcher.stale_inventory_notifier.symbol_counter.delete()
+ await self.item_fetcher.stale_inventory_notifier.symbol_counter.delete(package_name)
await ctx.send(f"Successfully cleared the cache for `{package_name}`.")
else:
await ctx.send("No keys matching the package found.")
diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py
index 743dfdd3f..06799fb71 100644
--- a/bot/exts/info/help.py
+++ b/bot/exts/info/help.py
@@ -1,10 +1,12 @@
+from __future__ import annotations
+
import itertools
import re
from collections import namedtuple
from contextlib import suppress
-from typing import List, Union
+from typing import List, Optional, Union
-from discord import Colour, Embed
+from discord import ButtonStyle, Colour, Embed, Emoji, Interaction, PartialEmoji, ui
from discord.ext.commands import Bot, Cog, Command, CommandError, Context, DisabledCommand, Group, HelpCommand
from rapidfuzz import fuzz, process
from rapidfuzz.utils import default_process
@@ -26,6 +28,119 @@ NOT_ALLOWED_TO_RUN_MESSAGE = "***You cannot run this command.***\n\n"
Category = namedtuple("Category", ["name", "description", "cogs"])
+class SubcommandButton(ui.Button):
+ """
+ A button shown in a group's help embed.
+
+ The button represents a subcommand, and pressing it will edit the help embed to that of the subcommand.
+ """
+
+ def __init__(
+ self,
+ help_command: CustomHelpCommand,
+ command: Command,
+ *,
+ style: ButtonStyle = ButtonStyle.primary,
+ label: Optional[str] = None,
+ disabled: bool = False,
+ custom_id: Optional[str] = None,
+ url: Optional[str] = None,
+ emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
+ row: Optional[int] = None
+ ):
+ super().__init__(
+ style=style, label=label, disabled=disabled, custom_id=custom_id, url=url, emoji=emoji, row=row
+ )
+
+ self.help_command = help_command
+ self.command = command
+
+ async def callback(self, interaction: Interaction) -> None:
+ """Edits the help embed to that of the subcommand."""
+ message = interaction.message
+ if not message:
+ return
+
+ subcommand = self.command
+ if isinstance(subcommand, Group):
+ embed, subcommand_view = await self.help_command.format_group_help(subcommand)
+ else:
+ embed, subcommand_view = await self.help_command.command_formatting(subcommand)
+ await message.edit(embed=embed, view=subcommand_view)
+
+
+class GroupButton(ui.Button):
+ """
+ A button shown in a subcommand's help embed.
+
+ The button represents the parent command, and pressing it will edit the help embed to that of the parent.
+ """
+
+ def __init__(
+ self,
+ help_command: CustomHelpCommand,
+ command: Command,
+ *,
+ style: ButtonStyle = ButtonStyle.secondary,
+ label: Optional[str] = None,
+ disabled: bool = False,
+ custom_id: Optional[str] = None,
+ url: Optional[str] = None,
+ emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
+ row: Optional[int] = None
+ ):
+ super().__init__(
+ style=style, label=label, disabled=disabled, custom_id=custom_id, url=url, emoji=emoji, row=row
+ )
+
+ self.help_command = help_command
+ self.command = command
+
+ async def callback(self, interaction: Interaction) -> None:
+ """Edits the help embed to that of the parent."""
+ message = interaction.message
+ if not message:
+ return
+
+ embed, group_view = await self.help_command.format_group_help(self.command.parent)
+ await message.edit(embed=embed, view=group_view)
+
+
+class CommandView(ui.View):
+ """
+ The view added to any command's help embed.
+
+ If the command has a parent, a button is added to the view to show that parent's help embed.
+ """
+
+ def __init__(self, help_command: CustomHelpCommand, command: Command):
+ super().__init__()
+
+ if command.parent:
+ self.children.append(GroupButton(help_command, command, emoji="↩️"))
+
+
+class GroupView(CommandView):
+ """
+ The view added to a group's help embed.
+
+ The view generates a SubcommandButton for every subcommand the group has.
+ """
+
+ MAX_BUTTONS_IN_ROW = 5
+ MAX_ROWS = 5
+
+ def __init__(self, help_command: CustomHelpCommand, group: Group, subcommands: list[Command]):
+ super().__init__(help_command, group)
+ # Don't add buttons if only a portion of the subcommands can be shown.
+ if len(subcommands) + len(self.children) > self.MAX_ROWS * self.MAX_BUTTONS_IN_ROW:
+ log.trace(f"Attempted to add navigation buttons for `{group.qualified_name}`, but there was no space.")
+ return
+
+ for subcommand in subcommands:
+ self.add_item(SubcommandButton(help_command, subcommand, label=subcommand.name))
+
+
class HelpQueryNotFound(ValueError):
"""
Raised when a HelpSession Query doesn't match a command or cog.
@@ -148,7 +263,7 @@ class CustomHelpCommand(HelpCommand):
await self.context.send(embed=embed)
- async def command_formatting(self, command: Command) -> Embed:
+ async def command_formatting(self, command: Command) -> tuple[Embed, Optional[CommandView]]:
"""
Takes a command and turns it into an embed.
@@ -186,12 +301,14 @@ class CustomHelpCommand(HelpCommand):
command_details += f"*{formatted_doc or 'No details provided.'}*\n"
embed.description = command_details
- return embed
+ # If the help is invoked in the context of an error, don't show subcommand navigation.
+ view = CommandView(self, command) if not self.context.command_failed else None
+ return embed, view
async def send_command_help(self, command: Command) -> None:
"""Send help for a single command."""
- embed = await self.command_formatting(command)
- message = await self.context.send(embed=embed)
+ embed, view = await self.command_formatting(command)
+ message = await self.context.send(embed=embed, view=view)
await wait_for_deletion(message, (self.context.author.id,))
@staticmethod
@@ -212,25 +329,31 @@ class CustomHelpCommand(HelpCommand):
else:
return "".join(details)
- async def send_group_help(self, group: Group) -> None:
- """Sends help for a group command."""
+ async def format_group_help(self, group: Group) -> tuple[Embed, Optional[CommandView]]:
+ """Formats help for a group command."""
subcommands = group.commands
if len(subcommands) == 0:
# no subcommands, just treat it like a regular command
- await self.send_command_help(group)
- return
+ return await self.command_formatting(group)
# remove commands that the user can't run and are hidden, and sort by name
commands_ = await self.filter_commands(subcommands, sort=True)
- embed = await self.command_formatting(group)
+ embed, _ = await self.command_formatting(group)
command_details = self.get_commands_brief_details(commands_)
if command_details:
embed.description += f"\n**Subcommands:**\n{command_details}"
- message = await self.context.send(embed=embed)
+ # If the help is invoked in the context of an error, don't show subcommand navigation.
+ view = GroupView(self, group, commands_) if not self.context.command_failed else None
+ return embed, view
+
+ async def send_group_help(self, group: Group) -> None:
+ """Sends help for a group command."""
+ embed, view = await self.format_group_help(group)
+ message = await self.context.send(embed=embed, view=view)
await wait_for_deletion(message, (self.context.author.id,))
async def send_cog_help(self, cog: Cog) -> None:
diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py
index 7f4811a43..73357211e 100644
--- a/bot/exts/info/information.py
+++ b/bot/exts/info/information.py
@@ -2,11 +2,12 @@ import colorsys
import pprint
import textwrap
from collections import defaultdict
+from textwrap import shorten
from typing import Any, DefaultDict, Mapping, Optional, Tuple, Union
import rapidfuzz
from discord import AllowedMentions, Colour, Embed, Guild, Message, Role
-from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role
+from discord.ext.commands import BucketType, Cog, Context, Greedy, Paginator, command, group, has_any_role
from discord.utils import escape_markdown
from bot import constants
@@ -173,12 +174,14 @@ class Information(Cog):
embed = Embed(colour=Colour.og_blurple(), title="Server Information")
created = discord_timestamp(ctx.guild.created_at, TimestampFormats.RELATIVE)
- region = ctx.guild.region
num_roles = len(ctx.guild.roles) - 1 # Exclude @everyone
# Server Features are only useful in certain channels
if ctx.channel.id in (
- *constants.MODERATION_CHANNELS, constants.Channels.dev_core, constants.Channels.dev_contrib
+ *constants.MODERATION_CHANNELS,
+ constants.Channels.dev_core,
+ constants.Channels.dev_contrib,
+ constants.Channels.bot_commands
):
features = f"\nFeatures: {', '.join(ctx.guild.features)}"
else:
@@ -195,7 +198,6 @@ class Information(Cog):
embed.description = (
f"Created: {created}"
- f"\nVoice region: {region}"
f"{features}"
f"\nRoles: {num_roles}"
f"\nMember status: {member_status}"
@@ -419,7 +421,12 @@ class Information(Cog):
activity_output = "No activity"
else:
activity_output.append(user_activity["total_messages"] or "No messages")
- activity_output.append(user_activity["activity_blocks"] or "No activity")
+
+ if (activity_blocks := user_activity.get("activity_blocks")) is not None:
+ # activity_blocks is not included in the response if the user has a lot of messages
+ activity_output.append(activity_blocks or "No activity") # Special case when activity_blocks is 0.
+ else:
+ activity_output.append("Too many to count!")
activity_output = "\n".join(
f"{name}: {metric}" for name, metric in zip(["Messages", "Activity blocks"], activity_output)
@@ -515,6 +522,40 @@ class Information(Cog):
"""Shows information about the raw API response in a copy-pasteable Python format."""
await self.send_raw_content(ctx, message, json=True)
+ @command(aliases=("rule",))
+ async def rules(self, ctx: Context, rules: Greedy[int]) -> None:
+ """Provides a link to all rules or, if specified, displays specific rule(s)."""
+ rules_embed = Embed(title="Rules", color=Colour.og_blurple(), url="https://www.pythondiscord.com/pages/rules")
+
+ if not rules:
+ # Rules were not submitted. Return the default description.
+ rules_embed.description = (
+ "The rules and guidelines that apply to this community can be found on"
+ " our [rules page](https://www.pythondiscord.com/pages/rules). We expect"
+ " all members of the community to have read and understood these."
+ )
+
+ await ctx.send(embed=rules_embed)
+ return
+
+ full_rules = await self.bot.api_client.get("rules", params={"link_format": "md"})
+
+ # Remove duplicates and sort the rule indices
+ rules = sorted(set(rules))
+
+ invalid = ", ".join(str(index) for index in rules if index < 1 or index > len(full_rules))
+
+ if invalid:
+ await ctx.send(shorten(":x: Invalid rule indices: " + invalid, 75, placeholder=" ..."))
+ return
+
+ for rule in rules:
+ self.bot.stats.incr(f"rule_uses.{rule}")
+
+ final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules)
+
+ await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3)
+
def setup(bot: Bot) -> None:
"""Load the Information cog."""
diff --git a/bot/exts/info/pep.py b/bot/exts/info/pep.py
index 259095b50..67866620b 100644
--- a/bot/exts/info/pep.py
+++ b/bot/exts/info/pep.py
@@ -16,7 +16,7 @@ log = get_logger(__name__)
ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png"
BASE_PEP_URL = "http://www.python.org/dev/peps/pep-"
-PEPS_LISTING_API_URL = "https://api.github.com/repos/python/peps/contents?ref=master"
+PEPS_LISTING_API_URL = "https://api.github.com/repos/python/peps/contents?ref=main"
pep_cache = AsyncCache()
@@ -97,9 +97,12 @@ class PythonEnhancementProposals(Cog):
def generate_pep_embed(self, pep_header: Dict, pep_nr: int) -> Embed:
"""Generate PEP embed based on PEP headers data."""
+ # the parsed header can be wrapped to multiple lines, so we need to make sure that is removed
+ # for an example of a pep with this issue, see pep 500
+ title = " ".join(pep_header["Title"].split())
# Assemble the embed
pep_embed = Embed(
- title=f"**PEP {pep_nr} - {pep_header['Title']}**",
+ title=f"**PEP {pep_nr} - {title}**",
description=f"[Link]({BASE_PEP_URL}{pep_nr:04})",
)
diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py
deleted file mode 100644
index e8e71558b..000000000
--- a/bot/exts/info/site.py
+++ /dev/null
@@ -1,142 +0,0 @@
-from discord import Colour, Embed
-from discord.ext.commands import Cog, Context, Greedy, group
-
-from bot.bot import Bot
-from bot.constants import URLs
-from bot.log import get_logger
-from bot.pagination import LinePaginator
-
-log = get_logger(__name__)
-
-BASE_URL = f"{URLs.site_schema}{URLs.site}"
-
-
-class Site(Cog):
- """Commands for linking to different parts of the site."""
-
- def __init__(self, bot: Bot):
- self.bot = bot
-
- @group(name="site", aliases=("s",), invoke_without_command=True)
- async def site_group(self, ctx: Context) -> None:
- """Commands for getting info about our website."""
- await ctx.send_help(ctx.command)
-
- @site_group.command(name="home", aliases=("about",), root_aliases=("home",))
- async def site_main(self, ctx: Context) -> None:
- """Info about the website itself."""
- url = f"{URLs.site_schema}{URLs.site}/"
-
- embed = Embed(title="Python Discord website")
- embed.set_footer(text=url)
- embed.colour = Colour.og_blurple()
- embed.description = (
- f"[Our official website]({url}) is an open-source community project "
- "created with Python and Django. It contains information about the server "
- "itself, lets you sign up for upcoming events, has its own wiki, contains "
- "a list of valuable learning resources, and much more."
- )
-
- await ctx.send(embed=embed)
-
- @site_group.command(name="resources", root_aliases=("resources", "resource"))
- async def site_resources(self, ctx: Context) -> None:
- """Info about the site's Resources page."""
- learning_url = f"{BASE_URL}/resources"
-
- embed = Embed(title="Resources")
- embed.set_footer(text=f"{learning_url}")
- embed.colour = Colour.og_blurple()
- embed.description = (
- f"The [Resources page]({learning_url}) on our website contains a "
- "list of hand-selected learning resources that we regularly recommend "
- f"to both beginners and experts."
- )
-
- await ctx.send(embed=embed)
-
- @site_group.command(name="tools", root_aliases=("tools",))
- async def site_tools(self, ctx: Context) -> None:
- """Info about the site's Tools page."""
- tools_url = f"{BASE_URL}/resources/tools"
-
- embed = Embed(title="Tools")
- embed.set_footer(text=f"{tools_url}")
- embed.colour = Colour.og_blurple()
- embed.description = (
- f"The [Tools page]({tools_url}) on our website contains a "
- f"couple of the most popular tools for programming in Python."
- )
-
- await ctx.send(embed=embed)
-
- @site_group.command(name="help")
- async def site_help(self, ctx: Context) -> None:
- """Info about the site's Getting Help page."""
- url = f"{BASE_URL}/pages/guides/pydis-guides/asking-good-questions/"
-
- embed = Embed(title="Asking Good Questions")
- embed.set_footer(text=url)
- embed.colour = Colour.og_blurple()
- embed.description = (
- "Asking the right question about something that's new to you can sometimes be tricky. "
- f"To help with this, we've created a [guide to asking good questions]({url}) on our website. "
- "It contains everything you need to get the very best help from our community."
- )
-
- await ctx.send(embed=embed)
-
- @site_group.command(name="faq", root_aliases=("faq",))
- async def site_faq(self, ctx: Context) -> None:
- """Info about the site's FAQ page."""
- url = f"{BASE_URL}/pages/frequently-asked-questions"
-
- embed = Embed(title="FAQ")
- embed.set_footer(text=url)
- embed.colour = Colour.og_blurple()
- embed.description = (
- "As the largest Python community on Discord, we get hundreds of questions every day. "
- "Many of these questions have been asked before. We've compiled a list of the most "
- "frequently asked questions along with their answers, which can be found on "
- f"our [FAQ page]({url})."
- )
-
- await ctx.send(embed=embed)
-
- @site_group.command(name="rules", aliases=("r", "rule"), root_aliases=("rules", "rule"))
- async def site_rules(self, ctx: Context, rules: Greedy[int]) -> None:
- """Provides a link to all rules or, if specified, displays specific rule(s)."""
- rules_embed = Embed(title='Rules', color=Colour.og_blurple(), url=f'{BASE_URL}/pages/rules')
-
- if not rules:
- # Rules were not submitted. Return the default description.
- rules_embed.description = (
- "The rules and guidelines that apply to this community can be found on"
- f" our [rules page]({BASE_URL}/pages/rules). We expect"
- " all members of the community to have read and understood these."
- )
-
- await ctx.send(embed=rules_embed)
- return
-
- full_rules = await self.bot.api_client.get('rules', params={'link_format': 'md'})
-
- # Remove duplicates and sort the rule indices
- rules = sorted(set(rules))
- invalid = ', '.join(str(index) for index in rules if index < 1 or index > len(full_rules))
-
- if invalid:
- await ctx.send(f":x: Invalid rule indices: {invalid}")
- return
-
- for rule in rules:
- self.bot.stats.incr(f"rule_uses.{rule}")
-
- final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules)
-
- await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3)
-
-
-def setup(bot: Bot) -> None:
- """Load the Site cog."""
- bot.add_cog(Site(bot))
diff --git a/bot/exts/info/source.py b/bot/exts/info/source.py
index 8ce25b4e8..e3e7029ca 100644
--- a/bot/exts/info/source.py
+++ b/bot/exts/info/source.py
@@ -8,8 +8,9 @@ from discord.ext import commands
from bot.bot import Bot
from bot.constants import URLs
from bot.converters import SourceConverter
+from bot.exts.info.tags import TagIdentifier
-SourceType = Union[commands.HelpCommand, commands.Command, commands.Cog, str, commands.ExtensionNotLoaded]
+SourceType = Union[commands.HelpCommand, commands.Command, commands.Cog, TagIdentifier, commands.ExtensionNotLoaded]
class BotSource(commands.Cog):
@@ -41,9 +42,9 @@ class BotSource(commands.Cog):
source_item = inspect.unwrap(source_item.callback)
src = source_item.__code__
filename = src.co_filename
- elif isinstance(source_item, str):
+ elif isinstance(source_item, TagIdentifier):
tags_cog = self.bot.get_cog("Tags")
- filename = tags_cog._cache[source_item]["location"]
+ filename = tags_cog.tags[source_item].file_path
else:
src = type(source_item)
try:
@@ -51,7 +52,7 @@ class BotSource(commands.Cog):
except TypeError:
raise commands.BadArgument("Cannot get source for a dynamically-created object.")
- if not isinstance(source_item, str):
+ if not isinstance(source_item, TagIdentifier):
try:
lines, first_line_no = inspect.getsourcelines(src)
except OSError:
@@ -64,7 +65,7 @@ class BotSource(commands.Cog):
# Handle tag file location differently than others to avoid errors in some cases
if not first_line_no:
- file_location = Path(filename).relative_to("/bot/")
+ file_location = Path(filename).relative_to("bot/")
else:
file_location = Path(filename).relative_to(Path.cwd()).as_posix()
@@ -82,7 +83,7 @@ class BotSource(commands.Cog):
elif isinstance(source_object, commands.Command):
description = source_object.short_doc
title = f"Command: {source_object.qualified_name}"
- elif isinstance(source_object, str):
+ elif isinstance(source_object, TagIdentifier):
title = f"Tag: {source_object}"
description = ""
else:
diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py
new file mode 100644
index 000000000..1299d5d59
--- /dev/null
+++ b/bot/exts/info/subscribe.py
@@ -0,0 +1,201 @@
+import calendar
+import operator
+import typing as t
+from dataclasses import dataclass
+
+import arrow
+import discord
+from discord.ext import commands
+from discord.interactions import Interaction
+
+from bot import constants
+from bot.bot import Bot
+from bot.decorators import redirect_output
+from bot.log import get_logger
+from bot.utils import members, scheduling
+
+
+@dataclass(frozen=True)
+class AssignableRole:
+ """
+ A role that can be assigned to a user.
+
+ months_available is a tuple that signifies what months the role should be
+ self-assignable, using None for when it should always be available.
+ """
+
+ role_id: int
+ months_available: t.Optional[tuple[int]]
+ name: t.Optional[str] = None # This gets populated within Subscribe.init_cog()
+
+ def is_currently_available(self) -> bool:
+ """Check if the role is available for the current month."""
+ if self.months_available is None:
+ return True
+ return arrow.utcnow().month in self.months_available
+
+ def get_readable_available_months(self) -> str:
+ """Get a readable string of the months the role is available."""
+ if self.months_available is None:
+ return f"{self.name} is always available."
+
+ # Join the months together with comma separators, but use "and" for the final seperator.
+ month_names = [calendar.month_name[month] for month in self.months_available]
+ available_months_str = ", ".join(month_names[:-1]) + f" and {month_names[-1]}"
+ return f"{self.name} can only be assigned during {available_months_str}."
+
+
+ASSIGNABLE_ROLES = (
+ AssignableRole(constants.Roles.announcements, None),
+ AssignableRole(constants.Roles.pyweek_announcements, None),
+ AssignableRole(constants.Roles.lovefest, (1, 2)),
+ AssignableRole(constants.Roles.advent_of_code, (11, 12)),
+)
+
+ITEMS_PER_ROW = 3
+DELETE_MESSAGE_AFTER = 300 # Seconds
+
+log = get_logger(__name__)
+
+
+class RoleButtonView(discord.ui.View):
+ """A list of SingleRoleButtons to show to the member."""
+
+ def __init__(self, member: discord.Member):
+ super().__init__()
+ self.interaction_owner = member
+
+ async def interaction_check(self, interaction: Interaction) -> bool:
+ """Ensure that the user clicking the button is the member who invoked the command."""
+ if interaction.user != self.interaction_owner:
+ await interaction.response.send_message(
+ ":x: This is not your command to react to!",
+ ephemeral=True
+ )
+ return False
+ return True
+
+
+class SingleRoleButton(discord.ui.Button):
+ """A button that adds or removes a role from the member depending on it's current state."""
+
+ ADD_STYLE = discord.ButtonStyle.success
+ REMOVE_STYLE = discord.ButtonStyle.red
+ UNAVAILABLE_STYLE = discord.ButtonStyle.secondary
+ LABEL_FORMAT = "{action} role {role_name}."
+ CUSTOM_ID_FORMAT = "subscribe-{role_id}"
+
+ def __init__(self, role: AssignableRole, assigned: bool, row: int):
+ if role.is_currently_available():
+ style = self.REMOVE_STYLE if assigned else self.ADD_STYLE
+ label = self.LABEL_FORMAT.format(action="Remove" if assigned else "Add", role_name=role.name)
+ else:
+ style = self.UNAVAILABLE_STYLE
+ label = f"🔒 {role.name}"
+
+ super().__init__(
+ style=style,
+ label=label,
+ custom_id=self.CUSTOM_ID_FORMAT.format(role_id=role.role_id),
+ row=row,
+ )
+ self.role = role
+ self.assigned = assigned
+
+ async def callback(self, interaction: Interaction) -> None:
+ """Update the member's role and change button text to reflect current text."""
+ if isinstance(interaction.user, discord.User):
+ log.trace("User %s is not a member", interaction.user)
+ await interaction.message.delete()
+ self.view.stop()
+ return
+
+ if not self.role.is_currently_available():
+ await interaction.response.send_message(self.role.get_readable_available_months(), ephemeral=True)
+ return
+
+ await members.handle_role_change(
+ interaction.user,
+ interaction.user.remove_roles if self.assigned else interaction.user.add_roles,
+ discord.Object(self.role.role_id),
+ )
+
+ self.assigned = not self.assigned
+ await self.update_view(interaction)
+ await interaction.response.send_message(
+ self.LABEL_FORMAT.format(action="Added" if self.assigned else "Removed", role_name=self.role.name),
+ ephemeral=True,
+ )
+
+ async def update_view(self, interaction: Interaction) -> None:
+ """Updates the original interaction message with a new view object with the updated buttons."""
+ self.style = self.REMOVE_STYLE if self.assigned else self.ADD_STYLE
+ self.label = self.LABEL_FORMAT.format(action="Remove" if self.assigned else "Add", role_name=self.role.name)
+ try:
+ await interaction.message.edit(view=self.view)
+ except discord.NotFound:
+ log.debug("Subscribe message for %s removed before buttons could be updated", interaction.user)
+ self.view.stop()
+
+
+class Subscribe(commands.Cog):
+ """Cog to allow user to self-assign & remove the roles present in ASSIGNABLE_ROLES."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.init_task = scheduling.create_task(self.init_cog(), event_loop=self.bot.loop)
+ self.assignable_roles: list[AssignableRole] = []
+ self.guild: discord.Guild = None
+
+ async def init_cog(self) -> None:
+ """Initialise the cog by resolving the role IDs in ASSIGNABLE_ROLES to role names."""
+ await self.bot.wait_until_guild_available()
+
+ self.guild = self.bot.get_guild(constants.Guild.id)
+
+ for role in ASSIGNABLE_ROLES:
+ discord_role = self.guild.get_role(role.role_id)
+ if discord_role is None:
+ log.warning("Could not resolve %d to a role in the guild, skipping.", role.role_id)
+ continue
+ self.assignable_roles.append(
+ AssignableRole(
+ role_id=role.role_id,
+ months_available=role.months_available,
+ name=discord_role.name,
+ )
+ )
+
+ # Sort by role name, then shift unavailable roles to the end of the list
+ self.assignable_roles.sort(key=operator.attrgetter("name"))
+ self.assignable_roles.sort(key=operator.methodcaller("is_currently_available"), reverse=True)
+
+ @commands.cooldown(1, 10, commands.BucketType.member)
+ @commands.command(name="subscribe")
+ @redirect_output(
+ destination_channel=constants.Channels.bot_commands,
+ bypass_roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES,
+ )
+ async def subscribe_command(self, ctx: commands.Context, *_) -> None: # We don't actually care about the args
+ """Display the member's current state for each role, and allow them to add/remove the roles."""
+ await self.init_task
+
+ button_view = RoleButtonView(ctx.author)
+ author_roles = [role.id for role in ctx.author.roles]
+ for index, role in enumerate(self.assignable_roles):
+ row = index // ITEMS_PER_ROW
+ button_view.add_item(SingleRoleButton(role, role.role_id in author_roles, row))
+
+ await ctx.send(
+ "Click the buttons below to add or remove your roles!",
+ view=button_view,
+ delete_after=DELETE_MESSAGE_AFTER,
+ )
+
+
+def setup(bot: Bot) -> None:
+ """Load the Subscribe cog."""
+ if len(ASSIGNABLE_ROLES) > ITEMS_PER_ROW*5: # Discord limits views to 5 rows of buttons.
+ log.error("Too many roles for 5 rows, not loading the Subscribe cog.")
+ else:
+ bot.add_cog(Subscribe(bot))
diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py
index 842647555..f66237c8e 100644
--- a/bot/exts/info/tags.py
+++ b/bot/exts/info/tags.py
@@ -1,14 +1,18 @@
+from __future__ import annotations
+
+import enum
import re
import time
from pathlib import Path
-from typing import Callable, Dict, Iterable, List, Optional
+from typing import Callable, Iterable, Literal, NamedTuple, Optional, Union
-from discord import Colour, Embed, Member
+import discord
+import frontmatter
+from discord import Embed, Member
from discord.ext.commands import Cog, Context, group
from bot import constants
from bot.bot import Bot
-from bot.converters import TagNameConverter
from bot.log import get_logger
from bot.pagination import LinePaginator
from bot.utils.messages import wait_for_deletion
@@ -24,99 +28,168 @@ REGEX_NON_ALPHABET = re.compile(r"[^a-z]", re.MULTILINE & re.IGNORECASE)
FOOTER_TEXT = f"To show a tag, type {constants.Bot.prefix}tags <tagname>."
+class COOLDOWN(enum.Enum):
+ """Sentinel value to signal that a tag is on cooldown."""
+
+ obj = object()
+
+
+class TagIdentifier(NamedTuple):
+ """Stores the group and name used as an identifier for a tag."""
+
+ group: Optional[str]
+ name: str
+
+ def get_fuzzy_score(self, fuzz_tag_identifier: TagIdentifier) -> float:
+ """Get fuzzy score, using `fuzz_tag_identifier` as the identifier to fuzzy match with."""
+ if (self.group is None) != (fuzz_tag_identifier.group is None):
+ # Ignore tags without groups if the identifier has a group and vice versa
+ return .0
+ if self.group == fuzz_tag_identifier.group:
+ # Completely identical, or both None
+ group_score = 1
+ else:
+ group_score = _fuzzy_search(fuzz_tag_identifier.group, self.group)
+
+ fuzzy_score = group_score * _fuzzy_search(fuzz_tag_identifier.name, self.name) * 100
+ if fuzzy_score:
+ log.trace(f"Fuzzy score {fuzzy_score:=06.2f} for tag {self!r} with fuzz {fuzz_tag_identifier!r}")
+ return fuzzy_score
+
+ def __str__(self) -> str:
+ if self.group is not None:
+ return f"{self.group} {self.name}"
+ else:
+ return self.name
+
+ @classmethod
+ def from_string(cls, string: str) -> TagIdentifier:
+ """Create a `TagIdentifier` instance from the beginning of `string`."""
+ split_string = string.removeprefix(constants.Bot.prefix).split(" ", maxsplit=2)
+ if len(split_string) == 1:
+ return cls(None, split_string[0])
+ else:
+ return cls(split_string[0], split_string[1])
+
+
+class Tag:
+ """Provide an interface to a tag from resources with `file_content`."""
+
+ def __init__(self, content_path: Path):
+ post = frontmatter.loads(content_path.read_text("utf8"))
+ self.file_path = content_path
+ self.content = post.content
+ self.metadata = post.metadata
+ self._restricted_to: set[int] = set(self.metadata.get("restricted_to", ()))
+ self._cooldowns: dict[discord.TextChannel, float] = {}
+
+ @property
+ def embed(self) -> Embed:
+ """Create an embed for the tag."""
+ embed = Embed.from_dict(self.metadata.get("embed", {}))
+ embed.description = self.content
+ return embed
+
+ def accessible_by(self, member: discord.Member) -> bool:
+ """Check whether `member` can access the tag."""
+ return bool(
+ not self._restricted_to
+ or self._restricted_to & {role.id for role in member.roles}
+ )
+
+ def on_cooldown_in(self, channel: discord.TextChannel) -> bool:
+ """Check whether the tag is on cooldown in `channel`."""
+ return self._cooldowns.get(channel, float("-inf")) > time.time()
+
+ def set_cooldown_for(self, channel: discord.TextChannel) -> None:
+ """Set the tag to be on cooldown in `channel` for `constants.Cooldowns.tags` seconds."""
+ self._cooldowns[channel] = time.time() + constants.Cooldowns.tags
+
+
+def _fuzzy_search(search: str, target: str) -> float:
+ """A simple scoring algorithm based on how many letters are found / total, with order in mind."""
+ _search = REGEX_NON_ALPHABET.sub("", search.lower())
+ if not _search:
+ return 0
+
+ _targets = iter(REGEX_NON_ALPHABET.split(target.lower()))
+
+ current = 0
+ for _target in _targets:
+ index = 0
+ try:
+ while index < len(_target) and _search[current] == _target[index]:
+ current += 1
+ index += 1
+ except IndexError:
+ # Exit when _search runs out
+ break
+
+ return current / len(_search)
+
+
class Tags(Cog):
- """Save new tags and fetch existing tags."""
+ """Fetch tags by name or content."""
+
+ PAGINATOR_DEFAULTS = dict(max_lines=15, empty=False, footer_text=FOOTER_TEXT)
def __init__(self, bot: Bot):
self.bot = bot
- self.tag_cooldowns = {}
- self._cache = self.get_tags()
-
- @staticmethod
- def get_tags() -> dict:
- """Get all tags."""
- cache = {}
+ self.tags: dict[TagIdentifier, Tag] = {}
+ self.initialize_tags()
+ def initialize_tags(self) -> None:
+ """Load all tags from resources into `self.tags`."""
base_path = Path("bot", "resources", "tags")
+
for file in base_path.glob("**/*"):
if file.is_file():
- tag_title = file.stem
- tag = {
- "title": tag_title,
- "embed": {
- "description": file.read_text(encoding="utf8"),
- },
- "restricted_to": None,
- "location": f"/bot/{file}"
- }
-
- # Convert to a list to allow negative indexing.
- parents = list(file.relative_to(base_path).parents)
- if len(parents) > 1:
- # -1 would be '.' hence -2 is used as the index.
- tag["restricted_to"] = parents[-2].name
-
- cache[tag_title] = tag
-
- return cache
-
- @staticmethod
- def check_accessibility(user: Member, tag: dict) -> bool:
- """Check if user can access a tag."""
- return not tag["restricted_to"] or tag["restricted_to"].lower() in [role.name.lower() for role in user.roles]
-
- @staticmethod
- def _fuzzy_search(search: str, target: str) -> float:
- """A simple scoring algorithm based on how many letters are found / total, with order in mind."""
- current, index = 0, 0
- _search = REGEX_NON_ALPHABET.sub('', search.lower())
- _targets = iter(REGEX_NON_ALPHABET.split(target.lower()))
- _target = next(_targets)
- try:
- while True:
- while index < len(_target) and _search[current] == _target[index]:
- current += 1
- index += 1
- index, _target = 0, next(_targets)
- except (StopIteration, IndexError):
- pass
- return current / len(_search) * 100
-
- def _get_suggestions(self, tag_name: str, thresholds: Optional[List[int]] = None) -> List[str]:
- """Return a list of suggested tags."""
- scores: Dict[str, int] = {
- tag_title: Tags._fuzzy_search(tag_name, tag['title'])
- for tag_title, tag in self._cache.items()
- }
-
- thresholds = thresholds or [100, 90, 80, 70, 60]
-
- for threshold in thresholds:
+ parent_dir = file.relative_to(base_path).parent
+ tag_name = file.stem
+ # Files directly under `base_path` have an empty string as the parent directory name
+ tag_group = parent_dir.name or None
+
+ self.tags[TagIdentifier(tag_group, tag_name)] = Tag(file)
+
+ def _get_suggestions(self, tag_identifier: TagIdentifier) -> list[tuple[TagIdentifier, Tag]]:
+ """Return a list of suggested tags for `tag_identifier`."""
+ for threshold in [100, 90, 80, 70, 60]:
suggestions = [
- self._cache[tag_title]
- for tag_title, matching_score in scores.items()
- if matching_score >= threshold
+ (identifier, tag)
+ for identifier, tag in self.tags.items()
+ if identifier.get_fuzzy_score(tag_identifier) >= threshold
]
if suggestions:
return suggestions
return []
- def _get_tag(self, tag_name: str) -> list:
- """Get a specific tag."""
- found = [self._cache.get(tag_name.lower(), None)]
- if not found[0]:
- return self._get_suggestions(tag_name)
- return found
+ def get_fuzzy_matches(self, tag_identifier: TagIdentifier) -> list[tuple[TagIdentifier, Tag]]:
+ """Get tags with identifiers similar to `tag_identifier`."""
+ suggestions = []
+
+ if tag_identifier.group is not None and len(tag_identifier.group) >= 2:
+ # Try fuzzy matching with only a name first
+ suggestions += self._get_suggestions(TagIdentifier(None, tag_identifier.group))
+
+ if len(tag_identifier.name) >= 2:
+ suggestions += self._get_suggestions(tag_identifier)
- def _get_tags_via_content(self, check: Callable[[Iterable], bool], keywords: str, user: Member) -> list:
+ return suggestions
+
+ def _get_tags_via_content(
+ self,
+ check: Callable[[Iterable], bool],
+ keywords: str,
+ user: Member,
+ ) -> list[tuple[TagIdentifier, Tag]]:
"""
Search for tags via contents.
`predicate` will be the built-in any, all, or a custom callable. Must return a bool.
"""
- keywords_processed: List[str] = []
- for keyword in keywords.split(','):
+ keywords_processed = []
+ for keyword in keywords.split(","):
keyword_sanitized = keyword.strip().casefold()
if not keyword_sanitized:
# this happens when there are leading / trailing / consecutive comma.
@@ -124,45 +197,48 @@ class Tags(Cog):
keywords_processed.append(keyword_sanitized)
if not keywords_processed:
- # after sanitizing, we can end up with an empty list, for example when keywords is ','
+ # after sanitizing, we can end up with an empty list, for example when keywords is ","
# in that case, we simply want to search for such keywords directly instead.
keywords_processed = [keywords]
matching_tags = []
- for tag in self._cache.values():
- matches = (query in tag['embed']['description'].casefold() for query in keywords_processed)
- if self.check_accessibility(user, tag) and check(matches):
- matching_tags.append(tag)
+ for identifier, tag in self.tags.items():
+ matches = (query in tag.content.casefold() for query in keywords_processed)
+ if tag.accessible_by(user) and check(matches):
+ matching_tags.append((identifier, tag))
return matching_tags
- async def _send_matching_tags(self, ctx: Context, keywords: str, matching_tags: list) -> None:
+ async def _send_matching_tags(
+ self,
+ ctx: Context,
+ keywords: str,
+ matching_tags: list[tuple[TagIdentifier, Tag]],
+ ) -> None:
"""Send the result of matching tags to user."""
- if not matching_tags:
- pass
- elif len(matching_tags) == 1:
- await ctx.send(embed=Embed().from_dict(matching_tags[0]['embed']))
- else:
- is_plural = keywords.strip().count(' ') > 0 or keywords.strip().count(',') > 0
+ if len(matching_tags) == 1:
+ await ctx.send(embed=matching_tags[0][1].embed)
+ elif matching_tags:
+ is_plural = keywords.strip().count(" ") > 0 or keywords.strip().count(",") > 0
embed = Embed(
title=f"Here are the tags containing the given keyword{'s' * is_plural}:",
- description='\n'.join(tag['title'] for tag in matching_tags[:10])
)
await LinePaginator.paginate(
- sorted(f"**»** {tag['title']}" for tag in matching_tags),
+ sorted(
+ f"**\N{RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK}** {identifier.name}"
+ for identifier, _ in matching_tags
+ ),
ctx,
embed,
- footer_text=FOOTER_TEXT,
- empty=False,
- max_lines=15
+ **self.PAGINATOR_DEFAULTS,
)
- @group(name='tags', aliases=('tag', 't'), invoke_without_command=True)
- async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:
+ @group(name="tags", aliases=("tag", "t"), invoke_without_command=True, usage="[tag_group] [tag_name]")
+ async def tags_group(self, ctx: Context, *, argument_string: Optional[str]) -> None:
"""Show all known tags, a single tag, or run a subcommand."""
- await self.get_command(ctx, tag_name=tag_name)
+ await self.get_command(ctx, argument_string=argument_string)
- @tags_group.group(name='search', invoke_without_command=True)
+ @tags_group.group(name="search", invoke_without_command=True)
async def search_tag_content(self, ctx: Context, *, keywords: str) -> None:
"""
Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma.
@@ -172,123 +248,151 @@ class Tags(Cog):
matching_tags = self._get_tags_via_content(all, keywords, ctx.author)
await self._send_matching_tags(ctx, keywords, matching_tags)
- @search_tag_content.command(name='any')
- async def search_tag_content_any_keyword(self, ctx: Context, *, keywords: Optional[str] = 'any') -> None:
+ @search_tag_content.command(name="any")
+ async def search_tag_content_any_keyword(self, ctx: Context, *, keywords: Optional[str] = "any") -> None:
"""
Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma.
Search for tags that has ANY of the keywords.
"""
- matching_tags = self._get_tags_via_content(any, keywords or 'any', ctx.author)
+ matching_tags = self._get_tags_via_content(any, keywords or "any", ctx.author)
await self._send_matching_tags(ctx, keywords, matching_tags)
- async def display_tag(self, ctx: Context, tag_name: str = None) -> bool:
+ async def get_tag_embed(
+ self,
+ ctx: Context,
+ tag_identifier: TagIdentifier,
+ ) -> Optional[Union[Embed, Literal[COOLDOWN.obj]]]:
"""
- If a tag is not found, display similar tag names as suggestions.
-
- If a tag is not specified, display a paginated embed of all tags.
+ Generate an embed of the requested tag or of suggestions if the tag doesn't exist/isn't accessible by the user.
- Tags are on cooldowns on a per-tag, per-channel basis. If a tag is on cooldown, display
- nothing and return True.
+ If the requested tag is on cooldown return `COOLDOWN.obj`, otherwise if no suggestions were found return None.
"""
- def _command_on_cooldown(tag_name: str) -> bool:
- """
- Check if the command is currently on cooldown, on a per-tag, per-channel basis.
-
- The cooldown duration is set in constants.py.
- """
- now = time.time()
-
- cooldown_conditions = (
- tag_name
- and tag_name in self.tag_cooldowns
- and (now - self.tag_cooldowns[tag_name]["time"]) < constants.Cooldowns.tags
- and self.tag_cooldowns[tag_name]["channel"] == ctx.channel.id
+ filtered_tags = [
+ (ident, tag) for ident, tag in
+ self.get_fuzzy_matches(tag_identifier)[:10]
+ if tag.accessible_by(ctx.author)
+ ]
+
+ tag = self.tags.get(tag_identifier)
+
+ if tag is None and tag_identifier.group is not None:
+ # Try exact match with only the name
+ tag = self.tags.get(TagIdentifier(None, tag_identifier.group))
+
+ if tag is None and len(filtered_tags) == 1:
+ tag_identifier = filtered_tags[0][0]
+ tag = filtered_tags[0][1]
+
+ if tag is not None:
+ if tag.on_cooldown_in(ctx.channel):
+ log.debug(f"Tag {str(tag_identifier)!r} is on cooldown.")
+ return COOLDOWN.obj
+ tag.set_cooldown_for(ctx.channel)
+
+ self.bot.stats.incr(
+ f"tags.usages"
+ f"{'.' + tag_identifier.group.replace('-', '_') if tag_identifier.group else ''}"
+ f".{tag_identifier.name.replace('-', '_')}"
)
+ return tag.embed
- if cooldown_conditions:
- return True
- return False
-
- if _command_on_cooldown(tag_name):
- time_elapsed = time.time() - self.tag_cooldowns[tag_name]["time"]
- time_left = constants.Cooldowns.tags - time_elapsed
- log.info(
- f"{ctx.author} tried to get the '{tag_name}' tag, but the tag is on cooldown. "
- f"Cooldown ends in {time_left:.1f} seconds."
+ else:
+ if not filtered_tags:
+ return None
+ suggested_tags_text = "\n".join(
+ f"**\N{RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK}** {identifier}"
+ for identifier, tag in filtered_tags
+ if not tag.on_cooldown_in(ctx.channel)
+ )
+ return Embed(
+ title="Did you mean ...",
+ description=suggested_tags_text
)
- return True
-
- if tag_name is not None:
- temp_founds = self._get_tag(tag_name)
-
- founds = []
-
- for found_tag in temp_founds:
- if self.check_accessibility(ctx.author, found_tag):
- founds.append(found_tag)
- if len(founds) == 1:
- tag = founds[0]
- if ctx.channel.id not in TEST_CHANNELS:
- self.tag_cooldowns[tag_name] = {
- "time": time.time(),
- "channel": ctx.channel.id
- }
+ def accessible_tags(self, user: Member) -> list[str]:
+ """Return a formatted list of tags that are accessible by `user`; groups first, and alphabetically sorted."""
+ def tag_sort_key(tag_item: tuple[TagIdentifier, Tag]) -> str:
+ group, name = tag_item[0]
+ if group is None:
+ # Max codepoint character to force tags without a group to the end
+ group = chr(0x10ffff)
+
+ return group + name
+
+ result_lines = []
+ current_group = ""
+ group_accessible = True
+
+ for identifier, tag in sorted(self.tags.items(), key=tag_sort_key):
+
+ if identifier.group != current_group:
+ if not group_accessible:
+ # Remove group separator line if no tags in the previous group were accessible by the user.
+ result_lines.pop()
+ # A new group began, add a separator with the group name.
+ current_group = identifier.group
+ if current_group is not None:
+ group_accessible = False
+ result_lines.append(f"\n\N{BULLET} **{current_group}**")
+ else:
+ result_lines.append("\n\N{BULLET}")
+
+ if tag.accessible_by(user):
+ result_lines.append(f"**\N{RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK}** {identifier.name}")
+ group_accessible = True
+
+ return result_lines
+
+ def accessible_tags_in_group(self, group: str, user: discord.Member) -> list[str]:
+ """Return a formatted list of tags in `group`, that are accessible by `user`."""
+ return sorted(
+ f"**\N{RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK}** {identifier}"
+ for identifier, tag in self.tags.items()
+ if identifier.group == group and tag.accessible_by(user)
+ )
+
+ @tags_group.command(name="get", aliases=("show", "g"), usage="[tag_group] [tag_name]")
+ async def get_command(self, ctx: Context, *, argument_string: Optional[str]) -> bool:
+ """
+ If a single argument matching a group name is given, list all accessible tags from that group
+ Otherwise display the tag if one was found for the given arguments, or try to display suggestions for that name.
- self.bot.stats.incr(f"tags.usages.{tag['title'].replace('-', '_')}")
+ With no arguments, list all accessible tags.
- await wait_for_deletion(
- await ctx.send(embed=Embed.from_dict(tag['embed'])),
- [ctx.author.id],
- )
- return True
- elif founds and len(tag_name) >= 3:
- await wait_for_deletion(
- await ctx.send(
- embed=Embed(
- title='Did you mean ...',
- description='\n'.join(tag['title'] for tag in founds[:10])
- )
- ),
- [ctx.author.id],
+ Returns True if a message was sent, or if the tag is on cooldown.
+ Returns False if no message was sent.
+ """ # noqa: D205, D415
+ if not argument_string:
+ if self.tags:
+ await LinePaginator.paginate(
+ self.accessible_tags(ctx.author), ctx, Embed(title="Available tags"), **self.PAGINATOR_DEFAULTS
)
- return True
-
- else:
- tags = self._cache.values()
- if not tags:
- await ctx.send(embed=Embed(
- description="**There are no tags in the database!**",
- colour=Colour.red()
- ))
- return True
else:
- embed: Embed = Embed(title="**Current tags**")
+ await ctx.send(embed=Embed(description="**There are no tags!**"))
+ return True
+
+ identifier = TagIdentifier.from_string(argument_string)
+
+ if identifier.group is None:
+ # Try to find accessible tags from a group matching the identifier's name.
+ if group_tags := self.accessible_tags_in_group(identifier.name, ctx.author):
await LinePaginator.paginate(
- sorted(
- f"**»** {tag['title']}" for tag in tags
- if self.check_accessibility(ctx.author, tag)
- ),
- ctx,
- embed,
- footer_text=FOOTER_TEXT,
- empty=False,
- max_lines=15
+ group_tags, ctx, Embed(title=f"Tags under *{identifier.name}*"), **self.PAGINATOR_DEFAULTS
)
return True
- return False
-
- @tags_group.command(name='get', aliases=('show', 'g'))
- async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> bool:
- """
- Get a specified tag, or a list of all tags if no tag is specified.
+ embed = await self.get_tag_embed(ctx, identifier)
+ if embed is None:
+ return False
- Returns True if something can be sent, or if the tag is on cooldown.
- Returns False if no matches are found.
- """
- return await self.display_tag(ctx, tag_name)
+ if embed is not COOLDOWN.obj:
+ await wait_for_deletion(
+ await ctx.send(embed=embed),
+ (ctx.author.id,)
+ )
+ # A valid tag was found and was either sent, or is on cooldown
+ return True
def setup(bot: Bot) -> None:
diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py
index 94494b983..e61ef7880 100644
--- a/bot/exts/moderation/clean.py
+++ b/bot/exts/moderation/clean.py
@@ -1,12 +1,11 @@
import contextlib
-import logging
import re
import time
from collections import defaultdict
from contextlib import suppress
from datetime import datetime
-from itertools import islice
-from typing import Any, Callable, Iterable, Literal, Optional, TYPE_CHECKING, Union
+from itertools import takewhile
+from typing import Callable, Iterable, Literal, Optional, TYPE_CHECKING, Union
from discord import Colour, Message, NotFound, TextChannel, User, errors
from discord.ext.commands import Cog, Context, Converter, Greedy, group, has_any_role
@@ -17,12 +16,11 @@ from bot.bot import Bot
from bot.constants import Channels, CleanMessages, Colours, Emojis, Event, Icons, MODERATION_ROLES
from bot.converters import Age, ISODateTime
from bot.exts.moderation.modlog import ModLog
+from bot.log import get_logger
from bot.utils.channel import is_mod_channel
-log = logging.getLogger(__name__)
+log = get_logger(__name__)
-# Default number of messages to look at in each channel.
-DEFAULT_TRAVERSE = 10
# Number of seconds before command invocations and responses are deleted in non-moderation channels.
MESSAGE_DELETE_DELAY = 5
@@ -33,12 +31,12 @@ CleanLimit = Union[Message, Age, ISODateTime]
class CleanChannels(Converter):
- """A converter that turns the given string to a list of channels to clean, or the literal `*` for all channels."""
+ """A converter to turn the string into a list of channels to clean, or the literal `*` for all public channels."""
_channel_converter = TextChannelConverter()
async def convert(self, ctx: Context, argument: str) -> Union[Literal["*"], list[TextChannel]]:
- """Converts a string to a list of channels to clean, or the literal `*` for all channels."""
+ """Converts a string to a list of channels to clean, or the literal `*` for all public channels."""
if argument == "*":
return "*"
return [await self._channel_converter.convert(ctx, channel) for channel in argument.split()]
@@ -87,7 +85,6 @@ class Clean(Cog):
@staticmethod
def _validate_input(
- traverse: int,
channels: Optional[CleanChannels],
bots_only: bool,
users: Optional[list[User]],
@@ -95,9 +92,9 @@ class Clean(Cog):
second_limit: Optional[CleanLimit],
) -> None:
"""Raise errors if an argument value or a combination of values is invalid."""
- # Is this an acceptable amount of messages to traverse?
- if traverse > CleanMessages.message_limit:
- raise BadArgument(f"Cannot traverse more than {CleanMessages.message_limit} messages.")
+ if first_limit is None:
+ # This is an optional argument for the sake of the master command, but it's actually required.
+ raise BadArgument("Missing cleaning limit.")
if (isinstance(first_limit, Message) or isinstance(second_limit, Message)) and channels:
raise BadArgument("Both a message limit and channels specified.")
@@ -110,10 +107,6 @@ class Clean(Cog):
if users and bots_only:
raise BadArgument("Marked as bots only, but users were specified.")
- # This is an implementation error rather than user error.
- if second_limit and not first_limit:
- raise ValueError("Second limit specified without the first.")
-
@staticmethod
async def _send_expiring_message(ctx: Context, content: str) -> None:
"""Send `content` to the context channel. Automatically delete if it's not a mod channel."""
@@ -121,12 +114,39 @@ class Clean(Cog):
await ctx.send(content, delete_after=delete_after)
@staticmethod
+ def _channels_set(
+ channels: CleanChannels, ctx: Context, first_limit: CleanLimit, second_limit: CleanLimit
+ ) -> set[TextChannel]:
+ """Standardize the input `channels` argument to a usable set of text channels."""
+ # Default to using the invoking context's channel or the channel of the message limit(s).
+ if not channels:
+ # Input was validated - if first_limit is a message, second_limit won't point at a different channel.
+ if isinstance(first_limit, Message):
+ channels = {first_limit.channel}
+ elif isinstance(second_limit, Message):
+ channels = {second_limit.channel}
+ else:
+ channels = {ctx.channel}
+ else:
+ if channels == "*":
+ channels = {
+ channel for channel in ctx.guild.channels
+ if isinstance(channel, TextChannel)
+ # Assume that non-public channels are not needed to optimize for speed.
+ and channel.permissions_for(ctx.guild.default_role).view_channel
+ }
+ else:
+ channels = set(channels)
+
+ return channels
+
+ @staticmethod
def _build_predicate(
+ first_limit: datetime,
+ second_limit: Optional[datetime] = None,
bots_only: bool = False,
users: Optional[list[User]] = None,
regex: Optional[re.Pattern] = None,
- first_limit: Optional[datetime] = None,
- second_limit: Optional[datetime] = None,
) -> Predicate:
"""Return the predicate that decides whether to delete a given message."""
def predicate_bots_only(message: Message) -> bool:
@@ -167,20 +187,18 @@ class Clean(Cog):
predicates = []
# Set up the correct predicate
+ if second_limit:
+ predicates.append(predicate_range) # Delete messages in the specified age range
+ else:
+ predicates.append(predicate_after) # Delete messages older than the specified age
+
if bots_only:
predicates.append(predicate_bots_only) # Delete messages from bots
if users:
predicates.append(predicate_specific_users) # Delete messages from specific user
if regex:
predicates.append(predicate_regex) # Delete messages that match regex
- # Add up to one of the following:
- if second_limit:
- predicates.append(predicate_range) # Delete messages in the specified age range
- elif first_limit:
- predicates.append(predicate_after) # Delete messages older than specific message
- if not predicates:
- return lambda m: True
if len(predicates) == 1:
return predicates[0]
return lambda m: all(pred(m) for pred in predicates)
@@ -195,16 +213,25 @@ class Clean(Cog):
# Invocation message has already been deleted
log.info("Tried to delete invocation message, but it was already deleted.")
- def _get_messages_from_cache(self, traverse: int, to_delete: Predicate) -> tuple[defaultdict[Any, list], list[int]]:
+ def _use_cache(self, limit: datetime) -> bool:
+ """Tell whether all messages to be cleaned can be found in the cache."""
+ return self.bot.cached_messages[0].created_at <= limit
+
+ def _get_messages_from_cache(
+ self,
+ channels: set[TextChannel],
+ to_delete: Predicate,
+ lower_limit: datetime
+ ) -> tuple[defaultdict[TextChannel, list], list[int]]:
"""Helper function for getting messages from the cache."""
message_mappings = defaultdict(list)
message_ids = []
- for message in islice(self.bot.cached_messages, traverse):
+ for message in takewhile(lambda m: m.created_at > lower_limit, reversed(self.bot.cached_messages)):
if not self.cleaning:
# Cleaning was canceled
return message_mappings, message_ids
- if to_delete(message):
+ if message.channel in channels and to_delete(message):
message_mappings[message.channel].append(message)
message_ids.append(message.id)
@@ -212,17 +239,16 @@ class Clean(Cog):
async def _get_messages_from_channels(
self,
- traverse: int,
channels: Iterable[TextChannel],
to_delete: Predicate,
- before: Optional[datetime] = None,
+ before: datetime,
after: Optional[datetime] = None
- ) -> tuple[defaultdict[Any, list], list]:
+ ) -> tuple[defaultdict[TextChannel, list], list]:
message_mappings = defaultdict(list)
message_ids = []
for channel in channels:
- async for message in channel.history(limit=traverse, before=before, after=after):
+ async for message in channel.history(limit=CleanMessages.message_limit, before=before, after=after):
if not self.cleaning:
# Cleaning was canceled, return empty containers.
@@ -293,7 +319,8 @@ class Clean(Cog):
return deleted
if len(to_delete) > 0:
# Deleting any leftover messages if there are any
- await channel.delete_messages(to_delete)
+ with suppress(NotFound):
+ await channel.delete_messages(to_delete)
deleted.extend(to_delete)
if not self.cleaning:
@@ -317,7 +344,7 @@ class Clean(Cog):
# Build the embed and send it
if channels == "*":
- target_channels = "all channels"
+ target_channels = "all public channels"
else:
target_channels = ", ".join(channel.mention for channel in channels)
@@ -342,17 +369,15 @@ class Clean(Cog):
async def _clean_messages(
self,
ctx: Context,
- traverse: int,
channels: Optional[CleanChannels],
bots_only: bool = False,
users: Optional[list[User]] = None,
regex: Optional[re.Pattern] = None,
first_limit: Optional[CleanLimit] = None,
second_limit: Optional[CleanLimit] = None,
- use_cache: Optional[bool] = True
) -> None:
"""A helper function that does the actual message cleaning."""
- self._validate_input(traverse, channels, bots_only, users, first_limit, second_limit)
+ self._validate_input(channels, bots_only, users, first_limit, second_limit)
# Are we already performing a clean?
if self.cleaning:
@@ -362,15 +387,7 @@ class Clean(Cog):
return
self.cleaning = True
- # Default to using the invoking context's channel or the channel of the message limit(s).
- if not channels:
- # Input was validated - if first_limit is a message, second_limit won't point at a different channel.
- if isinstance(first_limit, Message):
- channels = [first_limit.channel]
- elif isinstance(second_limit, Message):
- channels = [second_limit.channel]
- else:
- channels = [ctx.channel]
+ deletion_channels = self._channels_set(channels, ctx, first_limit, second_limit)
if isinstance(first_limit, Message):
first_limit = first_limit.created_at
@@ -380,19 +397,19 @@ class Clean(Cog):
first_limit, second_limit = sorted([first_limit, second_limit])
# Needs to be called after standardizing the input.
- predicate = self._build_predicate(bots_only, users, regex, first_limit, second_limit)
+ predicate = self._build_predicate(first_limit, second_limit, bots_only, users, regex)
# Delete the invocation first
await self._delete_invocation(ctx)
- if channels == "*" and use_cache:
- message_mappings, message_ids = self._get_messages_from_cache(traverse=traverse, to_delete=predicate)
+ if self._use_cache(first_limit):
+ log.trace(f"Messages for cleaning by {ctx.author.id} will be searched in the cache.")
+ message_mappings, message_ids = self._get_messages_from_cache(
+ channels=deletion_channels, to_delete=predicate, lower_limit=first_limit
+ )
else:
- deletion_channels = channels
- if channels == "*":
- deletion_channels = [channel for channel in ctx.guild.channels if isinstance(channel, TextChannel)]
+ log.trace(f"Messages for cleaning by {ctx.author.id} will be searched in channel histories.")
message_mappings, message_ids = await self._get_messages_from_channels(
- traverse=traverse,
channels=deletion_channels,
to_delete=predicate,
before=second_limit,
@@ -408,6 +425,8 @@ class Clean(Cog):
deleted_messages = await self._delete_found(message_mappings)
self.cleaning = False
+ if not channels:
+ channels = deletion_channels
logged = await self._modlog_cleaned_messages(deleted_messages, channels, ctx)
if logged and is_mod_channel(ctx.channel):
@@ -421,12 +440,10 @@ class Clean(Cog):
self,
ctx: Context,
users: Greedy[User] = None,
- traverse: Optional[int] = None,
first_limit: Optional[CleanLimit] = None,
second_limit: Optional[CleanLimit] = None,
- use_cache: Optional[bool] = None,
- bots_only: Optional[bool] = False,
regex: Optional[Regex] = None,
+ bots_only: Optional[bool] = False,
*,
channels: CleanChannels = None # "Optional" with discord.py silently ignores incorrect input.
) -> None:
@@ -436,91 +453,74 @@ class Clean(Cog):
If arguments are provided, will act as a master command from which all subcommands can be derived.
\u2003• `users`: A series of user mentions, ID's, or names.
- \u2003• `traverse`: The number of messages to look at in each channel. If using the cache, will look at the
- first `traverse` messages in the cache.
\u2003• `first_limit` and `second_limit`: A message, a duration delta, or an ISO datetime.
+ At least one limit is required.
If a message is provided, cleaning will happen in that channel, and channels cannot be provided.
- If a limit is provided, multiple channels cannot be provided.
If only one of them is provided, acts as `clean until`. If both are provided, acts as `clean between`.
- \u2003• `use_cache`: Whether to use the message cache.
- If not provided, will default to False unless an asterisk is used for the channels.
- \u2003• `bots_only`: Whether to delete only bots. If specified, users cannot be specified.
\u2003• `regex`: A regex pattern the message must contain to be deleted.
The pattern must be provided enclosed in backticks.
If the pattern contains spaces, it still needs to be enclosed in double quotes on top of that.
- \u2003• `channels`: A series of channels to delete in, or an asterisk to delete from all channels.
+ \u2003• `bots_only`: Whether to delete only bots. If specified, users cannot be specified.
+ \u2003• `channels`: A series of channels to delete in, or an asterisk to delete from all public channels.
"""
- if not any([traverse, users, first_limit, second_limit, regex, channels]):
+ if not any([users, first_limit, second_limit, regex, channels]):
await ctx.send_help(ctx.command)
return
- if not traverse:
- if first_limit:
- traverse = CleanMessages.message_limit
- else:
- traverse = DEFAULT_TRAVERSE
- if use_cache is None:
- use_cache = channels == "*"
-
- await self._clean_messages(
- ctx, traverse, channels, bots_only, users, regex, first_limit, second_limit, use_cache
- )
+ await self._clean_messages(ctx, channels, bots_only, users, regex, first_limit, second_limit)
@clean_group.command(name="user", aliases=["users"])
async def clean_user(
self,
ctx: Context,
user: User,
- traverse: Optional[int] = DEFAULT_TRAVERSE,
- use_cache: Optional[bool] = True,
+ message_or_time: CleanLimit,
*,
channels: CleanChannels = None
) -> None:
- """Delete messages posted by the provided user, stop cleaning after traversing `traverse` messages."""
- await self._clean_messages(ctx, traverse, users=[user], channels=channels, use_cache=use_cache)
+ """
+ Delete messages posted by the provided user, stop cleaning after reaching `message_or_time`.
- @clean_group.command(name="all", aliases=["everything"])
- async def clean_all(
- self,
- ctx: Context,
- traverse: Optional[int] = DEFAULT_TRAVERSE,
- use_cache: Optional[bool] = True,
- *,
- channels: CleanChannels = None
- ) -> None:
- """Delete all messages, regardless of poster, stop cleaning after traversing `traverse` messages."""
- await self._clean_messages(ctx, traverse, channels=channels, use_cache=use_cache)
+ `message_or_time` can be either a message to stop at (exclusive), a timedelta for max message age, or an ISO
+ datetime.
+
+ If a message is specified, `channels` cannot be specified.
+ """
+ await self._clean_messages(ctx, users=[user], channels=channels, first_limit=message_or_time)
@clean_group.command(name="bots", aliases=["bot"])
- async def clean_bots(
- self,
- ctx: Context,
- traverse: Optional[int] = DEFAULT_TRAVERSE,
- use_cache: Optional[bool] = True,
- *,
- channels: CleanChannels = None
- ) -> None:
- """Delete all messages posted by a bot, stop cleaning after traversing `traverse` messages."""
- await self._clean_messages(ctx, traverse, bots_only=True, channels=channels, use_cache=use_cache)
+ async def clean_bots(self, ctx: Context, message_or_time: CleanLimit, *, channels: CleanChannels = None) -> None:
+ """
+ Delete all messages posted by a bot, stop cleaning after traversing `traverse` messages.
+
+ `message_or_time` can be either a message to stop at (exclusive), a timedelta for max message age, or an ISO
+ datetime.
+
+ If a message is specified, `channels` cannot be specified.
+ """
+ await self._clean_messages(ctx, bots_only=True, channels=channels, first_limit=message_or_time)
@clean_group.command(name="regex", aliases=["word", "expression", "pattern"])
async def clean_regex(
self,
ctx: Context,
regex: Regex,
- traverse: Optional[int] = DEFAULT_TRAVERSE,
- use_cache: Optional[bool] = True,
+ message_or_time: CleanLimit,
*,
channels: CleanChannels = None
) -> None:
"""
- Delete all messages that match a certain regex, stop cleaning after traversing `traverse` messages.
+ Delete all messages that match a certain regex, stop cleaning after reaching `message_or_time`.
+
+ `message_or_time` can be either a message to stop at (exclusive), a timedelta for max message age, or an ISO
+ datetime.
+ If a message is specified, `channels` cannot be specified.
The pattern must be provided enclosed in backticks.
If the pattern contains spaces, it still needs to be enclosed in double quotes on top of that.
For example: `[0-9]`
"""
- await self._clean_messages(ctx, traverse, regex=regex, channels=channels, use_cache=use_cache)
+ await self._clean_messages(ctx, regex=regex, channels=channels, first_limit=message_or_time)
@clean_group.command(name="until")
async def clean_until(
@@ -537,7 +537,6 @@ class Clean(Cog):
"""
await self._clean_messages(
ctx,
- CleanMessages.message_limit,
channels=[channel] if channel else None,
first_limit=until,
)
@@ -561,7 +560,6 @@ class Clean(Cog):
"""
await self._clean_messages(
ctx,
- CleanMessages.message_limit,
channels=[channel] if channel else None,
first_limit=first_limit,
second_limit=second_limit,
diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py
index e265e29d3..77dfad255 100644
--- a/bot/exts/moderation/incidents.py
+++ b/bot/exts/moderation/incidents.py
@@ -1,16 +1,18 @@
import asyncio
-import typing as t
+import re
from datetime import datetime
from enum import Enum
+from typing import Optional
import discord
-from discord.ext.commands import Cog
+from async_rediscache import RedisCache
+from discord.ext.commands import Cog, Context, MessageConverter, MessageNotFound
from bot.bot import Bot
-from bot.constants import Channels, Colours, Emojis, Guild, Webhooks
+from bot.constants import Channels, Colours, Emojis, Guild, Roles, Webhooks
from bot.log import get_logger
from bot.utils import scheduling
-from bot.utils.messages import sub_clyde
+from bot.utils.messages import format_user, sub_clyde
log = get_logger(__name__)
@@ -22,6 +24,12 @@ CRAWL_LIMIT = 50
# Seconds for `crawl_task` to sleep after adding reactions to a message
CRAWL_SLEEP = 2
+DISCORD_MESSAGE_LINK_RE = re.compile(
+ r"(https?:\/\/(?:(ptb|canary|www)\.)?discord(?:app)?\.com\/channels\/"
+ r"[0-9]{15,20}"
+ r"\/[0-9]{15,20}\/[0-9]{15,20})"
+)
+
class Signal(Enum):
"""
@@ -37,17 +45,17 @@ class Signal(Enum):
# Reactions from non-mod roles will be removed
-ALLOWED_ROLES: t.Set[int] = set(Guild.moderation_roles)
+ALLOWED_ROLES: set[int] = set(Guild.moderation_roles)
# Message must have all of these emoji to pass the `has_signals` check
-ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal}
+ALL_SIGNALS: set[str] = {signal.value for signal in Signal}
# An embed coupled with an optional file to be dispatched
# If the file is not None, the embed attempts to show it in its body
-FileEmbed = t.Tuple[discord.Embed, t.Optional[discord.File]]
+FileEmbed = tuple[discord.Embed, Optional[discord.File]]
-async def download_file(attachment: discord.Attachment) -> t.Optional[discord.File]:
+async def download_file(attachment: discord.Attachment) -> Optional[discord.File]:
"""
Download & return `attachment` file.
@@ -121,7 +129,7 @@ def is_incident(message: discord.Message) -> bool:
return all(conditions)
-def own_reactions(message: discord.Message) -> t.Set[str]:
+def own_reactions(message: discord.Message) -> set[str]:
"""Get the set of reactions placed on `message` by the bot itself."""
return {str(reaction.emoji) for reaction in message.reactions if reaction.me}
@@ -131,6 +139,108 @@ def has_signals(message: discord.Message) -> bool:
return ALL_SIGNALS.issubset(own_reactions(message))
+def shorten_text(text: str) -> str:
+ """
+ Truncate the text if there are over 3 lines or 300 characters, or if it is a single word.
+
+ The maximum length of the string would be 303 characters across 3 lines at maximum.
+ """
+ original_length = len(text)
+ # Truncate text to a maximum of 300 characters
+ if len(text) > 300:
+ text = text[:300]
+
+ # Limit to a maximum of three lines
+ text = "\n".join(text.split("\n", maxsplit=3)[:3])
+
+ # If it is a single word, then truncate it to 50 characters
+ if text.find(" ") == -1:
+ text = text[:50]
+
+ # Remove extra whitespaces from the `text`
+ text = text.strip()
+
+ # Add placeholder if the text was shortened
+ if len(text) < original_length:
+ text = f"{text}..."
+
+ return text
+
+
+async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[discord.Embed]:
+ """
+ Create an embedded representation of the discord message link contained in the incident report.
+
+ The Embed would contain the following information -->
+ Author: @Jason Terror ♦ (736234578745884682)
+ Channel: Special/#bot-commands (814190307980607493)
+ Content: This is a very important message!
+ """
+ embed = None
+
+ try:
+ message: discord.Message = await MessageConverter().convert(ctx, message_link)
+ except MessageNotFound:
+ mod_logs_channel = ctx.bot.get_channel(Channels.mod_log)
+
+ last_100_logs: list[discord.Message] = await mod_logs_channel.history(limit=100).flatten()
+
+ for log_entry in last_100_logs:
+ if not log_entry.embeds:
+ continue
+
+ log_embed: discord.Embed = log_entry.embeds[0]
+ if (
+ log_embed.author.name == "Message deleted"
+ and f"[Jump to message]({message_link})" in log_embed.description
+ ):
+ embed = discord.Embed(
+ colour=discord.Colour.dark_gold(),
+ title="Deleted Message Link",
+ description=(
+ f"Found <#{Channels.mod_log}> entry for deleted message: "
+ f"[Jump to message]({log_entry.jump_url})."
+ )
+ )
+ if not embed:
+ embed = discord.Embed(
+ colour=discord.Colour.red(),
+ title="Bad Message Link",
+ description=f"Message {message_link} not found."
+ )
+ except discord.DiscordException as e:
+ log.exception(f"Failed to make message link embed for '{message_link}', raised exception: {e}")
+ else:
+ channel = message.channel
+ if not channel.permissions_for(channel.guild.get_role(Roles.helpers)).view_channel:
+ log.info(
+ f"Helpers don't have read permissions in #{channel.name},"
+ f" not sending message link embed for {message_link}"
+ )
+ return
+
+ embed = discord.Embed(
+ colour=discord.Colour.gold(),
+ description=(
+ f"**Author:** {format_user(message.author)}\n"
+ f"**Channel:** {channel.mention} ({channel.category}"
+ f"{f'/#{channel.parent.name} - ' if isinstance(channel, discord.Thread) else '/#'}"
+ f"{channel.name})\n"
+ ),
+ timestamp=message.created_at
+ )
+ embed.add_field(
+ name="Content",
+ value=shorten_text(message.content) if message.content else "[No Message Content]"
+ )
+ embed.set_footer(text=f"Message ID: {message.id}")
+
+ if message.attachments:
+ embed.set_image(url=message.attachments[0].url)
+
+ return embed
+
+
async def add_signals(incident: discord.Message) -> None:
"""
Add `Signal` member emoji to `incident` as reactions.
@@ -168,6 +278,7 @@ class Incidents(Cog):
* See: `crawl_incidents`
On message:
+ * Run message through `extract_message_links` and send them into the channel
* Add `Signal` member emoji if message qualifies as an incident
* Ignore messages starting with #
* Use this if verbal communication is necessary
@@ -181,18 +292,35 @@ class Incidents(Cog):
* If `Signal.ACTIONED` or `Signal.NOT_ACTIONED` were chosen, attempt to
relay the incident message to #incidents-archive
* If relay successful, delete original message
+ * Delete quotation message if cached
* See: `on_raw_reaction_add`
Please refer to function docstrings for implementation details.
"""
+ # This dictionary maps an incident report message to the message link embed's ID
+ # RedisCache[discord.Message.id, discord.Message.id]
+ message_link_embeds_cache = RedisCache()
+
def __init__(self, bot: Bot) -> None:
"""Prepare `event_lock` and schedule `crawl_task` on start-up."""
self.bot = bot
+ self.incidents_webhook = None
+
+ scheduling.create_task(self.fetch_webhook(), event_loop=self.bot.loop)
self.event_lock = asyncio.Lock()
self.crawl_task = scheduling.create_task(self.crawl_incidents(), event_loop=self.bot.loop)
+ async def fetch_webhook(self) -> None:
+ """Fetch the incidents webhook object, so we can post message link embeds to it."""
+ await self.bot.wait_until_guild_available()
+
+ try:
+ self.incidents_webhook = await self.bot.fetch_webhook(Webhooks.incidents)
+ except discord.HTTPException:
+ log.error(f"Failed to fetch incidents webhook with id `{Webhooks.incidents}`.")
+
async def crawl_incidents(self) -> None:
"""
Crawl #incidents and add missing emoji where necessary.
@@ -292,8 +420,11 @@ class Incidents(Cog):
This ensures that if there is a racing event awaiting the lock, it will fail to find the
message, and will abort. There is a `timeout` to ensure that this doesn't hold the lock
forever should something go wrong.
+
+ Deletes cache value (`message_link_embeds_cache`) of `incident` if it exists. It then removes the
+ webhook message for that particular link from the channel.
"""
- members_roles: t.Set[int] = {role.id for role in member.roles}
+ members_roles: set[int] = {role.id for role in member.roles}
if not members_roles & ALLOWED_ROLES: # Intersection is truthy on at least 1 common element
log.debug(f"Removing invalid reaction: user {member} is not permitted to send signals")
try:
@@ -340,7 +471,11 @@ class Incidents(Cog):
else:
log.trace("Deletion was confirmed")
- async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]:
+ if self.incidents_webhook:
+ # Deletes the message link embeds found in cache from the channel and cache.
+ await self.delete_msg_link_embed(incident.id)
+
+ async def resolve_message(self, message_id: int) -> Optional[discord.Message]:
"""
Get `discord.Message` for `message_id` from cache, or API.
@@ -355,7 +490,7 @@ class Incidents(Cog):
"""
await self.bot.wait_until_guild_available() # First make sure that the cache is ready
log.trace(f"Resolving message for: {message_id=}")
- message: t.Optional[discord.Message] = self.bot._connection._get_message(message_id)
+ message: Optional[discord.Message] = self.bot._connection._get_message(message_id)
if message is not None:
log.trace("Message was found in cache")
@@ -419,9 +554,107 @@ class Incidents(Cog):
@Cog.listener()
async def on_message(self, message: discord.Message) -> None:
- """Pass `message` to `add_signals` if and only if it satisfies `is_incident`."""
- if is_incident(message):
- await add_signals(message)
+ """
+ Pass `message` to `add_signals` and `extract_message_links` if it satisfies `is_incident`.
+
+ If `message` is an incident report, then run it through `extract_message_links` to get all
+ the message link embeds (embeds which contain information about that particular link).
+ These message link embeds are then sent into the channel.
+
+ Also passes the message into `add_signals` if the message is an incident.
+ """
+ if not is_incident(message):
+ return
+
+ await add_signals(message)
+
+ # Only use this feature if incidents webhook embed is found
+ if self.incidents_webhook:
+ if embed_list := await self.extract_message_links(message):
+ await self.send_message_link_embeds(embed_list, message, self.incidents_webhook)
+
+ @Cog.listener()
+ async def on_raw_message_delete(self, payload: discord.RawMessageDeleteEvent) -> None:
+ """
+ Delete message link embeds for `payload.message_id`.
+
+ Search through the cache for message, if found delete it from cache and channel.
+ """
+ if self.incidents_webhook:
+ await self.delete_msg_link_embed(payload.message_id)
+
+ async def extract_message_links(self, message: discord.Message) -> Optional[list[discord.Embed]]:
+ """
+ Check if there's any message links in the text content.
+
+ Then pass the message_link into `make_message_link_embed` to format an
+ embed for it containing information about the link.
+
+ As Discord only allows a max of 10 embeds in a single webhook, just send the
+ first 10 embeds and don't care about the rest.
+
+ If no links are found for the message, just log a trace statement.
+ """
+ message_links = DISCORD_MESSAGE_LINK_RE.findall(message.content)
+ if not message_links:
+ log.trace(
+ f"No message links detected on incident message with id {message.id}."
+ )
+ return
+
+ embeds = []
+ for message_link in message_links[:10]:
+ ctx = await self.bot.get_context(message)
+ embed = await make_message_link_embed(ctx, message_link[0])
+ if embed:
+ embeds.append(embed)
+
+ return embeds
+
+ async def send_message_link_embeds(
+ self,
+ webhook_embed_list: list,
+ message: discord.Message,
+ webhook: discord.Webhook,
+ ) -> Optional[int]:
+ """
+ Send message link embeds to #incidents channel.
+
+ Using the `webhook` passed in as a parameter to send
+ the embeds in the `webhook_embed_list` parameter.
+
+ After sending each embed it maps the `message.id`
+ to the `webhook_msg_ids` IDs in the async redis-cache.
+ """
+ try:
+ webhook_msg = await webhook.send(
+ embeds=[embed for embed in webhook_embed_list if embed],
+ username=sub_clyde(message.author.name),
+ avatar_url=message.author.display_avatar.url,
+ wait=True,
+ )
+ except discord.DiscordException:
+ log.exception(
+ f"Failed to send message link embed {message.id} to #incidents."
+ )
+ else:
+ await self.message_link_embeds_cache.set(message.id, webhook_msg.id)
+ log.trace("Message link embeds sent successfully to #incidents!")
+ return webhook_msg.id
+
+ async def delete_msg_link_embed(self, message_id: int) -> None:
+ """Delete the Discord message link message found in cache for `message_id`."""
+ log.trace("Deleting Discord message link's webhook message.")
+ webhook_msg_id = await self.message_link_embeds_cache.get(int(message_id))
+
+ if webhook_msg_id:
+ try:
+ await self.incidents_webhook.delete_message(webhook_msg_id)
+ except discord.errors.NotFound:
+ log.trace(f"Incidents message link embed (`{webhook_msg_id}`) has already been deleted, skipping.")
+
+ await self.message_link_embeds_cache.delete(message_id)
+ log.trace("Successfully deleted discord links webhook message.")
def setup(bot: Bot) -> None:
diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py
index 433aa0b05..e683c9db4 100644
--- a/bot/exts/moderation/infraction/_utils.py
+++ b/bot/exts/moderation/infraction/_utils.py
@@ -31,9 +31,9 @@ Infraction = t.Dict[str, t.Union[str, int, bool]]
APPEAL_SERVER_INVITE = "https://discord.gg/WXrCJxWBnm"
INFRACTION_TITLE = "Please review our rules"
-INFRACTION_APPEAL_SERVER_FOOTER = f"\n\nTo appeal this infraction, join our [appeals server]({APPEAL_SERVER_INVITE})."
+INFRACTION_APPEAL_SERVER_FOOTER = f"\nTo appeal this infraction, join our [appeals server]({APPEAL_SERVER_INVITE})."
INFRACTION_APPEAL_MODMAIL_FOOTER = (
- '\n\nIf you would like to discuss or appeal this infraction, '
+ '\nIf you would like to discuss or appeal this infraction, '
'send a message to the ModMail bot.'
)
INFRACTION_AUTHOR_NAME = "Infraction information"
diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py
index 6416bc3c7..91709e5e5 100644
--- a/bot/exts/moderation/modlog.py
+++ b/bot/exts/moderation/modlog.py
@@ -775,6 +775,10 @@ class ModLog(Cog, name="ModLog"):
@Cog.listener()
async def on_thread_update(self, before: Thread, after: Thread) -> None:
"""Log thread archiving, un-archiving and name edits."""
+ if self.is_channel_ignored(after.id):
+ log.trace("Ignoring update of thread %s (%d)", after.mention, after.id)
+ return
+
if before.name != after.name:
await self.send_log_message(
Icons.hash_blurple,
@@ -811,6 +815,10 @@ class ModLog(Cog, name="ModLog"):
@Cog.listener()
async def on_thread_delete(self, thread: Thread) -> None:
"""Log thread deletion."""
+ if self.is_channel_ignored(thread.id):
+ log.trace("Ignoring deletion of thread %s (%d)", thread.mention, thread.id)
+ return
+
await self.send_log_message(
Icons.hash_red,
Colours.soft_red,
@@ -829,6 +837,10 @@ class ModLog(Cog, name="ModLog"):
if thread.me:
return
+ if self.is_channel_ignored(thread.id):
+ log.trace("Ignoring creation of thread %s (%d)", thread.mention, thread.id)
+ return
+
await self.send_log_message(
Icons.hash_green,
Colours.soft_green,
diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py
index f67d8f662..20a8c39d7 100644
--- a/bot/exts/moderation/modpings.py
+++ b/bot/exts/moderation/modpings.py
@@ -1,8 +1,9 @@
+import asyncio
import datetime
import arrow
from async_rediscache import RedisCache
-from dateutil.parser import isoparse
+from dateutil.parser import isoparse, parse as dateutil_parse
from discord import Embed, Member
from discord.ext.commands import Cog, Context, group, has_any_role
@@ -12,9 +13,12 @@ from bot.converters import Expiry
from bot.log import get_logger
from bot.utils import scheduling
from bot.utils.scheduling import Scheduler
+from bot.utils.time import TimestampFormats, discord_timestamp
log = get_logger(__name__)
+MAXIMUM_WORK_LIMIT = 16
+
class ModPings(Cog):
"""Commands for a moderator to turn moderator pings on and off."""
@@ -24,13 +28,23 @@ class ModPings(Cog):
# The cache's values are the times when the role should be re-applied to them, stored in ISO format.
pings_off_mods = RedisCache()
+ # RedisCache[discord.Member.id, 'start timestamp|total worktime in seconds']
+ # The cache's keys are mod's ID
+ # The cache's values are their pings on schedule timestamp and the total seconds (work time) until pings off
+ modpings_schedule = RedisCache()
+
def __init__(self, bot: Bot):
self.bot = bot
- self._role_scheduler = Scheduler(self.__class__.__name__)
+ self._role_scheduler = Scheduler("ModPingsOnOff")
+ self._modpings_scheduler = Scheduler("ModPingsSchedule")
self.guild = None
self.moderators_role = None
+ self.modpings_schedule_task = scheduling.create_task(
+ self.reschedule_modpings_schedule(),
+ event_loop=self.bot.loop
+ )
self.reschedule_task = scheduling.create_task(
self.reschedule_roles(),
name="mod-pings-reschedule",
@@ -61,6 +75,53 @@ class ModPings(Cog):
expiry = isoparse(pings_off[mod.id])
self._role_scheduler.schedule_at(expiry, mod.id, self.reapply_role(mod))
+ async def reschedule_modpings_schedule(self) -> None:
+ """Reschedule moderators schedule ping."""
+ await self.bot.wait_until_guild_available()
+ schedule_cache = await self.modpings_schedule.to_dict()
+
+ log.info("Scheduling modpings schedule for applicable moderators found in cache.")
+ for mod_id, schedule in schedule_cache.items():
+ start_timestamp, work_time = schedule.split("|")
+ start = datetime.datetime.fromtimestamp(float(start_timestamp))
+
+ mod = await self.bot.fetch_user(mod_id)
+ self._modpings_scheduler.schedule_at(
+ start,
+ mod_id,
+ self.add_role_schedule(mod, work_time, start)
+ )
+
+ async def remove_role_schedule(self, mod: Member, work_time: int, schedule_start: datetime.datetime) -> None:
+ """Removes the moderator's role to the given moderator."""
+ log.trace(f"Removing moderator role from mod with ID {mod.id}")
+ await mod.remove_roles(self.moderators_role, reason="Moderator schedule time expired.")
+
+ # Remove the task before scheduling it again
+ self._modpings_scheduler.cancel(mod.id)
+
+ # Add the task again
+ log.trace(f"Adding mod pings schedule task again for mod with ID {mod.id}")
+ schedule_start += datetime.timedelta(days=1)
+ self._modpings_scheduler.schedule_at(
+ schedule_start,
+ mod.id,
+ self.add_role_schedule(mod, work_time, schedule_start)
+ )
+
+ async def add_role_schedule(self, mod: Member, work_time: int, schedule_start: datetime.datetime) -> None:
+ """Adds the moderator's role to the given moderator."""
+ # If the moderator has pings off, then skip adding role
+ if mod.id in await self.pings_off_mods.to_dict():
+ log.trace(f"Skipping adding moderator role to mod with ID {mod.id} - found in pings off cache.")
+ else:
+ log.trace(f"Applying moderator role to mod with ID {mod.id}")
+ await mod.add_roles(self.moderators_role, reason="Moderator scheduled time started!")
+
+ log.trace(f"Sleeping for {work_time} seconds, worktime for mod with ID {mod.id}")
+ await asyncio.sleep(work_time)
+ await self.remove_role_schedule(mod, work_time, schedule_start)
+
async def reapply_role(self, mod: Member) -> None:
"""Reapply the moderator's role to the given moderator."""
log.trace(f"Re-applying role to mod with ID {mod.id}.")
@@ -132,12 +193,66 @@ class ModPings(Cog):
await ctx.send(f"{Emojis.check_mark} Moderators role has been re-applied.")
+ @modpings_group.group(
+ name='schedule',
+ aliases=('s',),
+ invoke_without_command=True
+ )
+ @has_any_role(*MODERATION_ROLES)
+ async def schedule_modpings(self, ctx: Context, start: str, end: str) -> None:
+ """Schedule modpings role to be added at <start> and removed at <end> everyday at UTC time!"""
+ start, end = dateutil_parse(start), dateutil_parse(end)
+
+ if end < start:
+ end += datetime.timedelta(days=1)
+
+ if (end - start) > datetime.timedelta(hours=MAXIMUM_WORK_LIMIT):
+ await ctx.send(
+ f":x: {ctx.author.mention} You can't have the modpings role for"
+ f" more than {MAXIMUM_WORK_LIMIT} hours!"
+ )
+ return
+
+ if start < datetime.datetime.utcnow():
+ # The datetime has already gone for the day, so make it tomorrow
+ # otherwise the scheduler would schedule it immediately
+ start += datetime.timedelta(days=1)
+
+ work_time = (end - start).total_seconds()
+
+ await self.modpings_schedule.set(ctx.author.id, f"{start.timestamp()}|{work_time}")
+
+ if ctx.author.id in self._modpings_scheduler:
+ self._modpings_scheduler.cancel(ctx.author.id)
+
+ self._modpings_scheduler.schedule_at(
+ start,
+ ctx.author.id,
+ self.add_role_schedule(ctx.author, work_time, start)
+ )
+
+ await ctx.send(
+ f"{Emojis.ok_hand} {ctx.author.mention} Scheduled mod pings from "
+ f"{discord_timestamp(start, TimestampFormats.TIME)} to "
+ f"{discord_timestamp(end, TimestampFormats.TIME)}!"
+ )
+
+ @schedule_modpings.command(name='delete', aliases=('del', 'd'))
+ async def modpings_schedule_delete(self, ctx: Context) -> None:
+ """Delete your modpings schedule."""
+ self._modpings_scheduler.cancel(ctx.author.id)
+ await self.modpings_schedule.delete(ctx.author.id)
+ await ctx.send(f"{Emojis.ok_hand} {ctx.author.mention} Deleted your modpings schedule!")
+
def cog_unload(self) -> None:
"""Cancel role tasks when the cog unloads."""
log.trace("Cog unload: canceling role tasks.")
self.reschedule_task.cancel()
self._role_scheduler.cancel_all()
+ self.modpings_schedule_task.cancel()
+ self._modpings_scheduler.cancel_all()
+
def setup(bot: Bot) -> None:
"""Load the ModPings cog."""
diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py
index 9583597e0..da04d1e98 100644
--- a/bot/exts/moderation/slowmode.py
+++ b/bot/exts/moderation/slowmode.py
@@ -16,7 +16,7 @@ SLOWMODE_MAX_DELAY = 21600 # seconds
COMMONLY_SLOWMODED_CHANNELS = {
Channels.python_general: "python_general",
- Channels.discord_py: "discordpy",
+ Channels.discord_bots: "discord_bots",
Channels.off_topic_0: "ot0",
}
diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py
index ed5571d2a..37338d19c 100644
--- a/bot/exts/moderation/verification.py
+++ b/bot/exts/moderation/verification.py
@@ -5,9 +5,7 @@ from discord.ext.commands import Cog, Context, command, has_any_role
from bot import constants
from bot.bot import Bot
-from bot.decorators import in_whitelist
from bot.log import get_logger
-from bot.utils.checks import InWhitelistCheckFailure
log = get_logger(__name__)
@@ -29,11 +27,11 @@ You can find a copy of our rules for reference at <https://pythondiscord.com/pag
Additionally, if you'd like to receive notifications for the announcements \
we post in <#{constants.Channels.announcements}>
-from time to time, you can send `!subscribe` to <#{constants.Channels.bot_commands}> at any time \
+from time to time, you can send `{constants.Bot.prefix}subscribe` to <#{constants.Channels.bot_commands}> at any time \
to assign yourself the **Announcements** role. We'll mention this role every time we make an announcement.
-If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \
-<#{constants.Channels.bot_commands}>.
+If you'd like to unsubscribe from the announcement notifications, simply send `{constants.Bot.prefix}subscribe` to \
+<#{constants.Channels.bot_commands}> and click the role again!.
To introduce you to our community, we've made the following video:
https://youtu.be/ZH26PuX3re0
@@ -61,11 +59,9 @@ async def safe_dm(coro: t.Coroutine) -> None:
class Verification(Cog):
"""
- User verification and role management.
+ User verification.
Statistics are collected in the 'verification.' namespace.
-
- Additionally, this cog offers the !subscribe and !unsubscribe commands,
"""
def __init__(self, bot: Bot) -> None:
@@ -108,67 +104,8 @@ class Verification(Cog):
log.exception("DM dispatch failed on unexpected error code")
# endregion
- # region: subscribe commands
-
- @command(name='subscribe')
- @in_whitelist(channels=(constants.Channels.bot_commands,))
- async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
- """Subscribe to announcement notifications by assigning yourself the role."""
- has_role = False
-
- for role in ctx.author.roles:
- if role.id == constants.Roles.announcements:
- has_role = True
- break
-
- if has_role:
- await ctx.send(f"{ctx.author.mention} You're already subscribed!")
- return
-
- log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.")
- await ctx.author.add_roles(discord.Object(constants.Roles.announcements), reason="Subscribed to announcements")
-
- log.trace(f"Deleting the message posted by {ctx.author}.")
-
- await ctx.send(
- f"{ctx.author.mention} Subscribed to <#{constants.Channels.announcements}> notifications.",
- )
-
- @command(name='unsubscribe')
- @in_whitelist(channels=(constants.Channels.bot_commands,))
- async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
- """Unsubscribe from announcement notifications by removing the role from yourself."""
- has_role = False
-
- for role in ctx.author.roles:
- if role.id == constants.Roles.announcements:
- has_role = True
- break
-
- if not has_role:
- await ctx.send(f"{ctx.author.mention} You're already unsubscribed!")
- return
-
- log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.")
- await ctx.author.remove_roles(
- discord.Object(constants.Roles.announcements), reason="Unsubscribed from announcements"
- )
-
- log.trace(f"Deleting the message posted by {ctx.author}.")
-
- await ctx.send(
- f"{ctx.author.mention} Unsubscribed from <#{constants.Channels.announcements}> notifications."
- )
-
- # endregion
# region: miscellaneous
- # This cannot be static (must have a __func__ attribute).
- async def cog_command_error(self, ctx: Context, error: Exception) -> None:
- """Check for & ignore any InWhitelistCheckFailure."""
- if isinstance(error, InWhitelistCheckFailure):
- error.handled = True
-
@command(name='verify')
@has_any_role(*constants.MODERATION_ROLES)
async def perform_manual_verification(self, ctx: Context, user: discord.Member) -> None:
diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py
index 31799ec73..ae55a03a0 100644
--- a/bot/exts/moderation/voice_gate.py
+++ b/bot/exts/moderation/voice_gate.py
@@ -171,8 +171,12 @@ class VoiceGate(Cog):
),
"total_messages": data["total_messages"] < GateConf.minimum_messages,
"voice_banned": data["voice_banned"],
- "activity_blocks": data["activity_blocks"] < GateConf.minimum_activity_blocks
}
+ if activity_blocks := data.get("activity_blocks"):
+ # activity_blocks is not included in the response if the user has a lot of messages.
+ # Only check if the user has enough activity blocks if it is included.
+ checks["activity_blocks"] = activity_blocks < GateConf.minimum_activity_blocks
+
failed = any(checks.values())
failed_reasons = [MESSAGE_FIELD_MAP[key] for key, value in checks.items() if value is True]
[self.bot.stats.incr(f"voice_gate.failed.{key}") for key, value in checks.items() if value is True]
diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py
index 2fafaec97..8fa0be5b1 100644
--- a/bot/exts/recruitment/talentpool/_cog.py
+++ b/bot/exts/recruitment/talentpool/_cog.py
@@ -483,12 +483,9 @@ class TalentPool(Cog, name="Talentpool"):
@has_any_role(*MODERATION_ROLES)
async def get_review(self, ctx: Context, user_id: int) -> None:
"""Get the user's review as a markdown file."""
- review = (await self.reviewer.make_review(user_id))[0]
- if review:
- file = discord.File(StringIO(review), f"{user_id}_review.md")
- await ctx.send(file=file)
- else:
- await ctx.send(f"There doesn't appear to be an active nomination for {user_id}")
+ review, _, _ = await self.reviewer.make_review(user_id)
+ file = discord.File(StringIO(review), f"{user_id}_review.md")
+ await ctx.send(file=file)
@nomination_group.command(aliases=('review',))
@has_any_role(*MODERATION_ROLES)
@@ -501,7 +498,7 @@ class TalentPool(Cog, name="Talentpool"):
await ctx.message.add_reaction(Emojis.check_mark)
@Cog.listener()
- async def on_member_ban(self, guild: Guild, user: Union[MemberOrUser]) -> None:
+ async def on_member_ban(self, guild: Guild, user: MemberOrUser) -> None:
"""Remove `user` from the talent pool after they are banned."""
await self.end_nomination(user.id, "User was banned.")
@@ -516,6 +513,9 @@ class TalentPool(Cog, name="Talentpool"):
if payload.channel_id != Channels.nomination_voting:
return
+ if payload.user_id == self.bot.user.id:
+ return
+
message: PartialMessage = self.bot.get_channel(payload.channel_id).get_partial_message(payload.message_id)
emoji = str(payload.emoji)
diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py
index d880c524c..0e7194892 100644
--- a/bot/exts/recruitment/talentpool/_review.py
+++ b/bot/exts/recruitment/talentpool/_review.py
@@ -10,12 +10,12 @@ from typing import List, Optional, Union
import arrow
from dateutil.parser import isoparse
-from discord import Embed, Emoji, Member, Message, NoMoreItems, PartialMessage, TextChannel
+from discord import Embed, Emoji, Member, Message, NoMoreItems, NotFound, PartialMessage, TextChannel
from discord.ext.commands import Context
from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.constants import Channels, Colours, Emojis, Guild
+from bot.constants import Channels, Colours, Emojis, Guild, Roles
from bot.log import get_logger
from bot.utils.members import get_or_fetch_member
from bot.utils.messages import count_unique_users_reaction, pin_no_system_message
@@ -36,9 +36,8 @@ MAX_MESSAGE_SIZE = 2000
MAX_EMBED_SIZE = 4000
# Regex for finding the first message of a nomination, and extracting the nominee.
-# Historic nominations will have 2 role mentions at the start, new ones won't, optionally match for this.
NOMINATION_MESSAGE_REGEX = re.compile(
- r"(?:<@&\d+> <@&\d+>\n)*?<@!?(\d+?)> \(.+#\d{4}\) for Helper!\n\n\*\*Nominated by:\*\*",
+ r"<@!?(\d+)> \(.+#\d{4}\) for Helper!\n\n",
re.MULTILINE
)
@@ -78,14 +77,14 @@ class Reviewer:
async def post_review(self, user_id: int, update_database: bool) -> None:
"""Format the review of a user and post it to the nomination voting channel."""
- review, reviewed_emoji = await self.make_review(user_id)
- if not review:
+ review, reviewed_emoji, nominee = await self.make_review(user_id)
+ if not nominee:
return
guild = self.bot.get_guild(Guild.id)
channel = guild.get_channel(Channels.nomination_voting)
- log.trace(f"Posting the review of {user_id}")
+ log.trace(f"Posting the review of {nominee} ({nominee.id})")
messages = await self._bulk_send(channel, review)
await pin_no_system_message(messages[0])
@@ -95,12 +94,17 @@ class Reviewer:
for reaction in (reviewed_emoji, "\N{THUMBS UP SIGN}", "\N{THUMBS DOWN SIGN}"):
await last_message.add_reaction(reaction)
+ thread = await last_message.create_thread(
+ name=f"Nomination - {nominee}",
+ )
+ await thread.send(fr"<@&{Roles.mod_team}> <@&{Roles.admins}>")
+
if update_database:
nomination = self._pool.cache.get(user_id)
await self.bot.api_client.patch(f"bot/nominations/{nomination['id']}", json={"reviewed": True})
- async def make_review(self, user_id: int) -> typing.Tuple[str, Optional[Emoji]]:
- """Format a generic review of a user and return it with the reviewed emoji."""
+ async def make_review(self, user_id: int) -> typing.Tuple[str, Optional[Emoji], Optional[Member]]:
+ """Format a generic review of a user and return it with the reviewed emoji and the user themselves."""
log.trace(f"Formatting the review of {user_id}")
# Since `cache` is a defaultdict, we should take care
@@ -110,17 +114,17 @@ class Reviewer:
nomination = self._pool.cache.get(user_id)
if not nomination:
log.trace(f"There doesn't appear to be an active nomination for {user_id}")
- return "", None
+ return f"There doesn't appear to be an active nomination for {user_id}", None, None
guild = self.bot.get_guild(Guild.id)
- member = await get_or_fetch_member(guild, user_id)
+ nominee = await get_or_fetch_member(guild, user_id)
- if not member:
+ if not nominee:
return (
f"I tried to review the user with ID `{user_id}`, but they don't appear to be on the server :pensive:"
- ), None
+ ), None, None
- opening = f"{member.mention} ({member}) for Helper!"
+ opening = f"{nominee.mention} ({nominee}) for Helper!"
current_nominations = "\n\n".join(
f"**<@{entry['actor']}>:** {entry['reason'] or '*no reason given*'}"
@@ -128,7 +132,7 @@ class Reviewer:
)
current_nominations = f"**Nominated by:**\n{current_nominations}"
- review_body = await self._construct_review_body(member)
+ review_body = await self._construct_review_body(nominee)
reviewed_emoji = self._random_ducky(guild)
vote_request = (
@@ -138,7 +142,7 @@ class Reviewer:
)
review = "\n\n".join((opening, current_nominations, review_body, vote_request))
- return review, reviewed_emoji
+ return review, reviewed_emoji, nominee
async def archive_vote(self, message: PartialMessage, passed: bool) -> None:
"""Archive this vote to #nomination-archive."""
@@ -210,8 +214,21 @@ class Reviewer:
colour=colour
))
+ # Thread channel IDs are the same as the message ID of the parent message.
+ nomination_thread = message.guild.get_thread(message.id)
+ if not nomination_thread:
+ try:
+ nomination_thread = await message.guild.fetch_channel(message.id)
+ except NotFound:
+ log.warning(f"Could not find a thread linked to {message.channel.id}-{message.id}")
+ return
+
for message_ in messages:
- await message_.delete()
+ with contextlib.suppress(NotFound):
+ await message_.delete()
+
+ with contextlib.suppress(NotFound):
+ await nomination_thread.edit(archived=True)
async def _construct_review_body(self, member: Member) -> str:
"""Formats the body of the nomination, with details of activity, infractions, and previous nominations."""
@@ -360,10 +377,10 @@ class Reviewer:
@staticmethod
def _random_ducky(guild: Guild) -> Union[Emoji, str]:
- """Picks a random ducky emoji. If no duckies found returns :eyes:."""
+ """Picks a random ducky emoji. If no duckies found returns 👀."""
duckies = [emoji for emoji in guild.emojis if emoji.name.startswith("ducky")]
if not duckies:
- return ":eyes:"
+ return "\N{EYES}"
return random.choice(duckies)
@staticmethod
diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py
index 86e4505fa..90677b2dd 100644
--- a/bot/exts/utils/reminders.py
+++ b/bot/exts/utils/reminders.py
@@ -214,7 +214,7 @@ class Reminders(Cog):
@group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True)
async def remind_group(
- self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: str
+ self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: t.Optional[str] = None
) -> None:
"""
Commands for managing your reminders.
@@ -234,7 +234,7 @@ class Reminders(Cog):
@remind_group.command(name="new", aliases=("add", "create"))
async def new_reminder(
- self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: str
+ self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: t.Optional[str] = None
) -> None:
"""
Set yourself a simple reminder.
@@ -283,6 +283,20 @@ class Reminders(Cog):
mention_ids = [mention.id for mention in mentions]
+ # If `content` isn't provided then we try to get message content of a replied message
+ if not content:
+ if reference := ctx.message.reference:
+ if isinstance((resolved_message := reference.resolved), discord.Message):
+ content = resolved_message.content
+ # If we weren't able to get the content of a replied message
+ if content is None:
+ await send_denial(ctx, "Your reminder must have a content and/or reply to a message.")
+ return
+
+ # If the replied message has no content (e.g. only attachments/embeds)
+ if content == "":
+ content = "See referenced message."
+
# Now we can attempt to actually set the reminder.
reminder = await self.bot.api_client.post(
'bot/reminders',
diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py
index 821cebd8c..f76eea516 100644
--- a/bot/exts/utils/utils.py
+++ b/bot/exts/utils/utils.py
@@ -49,7 +49,7 @@ class Utils(Cog):
self.bot = bot
@command()
- @in_whitelist(channels=(Channels.bot_commands, Channels.discord_py), roles=STAFF_PARTNERS_COMMUNITY_ROLES)
+ @in_whitelist(channels=(Channels.bot_commands, Channels.discord_bots), roles=STAFF_PARTNERS_COMMUNITY_ROLES)
async def charinfo(self, ctx: Context, *, characters: str) -> None:
"""Shows you information on up to 50 unicode characters."""
match = re.match(r"<(a?):(\w+):(\d+)>", characters)
diff --git a/bot/log.py b/bot/log.py
index b3cecdcf2..100cd06f6 100644
--- a/bot/log.py
+++ b/bot/log.py
@@ -48,16 +48,17 @@ def setup() -> None:
logging.addLevelName(TRACE_LEVEL, "TRACE")
logging.setLoggerClass(CustomLogger)
+ root_log = get_logger()
+
format_string = "%(asctime)s | %(name)s | %(levelname)s | %(message)s"
log_format = logging.Formatter(format_string)
- log_file = Path("logs", "bot.log")
- log_file.parent.mkdir(exist_ok=True)
- file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7, encoding="utf8")
- file_handler.setFormatter(log_format)
-
- root_log = get_logger()
- root_log.addHandler(file_handler)
+ if constants.FILE_LOGS:
+ log_file = Path("logs", "bot.log")
+ log_file.parent.mkdir(exist_ok=True)
+ file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7, encoding="utf8")
+ file_handler.setFormatter(log_format)
+ root_log.addHandler(file_handler)
if "COLOREDLOGS_LEVEL_STYLES" not in os.environ:
coloredlogs.DEFAULT_LEVEL_STYLES = {
diff --git a/bot/monkey_patches.py b/bot/monkey_patches.py
index 23482f7c3..b5c0de8d9 100644
--- a/bot/monkey_patches.py
+++ b/bot/monkey_patches.py
@@ -5,6 +5,7 @@ from discord import Forbidden, http
from discord.ext import commands
from bot.log import get_logger
+from bot.utils.regex import MESSAGE_ID_RE
log = get_logger(__name__)
@@ -50,3 +51,25 @@ def patch_typing() -> None:
pass
http.HTTPClient.send_typing = honeybadger_type
+
+
+class FixedPartialMessageConverter(commands.PartialMessageConverter):
+ """
+ Make the Message converter infer channelID from the given context if only a messageID is given.
+
+ Discord.py's Message converter is supposed to infer channelID based
+ on ctx.channel if only a messageID is given. A refactor commit, linked below,
+ a few weeks before d.py's archival broke this defined behaviour of the converter.
+ Currently, if only a messageID is given to the converter, it will only find that message
+ if it's in the bot's cache.
+
+ https://github.com/Rapptz/discord.py/commit/1a4e73d59932cdbe7bf2c281f25e32529fc7ae1f
+ """
+
+ @staticmethod
+ def _get_id_matches(ctx: commands.Context, argument: str) -> tuple[int, int, int]:
+ """Inserts ctx.channel.id before calling super method if argument is just a messageID."""
+ match = MESSAGE_ID_RE.match(argument)
+ if match:
+ argument = f"{ctx.channel.id}-{match.group('message_id')}"
+ return commands.PartialMessageConverter._get_id_matches(ctx, argument)
diff --git a/bot/resources/tags/faq.md b/bot/resources/tags/faq.md
new file mode 100644
index 000000000..e1c57b3a0
--- /dev/null
+++ b/bot/resources/tags/faq.md
@@ -0,0 +1,6 @@
+---
+embed:
+ title: "Frequently asked questions"
+---
+
+As the largest Python community on Discord, we get hundreds of questions every day. Many of these questions have been asked before. We've compiled a list of the most frequently asked questions along with their answers, which can be found on our [FAQ page](https://www.pythondiscord.com/pages/frequently-asked-questions/).
diff --git a/bot/resources/tags/off-topic-names.md b/bot/resources/tags/off-topic-names.md
new file mode 100644
index 000000000..5d0614aaa
--- /dev/null
+++ b/bot/resources/tags/off-topic-names.md
@@ -0,0 +1,10 @@
+**Off-topic channels**
+
+There are three off-topic channels:
+• <#291284109232308226>
+• <#463035241142026251>
+• <#463035268514185226>
+
+The channel names change every night at midnight UTC and are often fun meta references to jokes or conversations that happened on the server.
+
+See our [off-topic etiquette](https://pythondiscord.com/pages/resources/guides/off-topic-etiquette/) page for more guidance on how the channels should be used.
diff --git a/bot/resources/tags/off-topic.md b/bot/resources/tags/off-topic.md
deleted file mode 100644
index 6a864a1d5..000000000
--- a/bot/resources/tags/off-topic.md
+++ /dev/null
@@ -1,10 +0,0 @@
-**Off-topic channels**
-
-There are three off-topic channels:
-• <#291284109232308226>
-• <#463035241142026251>
-• <#463035268514185226>
-
-Their names change randomly every 24 hours, but you can always find them under the `OFF-TOPIC/GENERAL` category in the channel list.
-
-Please read our [off-topic etiquette](https://pythondiscord.com/pages/resources/guides/off-topic-etiquette/) before participating in conversations.
diff --git a/bot/resources/tags/ot.md b/bot/resources/tags/ot.md
new file mode 100644
index 000000000..636e59110
--- /dev/null
+++ b/bot/resources/tags/ot.md
@@ -0,0 +1,3 @@
+**Off-topic channel:** <#463035268514185226>
+
+Please read our [off-topic etiquette](https://pythondiscord.com/pages/resources/guides/off-topic-etiquette/) before participating in conversations.
diff --git a/bot/resources/tags/resources.md b/bot/resources/tags/resources.md
new file mode 100644
index 000000000..201e0eb1e
--- /dev/null
+++ b/bot/resources/tags/resources.md
@@ -0,0 +1,6 @@
+---
+embed:
+ title: "Resources"
+---
+
+The [Resources page](https://www.pythondiscord.com/resources/) on our website contains a list of hand-selected learning resources that we regularly recommend to both beginners and experts.
diff --git a/bot/resources/tags/site.md b/bot/resources/tags/site.md
new file mode 100644
index 000000000..376f84742
--- /dev/null
+++ b/bot/resources/tags/site.md
@@ -0,0 +1,6 @@
+---
+embed:
+ title: "Python Discord Website"
+---
+
+[Our official website](https://www.pythondiscord.com/) is an open-source community project created with Python and Django. It contains information about the server itself, lets you sign up for upcoming events, has its own wiki, contains a list of valuable learning resources, and much more.
diff --git a/bot/resources/tags/tools.md b/bot/resources/tags/tools.md
new file mode 100644
index 000000000..3cae75552
--- /dev/null
+++ b/bot/resources/tags/tools.md
@@ -0,0 +1,6 @@
+---
+embed:
+ title: "Tools"
+---
+
+The [Tools page](https://www.pythondiscord.com/resources/tools/) on our website contains a couple of the most popular tools for programming in Python.
diff --git a/bot/utils/members.py b/bot/utils/members.py
index 77ddf1696..693286045 100644
--- a/bot/utils/members.py
+++ b/bot/utils/members.py
@@ -23,3 +23,26 @@ async def get_or_fetch_member(guild: discord.Guild, member_id: int) -> t.Optiona
return None
log.trace("%s fetched from API.", member)
return member
+
+
+async def handle_role_change(
+ member: discord.Member,
+ coro: t.Callable[..., t.Coroutine],
+ role: discord.Role
+) -> None:
+ """
+ Change `member`'s cooldown role via awaiting `coro` and handle errors.
+
+ `coro` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`.
+ """
+ try:
+ await coro(role)
+ except discord.NotFound:
+ log.debug(f"Failed to change role for {member} ({member.id}): member not found")
+ except discord.Forbidden:
+ log.debug(
+ f"Forbidden to change role for {member} ({member.id}); "
+ f"possibly due to role hierarchy"
+ )
+ except discord.HTTPException as e:
+ log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}")
diff --git a/bot/utils/regex.py b/bot/utils/regex.py
index d77f5950b..9dc1eba9d 100644
--- a/bot/utils/regex.py
+++ b/bot/utils/regex.py
@@ -12,3 +12,4 @@ INVITE_RE = re.compile(
r"(?P<invite>[a-zA-Z0-9\-]+)", # the invite code itself
flags=re.IGNORECASE
)
+MESSAGE_ID_RE = re.compile(r'(?P<message_id>[0-9]{15,20})$')
diff --git a/config-default.yml b/config-default.yml
index 4a85ccc56..583733fda 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -1,4 +1,5 @@
-debug: !ENV ["BOT_DEBUG", "true"]
+debug: !ENV ["BOT_DEBUG", "true"]
+file_logs: !ENV ["FILE_LOGS", "false"]
bot:
@@ -173,7 +174,7 @@ guild:
how_to_get_help: 704250143020417084
# Topical
- discord_py: 343944376055103488
+ discord_bots: 343944376055103488
# Logs
attachment_log: &ATTACH_LOG 649243850006855680
@@ -263,7 +264,12 @@ guild:
- *BLACK_FORMATTER
roles:
+ # Self-assignable roles, see the Subscribe cog
+ advent_of_code: 518565788744024082
announcements: 463658397560995840
+ lovefest: 542431903886606399
+ pyweek_announcements: 897568414044938310
+
contributors: 295488872404484098
help_cooldown: 699189276025421825
muted: &MUTED_ROLE 277914926603829249
@@ -307,6 +313,7 @@ guild:
big_brother: 569133704568373283
dev_log: 680501655111729222
duck_pond: 637821475327311927
+ incidents: 816650601844572212
incidents_archive: 720671599790915702
python_news: &PYNEWS_WEBHOOK 704381182279942324
@@ -370,7 +377,7 @@ urls:
site_logs_view: !JOIN [*STAFF, "/bot/logs"]
# Snekbox
- snekbox_eval_api: "http://snekbox.default.svc.cluster.local/eval"
+ snekbox_eval_api: !ENV ["SNEKBOX_EVAL_API", "http://snekbox.default.svc.cluster.local/eval"]
# Discord API URLs
discord_api: &DISCORD_API "https://discordapp.com/api/v7/"
diff --git a/docker-compose.yml b/docker-compose.yml
index b3ca6baa4..869d9acb6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -90,7 +90,6 @@ services:
context: .
dockerfile: Dockerfile
volumes:
- - ./logs:/bot/logs
- .:/bot:ro
tty: true
depends_on:
diff --git a/poetry.lock b/poetry.lock
index d91941d45..4155a57ff 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -281,6 +281,7 @@ voice = ["PyNaCl (>=1.3.0,<1.5)"]
[package.source]
type = "url"
url = "https://github.com/Rapptz/discord.py/archive/45d498c1b76deaf3b394d17ccf56112fa691d160.zip"
+
[[package]]
name = "distlib"
version = "0.3.3"
@@ -533,7 +534,7 @@ plugins = ["setuptools"]
[[package]]
name = "lxml"
-version = "4.6.3"
+version = "4.6.5"
description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
category = "main"
optional = false
@@ -1114,7 +1115,7 @@ multidict = ">=4.0"
[metadata]
lock-version = "1.1"
python-versions = "3.9.*"
-content-hash = "da321f13297501e62dd1eb362eccb586ea1a9c21ddb395e11a91b93a2f92e9d4"
+content-hash = "e6fe15a64ae57232a639149df793d6580a93f613425cae85c9892cf959710430"
[metadata.files]
aio-pika = [
@@ -1464,54 +1465,66 @@ isort = [
{file = "isort-5.9.3.tar.gz", hash = "sha256:9c2ea1e62d871267b78307fe511c0838ba0da28698c5732d54e2790bf3ba9899"},
]
lxml = [
- {file = "lxml-4.6.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2"},
- {file = "lxml-4.6.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:1b7584d421d254ab86d4f0b13ec662a9014397678a7c4265a02a6d7c2b18a75f"},
- {file = "lxml-4.6.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:079f3ae844f38982d156efce585bc540c16a926d4436712cf4baee0cce487a3d"},
- {file = "lxml-4.6.3-cp27-cp27m-win32.whl", hash = "sha256:bc4313cbeb0e7a416a488d72f9680fffffc645f8a838bd2193809881c67dd106"},
- {file = "lxml-4.6.3-cp27-cp27m-win_amd64.whl", hash = "sha256:8157dadbb09a34a6bd95a50690595e1fa0af1a99445e2744110e3dca7831c4ee"},
- {file = "lxml-4.6.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7728e05c35412ba36d3e9795ae8995e3c86958179c9770e65558ec3fdfd3724f"},
- {file = "lxml-4.6.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:4bff24dfeea62f2e56f5bab929b4428ae6caba2d1eea0c2d6eb618e30a71e6d4"},
- {file = "lxml-4.6.3-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:64812391546a18896adaa86c77c59a4998f33c24788cadc35789e55b727a37f4"},
- {file = "lxml-4.6.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c1a40c06fd5ba37ad39caa0b3144eb3772e813b5fb5b084198a985431c2f1e8d"},
- {file = "lxml-4.6.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:74f7d8d439b18fa4c385f3f5dfd11144bb87c1da034a466c5b5577d23a1d9b51"},
- {file = "lxml-4.6.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f90ba11136bfdd25cae3951af8da2e95121c9b9b93727b1b896e3fa105b2f586"},
- {file = "lxml-4.6.3-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:4c61b3a0db43a1607d6264166b230438f85bfed02e8cff20c22e564d0faff354"},
- {file = "lxml-4.6.3-cp35-cp35m-manylinux2014_x86_64.whl", hash = "sha256:5c8c163396cc0df3fd151b927e74f6e4acd67160d6c33304e805b84293351d16"},
- {file = "lxml-4.6.3-cp35-cp35m-win32.whl", hash = "sha256:f2380a6376dfa090227b663f9678150ef27543483055cc327555fb592c5967e2"},
- {file = "lxml-4.6.3-cp35-cp35m-win_amd64.whl", hash = "sha256:c4f05c5a7c49d2fb70223d0d5bcfbe474cf928310ac9fa6a7c6dddc831d0b1d4"},
- {file = "lxml-4.6.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d2e35d7bf1c1ac8c538f88d26b396e73dd81440d59c1ef8522e1ea77b345ede4"},
- {file = "lxml-4.6.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:289e9ca1a9287f08daaf796d96e06cb2bc2958891d7911ac7cae1c5f9e1e0ee3"},
- {file = "lxml-4.6.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:bccbfc27563652de7dc9bdc595cb25e90b59c5f8e23e806ed0fd623755b6565d"},
- {file = "lxml-4.6.3-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d916d31fd85b2f78c76400d625076d9124de3e4bda8b016d25a050cc7d603f24"},
- {file = "lxml-4.6.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:820628b7b3135403540202e60551e741f9b6d3304371712521be939470b454ec"},
- {file = "lxml-4.6.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:c47ff7e0a36d4efac9fd692cfa33fbd0636674c102e9e8d9b26e1b93a94e7617"},
- {file = "lxml-4.6.3-cp36-cp36m-win32.whl", hash = "sha256:5a0a14e264069c03e46f926be0d8919f4105c1623d620e7ec0e612a2e9bf1c04"},
- {file = "lxml-4.6.3-cp36-cp36m-win_amd64.whl", hash = "sha256:92e821e43ad382332eade6812e298dc9701c75fe289f2a2d39c7960b43d1e92a"},
- {file = "lxml-4.6.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:efd7a09678fd8b53117f6bae4fa3825e0a22b03ef0a932e070c0bdbb3a35e654"},
- {file = "lxml-4.6.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:efac139c3f0bf4f0939f9375af4b02c5ad83a622de52d6dfa8e438e8e01d0eb0"},
- {file = "lxml-4.6.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:0fbcf5565ac01dff87cbfc0ff323515c823081c5777a9fc7703ff58388c258c3"},
- {file = "lxml-4.6.3-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:36108c73739985979bf302006527cf8a20515ce444ba916281d1c43938b8bb96"},
- {file = "lxml-4.6.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:122fba10466c7bd4178b07dba427aa516286b846b2cbd6f6169141917283aae2"},
- {file = "lxml-4.6.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:cdaf11d2bd275bf391b5308f86731e5194a21af45fbaaaf1d9e8147b9160ea92"},
- {file = "lxml-4.6.3-cp37-cp37m-win32.whl", hash = "sha256:3439c71103ef0e904ea0a1901611863e51f50b5cd5e8654a151740fde5e1cade"},
- {file = "lxml-4.6.3-cp37-cp37m-win_amd64.whl", hash = "sha256:4289728b5e2000a4ad4ab8da6e1db2e093c63c08bdc0414799ee776a3f78da4b"},
- {file = "lxml-4.6.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b007cbb845b28db4fb8b6a5cdcbf65bacb16a8bd328b53cbc0698688a68e1caa"},
- {file = "lxml-4.6.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:76fa7b1362d19f8fbd3e75fe2fb7c79359b0af8747e6f7141c338f0bee2f871a"},
- {file = "lxml-4.6.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:26e761ab5b07adf5f555ee82fb4bfc35bf93750499c6c7614bd64d12aaa67927"},
- {file = "lxml-4.6.3-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:e1cbd3f19a61e27e011e02f9600837b921ac661f0c40560eefb366e4e4fb275e"},
- {file = "lxml-4.6.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:66e575c62792c3f9ca47cb8b6fab9e35bab91360c783d1606f758761810c9791"},
- {file = "lxml-4.6.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:1b38116b6e628118dea5b2186ee6820ab138dbb1e24a13e478490c7db2f326ae"},
- {file = "lxml-4.6.3-cp38-cp38-win32.whl", hash = "sha256:89b8b22a5ff72d89d48d0e62abb14340d9e99fd637d046c27b8b257a01ffbe28"},
- {file = "lxml-4.6.3-cp38-cp38-win_amd64.whl", hash = "sha256:2a9d50e69aac3ebee695424f7dbd7b8c6d6eb7de2a2eb6b0f6c7db6aa41e02b7"},
- {file = "lxml-4.6.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ce256aaa50f6cc9a649c51be3cd4ff142d67295bfc4f490c9134d0f9f6d58ef0"},
- {file = "lxml-4.6.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:7610b8c31688f0b1be0ef882889817939490a36d0ee880ea562a4e1399c447a1"},
- {file = "lxml-4.6.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f8380c03e45cf09f8557bdaa41e1fa7c81f3ae22828e1db470ab2a6c96d8bc23"},
- {file = "lxml-4.6.3-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:3082c518be8e97324390614dacd041bb1358c882d77108ca1957ba47738d9d59"},
- {file = "lxml-4.6.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:884ab9b29feaca361f7f88d811b1eea9bfca36cf3da27768d28ad45c3ee6f969"},
- {file = "lxml-4.6.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:6f12e1427285008fd32a6025e38e977d44d6382cf28e7201ed10d6c1698d2a9a"},
- {file = "lxml-4.6.3-cp39-cp39-win32.whl", hash = "sha256:33bb934a044cf32157c12bfcfbb6649807da20aa92c062ef51903415c704704f"},
- {file = "lxml-4.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:542d454665a3e277f76954418124d67516c5f88e51a900365ed54a9806122b83"},
- {file = "lxml-4.6.3.tar.gz", hash = "sha256:39b78571b3b30645ac77b95f7c69d1bffc4cf8c3b157c435a34da72e78c82468"},
+ {file = "lxml-4.6.5-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:abcf7daa5ebcc89328326254f6dd6d566adb483d4d00178892afd386ab389de2"},
+ {file = "lxml-4.6.5-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3884476a90d415be79adfa4e0e393048630d0d5bcd5757c4c07d8b4b00a1096b"},
+ {file = "lxml-4.6.5-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:add017c5bd6b9ec3a5f09248396b6ee2ce61c5621f087eb2269c813cd8813808"},
+ {file = "lxml-4.6.5-cp27-cp27m-win32.whl", hash = "sha256:a702005e447d712375433ed0499cb6e1503fadd6c96a47f51d707b4d37b76d3c"},
+ {file = "lxml-4.6.5-cp27-cp27m-win_amd64.whl", hash = "sha256:da07c7e7fc9a3f40446b78c54dbba8bfd5c9100dfecb21b65bfe3f57844f5e71"},
+ {file = "lxml-4.6.5-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a708c291900c40a7ecf23f1d2384ed0bc0604e24094dd13417c7e7f8f7a50d93"},
+ {file = "lxml-4.6.5-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:f33d8efb42e4fc2b31b3b4527940b25cdebb3026fb56a80c1c1c11a4271d2352"},
+ {file = "lxml-4.6.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:f6befb83bca720b71d6bd6326a3b26e9496ae6649e26585de024890fe50f49b8"},
+ {file = "lxml-4.6.5-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:59d77bfa3bea13caee95bc0d3f1c518b15049b97dd61ea8b3d71ce677a67f808"},
+ {file = "lxml-4.6.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:68a851176c931e2b3de6214347b767451243eeed3bea34c172127bbb5bf6c210"},
+ {file = "lxml-4.6.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a7790a273225b0c46e5f859c1327f0f659896cc72eaa537d23aa3ad9ff2a1cc1"},
+ {file = "lxml-4.6.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6548fc551de15f310dd0564751d9dc3d405278d45ea9b2b369ed1eccf142e1f5"},
+ {file = "lxml-4.6.5-cp310-cp310-win32.whl", hash = "sha256:dc8a0dbb2a10ae8bb609584f5c504789f0f3d0d81840da4849102ec84289f952"},
+ {file = "lxml-4.6.5-cp310-cp310-win_amd64.whl", hash = "sha256:1ccbfe5d17835db906f2bab6f15b34194db1a5b07929cba3cf45a96dbfbfefc0"},
+ {file = "lxml-4.6.5-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca9a40497f7e97a2a961c04fa8a6f23d790b0521350a8b455759d786b0bcb203"},
+ {file = "lxml-4.6.5-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e5b4b0d9440046ead3bd425eb2b852499241ee0cef1ae151038e4f87ede888c4"},
+ {file = "lxml-4.6.5-cp35-cp35m-win32.whl", hash = "sha256:87f8f7df70b90fbe7b49969f07b347e3f978f8bd1046bb8ecae659921869202b"},
+ {file = "lxml-4.6.5-cp35-cp35m-win_amd64.whl", hash = "sha256:ce52aad32ec6e46d1a91ff8b8014a91538800dd533914bfc4a82f5018d971408"},
+ {file = "lxml-4.6.5-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:8021eeff7fabde21b9858ed058a8250ad230cede91764d598c2466b0ba70db8b"},
+ {file = "lxml-4.6.5-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:cab343b265e38d4e00649cbbad9278b734c5715f9bcbb72c85a1f99b1a58e19a"},
+ {file = "lxml-4.6.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:3534d7c468c044f6aef3c0aff541db2826986a29ea73f2ca831f5d5284d9b570"},
+ {file = "lxml-4.6.5-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdb98f4c9e8a1735efddfaa995b0c96559792da15d56b76428bdfc29f77c4cdb"},
+ {file = "lxml-4.6.5-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:5ea121cb66d7e5cb396b4c3ca90471252b94e01809805cfe3e4e44be2db3a99c"},
+ {file = "lxml-4.6.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:121fc6f71c692b49af6c963b84ab7084402624ffbe605287da362f8af0668ea3"},
+ {file = "lxml-4.6.5-cp36-cp36m-win32.whl", hash = "sha256:1a2a7659b8eb93c6daee350a0d844994d49245a0f6c05c747f619386fb90ba04"},
+ {file = "lxml-4.6.5-cp36-cp36m-win_amd64.whl", hash = "sha256:2f77556266a8fe5428b8759fbfc4bd70be1d1d9c9b25d2a414f6a0c0b0f09120"},
+ {file = "lxml-4.6.5-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:558485218ee06458643b929765ac1eb04519ca3d1e2dcc288517de864c747c33"},
+ {file = "lxml-4.6.5-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:ba0006799f21d83c3717fe20e2707a10bbc296475155aadf4f5850f6659b96b9"},
+ {file = "lxml-4.6.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:916d457ad84e05b7db52700bad0a15c56e0c3000dcaf1263b2fb7a56fe148996"},
+ {file = "lxml-4.6.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c580c2a61d8297a6e47f4d01f066517dbb019be98032880d19ece7f337a9401d"},
+ {file = "lxml-4.6.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a21b78af7e2e13bec6bea12fc33bc05730197674f3e5402ce214d07026ccfebd"},
+ {file = "lxml-4.6.5-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:46515773570a33eae13e451c8fcf440222ef24bd3b26f40774dd0bd8b6db15b2"},
+ {file = "lxml-4.6.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:124f09614f999551ac65e5b9875981ce4b66ac4b8e2ba9284572f741935df3d9"},
+ {file = "lxml-4.6.5-cp37-cp37m-win32.whl", hash = "sha256:b4015baed99d046c760f09a4c59d234d8f398a454380c3cf0b859aba97136090"},
+ {file = "lxml-4.6.5-cp37-cp37m-win_amd64.whl", hash = "sha256:12ae2339d32a2b15010972e1e2467345b7bf962e155671239fba74c229564b7f"},
+ {file = "lxml-4.6.5-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:76b6c296e4f7a1a8a128aec42d128646897f9ae9a700ef6839cdc9b3900db9b5"},
+ {file = "lxml-4.6.5-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:534032a5ceb34bba1da193b7d386ac575127cc39338379f39a164b10d97ade89"},
+ {file = "lxml-4.6.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:60aeb14ff9022d2687ef98ce55f6342944c40d00916452bb90899a191802137a"},
+ {file = "lxml-4.6.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:9801bcd52ac9c795a7d81ea67471a42cffe532e46cfb750cd5713befc5c019c0"},
+ {file = "lxml-4.6.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3b95fb7e6f9c2f53db88f4642231fc2b8907d854e614710996a96f1f32018d5c"},
+ {file = "lxml-4.6.5-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:642eb4cabd997c9b949a994f9643cd8ae00cf4ca8c5cd9c273962296fadf1c44"},
+ {file = "lxml-4.6.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:af4139172ff0263d269abdcc641e944c9de4b5d660894a3ec7e9f9db63b56ac9"},
+ {file = "lxml-4.6.5-cp38-cp38-win32.whl", hash = "sha256:57cf05466917e08f90e323f025b96f493f92c0344694f5702579ab4b7e2eb10d"},
+ {file = "lxml-4.6.5-cp38-cp38-win_amd64.whl", hash = "sha256:4f415624cf8b065796649a5e4621773dc5c9ea574a944c76a7f8a6d3d2906b41"},
+ {file = "lxml-4.6.5-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:7679bb6e4d9a3978a46ab19a3560e8d2b7265ef3c88152e7fdc130d649789887"},
+ {file = "lxml-4.6.5-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c34234a1bc9e466c104372af74d11a9f98338a3f72fae22b80485171a64e0144"},
+ {file = "lxml-4.6.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:4b9390bf973e3907d967b75be199cf1978ca8443183cf1e78ad80ad8be9cf242"},
+ {file = "lxml-4.6.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:fcc849b28f584ed1dbf277291ded5c32bb3476a37032df4a1d523b55faa5f944"},
+ {file = "lxml-4.6.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:46f21f2600d001af10e847df9eb3b832e8a439f696c04891bcb8a8cedd859af9"},
+ {file = "lxml-4.6.5-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:99cf827f5a783038eb313beee6533dddb8bdb086d7269c5c144c1c952d142ace"},
+ {file = "lxml-4.6.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:925174cafb0f1179a7fd38da90302555d7445e34c9ece68019e53c946be7f542"},
+ {file = "lxml-4.6.5-cp39-cp39-win32.whl", hash = "sha256:12d8d6fe3ddef629ac1349fa89a638b296a34b6529573f5055d1cb4e5245f73b"},
+ {file = "lxml-4.6.5-cp39-cp39-win_amd64.whl", hash = "sha256:a52e8f317336a44836475e9c802f51c2dc38d612eaa76532cb1d17690338b63b"},
+ {file = "lxml-4.6.5-pp37-pypy37_pp73-macosx_10_14_x86_64.whl", hash = "sha256:11ae552a78612620afd15625be9f1b82e3cc2e634f90d6b11709b10a100cba59"},
+ {file = "lxml-4.6.5-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:473701599665d874919d05bb33b56180447b3a9da8d52d6d9799f381ce23f95c"},
+ {file = "lxml-4.6.5-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:7f00cc64b49d2ef19ddae898a3def9dd8fda9c3d27c8a174c2889ee757918e71"},
+ {file = "lxml-4.6.5-pp38-pypy38_pp73-macosx_10_14_x86_64.whl", hash = "sha256:73e8614258404b2689a26cb5d002512b8bc4dfa18aca86382f68f959aee9b0c8"},
+ {file = "lxml-4.6.5-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:ff44de36772b05c2eb74f2b4b6d1ae29b8f41ed5506310ce1258d44826ee38c1"},
+ {file = "lxml-4.6.5-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:5d5254c815c186744c8f922e2ce861a2bdeabc06520b4b30b2f7d9767791ce6e"},
+ {file = "lxml-4.6.5.tar.gz", hash = "sha256:6e84edecc3a82f90d44ddee2ee2a2630d4994b8471816e226d2b771cda7ac4ca"},
]
markdownify = [
{file = "markdownify-0.6.1-py3-none-any.whl", hash = "sha256:7489fd5c601536996a376c4afbcd1dd034db7690af807120681461e82fbc0acc"},
diff --git a/pyproject.toml b/pyproject.toml
index 563bf4a27..44d09f89e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -21,7 +21,7 @@ deepdiff = "~=4.0"
emoji = "~=0.6"
feedparser = "~=6.0.2"
rapidfuzz = "~=1.4"
-lxml = "~=4.4"
+lxml = "~=4.6"
markdownify = "==0.6.1"
more_itertools = "~=8.2"
python-dateutil = "~=2.8"
diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py
index 462f718e6..35fa0ee59 100644
--- a/tests/bot/exts/backend/test_error_handler.py
+++ b/tests/bot/exts/backend/test_error_handler.py
@@ -337,14 +337,12 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase):
async def test_try_get_tag_get_command(self):
"""Should call `Bot.get_command` with `tags get` argument."""
self.bot.get_command.reset_mock()
- self.ctx.invoked_with = "foo"
await self.cog.try_get_tag(self.ctx)
self.bot.get_command.assert_called_once_with("tags get")
async def test_try_get_tag_invoked_from_error_handler(self):
"""`self.ctx` should have `invoked_from_error_handler` `True`."""
self.ctx.invoked_from_error_handler = False
- self.ctx.invoked_with = "foo"
await self.cog.try_get_tag(self.ctx)
self.assertTrue(self.ctx.invoked_from_error_handler)
@@ -359,38 +357,12 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase):
err = errors.CommandError()
self.tag.get_command.can_run = AsyncMock(side_effect=err)
self.cog.on_command_error = AsyncMock()
- self.ctx.invoked_with = "foo"
self.assertIsNone(await self.cog.try_get_tag(self.ctx))
self.cog.on_command_error.assert_awaited_once_with(self.ctx, err)
- @patch("bot.exts.backend.error_handler.TagNameConverter")
- async def test_try_get_tag_convert_success(self, tag_converter):
- """Converting tag should successful."""
- self.ctx.invoked_with = "foo"
- tag_converter.convert = AsyncMock(return_value="foo")
- self.assertIsNone(await self.cog.try_get_tag(self.ctx))
- tag_converter.convert.assert_awaited_once_with(self.ctx, "foo")
- self.ctx.invoke.assert_awaited_once()
-
- @patch("bot.exts.backend.error_handler.TagNameConverter")
- async def test_try_get_tag_convert_fail(self, tag_converter):
- """Converting tag should raise `BadArgument`."""
- self.ctx.reset_mock()
- self.ctx.invoked_with = "bar"
- tag_converter.convert = AsyncMock(side_effect=errors.BadArgument())
- self.assertIsNone(await self.cog.try_get_tag(self.ctx))
- self.ctx.invoke.assert_not_awaited()
-
- async def test_try_get_tag_ctx_invoke(self):
- """Should call `ctx.invoke` with proper args/kwargs."""
- self.ctx.reset_mock()
- self.ctx.invoked_with = "foo"
- self.assertIsNone(await self.cog.try_get_tag(self.ctx))
- self.ctx.invoke.assert_awaited_once_with(self.tag.get_command, tag_name="foo")
-
async def test_dont_call_suggestion_tag_sent(self):
"""Should never call command suggestion if tag is already sent."""
- self.ctx.invoked_with = "foo"
+ self.ctx.message = MagicMock(content="foo")
self.ctx.invoke = AsyncMock(return_value=True)
self.cog.send_command_suggestion = AsyncMock()
@@ -572,38 +544,6 @@ class IndividualErrorHandlerTests(unittest.IsolatedAsyncioTestCase):
push_scope_mock.set_extra.has_calls(set_extra_calls)
-class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase):
- """Other `ErrorHandler` tests."""
-
- def setUp(self):
- self.bot = MockBot()
- self.ctx = MockContext()
-
- async def test_get_help_command_command_specified(self):
- """Should return coroutine of help command of specified command."""
- self.ctx.command = "foo"
- result = ErrorHandler.get_help_command(self.ctx)
- expected = self.ctx.send_help("foo")
- self.assertEqual(result.__qualname__, expected.__qualname__)
- self.assertEqual(result.cr_frame.f_locals, expected.cr_frame.f_locals)
-
- # Await coroutines to avoid warnings
- await result
- await expected
-
- async def test_get_help_command_no_command_specified(self):
- """Should return coroutine of help command."""
- self.ctx.command = None
- result = ErrorHandler.get_help_command(self.ctx)
- expected = self.ctx.send_help()
- self.assertEqual(result.__qualname__, expected.__qualname__)
- self.assertEqual(result.cr_frame.f_locals, expected.cr_frame.f_locals)
-
- # Await coroutines to avoid warnings
- await result
- await expected
-
-
class ErrorHandlerSetupTests(unittest.TestCase):
"""Tests for `ErrorHandler` `setup` function."""
diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py
index ccc842050..cfe0c4b03 100644
--- a/tests/bot/exts/moderation/test_incidents.py
+++ b/tests/bot/exts/moderation/test_incidents.py
@@ -3,13 +3,16 @@ import enum
import logging
import typing as t
import unittest
+from unittest import mock
from unittest.mock import AsyncMock, MagicMock, Mock, call, patch
import aiohttp
import discord
+from async_rediscache import RedisSession
from bot.constants import Colours
from bot.exts.moderation import incidents
+from bot.utils.messages import format_user
from tests.helpers import (
MockAsyncWebhook, MockAttachment, MockBot, MockMember, MockMessage, MockReaction, MockRole, MockTextChannel,
MockUser
@@ -276,6 +279,22 @@ class TestIncidents(unittest.IsolatedAsyncioTestCase):
the instance as they wish.
"""
+ session = None
+
+ async def flush(self):
+ """Flush everything from the database to prevent carry-overs between tests."""
+ with await self.session.pool as connection:
+ await connection.flushall()
+
+ async def asyncSetUp(self): # noqa: N802
+ self.session = RedisSession(use_fakeredis=True)
+ await self.session.connect()
+ await self.flush()
+
+ async def asyncTearDown(self): # noqa: N802
+ if self.session:
+ await self.session.close()
+
def setUp(self):
"""
Prepare a fresh `Incidents` instance for each test.
@@ -506,7 +525,7 @@ class TestProcessEvent(TestIncidents):
with patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", mock_task):
await self.cog_instance.process_event(
reaction=incidents.Signal.ACTIONED.value,
- incident=MockMessage(),
+ incident=MockMessage(id=123),
member=MockMember(roles=[MockRole(id=1)])
)
@@ -526,7 +545,7 @@ class TestProcessEvent(TestIncidents):
with patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", mock_task):
await self.cog_instance.process_event(
reaction=incidents.Signal.ACTIONED.value,
- incident=MockMessage(),
+ incident=MockMessage(id=123),
member=MockMember(roles=[MockRole(id=1)])
)
except asyncio.TimeoutError:
@@ -761,3 +780,74 @@ class TestOnMessage(TestIncidents):
await self.cog_instance.on_message(MockMessage())
mock_add_signals.assert_not_called()
+
+
+class TestMessageLinkEmbeds(TestIncidents):
+ """Tests for `extract_message_links` coroutine."""
+
+ async def test_shorten_text(self):
+ """Test all cases of text shortening by mocking messages."""
+ tests = {
+ "thisisasingleword"*10: "thisisasinglewordthisisasinglewordthisisasinglewor...",
+
+ "\n".join("Lets make a new line test".split()): "Lets\nmake\na...",
+
+ 'Hello, World!' * 300: (
+ "Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!"
+ "Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!"
+ "Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!"
+ "Hello, World!Hello, World!H..."
+ )
+ }
+
+ for content, expected_conversion in tests.items():
+ with self.subTest(content=content, expected_conversion=expected_conversion):
+ conversion = incidents.shorten_text(content)
+ self.assertEqual(conversion, expected_conversion)
+
+ async def extract_and_form_message_link_embeds(self):
+ """
+ Extract message links from a mocked message and form the message link embed.
+
+ Considers all types of message links, discord supports.
+ """
+ self.guild_id_patcher = mock.patch("bot.exts.backend.sync._cog.constants.Guild.id", 5)
+ self.guild_id = self.guild_id_patcher.start()
+
+ msg = MockMessage(id=555, content="Hello, World!" * 3000)
+ msg.channel.mention = "#lemonade-stand"
+
+ msg_links = [
+ # Valid Message links
+ f"https://discord.com/channels/{self.guild_id}/{msg.channel.discord_id}/{msg.discord_id}",
+ f"http://canary.discord.com/channels/{self.guild_id}/{msg.channel.discord_id}/{msg.discord_id}",
+
+ # Invalid Message links
+ f"https://discord.com/channels/{msg.channel.discord_id}/{msg.discord_id}",
+ f"https://discord.com/channels/{self.guild_id}/{msg.channel.discord_id}000/{msg.discord_id}",
+ ]
+
+ incident_msg = MockMessage(
+ id=777,
+ content=(
+ f"I would like to report the following messages, "
+ f"as they break our rules: \n{', '.join(msg_links)}"
+ )
+ )
+
+ with patch(
+ "bot.exts.moderation.incidents.Incidents.extract_message_links", AsyncMock()
+ ) as mock_extract_message_links:
+ embeds = mock_extract_message_links(incident_msg)
+ description = (
+ f"**Author:** {format_user(msg.author)}\n"
+ f"**Channel:** {msg.channel.mention} ({msg.channel.category}/#{msg.channel.name})\n"
+ f"**Content:** {('Hello, World!' * 3000)[:300] + '...'}\n"
+ )
+
+ # Check number of embeds returned with number of valid links
+ self.assertEqual(len(embeds), 2)
+
+ # Check for the embed descriptions
+ for embed in embeds:
+ self.assertEqual(embed.description, description)
diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py
index 988b3857b..1bb678db2 100644
--- a/tests/bot/test_converters.py
+++ b/tests/bot/test_converters.py
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch
from dateutil.relativedelta import relativedelta
from discord.ext.commands import BadArgument
-from bot.converters import Duration, HushDurationConverter, ISODateTime, PackageName, TagNameConverter
+from bot.converters import Duration, HushDurationConverter, ISODateTime, PackageName
class ConverterTests(unittest.IsolatedAsyncioTestCase):
@@ -19,21 +19,6 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase):
cls.fixed_utc_now = datetime.fromisoformat('2019-01-01T00:00:00+00:00')
- async def test_tag_name_converter_for_invalid(self):
- """TagNameConverter should raise the correct exception for invalid tag names."""
- test_values = (
- ('👋', "Don't be ridiculous, you can't use that character!"),
- ('', "Tag names should not be empty, or filled with whitespace."),
- (' ', "Tag names should not be empty, or filled with whitespace."),
- ('42', "Tag names must contain at least one letter."),
- ('x' * 128, "Are you insane? That's way too long!"),
- )
-
- for invalid_name, exception_message in test_values:
- with self.subTest(invalid_name=invalid_name, exception_message=exception_message):
- with self.assertRaisesRegex(BadArgument, re.escape(exception_message)):
- await TagNameConverter.convert(self.context, invalid_name)
-
async def test_package_name_for_valid(self):
"""PackageName returns valid package names unchanged."""
test_values = ('foo', 'le_mon', 'num83r')