diff options
| author | 2021-01-23 18:02:21 +0800 | |
|---|---|---|
| committer | 2021-01-23 18:02:21 +0800 | |
| commit | 87e4b09f71485f27911e5f9f71a82fca72dde09c (patch) | |
| tree | 1486f49df8fd4fee597ceddf29bc160b2c51bc28 | |
| parent | Handle unloaded cogs when retrieving server info. (diff) | |
| parent | Merge pull request #1346 from python-discord/bug/help/1341/race-conditions (diff) | |
Merge branch 'master' into feat/info/server-cmd-enhancements
| -rw-r--r-- | README.md | 7 | ||||
| -rw-r--r-- | bot/constants.py | 3 | ||||
| -rw-r--r-- | bot/exts/help_channels/_channel.py | 42 | ||||
| -rw-r--r-- | bot/exts/help_channels/_cog.py | 255 | ||||
| -rw-r--r-- | bot/exts/help_channels/_stats.py | 42 | ||||
| -rw-r--r-- | bot/exts/info/help.py | 17 | ||||
| -rw-r--r-- | bot/exts/info/information.py | 23 | ||||
| -rw-r--r-- | bot/exts/moderation/dm_relay.py | 4 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/infractions.py | 2 | ||||
| -rw-r--r-- | bot/exts/moderation/slowmode.py | 23 | ||||
| -rw-r--r-- | bot/log.py | 3 | ||||
| -rw-r--r-- | bot/utils/lock.py | 62 | ||||
| -rw-r--r-- | bot/utils/scheduling.py | 17 | ||||
| -rw-r--r-- | config-default.yml | 7 | ||||
| -rw-r--r-- | tests/bot/exts/info/test_information.py | 2 | ||||
| -rw-r--r-- | tests/bot/exts/moderation/test_slowmode.py | 16 | 
16 files changed, 288 insertions, 237 deletions
| @@ -5,8 +5,7 @@  [![Build][3]][4]  [![Deploy][5]][6]  [](https://coveralls.io/github/python-discord/bot) -[](LICENSE) -[](https://pythondiscord.com) +[](LICENSE)  This project is a Discord bot specifically for use with the Python Discord server. It provides numerous utilities  and other tools to help keep the server running like a well-oiled machine. @@ -19,5 +18,5 @@ Read the [Contributing Guide](https://pythondiscord.com/pages/contributing/bot/)  [4]: https://github.com/python-discord/bot/actions?query=workflow%3ABuild+branch%3Amaster  [5]: https://github.com/python-discord/bot/workflows/Deploy/badge.svg?branch=master  [6]: https://github.com/python-discord/bot/actions?query=workflow%3ADeploy+branch%3Amaster -[7]: https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E100k%20members&color=%237289DA&logoColor=white -[8]: https://discord.gg/2B963hn +[7]: https://raw.githubusercontent.com/python-discord/branding/master/logos/badge/badge_github.svg +[8]: https://discord.gg/python diff --git a/bot/constants.py b/bot/constants.py index be8d303f6..2f5cf0e8a 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -408,6 +408,7 @@ class Channels(metaclass=YAMLGetter):      code_help_voice_2: int      cooldown: int      defcon: int +    discord_py: int      dev_contrib: int      dev_core: int      dev_log: int @@ -429,7 +430,7 @@ class Channels(metaclass=YAMLGetter):      off_topic_1: int      off_topic_2: int      organisation: int -    python_discussion: int +    python_general: int      python_events: int      python_news: int      reddit: int diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index e717d7af8..224214b00 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -4,8 +4,10 @@ from datetime import datetime, timedelta  import discord +import bot  from bot import constants  from bot.exts.help_channels import _caches, _message +from bot.utils.channel import try_get_channel  log = logging.getLogger(__name__) @@ -55,3 +57,43 @@ async def get_in_use_time(channel_id: int) -> t.Optional[timedelta]:  def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool:      """Check if a channel should be excluded from the help channel system."""      return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS + + +async def move_to_bottom(channel: discord.TextChannel, category_id: int, **options) -> None: +    """ +    Move the `channel` to the bottom position of `category` and edit channel attributes. + +    To ensure "stable sorting", we use the `bulk_channel_update` endpoint and provide the current +    positions of the other channels in the category as-is. This should make sure that the channel +    really ends up at the bottom of the category. + +    If `options` are provided, the channel will be edited after the move is completed. This is the +    same order of operations that `discord.TextChannel.edit` uses. For information on available +    options, see the documentation on `discord.TextChannel.edit`. While possible, position-related +    options should be avoided, as it may interfere with the category move we perform. +    """ +    # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. +    category = await try_get_channel(category_id) + +    payload = [{"id": c.id, "position": c.position} for c in category.channels] + +    # Calculate the bottom position based on the current highest position in the category. If the +    # category is currently empty, we simply use the current position of the channel to avoid making +    # unnecessary changes to positions in the guild. +    bottom_position = payload[-1]["position"] + 1 if payload else channel.position + +    payload.append( +        { +            "id": channel.id, +            "position": bottom_position, +            "parent_id": category.id, +            "lock_permissions": True, +        } +    ) + +    # We use d.py's method to ensure our request is processed by d.py's rate limit manager +    await bot.instance.http.bulk_channel_update(category.guild.id, payload) + +    # Now that the channel is moved, we can edit the other attributes +    if options: +        await channel.edit(**options) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 983c5d183..0995c8a79 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -3,6 +3,7 @@ import logging  import random  import typing as t  from datetime import datetime, timezone +from operator import attrgetter  import discord  import discord.abc @@ -10,12 +11,12 @@ from discord.ext import commands  from bot import constants  from bot.bot import Bot -from bot.exts.help_channels import _caches, _channel, _cooldown, _message, _name -from bot.utils import channel as channel_utils -from bot.utils.scheduling import Scheduler +from bot.exts.help_channels import _caches, _channel, _cooldown, _message, _name, _stats +from bot.utils import channel as channel_utils, lock, scheduling  log = logging.getLogger(__name__) +NAMESPACE = "help"  HELP_CHANNEL_TOPIC = """  This is a Python help channel. You can claim your own help channel in the Python Help: Available category.  """ @@ -58,7 +59,7 @@ class HelpChannels(commands.Cog):      def __init__(self, bot: Bot):          self.bot = bot -        self.scheduler = Scheduler(self.__class__.__name__) +        self.scheduler = scheduling.Scheduler(self.__class__.__name__)          # Categories          self.available_category: discord.CategoryChannel = None @@ -73,7 +74,6 @@ class HelpChannels(commands.Cog):          # Asyncio stuff          self.queue_tasks: t.List[asyncio.Task] = [] -        self.on_message_lock = asyncio.Lock()          self.init_task = self.bot.loop.create_task(self.init_cog())      def cog_unload(self) -> None: @@ -87,6 +87,36 @@ class HelpChannels(commands.Cog):          self.scheduler.cancel_all() +    @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) +    async def claim_channel(self, message: discord.Message) -> None: +        """ +        Claim the channel in which the question `message` was sent. + +        Move the channel to the In Use category and pin the `message`. Add a cooldown to the +        claimant to prevent them from asking another question. Lastly, make a new channel available. +        """ +        log.info(f"Channel #{message.channel} was claimed by `{message.author.id}`.") +        await self.move_to_in_use(message.channel) +        await _cooldown.revoke_send_permissions(message.author, self.scheduler) + +        await _message.pin(message) + +        # Add user with channel for dormant check. +        await _caches.claimants.set(message.channel.id, message.author.id) + +        self.bot.stats.incr("help.claimed") + +        # Must use a timezone-aware datetime to ensure a correct POSIX timestamp. +        timestamp = datetime.now(timezone.utc).timestamp() +        await _caches.claim_times.set(message.channel.id, timestamp) + +        await _caches.unanswered.set(message.channel.id, True) + +        # Not awaited because it may indefinitely hold the lock while waiting for a channel. +        scheduling.create_task(self.move_to_available(), name=f"help_claim_{message.id}") +      def create_channel_queue(self) -> asyncio.Queue:          """          Return a queue of dormant channels to use for getting the next available channel. @@ -124,8 +154,12 @@ class HelpChannels(commands.Cog):          log.debug(f"Creating a new dormant channel named {name}.")          return await self.dormant_category.create_text_channel(name, topic=HELP_CHANNEL_TOPIC) -    async def dormant_check(self, ctx: commands.Context) -> bool: -        """Return True if the user is the help channel claimant or passes the role check.""" +    async def close_check(self, ctx: commands.Context) -> bool: +        """Return True if the channel is in use and the user is the claimant or has a whitelisted role.""" +        if ctx.channel.category != self.in_use_category: +            log.debug(f"{ctx.author} invoked command 'close' outside an in-use help channel") +            return False +          if await _caches.claimants.get(ctx.channel.id) == ctx.author.id:              log.trace(f"{ctx.author} is the help channel claimant, passing the check for dormant.")              self.bot.stats.incr("help.dormant_invoke.claimant") @@ -144,18 +178,12 @@ class HelpChannels(commands.Cog):          """          Make the current in-use help channel dormant. -        Make the channel dormant if the user passes the `dormant_check`, -        delete the message that invoked this. +        May only be invoked by the channel's claimant or by staff.          """ -        log.trace("close command invoked; checking if the channel is in-use.") - -        if ctx.channel.category != self.in_use_category: -            log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") -            return - -        if await self.dormant_check(ctx): -            await self.move_to_dormant(ctx.channel, "command") -            self.scheduler.cancel(ctx.channel.id) +        # Don't use a discord.py check because the check needs to fail silently. +        if await self.close_check(ctx): +            log.info(f"Close command invoked by {ctx.author} in #{ctx.channel}.") +            await self.unclaim_channel(ctx.channel, is_auto=False)      async def get_available_candidate(self) -> discord.TextChannel:          """ @@ -201,7 +229,7 @@ class HelpChannels(commands.Cog):          elif missing < 0:              log.trace(f"Moving {abs(missing)} superfluous available channels over to the Dormant category.")              for channel in channels[:abs(missing)]: -                await self.move_to_dormant(channel, "auto") +                await self.unclaim_channel(channel)      async def init_categories(self) -> None:          """Get the help category objects. Remove the cog if retrieval fails.""" @@ -248,20 +276,10 @@ class HelpChannels(commands.Cog):          self.close_command.enabled = True          await self.init_available() -        self.report_stats() +        _stats.report_counts()          log.info("Cog is ready!") -    def report_stats(self) -> None: -        """Report the channel count stats.""" -        total_in_use = sum(1 for _ in _channel.get_category_channels(self.in_use_category)) -        total_available = sum(1 for _ in _channel.get_category_channels(self.available_category)) -        total_dormant = sum(1 for _ in _channel.get_category_channels(self.dormant_category)) - -        self.bot.stats.gauge("help.total.in_use", total_in_use) -        self.bot.stats.gauge("help.total.available", total_available) -        self.bot.stats.gauge("help.total.dormant", total_dormant) -      async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None:          """          Make the `channel` dormant if idle or schedule the move if still active. @@ -284,7 +302,7 @@ class HelpChannels(commands.Cog):                  f"and will be made dormant."              ) -            await self.move_to_dormant(channel, "auto") +            await self.unclaim_channel(channel)          else:              # Cancel the existing task, if any.              if has_task: @@ -298,45 +316,6 @@ class HelpChannels(commands.Cog):              self.scheduler.schedule_later(delay, channel.id, self.move_idle_channel(channel)) -    async def move_to_bottom_position(self, channel: discord.TextChannel, category_id: int, **options) -> None: -        """ -        Move the `channel` to the bottom position of `category` and edit channel attributes. - -        To ensure "stable sorting", we use the `bulk_channel_update` endpoint and provide the current -        positions of the other channels in the category as-is. This should make sure that the channel -        really ends up at the bottom of the category. - -        If `options` are provided, the channel will be edited after the move is completed. This is the -        same order of operations that `discord.TextChannel.edit` uses. For information on available -        options, see the documentation on `discord.TextChannel.edit`. While possible, position-related -        options should be avoided, as it may interfere with the category move we perform. -        """ -        # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. -        category = await channel_utils.try_get_channel(category_id) - -        payload = [{"id": c.id, "position": c.position} for c in category.channels] - -        # Calculate the bottom position based on the current highest position in the category. If the -        # category is currently empty, we simply use the current position of the channel to avoid making -        # unnecessary changes to positions in the guild. -        bottom_position = payload[-1]["position"] + 1 if payload else channel.position - -        payload.append( -            { -                "id": channel.id, -                "position": bottom_position, -                "parent_id": category.id, -                "lock_permissions": True, -            } -        ) - -        # We use d.py's method to ensure our request is processed by d.py's rate limit manager -        await self.bot.http.bulk_channel_update(category.guild.id, payload) - -        # Now that the channel is moved, we can edit the other attributes -        if options: -            await channel.edit(**options) -      async def move_to_available(self) -> None:          """Make a channel available."""          log.trace("Making a channel available.") @@ -348,78 +327,81 @@ class HelpChannels(commands.Cog):          log.trace(f"Moving #{channel} ({channel.id}) to the Available category.") -        await self.move_to_bottom_position( +        await _channel.move_to_bottom(              channel=channel,              category_id=constants.Categories.help_available,          ) -        self.report_stats() - -    async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None: -        """ -        Make the `channel` dormant. +        _stats.report_counts() -        A caller argument is provided for metrics. -        """ +    async def move_to_dormant(self, channel: discord.TextChannel) -> None: +        """Make the `channel` dormant."""          log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") - -        await self.move_to_bottom_position( +        await _channel.move_to_bottom(              channel=channel,              category_id=constants.Categories.help_dormant,          ) -        await self.unclaim_channel(channel) - -        self.bot.stats.incr(f"help.dormant_calls.{caller}") - -        in_use_time = await _channel.get_in_use_time(channel.id) -        if in_use_time: -            self.bot.stats.timing("help.in_use_time", in_use_time) - -        unanswered = await _caches.unanswered.get(channel.id) -        if unanswered: -            self.bot.stats.incr("help.sessions.unanswered") -        elif unanswered is not None: -            self.bot.stats.incr("help.sessions.answered") - -        log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.")          log.trace(f"Sending dormant message for #{channel} ({channel.id}).")          embed = discord.Embed(description=_message.DORMANT_MSG)          await channel.send(embed=embed) -        await _message.unpin(channel) -          log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.")          self.channel_queue.put_nowait(channel) -        self.report_stats() -    async def unclaim_channel(self, channel: discord.TextChannel) -> None: +        _stats.report_counts() + +    @lock.lock_arg(f"{NAMESPACE}.unclaim", "channel") +    async def unclaim_channel(self, channel: discord.TextChannel, *, is_auto: bool = True) -> None:          """ -        Mark the channel as unclaimed and remove the cooldown role from the claimant if needed. +        Unclaim an in-use help `channel` to make it dormant. + +        Unpin the claimant's question message and move the channel to the Dormant category. +        Remove the cooldown role from the channel claimant if they have no other channels claimed. +        Cancel the scheduled cooldown role removal task. -        The role is only removed if they have no claimed channels left once the current one is unclaimed. -        This method also handles canceling the automatic removal of the cooldown role. +        Set `is_auto` to True if the channel was automatically closed or False if manually closed.          """ -        claimant_id = await _caches.claimants.pop(channel.id) +        claimant_id = await _caches.claimants.get(channel.id) +        _unclaim_channel = self._unclaim_channel + +        # It could be possible that there is no claimant cached. In such case, it'd be useless and +        # possibly incorrect to lock on None. Therefore, the lock is applied conditionally. +        if claimant_id is not None: +            decorator = lock.lock_arg(f"{NAMESPACE}.unclaim", "claimant_id", wait=True) +            _unclaim_channel = decorator(_unclaim_channel) + +        return await _unclaim_channel(channel, claimant_id, is_auto) -        # Ignore missing task when cooldown has passed but the channel still isn't dormant. +    async def _unclaim_channel(self, channel: discord.TextChannel, claimant_id: int, is_auto: bool) -> None: +        """Actual implementation of `unclaim_channel`. See that for full documentation.""" +        await _caches.claimants.delete(channel.id) + +        # Ignore missing tasks because a channel may still be dormant after the cooldown expires.          if claimant_id in self.scheduler:              self.scheduler.cancel(claimant_id)          claimant = self.bot.get_guild(constants.Guild.id).get_member(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") -            return - -        # Remove the cooldown role if the claimant has no other channels left -        if not any(claimant.id == user_id for _, user_id in await _caches.claimants.items()): +        elif not any(claimant.id == user_id for _, user_id in await _caches.claimants.items()): +            # Remove the cooldown role if the claimant has no other channels left              await _cooldown.remove_cooldown_role(claimant) +        await _message.unpin(channel) +        await _stats.report_complete_session(channel.id, is_auto) +        await self.move_to_dormant(channel) + +        # Cancel the task that makes the channel dormant only if called by the close command. +        # In other cases, the task is either already done or not-existent. +        if not is_auto: +            self.scheduler.cancel(channel.id) +      async def move_to_in_use(self, channel: discord.TextChannel) -> None:          """Make a channel in-use and schedule it to be made dormant."""          log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") -        await self.move_to_bottom_position( +        await _channel.move_to_bottom(              channel=channel,              category_id=constants.Categories.help_in_use,          ) @@ -428,7 +410,7 @@ class HelpChannels(commands.Cog):          log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.")          self.scheduler.schedule_later(timeout, channel.id, self.move_idle_channel(channel)) -        self.report_stats() +        _stats.report_counts()      @commands.Cog.listener()      async def on_message(self, message: discord.Message) -> None: @@ -436,51 +418,13 @@ class HelpChannels(commands.Cog):          if message.author.bot:              return  # Ignore messages sent by bots. -        channel = message.channel - -        await _message.check_for_answer(message) - -        is_available = channel_utils.is_in_category(channel, constants.Categories.help_available) -        if not is_available or _channel.is_excluded_channel(channel): -            return  # Ignore messages outside the Available category or in excluded channels. - -        log.trace("Waiting for the cog to be ready before processing messages.")          await self.init_task -        log.trace("Acquiring lock to prevent a channel from being processed twice...") -        async with self.on_message_lock: -            log.trace(f"on_message lock acquired for {message.id}.") - -            if not channel_utils.is_in_category(channel, constants.Categories.help_available): -                log.debug( -                    f"Message {message.id} will not make #{channel} ({channel.id}) in-use " -                    f"because another message in the channel already triggered that." -                ) -                return - -            log.info(f"Channel #{channel} was claimed by `{message.author.id}`.") -            await self.move_to_in_use(channel) -            await _cooldown.revoke_send_permissions(message.author, self.scheduler) - -            await _message.pin(message) - -            # Add user with channel for dormant check. -            await _caches.claimants.set(channel.id, message.author.id) - -            self.bot.stats.incr("help.claimed") - -            # Must use a timezone-aware datetime to ensure a correct POSIX timestamp. -            timestamp = datetime.now(timezone.utc).timestamp() -            await _caches.claim_times.set(channel.id, timestamp) - -            await _caches.unanswered.set(channel.id, True) - -            log.trace(f"Releasing on_message lock for {message.id}.") - -        # Move a dormant channel to the Available category to fill in the gap. -        # This is done last and outside the lock because it may wait indefinitely for a channel to -        # be put in the queue. -        await self.move_to_available() +        if channel_utils.is_in_category(message.channel, constants.Categories.help_available): +            if not _channel.is_excluded_channel(message.channel): +                await self.claim_channel(message) +        else: +            await _message.check_for_answer(message)      @commands.Cog.listener()      async def on_message_delete(self, msg: discord.Message) -> None: @@ -489,15 +433,14 @@ class HelpChannels(commands.Cog):          The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`.          """ +        await self.init_task +          if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use):              return          if not await _message.is_empty(msg.channel):              return -        log.trace("Waiting for the cog to be ready before processing deleted messages.") -        await self.init_task -          log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.")          # Cancel existing dormant task before scheduling new. diff --git a/bot/exts/help_channels/_stats.py b/bot/exts/help_channels/_stats.py new file mode 100644 index 000000000..b8778e7d9 --- /dev/null +++ b/bot/exts/help_channels/_stats.py @@ -0,0 +1,42 @@ +import logging + +from more_itertools import ilen + +import bot +from bot import constants +from bot.exts.help_channels import _caches, _channel + +log = logging.getLogger(__name__) + + +def report_counts() -> None: +    """Report channel count stats of each help category.""" +    for name in ("in_use", "available", "dormant"): +        id_ = getattr(constants.Categories, f"help_{name}") +        category = bot.instance.get_channel(id_) + +        if category: +            total = ilen(_channel.get_category_channels(category)) +            bot.instance.stats.gauge(f"help.total.{name}", total) +        else: +            log.warning(f"Couldn't find category {name!r} to track channel count stats.") + + +async def report_complete_session(channel_id: int, is_auto: bool) -> None: +    """ +    Report stats for a completed help session channel `channel_id`. + +    Set `is_auto` to True if the channel was automatically closed or False if manually closed. +    """ +    caller = "auto" if is_auto else "command" +    bot.instance.stats.incr(f"help.dormant_calls.{caller}") + +    in_use_time = await _channel.get_in_use_time(channel_id) +    if in_use_time: +        bot.instance.stats.timing("help.in_use_time", in_use_time) + +    unanswered = await _caches.unanswered.get(channel_id) +    if unanswered: +        bot.instance.stats.incr("help.sessions.unanswered") +    elif unanswered is not None: +        bot.instance.stats.incr("help.sessions.answered") diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 461ff82fd..3a05b2c8a 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -5,7 +5,7 @@ from contextlib import suppress  from typing import List, Union  from discord import Colour, Embed -from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand +from discord.ext.commands import Bot, Cog, Command, CommandError, Context, DisabledCommand, Group, HelpCommand  from fuzzywuzzy import fuzz, process  from fuzzywuzzy.utils import full_process @@ -20,6 +20,8 @@ log = logging.getLogger(__name__)  COMMANDS_PER_PAGE = 8  PREFIX = constants.Bot.prefix +NOT_ALLOWED_TO_RUN_MESSAGE = "***You cannot run this command.***\n\n" +  Category = namedtuple("Category", ["name", "description", "cogs"]) @@ -173,9 +175,16 @@ class CustomHelpCommand(HelpCommand):          if aliases:              command_details += f"**Can also use:** {aliases}\n\n" -        # check if the user is allowed to run this command -        if not await command.can_run(self.context): -            command_details += "***You cannot run this command.***\n\n" +        # when command is disabled, show message about it, +        # when other CommandError or user is not allowed to run command, +        # add this to help message. +        try: +            if not await command.can_run(self.context): +                command_details += NOT_ALLOWED_TO_RUN_MESSAGE +        except DisabledCommand: +            command_details += "***This command is disabled.***\n\n" +        except CommandError: +            command_details += NOT_ALLOWED_TO_RUN_MESSAGE          command_details += f"*{command.help or 'No details provided.'}*\n"          embed.description = command_details diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 1fa045416..917465538 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -5,7 +5,8 @@ import textwrap  from collections import defaultdict  from typing import Any, DefaultDict, Dict, Mapping, Optional, Tuple, Union -from discord import Colour, Embed, Guild, Message, Role, utils +import fuzzywuzzy +from discord import Colour, Embed, Guild, Message, Role  from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role  from bot import constants @@ -104,22 +105,28 @@ class Information(Cog):          To specify multiple roles just add to the arguments, delimit roles with spaces in them using quotation marks.          """ -        parsed_roles = [] -        failed_roles = [] +        parsed_roles = set() +        failed_roles = set() +        all_roles = {role.id: role.name for role in ctx.guild.roles}          for role_name in roles:              if isinstance(role_name, Role):                  # Role conversion has already succeeded -                parsed_roles.append(role_name) +                parsed_roles.add(role_name)                  continue -            role = utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles) +            match = fuzzywuzzy.process.extractOne( +                role_name, all_roles, score_cutoff=80, +                scorer=fuzzywuzzy.fuzz.ratio +            ) -            if not role: -                failed_roles.append(role_name) +            if not match: +                failed_roles.add(role_name)                  continue -            parsed_roles.append(role) +            # `match` is a (role name, score, role id) tuple +            role = ctx.guild.get_role(match[2]) +            parsed_roles.add(role)          if failed_roles:              await ctx.send(f":x: Could not retrieve the following roles: {', '.join(failed_roles)}") diff --git a/bot/exts/moderation/dm_relay.py b/bot/exts/moderation/dm_relay.py index 4d5142b55..6d081741c 100644 --- a/bot/exts/moderation/dm_relay.py +++ b/bot/exts/moderation/dm_relay.py @@ -52,6 +52,10 @@ class DMRelay(Cog):              await ctx.message.add_reaction("❌")              return +        if member.id == self.bot.user.id: +            log.debug("Not sending message to bot user") +            return await ctx.send("🚫 I can't send messages to myself!") +          try:              await member.send(message)          except discord.errors.Forbidden: diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 18e937e87..b3d069b34 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -198,7 +198,7 @@ class Infractions(InfractionScheduler, commands.Cog):      # endregion      # region: Temporary shadow infractions -    @command(hidden=True, aliases=["shadowtempban, stempban"]) +    @command(hidden=True, aliases=["shadowtempban", "stempban"])      async def shadow_tempban(          self,          ctx: Context, diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py index efd862aa5..c449752e1 100644 --- a/bot/exts/moderation/slowmode.py +++ b/bot/exts/moderation/slowmode.py @@ -7,7 +7,7 @@ from discord import TextChannel  from discord.ext.commands import Cog, Context, group, has_any_role  from bot.bot import Bot -from bot.constants import Emojis, MODERATION_ROLES +from bot.constants import Channels, Emojis, MODERATION_ROLES  from bot.converters import DurationDelta  from bot.utils import time @@ -15,6 +15,12 @@ log = logging.getLogger(__name__)  SLOWMODE_MAX_DELAY = 21600  # seconds +COMMONLY_SLOWMODED_CHANNELS = { +    Channels.python_general: "python_general", +    Channels.discord_py: "discordpy", +    Channels.off_topic_0: "ot0", +} +  class Slowmode(Cog):      """Commands for getting and setting slowmode delays of text channels.""" @@ -58,6 +64,10 @@ class Slowmode(Cog):              log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.')              await channel.edit(slowmode_delay=slowmode_delay) +            if channel.id in COMMONLY_SLOWMODED_CHANNELS: +                log.info(f'Recording slowmode change in stats for {channel.name}.') +                self.bot.stats.gauge(f"slowmode.{COMMONLY_SLOWMODED_CHANNELS[channel.id]}", slowmode_delay) +              await ctx.send(                  f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {humanized_delay}.'              ) @@ -75,16 +85,7 @@ class Slowmode(Cog):      @slowmode_group.command(name='reset', aliases=['r'])      async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None:          """Reset the slowmode delay for a text channel to 0 seconds.""" -        # Use the channel this command was invoked in if one was not given -        if channel is None: -            channel = ctx.channel - -        log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.') - -        await channel.edit(slowmode_delay=0) -        await ctx.send( -            f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.' -        ) +        await self.set_slowmode(ctx, channel, relativedelta(seconds=0))      async def cog_check(self, ctx: Context) -> bool:          """Only allow moderators to invoke the commands in this cog.""" diff --git a/bot/log.py b/bot/log.py index 0935666d1..e92233a33 100644 --- a/bot/log.py +++ b/bot/log.py @@ -54,6 +54,9 @@ def setup() -> None:      logging.getLogger("chardet").setLevel(logging.WARNING)      logging.getLogger("async_rediscache").setLevel(logging.WARNING) +    # Set back to the default of INFO even if asyncio's debug mode is enabled. +    logging.getLogger("asyncio").setLevel(logging.INFO) +  def setup_sentry() -> None:      """Set up the Sentry logging integrations.""" diff --git a/bot/utils/lock.py b/bot/utils/lock.py index 7aaafbc88..e44776340 100644 --- a/bot/utils/lock.py +++ b/bot/utils/lock.py @@ -1,3 +1,4 @@ +import asyncio  import inspect  import logging  from collections import defaultdict @@ -16,39 +17,21 @@ _IdCallable = Callable[[function.BoundArgs], _IdCallableReturn]  ResourceId = Union[Hashable, _IdCallable] -class LockGuard: -    """ -    A context manager which acquires and releases a lock (mutex). - -    Raise RuntimeError if trying to acquire a locked lock. -    """ - -    def __init__(self): -        self._locked = False - -    @property -    def locked(self) -> bool: -        """Return True if currently locked or False if unlocked.""" -        return self._locked - -    def __enter__(self): -        if self._locked: -            raise RuntimeError("Cannot acquire a locked lock.") - -        self._locked = True - -    def __exit__(self, _exc_type, _exc_value, _traceback):  # noqa: ANN001 -        self._locked = False -        return False  # Indicate any raised exception shouldn't be suppressed. - - -def lock(namespace: Hashable, resource_id: ResourceId, *, raise_error: bool = False) -> Callable: +def lock( +    namespace: Hashable, +    resource_id: ResourceId, +    *, +    raise_error: bool = False, +    wait: bool = False, +) -> Callable:      """      Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`. -    If any other mutually exclusive function currently holds the lock for a resource, do not run the -    decorated function and return None. If `raise_error` is True, raise `LockedResourceError` if -    the lock cannot be acquired. +    If `wait` is True, wait until the lock becomes available. Otherwise, if any other mutually +    exclusive function currently holds the lock for a resource, do not run the decorated function +    and return None. + +    If `raise_error` is True, raise `LockedResourceError` if the lock cannot be acquired.      `namespace` is an identifier used to prevent collisions among resource IDs. @@ -78,15 +61,19 @@ def lock(namespace: Hashable, resource_id: ResourceId, *, raise_error: bool = Fa              else:                  id_ = resource_id -            log.trace(f"{name}: getting lock for resource {id_!r} under namespace {namespace!r}") +            log.trace(f"{name}: getting the lock object for resource {namespace!r}:{id_!r}")              # Get the lock for the ID. Create a lock if one doesn't exist yet.              locks = __lock_dicts[namespace] -            lock_guard = locks.setdefault(id_, LockGuard()) - -            if not lock_guard.locked: -                log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") -                with lock_guard: +            lock_ = locks.setdefault(id_, asyncio.Lock()) + +            # It's safe to check an asyncio.Lock is free before acquiring it because: +            #   1. Synchronous code like `if not lock_.locked()` does not yield execution +            #   2. `asyncio.Lock.acquire()` does not internally await anything if the lock is free +            #   3. awaits only yield execution to the event loop at actual I/O boundaries +            if wait or not lock_.locked(): +                log.debug(f"{name}: acquiring lock for resource {namespace!r}:{id_!r}...") +                async with lock_:                      return await func(*args, **kwargs)              else:                  log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") @@ -103,6 +90,7 @@ def lock_arg(      func: Callable[[Any], _IdCallableReturn] = None,      *,      raise_error: bool = False, +    wait: bool = False,  ) -> Callable:      """      Apply the `lock` decorator using the value of the arg at the given name/position as the ID. @@ -110,5 +98,5 @@ def lock_arg(      `func` is an optional callable or awaitable which will return the ID given the argument value.      See `lock` docs for more information.      """ -    decorator_func = partial(lock, namespace, raise_error=raise_error) +    decorator_func = partial(lock, namespace, raise_error=raise_error, wait=wait)      return function.get_arg_value_wrapper(decorator_func, name_or_pos, func) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 03f31d78f..4dd036e4f 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -155,3 +155,20 @@ class Scheduler:              # Log the exception if one exists.              if exception:                  self._log.error(f"Error in task #{task_id} {id(done_task)}!", exc_info=exception) + + +def create_task(*args, **kwargs) -> asyncio.Task: +    """Wrapper for `asyncio.create_task` which logs exceptions raised in the task.""" +    task = asyncio.create_task(*args, **kwargs) +    task.add_done_callback(_log_task_exception) +    return task + + +def _log_task_exception(task: asyncio.Task) -> None: +    """Retrieve and log the exception raised in `task` if one exists.""" +    with contextlib.suppress(asyncio.CancelledError): +        exception = task.exception() +        # Log the exception if one exists. +        if exception: +            log = logging.getLogger(__name__) +            log.error(f"Error in task {task.get_name()} {id(task)}!", exc_info=exception) diff --git a/config-default.yml b/config-default.yml index f8368c5d2..6695cffed 100644 --- a/config-default.yml +++ b/config-default.yml @@ -157,11 +157,14 @@ guild:          # Discussion          meta:                               429409067623251969 -        python_discussion:  &PY_DISCUSSION  267624335836053506 +        python_general:     &PY_GENERAL     267624335836053506          # Python Help: Available          cooldown:           720603994149486673 +        # Topical +        discord_py:         343944376055103488 +          # Logs          attachment_log:     &ATTACH_LOG     649243850006855680          message_log:        &MESSAGE_LOG    467752170159079424 @@ -430,7 +433,7 @@ code_block:      # The channels which will be affected by a cooldown. These channels are also whitelisted.      cooldown_channels: -        - *PY_DISCUSSION +        - *PY_GENERAL      # Sending instructions triggers a cooldown on a per-channel basis.      # More instruction messages will not be sent in the same channel until the cooldown has elapsed. diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index d077be960..80731c9f0 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -65,7 +65,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase):              permissions=discord.Permissions(0),          ) -        self.ctx.guild.roles.append([dummy_role, admin_role]) +        self.ctx.guild.roles.extend([dummy_role, admin_role])          self.cog.role_info.can_run = unittest.mock.AsyncMock()          self.cog.role_info.can_run.return_value = True diff --git a/tests/bot/exts/moderation/test_slowmode.py b/tests/bot/exts/moderation/test_slowmode.py index dad751e0d..5483b7a64 100644 --- a/tests/bot/exts/moderation/test_slowmode.py +++ b/tests/bot/exts/moderation/test_slowmode.py @@ -85,22 +85,14 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase):              self.ctx.reset_mock() -    async def test_reset_slowmode_no_channel(self) -> None: -        """Reset slowmode without a given channel.""" -        self.ctx.channel = MockTextChannel(name='careers', slowmode_delay=6) - -        await self.cog.reset_slowmode(self.cog, self.ctx, None) -        self.ctx.send.assert_called_once_with( -            f'{Emojis.check_mark} The slowmode delay for #careers has been reset to 0 seconds.' -        ) - -    async def test_reset_slowmode_with_channel(self) -> None: +    async def test_reset_slowmode_sets_delay_to_zero(self) -> None:          """Reset slowmode with a given channel."""          text_channel = MockTextChannel(name='meta', slowmode_delay=1) +        self.cog.set_slowmode = mock.AsyncMock()          await self.cog.reset_slowmode(self.cog, self.ctx, text_channel) -        self.ctx.send.assert_called_once_with( -            f'{Emojis.check_mark} The slowmode delay for #meta has been reset to 0 seconds.' +        self.cog.set_slowmode.assert_awaited_once_with( +            self.ctx, text_channel, relativedelta(seconds=0)          )      @mock.patch("bot.exts.moderation.slowmode.has_any_role") | 
