diff options
| author | 2020-12-02 18:49:47 +0200 | |
|---|---|---|
| committer | 2020-12-02 18:49:47 +0200 | |
| commit | eb3205d6e9c868f8995fd1b8a6fc255e4acd16bb (patch) | |
| tree | 87e58534a8d571c5112687eb40d925c84fc4e192 | |
| parent | Remove unnecessary f-string (diff) | |
| parent | Merge pull request #1305 from HassanAbouelela/seperate-voice-chat-channels (diff) | |
Merge branch 'master' into bug-fixes
Diffstat (limited to '')
| -rw-r--r-- | .github/CODEOWNERS | 39 | ||||
| -rw-r--r-- | .github/workflows/build.yml | 2 | ||||
| -rw-r--r-- | .github/workflows/deploy.yml | 5 | ||||
| -rw-r--r-- | bot/constants.py | 9 | ||||
| -rw-r--r-- | bot/converters.py | 29 | ||||
| -rw-r--r-- | bot/exts/help_channels.py | 940 | ||||
| -rw-r--r-- | bot/exts/help_channels/__init__.py | 41 | ||||
| -rw-r--r-- | bot/exts/help_channels/_caches.py | 19 | ||||
| -rw-r--r-- | bot/exts/help_channels/_channel.py | 57 | ||||
| -rw-r--r-- | bot/exts/help_channels/_cog.py | 502 | ||||
| -rw-r--r-- | bot/exts/help_channels/_cooldown.py | 95 | ||||
| -rw-r--r-- | bot/exts/help_channels/_message.py | 217 | ||||
| -rw-r--r-- | bot/exts/help_channels/_name.py | 69 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/_scheduler.py | 22 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/infractions.py | 46 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/management.py | 74 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/superstarify.py | 77 | ||||
| -rw-r--r-- | bot/resources/tags/microsoft-build-tools.md | 15 | ||||
| -rw-r--r-- | config-default.yml | 15 | ||||
| -rw-r--r-- | deployment.yaml | 21 | 
20 files changed, 1215 insertions, 1079 deletions
| diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cf5f1590d..642676078 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1,38 @@ -* @python-discord/core-developers +# Request Dennis for any PR +* @Den4200 + +# Extensions +**/bot/exts/backend/sync/**             @MarkKoz +**/bot/exts/filters/*token_remover.py   @MarkKoz +**/bot/exts/moderation/*silence.py      @MarkKoz +bot/exts/info/codeblock/**              @MarkKoz +bot/exts/utils/extensions.py            @MarkKoz +bot/exts/utils/snekbox.py               @MarkKoz @Akarys42 +bot/exts/help_channels/**               @MarkKoz @Akarys42 +bot/exts/moderation/**                  @Akarys42 @mbaruh +bot/exts/info/**                        @Akarys42 @mbaruh +bot/exts/filters/**                     @mbaruh + +# Utils +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 +Dockerfile                              @MarkKoz @Akarys42 +docker-compose.yml                      @MarkKoz @Akarys42 + +# Tools +Pipfile*                                @Akarys42 + +# Statistics +bot/async_stats.py                      @jb3 +bot/exts/info/stats.py                  @jb3 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 706ab462f..6152f1543 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,7 +10,7 @@ on:  jobs:    build: -    if: github.event.workflow_run.conclusion == 'success' +    if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'      name: Build & Push      runs-on: ubuntu-latest diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 90555a8ee..5a4aede30 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -23,6 +23,9 @@ jobs:        - name: Checkout code          uses: actions/checkout@v2 +        with: +          repository: python-discord/kubernetes +          token: ${{ secrets.REPO_TOKEN }}        - name: Authenticate with Kubernetes          uses: azure/k8s-set-context@v1 @@ -34,6 +37,6 @@ jobs:          uses: Azure/k8s-deploy@v1          with:            manifests: | -              deployment.yaml +              bot/deployment.yaml            images: 'ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }}'            kubectl-version: 'latest' diff --git a/bot/constants.py b/bot/constants.py index 6bb6aacd2..08ae0d52f 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -391,12 +391,15 @@ class Channels(metaclass=YAMLGetter):      admin_announcements: int      admin_spam: int      admins: int +    admins_voice: int      announcements: int      attachment_log: int      big_brother_logs: int      bot_commands: int      change_log: int -    code_help_voice: int +    code_help_chat_1: int +    code_help_chat_2: int +    code_help_voice_1: int      code_help_voice_2: int      cooldown: int      defcon: int @@ -405,8 +408,8 @@ class Channels(metaclass=YAMLGetter):      dev_log: int      dm_log: int      esoteric: int +    general_voice: int      helpers: int -    how_to_get_help: int      incidents: int      incidents_archive: int      mailing_lists: int @@ -426,6 +429,8 @@ class Channels(metaclass=YAMLGetter):      python_news: int      reddit: int      staff_announcements: int +    staff_voice: int +    staff_voice_chat: int      talent_pool: int      user_event_announcements: int      user_log: int diff --git a/bot/converters.py b/bot/converters.py index 2e118d476..d0a9731d6 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -549,6 +549,35 @@ def _snowflake_from_regex(pattern: t.Pattern, arg: str) -> int:      return int(match.group(1)) +class Infraction(Converter): +    """ +    Attempts to convert a given infraction ID into an infraction. + +    Alternatively, `l`, `last`, or `recent` can be passed in order to +    obtain the most recent infraction by the actor. +    """ + +    async def convert(self, ctx: Context, arg: str) -> t.Optional[dict]: +        """Attempts to convert `arg` into an infraction `dict`.""" +        if arg in ("l", "last", "recent"): +            params = { +                "actor__id": ctx.author.id, +                "ordering": "-inserted_at" +            } + +            infractions = await ctx.bot.api_client.get("bot/infractions", params=params) + +            if not infractions: +                raise BadArgument( +                    "Couldn't find most recent infraction; you have never given an infraction." +                ) +            else: +                return infractions[0] + +        else: +            return await ctx.bot.api_client.get(f"bot/infractions/{arg}") + +  Expiry = t.Union[Duration, ISODateTime]  FetchedMember = t.Union[discord.Member, FetchedUser]  UserMention = partial(_snowflake_from_regex, RE_USER_MENTION) diff --git a/bot/exts/help_channels.py b/bot/exts/help_channels.py deleted file mode 100644 index ced2f72ef..000000000 --- a/bot/exts/help_channels.py +++ /dev/null @@ -1,940 +0,0 @@ -import asyncio -import json -import logging -import random -import typing as t -from collections import deque -from datetime import datetime, timedelta, timezone -from pathlib import Path - -import discord -import discord.abc -from async_rediscache import RedisCache -from discord.ext import commands - -from bot import constants -from bot.bot import Bot -from bot.utils import channel as channel_utils -from bot.utils.scheduling import Scheduler - -log = logging.getLogger(__name__) - -ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" -MAX_CHANNELS_PER_CATEGORY = 50 -EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) - -HELP_CHANNEL_TOPIC = """ -This is a Python help channel. You can claim your own help channel in the Python Help: Available category. -""" - -AVAILABLE_MSG = f""" -**Send your question here to claim the channel** -This channel will be dedicated to answering your question only. Others will try to answer and help you solve the issue. - -**Keep in mind:** -• It's always ok to just ask your question. You don't need permission. -• Explain what you expect to happen and what actually happens. -• Include a code sample and error message, if you got any. - -For more tips, check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**. -""" - -AVAILABLE_TITLE = "Available help channel" - -AVAILABLE_FOOTER = f"Closes after {constants.HelpChannels.idle_minutes} minutes of inactivity or when you send !close." - -DORMANT_MSG = f""" -This help channel has been marked as **dormant**, and has been moved into the **Help: Dormant** \ -category at the bottom of the channel list. It is no longer possible to send messages in this \ -channel until it becomes available again. - -If your question wasn't answered yet, you can claim a new help channel from the \ -**Help: Available** category by simply asking your question again. Consider rephrasing the \ -question to maximize your chance of getting a good answer. If you're not sure how, have a look \ -through our guide for **[asking a good question]({ASKING_GUIDE_URL})**. -""" - -CoroutineFunc = t.Callable[..., t.Coroutine] - - -class HelpChannels(commands.Cog): -    """ -    Manage the help channel system of the guild. - -    The system is based on a 3-category system: - -    Available Category - -    * Contains channels which are ready to be occupied by someone who needs help -    * Will always contain `constants.HelpChannels.max_available` channels; refilled automatically -      from the pool of dormant channels -        * Prioritise using the channels which have been dormant for the longest amount of time -        * If there are no more dormant channels, the bot will automatically create a new one -        * If there are no dormant channels to move, helpers will be notified (see `notify()`) -    * When a channel becomes available, the dormant embed will be edited to show `AVAILABLE_MSG` -    * User can only claim a channel at an interval `constants.HelpChannels.claim_minutes` -        * To keep track of cooldowns, user which claimed a channel will have a temporary role - -    In Use Category - -    * Contains all channels which are occupied by someone needing help -    * Channel moves to dormant category after `constants.HelpChannels.idle_minutes` of being idle -    * Command can prematurely mark a channel as dormant -        * Channel claimant is allowed to use the command -        * Allowed roles for the command are configurable with `constants.HelpChannels.cmd_whitelist` -    * When a channel becomes dormant, an embed with `DORMANT_MSG` will be sent - -    Dormant Category - -    * Contains channels which aren't in use -    * Channels are used to refill the Available category - -    Help channels are named after the chemical elements in `bot/resources/elements.json`. -    """ - -    # This cache tracks which channels are claimed by which members. -    # RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] -    help_channel_claimants = RedisCache() - -    # This cache maps a help channel to whether it has had any -    # activity other than the original claimant. True being no other -    # activity and False being other activity. -    # RedisCache[discord.TextChannel.id, bool] -    unanswered = RedisCache() - -    # This dictionary maps a help channel to the time it was claimed -    # RedisCache[discord.TextChannel.id, UtcPosixTimestamp] -    claim_times = RedisCache() - -    # This cache maps a help channel to original question message in same channel. -    # RedisCache[discord.TextChannel.id, discord.Message.id] -    question_messages = RedisCache() - -    def __init__(self, bot: Bot): -        self.bot = bot -        self.scheduler = Scheduler(self.__class__.__name__) - -        # Categories -        self.available_category: discord.CategoryChannel = None -        self.in_use_category: discord.CategoryChannel = None -        self.dormant_category: discord.CategoryChannel = None - -        # Queues -        self.channel_queue: asyncio.Queue[discord.TextChannel] = None -        self.name_queue: t.Deque[str] = None - -        self.name_positions = self.get_names() -        self.last_notification: t.Optional[datetime] = None - -        # Asyncio stuff -        self.queue_tasks: t.List[asyncio.Task] = [] -        self.ready = asyncio.Event() -        self.on_message_lock = asyncio.Lock() -        self.init_task = self.bot.loop.create_task(self.init_cog()) - -    def cog_unload(self) -> None: -        """Cancel the init task and scheduled tasks when the cog unloads.""" -        log.trace("Cog unload: cancelling the init_cog task") -        self.init_task.cancel() - -        log.trace("Cog unload: cancelling the channel queue tasks") -        for task in self.queue_tasks: -            task.cancel() - -        self.scheduler.cancel_all() - -    def create_channel_queue(self) -> asyncio.Queue: -        """ -        Return a queue of dormant channels to use for getting the next available channel. - -        The channels are added to the queue in a random order. -        """ -        log.trace("Creating the channel queue.") - -        channels = list(self.get_category_channels(self.dormant_category)) -        random.shuffle(channels) - -        log.trace("Populating the channel queue with channels.") -        queue = asyncio.Queue() -        for channel in channels: -            queue.put_nowait(channel) - -        return queue - -    async def create_dormant(self) -> t.Optional[discord.TextChannel]: -        """ -        Create and return a new channel in the Dormant category. - -        The new channel will sync its permission overwrites with the category. - -        Return None if no more channel names are available. -        """ -        log.trace("Getting a name for a new dormant channel.") - -        try: -            name = self.name_queue.popleft() -        except IndexError: -            log.debug("No more names available for new dormant channels.") -            return None - -        log.debug(f"Creating a new dormant channel named {name}.") -        return await self.dormant_category.create_text_channel(name, topic=HELP_CHANNEL_TOPIC) - -    def create_name_queue(self) -> deque: -        """Return a queue of element names to use for creating new channels.""" -        log.trace("Creating the chemical element name queue.") - -        used_names = self.get_used_names() - -        log.trace("Determining the available names.") -        available_names = (name for name in self.name_positions if name not in used_names) - -        log.trace("Populating the name queue with names.") -        return deque(available_names) - -    async def dormant_check(self, ctx: commands.Context) -> bool: -        """Return True if the user is the help channel claimant or passes the role check.""" -        if await self.help_channel_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") -            return True - -        log.trace(f"{ctx.author} is not the help channel claimant, checking roles.") -        has_role = await commands.has_any_role(*constants.HelpChannels.cmd_whitelist).predicate(ctx) - -        if has_role: -            self.bot.stats.incr("help.dormant_invoke.staff") - -        return has_role - -    @commands.command(name="close", aliases=["dormant", "solved"], enabled=False) -    async def close_command(self, ctx: commands.Context) -> None: -        """ -        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, -        and reset the send permissions cooldown for the user who started the session. -        """ -        log.trace("close command invoked; checking if the channel is in-use.") -        if ctx.channel.category == self.in_use_category: -            if await self.dormant_check(ctx): -                await self.remove_cooldown_role(ctx.author) - -                # Ignore missing task when cooldown has passed but the channel still isn't dormant. -                if ctx.author.id in self.scheduler: -                    self.scheduler.cancel(ctx.author.id) - -                await self.move_to_dormant(ctx.channel, "command") -                self.scheduler.cancel(ctx.channel.id) -        else: -            log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") - -    async def get_available_candidate(self) -> discord.TextChannel: -        """ -        Return a dormant channel to turn into an available channel. - -        If no channel is available, wait indefinitely until one becomes available. -        """ -        log.trace("Getting an available channel candidate.") - -        try: -            channel = self.channel_queue.get_nowait() -        except asyncio.QueueEmpty: -            log.info("No candidate channels in the queue; creating a new channel.") -            channel = await self.create_dormant() - -            if not channel: -                log.info("Couldn't create a candidate channel; waiting to get one from the queue.") -                await self.notify() -                channel = await self.wait_for_dormant_channel() - -        return channel - -    @staticmethod -    def get_clean_channel_name(channel: discord.TextChannel) -> str: -        """Return a clean channel name without status emojis prefix.""" -        prefix = constants.HelpChannels.name_prefix -        try: -            # Try to remove the status prefix using the index of the channel prefix -            name = channel.name[channel.name.index(prefix):] -            log.trace(f"The clean name for `{channel}` is `{name}`") -        except ValueError: -            # If, for some reason, the channel name does not contain "help-" fall back gracefully -            log.info(f"Can't get clean name because `{channel}` isn't prefixed by `{prefix}`.") -            name = channel.name - -        return name - -    @staticmethod -    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 - -    def get_category_channels(self, category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: -        """Yield the text channels of the `category` in an unsorted manner.""" -        log.trace(f"Getting text channels in the category '{category}' ({category.id}).") - -        # This is faster than using category.channels because the latter sorts them. -        for channel in self.bot.get_guild(constants.Guild.id).channels: -            if channel.category_id == category.id and not self.is_excluded_channel(channel): -                yield channel - -    async def get_in_use_time(self, channel_id: int) -> t.Optional[timedelta]: -        """Return the duration `channel_id` has been in use. Return None if it's not in use.""" -        log.trace(f"Calculating in use time for channel {channel_id}.") - -        claimed_timestamp = await self.claim_times.get(channel_id) -        if claimed_timestamp: -            claimed = datetime.utcfromtimestamp(claimed_timestamp) -            return datetime.utcnow() - claimed - -    @staticmethod -    def get_names() -> t.List[str]: -        """ -        Return a truncated list of prefixed element names. - -        The amount of names is configured with `HelpChannels.max_total_channels`. -        The prefix is configured with `HelpChannels.name_prefix`. -        """ -        count = constants.HelpChannels.max_total_channels -        prefix = constants.HelpChannels.name_prefix - -        log.trace(f"Getting the first {count} element names from JSON.") - -        with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: -            all_names = json.load(elements_file) - -        if prefix: -            return [prefix + name for name in all_names[:count]] -        else: -            return all_names[:count] - -    def get_used_names(self) -> t.Set[str]: -        """Return channel names which are already being used.""" -        log.trace("Getting channel names which are already being used.") - -        names = set() -        for cat in (self.available_category, self.in_use_category, self.dormant_category): -            for channel in self.get_category_channels(cat): -                names.add(self.get_clean_channel_name(channel)) - -        if len(names) > MAX_CHANNELS_PER_CATEGORY: -            log.warning( -                f"Too many help channels ({len(names)}) already exist! " -                f"Discord only supports {MAX_CHANNELS_PER_CATEGORY} in a category." -            ) - -        log.trace(f"Got {len(names)} used names: {names}") -        return names - -    @classmethod -    async def get_idle_time(cls, channel: discord.TextChannel) -> t.Optional[int]: -        """ -        Return the time elapsed, in seconds, since the last message sent in the `channel`. - -        Return None if the channel has no messages. -        """ -        log.trace(f"Getting the idle time for #{channel} ({channel.id}).") - -        msg = await cls.get_last_message(channel) -        if not msg: -            log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages.") -            return None - -        idle_time = (datetime.utcnow() - msg.created_at).seconds - -        log.trace(f"#{channel} ({channel.id}) has been idle for {idle_time} seconds.") -        return idle_time - -    @staticmethod -    async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]: -        """Return the last message sent in the channel or None if no messages exist.""" -        log.trace(f"Getting the last message in #{channel} ({channel.id}).") - -        try: -            return await channel.history(limit=1).next()  # noqa: B305 -        except discord.NoMoreItems: -            log.debug(f"No last message available; #{channel} ({channel.id}) has no messages.") -            return None - -    async def init_available(self) -> None: -        """Initialise the Available category with channels.""" -        log.trace("Initialising the Available category with channels.") - -        channels = list(self.get_category_channels(self.available_category)) -        missing = constants.HelpChannels.max_available - len(channels) - -        # If we've got less than `max_available` channel available, we should add some. -        if missing > 0: -            log.trace(f"Moving {missing} missing channels to the Available category.") -            for _ in range(missing): -                await self.move_to_available() - -        # If for some reason we have more than `max_available` channels available, -        # we should move the superfluous ones over to dormant. -        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") - -    async def init_categories(self) -> None: -        """Get the help category objects. Remove the cog if retrieval fails.""" -        log.trace("Getting the CategoryChannel objects for the help categories.") - -        try: -            self.available_category = await channel_utils.try_get_channel( -                constants.Categories.help_available -            ) -            self.in_use_category = await channel_utils.try_get_channel( -                constants.Categories.help_in_use -            ) -            self.dormant_category = await channel_utils.try_get_channel( -                constants.Categories.help_dormant -            ) -        except discord.HTTPException: -            log.exception("Failed to get a category; cog will be removed") -            self.bot.remove_cog(self.qualified_name) - -    async def init_cog(self) -> None: -        """Initialise the help channel system.""" -        log.trace("Waiting for the guild to be available before initialisation.") -        await self.bot.wait_until_guild_available() - -        log.trace("Initialising the cog.") -        await self.init_categories() -        await self.check_cooldowns() - -        self.channel_queue = self.create_channel_queue() -        self.name_queue = self.create_name_queue() - -        log.trace("Moving or rescheduling in-use channels.") -        for channel in self.get_category_channels(self.in_use_category): -            await self.move_idle_channel(channel, has_task=False) - -        # Prevent the command from being used until ready. -        # The ready event wasn't used because channels could change categories between the time -        # the command is invoked and the cog is ready (e.g. if move_idle_channel wasn't called yet). -        # This may confuse users. So would potentially long delays for the cog to become ready. -        self.close_command.enabled = True - -        await self.init_available() - -        log.info("Cog is ready!") -        self.ready.set() - -        self.report_stats() - -    def report_stats(self) -> None: -        """Report the channel count stats.""" -        total_in_use = sum(1 for _ in self.get_category_channels(self.in_use_category)) -        total_available = sum(1 for _ in self.get_category_channels(self.available_category)) -        total_dormant = sum(1 for _ in self.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) - -    @staticmethod -    def is_claimant(member: discord.Member) -> bool: -        """Return True if `member` has the 'Help Cooldown' role.""" -        return any(constants.Roles.help_cooldown == role.id for role in member.roles) - -    def match_bot_embed(self, message: t.Optional[discord.Message], description: str) -> bool: -        """Return `True` if the bot's `message`'s embed description matches `description`.""" -        if not message or not message.embeds: -            return False - -        bot_msg_desc = message.embeds[0].description -        if bot_msg_desc is discord.Embed.Empty: -            log.trace("Last message was a bot embed but it was empty.") -            return False -        return message.author == self.bot.user and bot_msg_desc.strip() == description.strip() - -    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. - -        If `has_task` is True and rescheduling is required, the extant task to make the channel -        dormant will first be cancelled. -        """ -        log.trace(f"Handling in-use channel #{channel} ({channel.id}).") - -        if not await self.is_empty(channel): -            idle_seconds = constants.HelpChannels.idle_minutes * 60 -        else: -            idle_seconds = constants.HelpChannels.deleted_idle_minutes * 60 - -        time_elapsed = await self.get_idle_time(channel) - -        if time_elapsed is None or time_elapsed >= idle_seconds: -            log.info( -                f"#{channel} ({channel.id}) is idle longer than {idle_seconds} seconds " -                f"and will be made dormant." -            ) - -            await self.move_to_dormant(channel, "auto") -        else: -            # Cancel the existing task, if any. -            if has_task: -                self.scheduler.cancel(channel.id) - -            delay = idle_seconds - time_elapsed -            log.info( -                f"#{channel} ({channel.id}) is still active; " -                f"scheduling it to be moved after {delay} seconds." -            ) - -            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.") - -        channel = await self.get_available_candidate() -        log.info(f"Making #{channel} ({channel.id}) available.") - -        await self.send_available_message(channel) - -        log.trace(f"Moving #{channel} ({channel.id}) to the Available category.") - -        await self.move_to_bottom_position( -            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. - -        A caller argument is provided for metrics. -        """ -        log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") - -        await self.help_channel_claimants.delete(channel.id) -        await self.move_to_bottom_position( -            channel=channel, -            category_id=constants.Categories.help_dormant, -        ) - -        self.bot.stats.incr(f"help.dormant_calls.{caller}") - -        in_use_time = await self.get_in_use_time(channel.id) -        if in_use_time: -            self.bot.stats.timing("help.in_use_time", in_use_time) - -        unanswered = await self.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=DORMANT_MSG) -        await channel.send(embed=embed) - -        await self.unpin(channel) - -        log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") -        self.channel_queue.put_nowait(channel) -        self.report_stats() - -    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( -            channel=channel, -            category_id=constants.Categories.help_in_use, -        ) - -        timeout = constants.HelpChannels.idle_minutes * 60 - -        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() - -    async def notify(self) -> None: -        """ -        Send a message notifying about a lack of available help channels. - -        Configuration: - -        * `HelpChannels.notify` - toggle notifications -        * `HelpChannels.notify_channel` - destination channel for notifications -        * `HelpChannels.notify_minutes` - minimum interval between notifications -        * `HelpChannels.notify_roles` - roles mentioned in notifications -        """ -        if not constants.HelpChannels.notify: -            return - -        log.trace("Notifying about lack of channels.") - -        if self.last_notification: -            elapsed = (datetime.utcnow() - self.last_notification).seconds -            minimum_interval = constants.HelpChannels.notify_minutes * 60 -            should_send = elapsed >= minimum_interval -        else: -            should_send = True - -        if not should_send: -            log.trace("Notification not sent because it's too recent since the previous one.") -            return - -        try: -            log.trace("Sending notification message.") - -            channel = self.bot.get_channel(constants.HelpChannels.notify_channel) -            mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles) -            allowed_roles = [discord.Object(id_) for id_ in constants.HelpChannels.notify_roles] - -            message = await channel.send( -                f"{mentions} A new available help channel is needed but there " -                f"are no more dormant ones. Consider freeing up some in-use channels manually by " -                f"using the `{constants.Bot.prefix}dormant` command within the channels.", -                allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) -            ) - -            self.bot.stats.incr("help.out_of_channel_alerts") - -            self.last_notification = message.created_at -        except Exception: -            # Handle it here cause this feature isn't critical for the functionality of the system. -            log.exception("Failed to send notification about lack of dormant channels!") - -    async def check_for_answer(self, message: discord.Message) -> None: -        """Checks for whether new content in a help channel comes from non-claimants.""" -        channel = message.channel - -        # Confirm the channel is an in use help channel -        if channel_utils.is_in_category(channel, constants.Categories.help_in_use): -            log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") - -            # Check if there is an entry in unanswered -            if await self.unanswered.contains(channel.id): -                claimant_id = await self.help_channel_claimants.get(channel.id) -                if not claimant_id: -                    # The mapping for this channel doesn't exist, we can't do anything. -                    return - -                # Check the message did not come from the claimant -                if claimant_id != message.author.id: -                    # Mark the channel as answered -                    await self.unanswered.set(channel.id, False) - -    @commands.Cog.listener() -    async def on_message(self, message: discord.Message) -> None: -        """Move an available channel to the In Use category and replace it with a dormant one.""" -        if message.author.bot: -            return  # Ignore messages sent by bots. - -        channel = message.channel - -        await self.check_for_answer(message) - -        is_available = channel_utils.is_in_category(channel, constants.Categories.help_available) -        if not is_available or self.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.ready.wait() - -        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 self.revoke_send_permissions(message.author) - -            await self.pin(message) - -            # Add user with channel for dormant check. -            await self.help_channel_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 self.claim_times.set(channel.id, timestamp) - -            await self.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() - -    @commands.Cog.listener() -    async def on_message_delete(self, msg: discord.Message) -> None: -        """ -        Reschedule an in-use channel to become dormant sooner if the channel is empty. - -        The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`. -        """ -        if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use): -            return - -        if not await self.is_empty(msg.channel): -            return - -        log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") - -        # Cancel existing dormant task before scheduling new. -        self.scheduler.cancel(msg.channel.id) - -        delay = constants.HelpChannels.deleted_idle_minutes * 60 -        self.scheduler.schedule_later(delay, msg.channel.id, self.move_idle_channel(msg.channel)) - -    async def is_empty(self, channel: discord.TextChannel) -> bool: -        """Return True if there's an AVAILABLE_MSG and the messages leading up are bot messages.""" -        log.trace(f"Checking if #{channel} ({channel.id}) is empty.") - -        # A limit of 100 results in a single API call. -        # If AVAILABLE_MSG isn't found within 100 messages, then assume the channel is not empty. -        # Not gonna do an extensive search for it cause it's too expensive. -        async for msg in channel.history(limit=100): -            if not msg.author.bot: -                log.trace(f"#{channel} ({channel.id}) has a non-bot message.") -                return False - -            if self.match_bot_embed(msg, AVAILABLE_MSG): -                log.trace(f"#{channel} ({channel.id}) has the available message embed.") -                return True - -        return False - -    async def check_cooldowns(self) -> None: -        """Remove expired cooldowns and re-schedule active ones.""" -        log.trace("Checking all cooldowns to remove or re-schedule them.") -        guild = self.bot.get_guild(constants.Guild.id) -        cooldown = constants.HelpChannels.claim_minutes * 60 - -        for channel_id, member_id in await self.help_channel_claimants.items(): -            member = guild.get_member(member_id) -            if not member: -                continue  # Member probably left the guild. - -            in_use_time = await self.get_in_use_time(channel_id) - -            if not in_use_time or in_use_time.seconds > cooldown: -                # Remove the role if no claim time could be retrieved or if the cooldown expired. -                # Since the channel is in the claimants cache, it is definitely strange for a time -                # to not exist. However, it isn't a reason to keep the user stuck with a cooldown. -                await self.remove_cooldown_role(member) -            else: -                # The member is still on a cooldown; re-schedule it for the remaining time. -                delay = cooldown - in_use_time.seconds -                self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) - -    async def add_cooldown_role(self, member: discord.Member) -> None: -        """Add the help cooldown role to `member`.""" -        log.trace(f"Adding cooldown role for {member} ({member.id}).") -        await self._change_cooldown_role(member, member.add_roles) - -    async def remove_cooldown_role(self, member: discord.Member) -> None: -        """Remove the help cooldown role from `member`.""" -        log.trace(f"Removing cooldown role for {member} ({member.id}).") -        await self._change_cooldown_role(member, member.remove_roles) - -    async def _change_cooldown_role(self, member: discord.Member, coro_func: CoroutineFunc) -> None: -        """ -        Change `member`'s cooldown role via awaiting `coro_func` and handle errors. - -        `coro_func` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. -        """ -        guild = self.bot.get_guild(constants.Guild.id) -        role = guild.get_role(constants.Roles.help_cooldown) -        if role is None: -            log.warning(f"Help cooldown role ({constants.Roles.help_cooldown}) could not be found!") -            return - -        try: -            await coro_func(role) -        except discord.NotFound: -            log.debug(f"Failed to change role for {member} ({member.id}): member not found") -        except discord.Forbidden: -            log.debug( -                f"Forbidden to change role for {member} ({member.id}); " -                f"possibly due to role hierarchy" -            ) -        except discord.HTTPException as e: -            log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}") - -    async def revoke_send_permissions(self, member: discord.Member) -> None: -        """ -        Disallow `member` to send messages in the Available category for a certain time. - -        The time until permissions are reinstated can be configured with -        `HelpChannels.claim_minutes`. -        """ -        log.trace( -            f"Revoking {member}'s ({member.id}) send message permissions in the Available category." -        ) - -        await self.add_cooldown_role(member) - -        # Cancel the existing task, if any. -        # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). -        if member.id in self.scheduler: -            self.scheduler.cancel(member.id) - -        delay = constants.HelpChannels.claim_minutes * 60 -        self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) - -    async def send_available_message(self, channel: discord.TextChannel) -> None: -        """Send the available message by editing a dormant message or sending a new message.""" -        channel_info = f"#{channel} ({channel.id})" -        log.trace(f"Sending available message in {channel_info}.") - -        embed = discord.Embed( -            color=constants.Colours.bright_green, -            description=AVAILABLE_MSG, -        ) -        embed.set_author(name=AVAILABLE_TITLE, icon_url=constants.Icons.green_checkmark) -        embed.set_footer(text=AVAILABLE_FOOTER) - -        msg = await self.get_last_message(channel) -        if self.match_bot_embed(msg, DORMANT_MSG): -            log.trace(f"Found dormant message {msg.id} in {channel_info}; editing it.") -            await msg.edit(embed=embed) -        else: -            log.trace(f"Dormant message not found in {channel_info}; sending a new message.") -            await channel.send(embed=embed) - -    async def pin_wrapper(self, msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool: -        """ -        Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False. - -        Return True if successful and False otherwise. -        """ -        channel_str = f"#{channel} ({channel.id})" -        if pin: -            func = self.bot.http.pin_message -            verb = "pin" -        else: -            func = self.bot.http.unpin_message -            verb = "unpin" - -        try: -            await func(channel.id, msg_id) -        except discord.HTTPException as e: -            if e.code == 10008: -                log.debug(f"Message {msg_id} in {channel_str} doesn't exist; can't {verb}.") -            else: -                log.exception( -                    f"Error {verb}ning message {msg_id} in {channel_str}: {e.status} ({e.code})" -                ) -            return False -        else: -            log.trace(f"{verb.capitalize()}ned message {msg_id} in {channel_str}.") -            return True - -    async def pin(self, message: discord.Message) -> None: -        """Pin an initial question `message` and store it in a cache.""" -        if await self.pin_wrapper(message.id, message.channel, pin=True): -            await self.question_messages.set(message.channel.id, message.id) - -    async def unpin(self, channel: discord.TextChannel) -> None: -        """Unpin the initial question message sent in `channel`.""" -        msg_id = await self.question_messages.pop(channel.id) -        if msg_id is None: -            log.debug(f"#{channel} ({channel.id}) doesn't have a message pinned.") -        else: -            await self.pin_wrapper(msg_id, channel, pin=False) - -    async def wait_for_dormant_channel(self) -> discord.TextChannel: -        """Wait for a dormant channel to become available in the queue and return it.""" -        log.trace("Waiting for a dormant channel.") - -        task = asyncio.create_task(self.channel_queue.get()) -        self.queue_tasks.append(task) -        channel = await task - -        log.trace(f"Channel #{channel} ({channel.id}) finally retrieved from the queue.") -        self.queue_tasks.remove(task) - -        return channel - - -def validate_config() -> None: -    """Raise a ValueError if the cog's config is invalid.""" -    log.trace("Validating config.") -    total = constants.HelpChannels.max_total_channels -    available = constants.HelpChannels.max_available - -    if total == 0 or available == 0: -        raise ValueError("max_total_channels and max_available and must be greater than 0.") - -    if total < available: -        raise ValueError( -            f"max_total_channels ({total}) must be greater than or equal to max_available " -            f"({available})." -        ) - -    if total > MAX_CHANNELS_PER_CATEGORY: -        raise ValueError( -            f"max_total_channels ({total}) must be less than or equal to " -            f"{MAX_CHANNELS_PER_CATEGORY} due to Discord's limit on channels per category." -        ) - - -def setup(bot: Bot) -> None: -    """Load the HelpChannels cog.""" -    try: -        validate_config() -    except ValueError as e: -        log.error(f"HelpChannels cog will not be loaded due to misconfiguration: {e}") -    else: -        bot.add_cog(HelpChannels(bot)) diff --git a/bot/exts/help_channels/__init__.py b/bot/exts/help_channels/__init__.py new file mode 100644 index 000000000..781f40449 --- /dev/null +++ b/bot/exts/help_channels/__init__.py @@ -0,0 +1,41 @@ +import logging + +from bot import constants +from bot.bot import Bot +from bot.exts.help_channels._channel import MAX_CHANNELS_PER_CATEGORY + +log = logging.getLogger(__name__) + + +def validate_config() -> None: +    """Raise a ValueError if the cog's config is invalid.""" +    log.trace("Validating config.") +    total = constants.HelpChannels.max_total_channels +    available = constants.HelpChannels.max_available + +    if total == 0 or available == 0: +        raise ValueError("max_total_channels and max_available and must be greater than 0.") + +    if total < available: +        raise ValueError( +            f"max_total_channels ({total}) must be greater than or equal to max_available " +            f"({available})." +        ) + +    if total > MAX_CHANNELS_PER_CATEGORY: +        raise ValueError( +            f"max_total_channels ({total}) must be less than or equal to " +            f"{MAX_CHANNELS_PER_CATEGORY} due to Discord's limit on channels per category." +        ) + + +def setup(bot: Bot) -> None: +    """Load the HelpChannels cog.""" +    # Defer import to reduce side effects from importing the help_channels package. +    from bot.exts.help_channels._cog import HelpChannels +    try: +        validate_config() +    except ValueError as e: +        log.error(f"HelpChannels cog will not be loaded due to misconfiguration: {e}") +    else: +        bot.add_cog(HelpChannels(bot)) diff --git a/bot/exts/help_channels/_caches.py b/bot/exts/help_channels/_caches.py new file mode 100644 index 000000000..4cea385b7 --- /dev/null +++ b/bot/exts/help_channels/_caches.py @@ -0,0 +1,19 @@ +from async_rediscache import RedisCache + +# This dictionary maps a help channel to the time it was claimed +# RedisCache[discord.TextChannel.id, UtcPosixTimestamp] +claim_times = RedisCache(namespace="HelpChannels.claim_times") + +# This cache tracks which channels are claimed by which members. +# RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] +claimants = RedisCache(namespace="HelpChannels.help_channel_claimants") + +# This cache maps a help channel to original question message in same channel. +# RedisCache[discord.TextChannel.id, discord.Message.id] +question_messages = RedisCache(namespace="HelpChannels.question_messages") + +# This cache maps a help channel to whether it has had any +# activity other than the original claimant. True being no other +# activity and False being other activity. +# RedisCache[discord.TextChannel.id, bool] +unanswered = RedisCache(namespace="HelpChannels.unanswered") diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py new file mode 100644 index 000000000..e717d7af8 --- /dev/null +++ b/bot/exts/help_channels/_channel.py @@ -0,0 +1,57 @@ +import logging +import typing as t +from datetime import datetime, timedelta + +import discord + +from bot import constants +from bot.exts.help_channels import _caches, _message + +log = logging.getLogger(__name__) + +MAX_CHANNELS_PER_CATEGORY = 50 +EXCLUDED_CHANNELS = (constants.Channels.cooldown,) + + +def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: +    """Yield the text channels of the `category` in an unsorted manner.""" +    log.trace(f"Getting text channels in the category '{category}' ({category.id}).") + +    # This is faster than using category.channels because the latter sorts them. +    for channel in category.guild.channels: +        if channel.category_id == category.id and not is_excluded_channel(channel): +            yield channel + + +async def get_idle_time(channel: discord.TextChannel) -> t.Optional[int]: +    """ +    Return the time elapsed, in seconds, since the last message sent in the `channel`. + +    Return None if the channel has no messages. +    """ +    log.trace(f"Getting the idle time for #{channel} ({channel.id}).") + +    msg = await _message.get_last_message(channel) +    if not msg: +        log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages.") +        return None + +    idle_time = (datetime.utcnow() - msg.created_at).seconds + +    log.trace(f"#{channel} ({channel.id}) has been idle for {idle_time} seconds.") +    return idle_time + + +async def get_in_use_time(channel_id: int) -> t.Optional[timedelta]: +    """Return the duration `channel_id` has been in use. Return None if it's not in use.""" +    log.trace(f"Calculating in use time for channel {channel_id}.") + +    claimed_timestamp = await _caches.claim_times.get(channel_id) +    if claimed_timestamp: +        claimed = datetime.utcfromtimestamp(claimed_timestamp) +        return datetime.utcnow() - claimed + + +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 diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py new file mode 100644 index 000000000..e22d4663e --- /dev/null +++ b/bot/exts/help_channels/_cog.py @@ -0,0 +1,502 @@ +import asyncio +import logging +import random +import typing as t +from datetime import datetime, timezone + +import discord +import discord.abc +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 + +log = logging.getLogger(__name__) + +HELP_CHANNEL_TOPIC = """ +This is a Python help channel. You can claim your own help channel in the Python Help: Available category. +""" + + +class HelpChannels(commands.Cog): +    """ +    Manage the help channel system of the guild. + +    The system is based on a 3-category system: + +    Available Category + +    * Contains channels which are ready to be occupied by someone who needs help +    * Will always contain `constants.HelpChannels.max_available` channels; refilled automatically +      from the pool of dormant channels +        * Prioritise using the channels which have been dormant for the longest amount of time +        * If there are no more dormant channels, the bot will automatically create a new one +        * If there are no dormant channels to move, helpers will be notified (see `notify()`) +    * When a channel becomes available, the dormant embed will be edited to show `AVAILABLE_MSG` +    * User can only claim a channel at an interval `constants.HelpChannels.claim_minutes` +        * To keep track of cooldowns, user which claimed a channel will have a temporary role + +    In Use Category + +    * Contains all channels which are occupied by someone needing help +    * Channel moves to dormant category after `constants.HelpChannels.idle_minutes` of being idle +    * Command can prematurely mark a channel as dormant +        * Channel claimant is allowed to use the command +        * Allowed roles for the command are configurable with `constants.HelpChannels.cmd_whitelist` +    * When a channel becomes dormant, an embed with `DORMANT_MSG` will be sent + +    Dormant Category + +    * Contains channels which aren't in use +    * Channels are used to refill the Available category + +    Help channels are named after the chemical elements in `bot/resources/elements.json`. +    """ + +    def __init__(self, bot: Bot): +        self.bot = bot +        self.scheduler = Scheduler(self.__class__.__name__) + +        # Categories +        self.available_category: discord.CategoryChannel = None +        self.in_use_category: discord.CategoryChannel = None +        self.dormant_category: discord.CategoryChannel = None + +        # Queues +        self.channel_queue: asyncio.Queue[discord.TextChannel] = None +        self.name_queue: t.Deque[str] = None + +        self.last_notification: t.Optional[datetime] = None + +        # 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: +        """Cancel the init task and scheduled tasks when the cog unloads.""" +        log.trace("Cog unload: cancelling the init_cog task") +        self.init_task.cancel() + +        log.trace("Cog unload: cancelling the channel queue tasks") +        for task in self.queue_tasks: +            task.cancel() + +        self.scheduler.cancel_all() + +    def create_channel_queue(self) -> asyncio.Queue: +        """ +        Return a queue of dormant channels to use for getting the next available channel. + +        The channels are added to the queue in a random order. +        """ +        log.trace("Creating the channel queue.") + +        channels = list(_channel.get_category_channels(self.dormant_category)) +        random.shuffle(channels) + +        log.trace("Populating the channel queue with channels.") +        queue = asyncio.Queue() +        for channel in channels: +            queue.put_nowait(channel) + +        return queue + +    async def create_dormant(self) -> t.Optional[discord.TextChannel]: +        """ +        Create and return a new channel in the Dormant category. + +        The new channel will sync its permission overwrites with the category. + +        Return None if no more channel names are available. +        """ +        log.trace("Getting a name for a new dormant channel.") + +        try: +            name = self.name_queue.popleft() +        except IndexError: +            log.debug("No more names available for new dormant channels.") +            return None + +        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.""" +        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") +            return True + +        log.trace(f"{ctx.author} is not the help channel claimant, checking roles.") +        has_role = await commands.has_any_role(*constants.HelpChannels.cmd_whitelist).predicate(ctx) + +        if has_role: +            self.bot.stats.incr("help.dormant_invoke.staff") + +        return has_role + +    @commands.command(name="close", aliases=["dormant", "solved"], enabled=False) +    async def close_command(self, ctx: commands.Context) -> None: +        """ +        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, +        and reset the send permissions cooldown for the user who started the session. +        """ +        log.trace("close command invoked; checking if the channel is in-use.") +        if ctx.channel.category == self.in_use_category: +            if await self.dormant_check(ctx): +                await _cooldown.remove_cooldown_role(ctx.author) + +                # Ignore missing task when cooldown has passed but the channel still isn't dormant. +                if ctx.author.id in self.scheduler: +                    self.scheduler.cancel(ctx.author.id) + +                await self.move_to_dormant(ctx.channel, "command") +                self.scheduler.cancel(ctx.channel.id) +        else: +            log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") + +    async def get_available_candidate(self) -> discord.TextChannel: +        """ +        Return a dormant channel to turn into an available channel. + +        If no channel is available, wait indefinitely until one becomes available. +        """ +        log.trace("Getting an available channel candidate.") + +        try: +            channel = self.channel_queue.get_nowait() +        except asyncio.QueueEmpty: +            log.info("No candidate channels in the queue; creating a new channel.") +            channel = await self.create_dormant() + +            if not channel: +                log.info("Couldn't create a candidate channel; waiting to get one from the queue.") +                notify_channel = self.bot.get_channel(constants.HelpChannels.notify_channel) +                last_notification = await _message.notify(notify_channel, self.last_notification) +                if last_notification: +                    self.last_notification = last_notification +                    self.bot.stats.incr("help.out_of_channel_alerts") + +                channel = await self.wait_for_dormant_channel() + +        return channel + +    async def init_available(self) -> None: +        """Initialise the Available category with channels.""" +        log.trace("Initialising the Available category with channels.") + +        channels = list(_channel.get_category_channels(self.available_category)) +        missing = constants.HelpChannels.max_available - len(channels) + +        # If we've got less than `max_available` channel available, we should add some. +        if missing > 0: +            log.trace(f"Moving {missing} missing channels to the Available category.") +            for _ in range(missing): +                await self.move_to_available() + +        # If for some reason we have more than `max_available` channels available, +        # we should move the superfluous ones over to dormant. +        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") + +    async def init_categories(self) -> None: +        """Get the help category objects. Remove the cog if retrieval fails.""" +        log.trace("Getting the CategoryChannel objects for the help categories.") + +        try: +            self.available_category = await channel_utils.try_get_channel( +                constants.Categories.help_available +            ) +            self.in_use_category = await channel_utils.try_get_channel( +                constants.Categories.help_in_use +            ) +            self.dormant_category = await channel_utils.try_get_channel( +                constants.Categories.help_dormant +            ) +        except discord.HTTPException: +            log.exception("Failed to get a category; cog will be removed") +            self.bot.remove_cog(self.qualified_name) + +    async def init_cog(self) -> None: +        """Initialise the help channel system.""" +        log.trace("Waiting for the guild to be available before initialisation.") +        await self.bot.wait_until_guild_available() + +        log.trace("Initialising the cog.") +        await self.init_categories() +        await _cooldown.check_cooldowns(self.scheduler) + +        self.channel_queue = self.create_channel_queue() +        self.name_queue = _name.create_name_queue( +            self.available_category, +            self.in_use_category, +            self.dormant_category, +        ) + +        log.trace("Moving or rescheduling in-use channels.") +        for channel in _channel.get_category_channels(self.in_use_category): +            await self.move_idle_channel(channel, has_task=False) + +        # Prevent the command from being used until ready. +        # The ready event wasn't used because channels could change categories between the time +        # the command is invoked and the cog is ready (e.g. if move_idle_channel wasn't called yet). +        # This may confuse users. So would potentially long delays for the cog to become ready. +        self.close_command.enabled = True + +        await self.init_available() +        self.report_stats() + +        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. + +        If `has_task` is True and rescheduling is required, the extant task to make the channel +        dormant will first be cancelled. +        """ +        log.trace(f"Handling in-use channel #{channel} ({channel.id}).") + +        if not await _message.is_empty(channel): +            idle_seconds = constants.HelpChannels.idle_minutes * 60 +        else: +            idle_seconds = constants.HelpChannels.deleted_idle_minutes * 60 + +        time_elapsed = await _channel.get_idle_time(channel) + +        if time_elapsed is None or time_elapsed >= idle_seconds: +            log.info( +                f"#{channel} ({channel.id}) is idle longer than {idle_seconds} seconds " +                f"and will be made dormant." +            ) + +            await self.move_to_dormant(channel, "auto") +        else: +            # Cancel the existing task, if any. +            if has_task: +                self.scheduler.cancel(channel.id) + +            delay = idle_seconds - time_elapsed +            log.info( +                f"#{channel} ({channel.id}) is still active; " +                f"scheduling it to be moved after {delay} seconds." +            ) + +            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.") + +        channel = await self.get_available_candidate() +        log.info(f"Making #{channel} ({channel.id}) available.") + +        await _message.send_available_message(channel) + +        log.trace(f"Moving #{channel} ({channel.id}) to the Available category.") + +        await self.move_to_bottom_position( +            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. + +        A caller argument is provided for metrics. +        """ +        log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") + +        await _caches.claimants.delete(channel.id) +        await self.move_to_bottom_position( +            channel=channel, +            category_id=constants.Categories.help_dormant, +        ) + +        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 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( +            channel=channel, +            category_id=constants.Categories.help_in_use, +        ) + +        timeout = constants.HelpChannels.idle_minutes * 60 + +        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() + +    @commands.Cog.listener() +    async def on_message(self, message: discord.Message) -> None: +        """Move an available channel to the In Use category and replace it with a dormant one.""" +        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() + +    @commands.Cog.listener() +    async def on_message_delete(self, msg: discord.Message) -> None: +        """ +        Reschedule an in-use channel to become dormant sooner if the channel is empty. + +        The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`. +        """ +        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. +        self.scheduler.cancel(msg.channel.id) + +        delay = constants.HelpChannels.deleted_idle_minutes * 60 +        self.scheduler.schedule_later(delay, msg.channel.id, self.move_idle_channel(msg.channel)) + +    async def wait_for_dormant_channel(self) -> discord.TextChannel: +        """Wait for a dormant channel to become available in the queue and return it.""" +        log.trace("Waiting for a dormant channel.") + +        task = asyncio.create_task(self.channel_queue.get()) +        self.queue_tasks.append(task) +        channel = await task + +        log.trace(f"Channel #{channel} ({channel.id}) finally retrieved from the queue.") +        self.queue_tasks.remove(task) + +        return channel diff --git a/bot/exts/help_channels/_cooldown.py b/bot/exts/help_channels/_cooldown.py new file mode 100644 index 000000000..c5c39297f --- /dev/null +++ b/bot/exts/help_channels/_cooldown.py @@ -0,0 +1,95 @@ +import logging +from typing import Callable, Coroutine + +import discord + +import bot +from bot import constants +from bot.exts.help_channels import _caches, _channel +from bot.utils.scheduling import Scheduler + +log = logging.getLogger(__name__) +CoroutineFunc = Callable[..., Coroutine] + + +async def add_cooldown_role(member: discord.Member) -> None: +    """Add the help cooldown role to `member`.""" +    log.trace(f"Adding cooldown role for {member} ({member.id}).") +    await _change_cooldown_role(member, member.add_roles) + + +async def check_cooldowns(scheduler: Scheduler) -> None: +    """Remove expired cooldowns and re-schedule active ones.""" +    log.trace("Checking all cooldowns to remove or re-schedule them.") +    guild = bot.instance.get_guild(constants.Guild.id) +    cooldown = constants.HelpChannels.claim_minutes * 60 + +    for channel_id, member_id in await _caches.claimants.items(): +        member = guild.get_member(member_id) +        if not member: +            continue  # Member probably left the guild. + +        in_use_time = await _channel.get_in_use_time(channel_id) + +        if not in_use_time or in_use_time.seconds > cooldown: +            # Remove the role if no claim time could be retrieved or if the cooldown expired. +            # Since the channel is in the claimants cache, it is definitely strange for a time +            # to not exist. However, it isn't a reason to keep the user stuck with a cooldown. +            await remove_cooldown_role(member) +        else: +            # The member is still on a cooldown; re-schedule it for the remaining time. +            delay = cooldown - in_use_time.seconds +            scheduler.schedule_later(delay, member.id, remove_cooldown_role(member)) + + +async def remove_cooldown_role(member: discord.Member) -> None: +    """Remove the help cooldown role from `member`.""" +    log.trace(f"Removing cooldown role for {member} ({member.id}).") +    await _change_cooldown_role(member, member.remove_roles) + + +async def revoke_send_permissions(member: discord.Member, scheduler: Scheduler) -> None: +    """ +    Disallow `member` to send messages in the Available category for a certain time. + +    The time until permissions are reinstated can be configured with +    `HelpChannels.claim_minutes`. +    """ +    log.trace( +        f"Revoking {member}'s ({member.id}) send message permissions in the Available category." +    ) + +    await add_cooldown_role(member) + +    # Cancel the existing task, if any. +    # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). +    if member.id in scheduler: +        scheduler.cancel(member.id) + +    delay = constants.HelpChannels.claim_minutes * 60 +    scheduler.schedule_later(delay, member.id, remove_cooldown_role(member)) + + +async def _change_cooldown_role(member: discord.Member, coro_func: CoroutineFunc) -> None: +    """ +    Change `member`'s cooldown role via awaiting `coro_func` and handle errors. + +    `coro_func` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. +    """ +    guild = bot.instance.get_guild(constants.Guild.id) +    role = guild.get_role(constants.Roles.help_cooldown) +    if role is None: +        log.warning(f"Help cooldown role ({constants.Roles.help_cooldown}) could not be found!") +        return + +    try: +        await coro_func(role) +    except discord.NotFound: +        log.debug(f"Failed to change role for {member} ({member.id}): member not found") +    except discord.Forbidden: +        log.debug( +            f"Forbidden to change role for {member} ({member.id}); " +            f"possibly due to role hierarchy" +        ) +    except discord.HTTPException as e: +        log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}") diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py new file mode 100644 index 000000000..2bbd4bdd6 --- /dev/null +++ b/bot/exts/help_channels/_message.py @@ -0,0 +1,217 @@ +import logging +import typing as t +from datetime import datetime + +import discord + +import bot +from bot import constants +from bot.exts.help_channels import _caches +from bot.utils.channel import is_in_category + +log = logging.getLogger(__name__) + +ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" + +AVAILABLE_MSG = f""" +**Send your question here to claim the channel** +This channel will be dedicated to answering your question only. Others will try to answer and help you solve the issue. + +**Keep in mind:** +• It's always ok to just ask your question. You don't need permission. +• Explain what you expect to happen and what actually happens. +• Include a code sample and error message, if you got any. + +For more tips, check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**. +""" + +AVAILABLE_TITLE = "Available help channel" + +AVAILABLE_FOOTER = f"Closes after {constants.HelpChannels.idle_minutes} minutes of inactivity or when you send !close." + +DORMANT_MSG = f""" +This help channel has been marked as **dormant**, and has been moved into the **Help: Dormant** \ +category at the bottom of the channel list. It is no longer possible to send messages in this \ +channel until it becomes available again. + +If your question wasn't answered yet, you can claim a new help channel from the \ +**Help: Available** category by simply asking your question again. Consider rephrasing the \ +question to maximize your chance of getting a good answer. If you're not sure how, have a look \ +through our guide for **[asking a good question]({ASKING_GUIDE_URL})**. +""" + + +async def check_for_answer(message: discord.Message) -> None: +    """Checks for whether new content in a help channel comes from non-claimants.""" +    channel = message.channel + +    # Confirm the channel is an in use help channel +    if is_in_category(channel, constants.Categories.help_in_use): +        log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") + +        # Check if there is an entry in unanswered +        if await _caches.unanswered.contains(channel.id): +            claimant_id = await _caches.claimants.get(channel.id) +            if not claimant_id: +                # The mapping for this channel doesn't exist, we can't do anything. +                return + +            # Check the message did not come from the claimant +            if claimant_id != message.author.id: +                # Mark the channel as answered +                await _caches.unanswered.set(channel.id, False) + + +async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]: +    """Return the last message sent in the channel or None if no messages exist.""" +    log.trace(f"Getting the last message in #{channel} ({channel.id}).") + +    try: +        return await channel.history(limit=1).next()  # noqa: B305 +    except discord.NoMoreItems: +        log.debug(f"No last message available; #{channel} ({channel.id}) has no messages.") +        return None + + +async def is_empty(channel: discord.TextChannel) -> bool: +    """Return True if there's an AVAILABLE_MSG and the messages leading up are bot messages.""" +    log.trace(f"Checking if #{channel} ({channel.id}) is empty.") + +    # A limit of 100 results in a single API call. +    # If AVAILABLE_MSG isn't found within 100 messages, then assume the channel is not empty. +    # Not gonna do an extensive search for it cause it's too expensive. +    async for msg in channel.history(limit=100): +        if not msg.author.bot: +            log.trace(f"#{channel} ({channel.id}) has a non-bot message.") +            return False + +        if _match_bot_embed(msg, AVAILABLE_MSG): +            log.trace(f"#{channel} ({channel.id}) has the available message embed.") +            return True + +    return False + + +async def notify(channel: discord.TextChannel, last_notification: t.Optional[datetime]) -> t.Optional[datetime]: +    """ +    Send a message in `channel` notifying about a lack of available help channels. + +    If a notification was sent, return the `datetime` at which the message was sent. Otherwise, +    return None. + +    Configuration: + +    * `HelpChannels.notify` - toggle notifications +    * `HelpChannels.notify_minutes` - minimum interval between notifications +    * `HelpChannels.notify_roles` - roles mentioned in notifications +    """ +    if not constants.HelpChannels.notify: +        return + +    log.trace("Notifying about lack of channels.") + +    if last_notification: +        elapsed = (datetime.utcnow() - last_notification).seconds +        minimum_interval = constants.HelpChannels.notify_minutes * 60 +        should_send = elapsed >= minimum_interval +    else: +        should_send = True + +    if not should_send: +        log.trace("Notification not sent because it's too recent since the previous one.") +        return + +    try: +        log.trace("Sending notification message.") + +        mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles) +        allowed_roles = [discord.Object(id_) for id_ in constants.HelpChannels.notify_roles] + +        message = await channel.send( +            f"{mentions} A new available help channel is needed but there " +            f"are no more dormant ones. Consider freeing up some in-use channels manually by " +            f"using the `{constants.Bot.prefix}dormant` command within the channels.", +            allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) +        ) + +        return message.created_at +    except Exception: +        # Handle it here cause this feature isn't critical for the functionality of the system. +        log.exception("Failed to send notification about lack of dormant channels!") + + +async def pin(message: discord.Message) -> None: +    """Pin an initial question `message` and store it in a cache.""" +    if await _pin_wrapper(message.id, message.channel, pin=True): +        await _caches.question_messages.set(message.channel.id, message.id) + + +async def send_available_message(channel: discord.TextChannel) -> None: +    """Send the available message by editing a dormant message or sending a new message.""" +    channel_info = f"#{channel} ({channel.id})" +    log.trace(f"Sending available message in {channel_info}.") + +    embed = discord.Embed( +        color=constants.Colours.bright_green, +        description=AVAILABLE_MSG, +    ) +    embed.set_author(name=AVAILABLE_TITLE, icon_url=constants.Icons.green_checkmark) +    embed.set_footer(text=AVAILABLE_FOOTER) + +    msg = await get_last_message(channel) +    if _match_bot_embed(msg, DORMANT_MSG): +        log.trace(f"Found dormant message {msg.id} in {channel_info}; editing it.") +        await msg.edit(embed=embed) +    else: +        log.trace(f"Dormant message not found in {channel_info}; sending a new message.") +        await channel.send(embed=embed) + + +async def unpin(channel: discord.TextChannel) -> None: +    """Unpin the initial question message sent in `channel`.""" +    msg_id = await _caches.question_messages.pop(channel.id) +    if msg_id is None: +        log.debug(f"#{channel} ({channel.id}) doesn't have a message pinned.") +    else: +        await _pin_wrapper(msg_id, channel, pin=False) + + +def _match_bot_embed(message: t.Optional[discord.Message], description: str) -> bool: +    """Return `True` if the bot's `message`'s embed description matches `description`.""" +    if not message or not message.embeds: +        return False + +    bot_msg_desc = message.embeds[0].description +    if bot_msg_desc is discord.Embed.Empty: +        log.trace("Last message was a bot embed but it was empty.") +        return False +    return message.author == bot.instance.user and bot_msg_desc.strip() == description.strip() + + +async def _pin_wrapper(msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool: +    """ +    Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False. + +    Return True if successful and False otherwise. +    """ +    channel_str = f"#{channel} ({channel.id})" +    if pin: +        func = bot.instance.http.pin_message +        verb = "pin" +    else: +        func = bot.instance.http.unpin_message +        verb = "unpin" + +    try: +        await func(channel.id, msg_id) +    except discord.HTTPException as e: +        if e.code == 10008: +            log.debug(f"Message {msg_id} in {channel_str} doesn't exist; can't {verb}.") +        else: +            log.exception( +                f"Error {verb}ning message {msg_id} in {channel_str}: {e.status} ({e.code})" +            ) +        return False +    else: +        log.trace(f"{verb.capitalize()}ned message {msg_id} in {channel_str}.") +        return True diff --git a/bot/exts/help_channels/_name.py b/bot/exts/help_channels/_name.py new file mode 100644 index 000000000..728234b1e --- /dev/null +++ b/bot/exts/help_channels/_name.py @@ -0,0 +1,69 @@ +import json +import logging +import typing as t +from collections import deque +from pathlib import Path + +import discord + +from bot import constants +from bot.exts.help_channels._channel import MAX_CHANNELS_PER_CATEGORY, get_category_channels + +log = logging.getLogger(__name__) + + +def create_name_queue(*categories: discord.CategoryChannel) -> deque: +    """ +    Return a queue of element names to use for creating new channels. + +    Skip names that are already in use by channels in `categories`. +    """ +    log.trace("Creating the chemical element name queue.") + +    used_names = _get_used_names(*categories) + +    log.trace("Determining the available names.") +    available_names = (name for name in _get_names() if name not in used_names) + +    log.trace("Populating the name queue with names.") +    return deque(available_names) + + +def _get_names() -> t.List[str]: +    """ +    Return a truncated list of prefixed element names. + +    The amount of names is configured with `HelpChannels.max_total_channels`. +    The prefix is configured with `HelpChannels.name_prefix`. +    """ +    count = constants.HelpChannels.max_total_channels +    prefix = constants.HelpChannels.name_prefix + +    log.trace(f"Getting the first {count} element names from JSON.") + +    with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: +        all_names = json.load(elements_file) + +    if prefix: +        return [prefix + name for name in all_names[:count]] +    else: +        return all_names[:count] + + +def _get_used_names(*categories: discord.CategoryChannel) -> t.Set[str]: +    """Return names which are already being used by channels in `categories`.""" +    log.trace("Getting channel names which are already being used.") + +    names = set() +    for cat in categories: +        for channel in get_category_channels(cat): +            names.add(channel.name) + +    if len(names) > MAX_CHANNELS_PER_CATEGORY: +        log.warning( +            f"Too many help channels ({len(names)}) already exist! " +            f"Discord only supports {MAX_CHANNELS_PER_CATEGORY} in a category." +        ) + +    log.trace(f"Got {len(names)} used names: {names}") +    return names diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 44c31cd13..242b2d30f 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -95,15 +95,27 @@ class InfractionScheduler:          ctx: Context,          infraction: _utils.Infraction,          user: UserSnowflake, -        action_coro: t.Optional[t.Awaitable] = None -    ) -> None: -        """Apply an infraction to the user, log the infraction, and optionally notify the user.""" +        action_coro: t.Optional[t.Awaitable] = None, +        user_reason: t.Optional[str] = None, +        additional_info: str = "", +    ) -> bool: +        """ +        Apply an infraction to the user, log the infraction, and optionally notify the user. + +        `user_reason`, if provided, will be sent to the user in place of the infraction reason. +        `additional_info` will be attached to the text field in the mod-log embed. + +        Returns whether or not the infraction succeeded. +        """          infr_type = infraction["type"]          icon = _utils.INFRACTION_ICONS[infr_type][0]          reason = infraction["reason"]          expiry = time.format_infraction_with_duration(infraction["expires_at"])          id_ = infraction['id'] +        if user_reason is None: +            user_reason = reason +          log.trace(f"Applying {infr_type} infraction #{id_} to {user}.")          # Default values for the confirmation message and mod log. @@ -139,7 +151,7 @@ class InfractionScheduler:                  log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})")              else:                  # Accordingly display whether the user was successfully notified via DM. -                if await _utils.notify_infraction(user, " ".join(infr_type.split("_")).title(), expiry, reason, icon): +                if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon):                      dm_result = ":incoming_envelope: "                      dm_log_text = "\nDM: Sent" @@ -215,12 +227,14 @@ class InfractionScheduler:                  Member: {messages.format_user(user)}                  Actor: {ctx.author.mention}{dm_log_text}{expiry_log_text}                  Reason: {reason} +                {additional_info}              """),              content=log_content,              footer=f"ID {infraction['id']}"          )          log.info(f"Applied {infr_type} infraction #{id_} to {user}.") +        return not failed      async def pardon_infraction(              self, diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 746d4e154..18e937e87 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -10,7 +10,7 @@ from discord.ext.commands import Context, command  from bot import constants  from bot.bot import Bot  from bot.constants import Event -from bot.converters import Expiry, FetchedMember +from bot.converters import Duration, Expiry, FetchedMember  from bot.decorators import respect_role_hierarchy  from bot.exts.moderation.infraction import _utils  from bot.exts.moderation.infraction._scheduler import InfractionScheduler @@ -27,7 +27,7 @@ class Infractions(InfractionScheduler, commands.Cog):      category_description = "Server moderation tools."      def __init__(self, bot: Bot): -        super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning"}) +        super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning", "voice_ban"})          self.category = "Moderation"          self._muted_role = discord.Object(constants.Roles.muted) @@ -98,7 +98,13 @@ class Infractions(InfractionScheduler, commands.Cog):      # region: Temporary infractions      @command(aliases=["mute"]) -    async def tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: t.Optional[str] = None) -> None: +    async def tempmute( +        self, ctx: Context, +        user: Member, +        duration: t.Optional[Expiry] = None, +        *, +        reason: t.Optional[str] = None +    ) -> None:          """          Temporarily mute a user for the given reason and duration. @@ -113,7 +119,11 @@ class Infractions(InfractionScheduler, commands.Cog):          \u2003`s` - seconds          Alternatively, an ISO 8601 timestamp can be provided for the duration. + +        If no duration is given, a one hour duration is used by default.          """ +        if duration is None: +            duration = await Duration().convert(ctx, "1h")          await self.apply_mute(ctx, user, reason, expires_at=duration)      @command() @@ -180,11 +190,6 @@ class Infractions(InfractionScheduler, commands.Cog):          await self.apply_infraction(ctx, infraction, user) -    @command(hidden=True, aliases=['shadowkick', 'skick']) -    async def shadow_kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: -        """Kick a user for the given reason without notifying the user.""" -        await self.apply_kick(ctx, user, reason, hidden=True) -      @command(hidden=True, aliases=['shadowban', 'sban'])      async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None:          """Permanently ban a user for the given reason without notifying the user.""" @@ -193,31 +198,6 @@ class Infractions(InfractionScheduler, commands.Cog):      # endregion      # region: Temporary shadow infractions -    @command(hidden=True, aliases=["shadowtempmute, stempmute", "shadowmute", "smute"]) -    async def shadow_tempmute( -        self, ctx: Context, -        user: Member, -        duration: Expiry, -        *, -        reason: t.Optional[str] = None -    ) -> None: -        """ -        Temporarily mute a user for the given reason and duration without notifying the user. - -        A unit of time should be appended to the duration. -        Units (∗case-sensitive): -        \u2003`y` - years -        \u2003`m` - months∗ -        \u2003`w` - weeks -        \u2003`d` - days -        \u2003`h` - hours -        \u2003`M` - minutes∗ -        \u2003`s` - seconds - -        Alternatively, an ISO 8601 timestamp can be provided for the duration. -        """ -        await self.apply_mute(ctx, user, reason, expires_at=duration, hidden=True) -      @command(hidden=True, aliases=["shadowtempban, stempban"])      async def shadow_tempban(          self, diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 394f63da3..c58410f8c 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -10,7 +10,7 @@ from discord.utils import escape_markdown  from bot import constants  from bot.bot import Bot -from bot.converters import Expiry, Snowflake, UserMention, allowed_strings, proxy_user +from bot.converters import Expiry, Infraction, Snowflake, UserMention, allowed_strings, proxy_user  from bot.exts.moderation.infraction.infractions import Infractions  from bot.exts.moderation.modlog import ModLog  from bot.pagination import LinePaginator @@ -40,16 +40,55 @@ class ModManagement(commands.Cog):      # region: Edit infraction commands -    @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True) +    @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf', 'i'), invoke_without_command=True)      async def infraction_group(self, ctx: Context) -> None:          """Infraction manipulation commands."""          await ctx.send_help(ctx.command) -    @infraction_group.command(name='edit') +    @infraction_group.command(name="append", aliases=("amend", "add", "a")) +    async def infraction_append( +        self, +        ctx: Context, +        infraction: Infraction, +        duration: t.Union[Expiry, allowed_strings("p", "permanent"), None],   # noqa: F821 +        *, +        reason: str = None +    ) -> None: +        """ +        Append text and/or edit the duration of an infraction. + +        Durations are relative to the time of updating and should be appended with a unit of time. +        Units (∗case-sensitive): +        \u2003`y` - years +        \u2003`m` - months∗ +        \u2003`w` - weeks +        \u2003`d` - days +        \u2003`h` - hours +        \u2003`M` - minutes∗ +        \u2003`s` - seconds + +        Use "l", "last", or "recent" as the infraction ID to specify that the most recent infraction +        authored by the command invoker should be edited. + +        Use "p" or "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601 +        timestamp can be provided for the duration. + +        If a previous infraction reason does not end with an ending punctuation mark, this automatically +        adds a period before the amended reason. +        """ +        old_reason = infraction["reason"] + +        if old_reason is not None: +            add_period = not old_reason.endswith((".", "!", "?")) +            reason = old_reason + (". " if add_period else " ") + reason + +        await self.infraction_edit(ctx, infraction, duration, reason=reason) + +    @infraction_group.command(name='edit', aliases=('e',))      async def infraction_edit(          self,          ctx: Context, -        infraction_id: t.Union[int, allowed_strings("l", "last", "recent")],  # noqa: F821 +        infraction: Infraction,          duration: t.Union[Expiry, allowed_strings("p", "permanent"), None],   # noqa: F821          *,          reason: str = None @@ -77,30 +116,13 @@ class ModManagement(commands.Cog):              # Unlike UserInputError, the error handler will show a specified message for BadArgument              raise commands.BadArgument("Neither a new expiry nor a new reason was specified.") -        # Retrieve the previous infraction for its information. -        if isinstance(infraction_id, str): -            params = { -                "actor__id": ctx.author.id, -                "ordering": "-inserted_at" -            } -            infractions = await self.bot.api_client.get("bot/infractions", params=params) - -            if infractions: -                old_infraction = infractions[0] -                infraction_id = old_infraction["id"] -            else: -                await ctx.send( -                    ":x: Couldn't find most recent infraction; you have never given an infraction." -                ) -                return -        else: -            old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") +        infraction_id = infraction["id"]          request_data = {}          confirm_messages = []          log_text = "" -        if duration is not None and not old_infraction['active']: +        if duration is not None and not infraction['active']:              if reason is None:                  await ctx.send(":x: Cannot edit the expiration of an expired infraction.")                  return @@ -119,7 +141,7 @@ class ModManagement(commands.Cog):              request_data['reason'] = reason              confirm_messages.append("set a new reason")              log_text += f""" -                Previous reason: {old_infraction['reason']} +                Previous reason: {infraction['reason']}                  New reason: {reason}              """.rstrip()          else: @@ -134,7 +156,7 @@ class ModManagement(commands.Cog):          # Re-schedule infraction if the expiration has been updated          if 'expires_at' in request_data:              # A scheduled task should only exist if the old infraction wasn't permanent -            if old_infraction['expires_at']: +            if infraction['expires_at']:                  self.infractions_cog.scheduler.cancel(new_infraction['id'])              # If the infraction was not marked as permanent, schedule a new expiration task @@ -142,7 +164,7 @@ class ModManagement(commands.Cog):                  self.infractions_cog.schedule_expiration(new_infraction)              log_text += f""" -                Previous expiry: {old_infraction['expires_at'] or "Permanent"} +                Previous expiry: {infraction['expires_at'] or "Permanent"}                  New expiry: {new_infraction['expires_at'] or "Permanent"}              """.rstrip() diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index adfe42fcd..96dfb562f 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -5,7 +5,7 @@ import textwrap  import typing as t  from pathlib import Path -from discord import Colour, Embed, Member +from discord import Embed, Member  from discord.ext.commands import Cog, Context, command, has_any_role  from discord.utils import escape_markdown @@ -143,57 +143,44 @@ class Superstarify(InfractionScheduler, Cog):          forced_nick = self.get_nick(id_, member.id)          expiry_str = format_infraction(infraction["expires_at"]) -        # Apply the infraction and schedule the expiration task. -        log.debug(f"Changing nickname of {member} to {forced_nick}.") -        self.mod_log.ignore(constants.Event.member_update, member.id) -        await member.edit(nick=forced_nick, reason=reason) -        self.schedule_expiration(infraction) +        # Apply the infraction +        async def action() -> None: +            log.debug(f"Changing nickname of {member} to {forced_nick}.") +            self.mod_log.ignore(constants.Event.member_update, member.id) +            await member.edit(nick=forced_nick, reason=reason)          old_nick = escape_markdown(old_nick)          forced_nick = escape_markdown(forced_nick) -        # Send a DM to the user to notify them of their new infraction. -        await _utils.notify_infraction( -            user=member, -            infr_type="Superstarify", -            expires_at=expiry_str, -            icon_url=_utils.INFRACTION_ICONS["superstar"][0], -            reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})." +        superstar_reason = f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})." +        nickname_info = textwrap.dedent(f""" +            Old nickname: `{old_nick}` +            New nickname: `{forced_nick}` +        """).strip() + +        successful = await self.apply_infraction( +            ctx, infraction, member, action(), +            user_reason=superstar_reason, +            additional_info=nickname_info          ) -        # Send an embed with the infraction information to the invoking context. -        log.trace(f"Sending superstar #{id_} embed.") -        embed = Embed( -            title="Congratulations!", -            colour=constants.Colours.soft_orange, -            description=( -                f"Your previous nickname, **{old_nick}**, " -                f"was so bad that we have decided to change it. " -                f"Your new nickname will be **{forced_nick}**.\n\n" -                f"You will be unable to change your nickname until **{expiry_str}**.\n\n" -                "If you're confused by this, please read our " -                f"[official nickname policy]({NICKNAME_POLICY_URL})." +        # Send an embed with the infraction information to the invoking context if +        # superstar was successful. +        if successful: +            log.trace(f"Sending superstar #{id_} embed.") +            embed = Embed( +                title="Congratulations!", +                colour=constants.Colours.soft_orange, +                description=( +                    f"Your previous nickname, **{old_nick}**, " +                    f"was so bad that we have decided to change it. " +                    f"Your new nickname will be **{forced_nick}**.\n\n" +                    f"You will be unable to change your nickname until **{expiry_str}**.\n\n" +                    "If you're confused by this, please read our " +                    f"[official nickname policy]({NICKNAME_POLICY_URL})." +                )              ) -        ) -        await ctx.send(embed=embed) - -        # Log to the mod log channel. -        log.trace(f"Sending apply mod log for superstar #{id_}.") -        await self.mod_log.send_log_message( -            icon_url=_utils.INFRACTION_ICONS["superstar"][0], -            colour=Colour.gold(), -            title="Member achieved superstardom", -            thumbnail=member.avatar_url_as(static_format="png"), -            text=textwrap.dedent(f""" -                Member: {member.mention} -                Actor: {ctx.message.author.mention} -                Expires: {expiry_str} -                Old nickname: `{old_nick}` -                New nickname: `{forced_nick}` -                Reason: {reason} -            """), -            footer=f"ID {id_}" -        ) +            await ctx.send(embed=embed)      @command(name="unsuperstarify", aliases=("release_nick", "unstar"))      async def unsuperstarify(self, ctx: Context, member: Member) -> None: diff --git a/bot/resources/tags/microsoft-build-tools.md b/bot/resources/tags/microsoft-build-tools.md new file mode 100644 index 000000000..7c702e296 --- /dev/null +++ b/bot/resources/tags/microsoft-build-tools.md @@ -0,0 +1,15 @@ +**Microsoft Visual C++ Build Tools** + +When you install a library through `pip` on Windows, sometimes you may encounter this error: + +``` +error: Microsoft Visual C++ 14.0 or greater is required. Get it with "Microsoft C++ Build Tools": https://visualstudio.microsoft.com/visual-cpp-build-tools/ +``` + +This means the library you're installing has code written in other languages and needs additional tools to install. To install these tools, follow the following steps: (Requires 6GB+ disk space) + +**1.** Open [https://visualstudio.microsoft.com/visual-cpp-build-tools/](https://visualstudio.microsoft.com/visual-cpp-build-tools/). +**2.** Click **`Download Build Tools >`**. A file named `vs_BuildTools` or `vs_BuildTools.exe` should start downloading. If no downloads start after a few seconds, click **`click here to retry`**. +**3.** Run the downloaded file. Click **`Continue`** to proceed. +**4.** Choose **C++ build tools** and press **`Install`**. You may need a reboot after the installation. +**5.** Try installing the library via `pip` again. diff --git a/config-default.yml b/config-default.yml index 60eb437af..006743342 100644 --- a/config-default.yml +++ b/config-default.yml @@ -155,7 +155,6 @@ guild:          python_discussion:  &PY_DISCUSSION  267624335836053506          # Python Help: Available -        how_to_get_help:    704250143020417084          cooldown:           720603994149486673          # Logs @@ -196,13 +195,19 @@ guild:          mod_announcements:      &MOD_ANNOUNCEMENTS      372115205867700225          admin_announcements:    &ADMIN_ANNOUNCEMENTS    749736155569848370 -        # Voice -        code_help_voice:                    755154969761677312 -        code_help_voice_2:                  766330079135268884 -        voice_chat:                         412357430186344448 +        # Voice Channels          admins_voice:       &ADMINS_VOICE   500734494840717332 +        code_help_voice_1:                  751592231726481530 +        code_help_voice_2:                  764232549840846858 +        general_voice:                      751591688538947646          staff_voice:        &STAFF_VOICE    412375055910043655 +        # Voice Chat +        code_help_chat_1:                   755154969761677312 +        code_help_chat_2:                   766330079135268884 +        staff_voice_chat:                   541638762007101470 +        voice_chat:                         412357430186344448 +          # Watch          big_brother_logs:   &BB_LOGS        468507907357409333          talent_pool:        &TALENT_POOL    534321732593647616 diff --git a/deployment.yaml b/deployment.yaml deleted file mode 100644 index ca5ff5941..000000000 --- a/deployment.yaml +++ /dev/null @@ -1,21 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: -  name: bot -spec: -  replicas: 1 -  selector: -    matchLabels: -      app: bot -  template: -    metadata: -      labels: -        app: bot -    spec: -      containers: -      - name: bot -        image: ghcr.io/python-discord/bot:latest -        imagePullPolicy: Always -        envFrom: -        - secretRef: -            name: bot-env | 
