diff options
| -rw-r--r-- | .github/CODEOWNERS | 20 | ||||
| -rw-r--r-- | bot/__init__.py | 5 | ||||
| -rw-r--r-- | bot/constants.py | 9 | ||||
| -rw-r--r-- | bot/converters.py | 44 | ||||
| -rw-r--r-- | bot/exts/backend/error_handler.py | 13 | ||||
| -rw-r--r-- | bot/exts/filters/filtering.py | 33 | ||||
| -rw-r--r-- | bot/exts/info/information.py | 39 | ||||
| -rw-r--r-- | bot/exts/info/pep.py | 7 | ||||
| -rw-r--r-- | bot/exts/info/site.py | 145 | ||||
| -rw-r--r-- | bot/exts/info/source.py | 13 | ||||
| -rw-r--r-- | bot/exts/info/subscribe.py | 21 | ||||
| -rw-r--r-- | bot/exts/info/tags.py | 479 | ||||
| -rw-r--r-- | bot/exts/moderation/modpings.py | 119 | ||||
| -rw-r--r-- | bot/exts/moderation/slowmode.py | 2 | ||||
| -rw-r--r-- | bot/exts/recruitment/talentpool/_review.py | 10 | ||||
| -rw-r--r-- | bot/exts/utils/utils.py | 2 | ||||
| -rw-r--r-- | bot/monkey_patches.py | 23 | ||||
| -rw-r--r-- | bot/resources/tags/faq.md | 6 | ||||
| -rw-r--r-- | bot/resources/tags/resources.md | 6 | ||||
| -rw-r--r-- | bot/resources/tags/site.md | 6 | ||||
| -rw-r--r-- | bot/resources/tags/tools.md | 6 | ||||
| -rw-r--r-- | bot/utils/regex.py | 1 | ||||
| -rw-r--r-- | config-default.yml | 4 | ||||
| -rw-r--r-- | poetry.lock | 113 | ||||
| -rw-r--r-- | pyproject.toml | 2 | ||||
| -rw-r--r-- | tests/bot/exts/backend/test_error_handler.py | 30 | ||||
| -rw-r--r-- | tests/bot/test_converters.py | 17 | 
27 files changed, 631 insertions, 544 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 3170c2915..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 @@ -691,7 +691,7 @@ class VideoPermission(metaclass=YAMLGetter):  class ThreadArchiveTimes(Enum):      HOUR = 60      DAY = 1440 -    THREE_DAY = 4230 +    THREE_DAY = 4320      WEEK = 10080 @@ -699,11 +699,6 @@ class ThreadArchiveTimes(Enum):  DEBUG_MODE: bool = _CONFIG_YAML["debug"] == "true"  FILE_LOGS: bool = _CONFIG_YAML["file_logs"].lower() == "true" -if DEBUG_MODE: -    DEFAULT_THREAD_ARCHIVE_TIME = ThreadArchiveTimes.HOUR.value -else: -    DEFAULT_THREAD_ARCHIVE_TIME = ThreadArchiveTimes.WEEK.value -  # Paths  BOT_DIR = os.path.dirname(__file__)  PROJECT_ROOT = os.path.abspath(os.path.join(BOT_DIR, os.pardir)) 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 5bef72808..c79c7b2a7 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -7,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 @@ -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) 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/info/information.py b/bot/exts/info/information.py index 5b48495dc..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,7 +174,6 @@ 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 @@ -198,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}" @@ -523,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 f6499ecce..000000000 --- a/bot/exts/info/site.py +++ /dev/null @@ -1,145 +0,0 @@ -from textwrap import shorten - -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(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 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 index 2e6101d27..1299d5d59 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -10,9 +10,9 @@ from discord.interactions import Interaction  from bot import constants  from bot.bot import Bot -from bot.decorators import in_whitelist +from bot.decorators import redirect_output  from bot.log import get_logger -from bot.utils import checks, members, scheduling +from bot.utils import members, scheduling  @dataclass(frozen=True) @@ -165,12 +165,17 @@ class Subscribe(commands.Cog):                      name=discord_role.name,                  )              ) -        # Sort unavailable roles to the end of the list + +        # 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") -    @in_whitelist(channels=(constants.Channels.bot_commands,)) +    @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 @@ -181,18 +186,12 @@ class Subscribe(commands.Cog):              row = index // ITEMS_PER_ROW              button_view.add_item(SingleRoleButton(role, role.role_id in author_roles, row)) -        await ctx.reply( +        await ctx.send(              "Click the buttons below to add or remove your roles!",              view=button_view,              delete_after=DELETE_MESSAGE_AFTER,          ) -    # This cannot be static (must have a __func__ attribute). -    async def cog_command_error(self, ctx: commands.Context, error: Exception) -> None: -        """Check for & ignore any InWhitelistCheckFailure.""" -        if isinstance(error, checks.InWhitelistCheckFailure): -            error.handled = True -  def setup(bot: Bot) -> None:      """Load the Subscribe cog.""" diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 842647555..e5930a433 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,146 @@ 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 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/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/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 110ac47bc..0e7194892 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -15,7 +15,7 @@ from discord.ext.commands import Context  from bot.api import ResponseCodeError  from bot.bot import Bot -from bot.constants import Channels, Colours, DEFAULT_THREAD_ARCHIVE_TIME, Emojis, Guild, Roles +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 @@ -96,7 +96,6 @@ class Reviewer:          thread = await last_message.create_thread(              name=f"Nomination - {nominee}", -            auto_archive_duration=DEFAULT_THREAD_ARCHIVE_TIME          )          await thread.send(fr"<@&{Roles.mod_team}> <@&{Roles.admins}>") @@ -218,8 +217,11 @@ class Reviewer:          # 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: -            log.warning(f"Could not find a thread linked to {message.channel.id}-{message.id}") -            return +            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:              with contextlib.suppress(NotFound): 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/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/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/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 0d3ddc005..583733fda 100644 --- a/config-default.yml +++ b/config-default.yml @@ -174,7 +174,7 @@ guild:          how_to_get_help:    704250143020417084          # Topical -        discord_py:         343944376055103488 +        discord_bots:         343944376055103488          # Logs          attachment_log:     &ATTACH_LOG     649243850006855680 @@ -377,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/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 d12329b1f..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() 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') | 
