diff options
| author | 2020-04-05 14:18:00 +0100 | |
|---|---|---|
| committer | 2020-04-05 14:18:00 +0100 | |
| commit | b1373da0b64a3ab55bc53ea93b6e4948f95e99bc (patch) | |
| tree | 6d1d01138c4aab88b1c3aa416f8b40ec7820595d | |
| parent | Merge pull request #813 from python-discord/feat/ci/b000/cache-pipenv (diff) | |
| parent | Merge branch 'master' into feat/frontend/o200/help-channels (diff) | |
Merge pull request #786 from python-discord/feat/frontend/o200/help-channels
Implement a new help channel system
| -rw-r--r-- | bot/__main__.py | 13 | ||||
| -rw-r--r-- | bot/cogs/bot.py | 17 | ||||
| -rw-r--r-- | bot/cogs/free.py | 103 | ||||
| -rw-r--r-- | bot/cogs/help_channels.py | 671 | ||||
| -rw-r--r-- | bot/cogs/moderation/modlog.py | 8 | ||||
| -rw-r--r-- | bot/constants.py | 28 | ||||
| -rw-r--r-- | bot/resources/elements.json | 120 | ||||
| -rw-r--r-- | bot/utils/scheduling.py | 22 | ||||
| -rw-r--r-- | config-default.yml | 50 | 
9 files changed, 887 insertions, 145 deletions
| diff --git a/bot/__main__.py b/bot/__main__.py index 8c3ae02e3..bf98f2cfd 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -5,9 +5,8 @@ import sentry_sdk  from discord.ext.commands import when_mentioned_or  from sentry_sdk.integrations.logging import LoggingIntegration -from bot import patches +from bot import constants, patches  from bot.bot import Bot -from bot.constants import Bot as BotConfig  sentry_logging = LoggingIntegration(      level=logging.DEBUG, @@ -15,12 +14,12 @@ sentry_logging = LoggingIntegration(  )  sentry_sdk.init( -    dsn=BotConfig.sentry_dsn, +    dsn=constants.Bot.sentry_dsn,      integrations=[sentry_logging]  )  bot = Bot( -    command_prefix=when_mentioned_or(BotConfig.prefix), +    command_prefix=when_mentioned_or(constants.Bot.prefix),      activity=discord.Game(name="Commands: !help"),      case_insensitive=True,      max_messages=10_000, @@ -49,7 +48,6 @@ bot.load_extension("bot.cogs.alias")  bot.load_extension("bot.cogs.defcon")  bot.load_extension("bot.cogs.eval")  bot.load_extension("bot.cogs.duck_pond") -bot.load_extension("bot.cogs.free")  bot.load_extension("bot.cogs.information")  bot.load_extension("bot.cogs.jams")  bot.load_extension("bot.cogs.moderation") @@ -66,8 +64,11 @@ bot.load_extension("bot.cogs.watchchannels")  bot.load_extension("bot.cogs.webhook_remover")  bot.load_extension("bot.cogs.wolfram") +if constants.HelpChannels.enable: +    bot.load_extension("bot.cogs.help_channels") +  # Apply `message_edited_at` patch if discord.py did not yet release a bug fix.  if not hasattr(discord.message.Message, '_handle_edited_timestamp'):      patches.message_edited_at.apply_patch() -bot.run(BotConfig.token) +bot.run(constants.Bot.token) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 7b66b48c2..a6929b431 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -9,7 +9,7 @@ from discord.ext.commands import Cog, Context, command, group  from bot.bot import Bot  from bot.cogs.token_remover import TokenRemover -from bot.constants import Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs +from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs  from bot.decorators import with_role  from bot.utils.messages import wait_for_deletion @@ -26,14 +26,6 @@ class BotCog(Cog, name="Bot"):          # Stores allowed channels plus epoch time since last call.          self.channel_cooldowns = { -            Channels.help_0: 0, -            Channels.help_1: 0, -            Channels.help_2: 0, -            Channels.help_3: 0, -            Channels.help_4: 0, -            Channels.help_5: 0, -            Channels.help_6: 0, -            Channels.help_7: 0,              Channels.python_discussion: 0,          } @@ -231,9 +223,14 @@ class BotCog(Cog, name="Bot"):          If poorly formatted code is detected, send the user a helpful message explaining how to do          properly formatted Python syntax highlighting codeblocks.          """ +        is_help_channel = ( +            getattr(msg.channel, "category", None) +            and msg.channel.category.id in (Categories.help_available, Categories.help_in_use) +        )          parse_codeblock = (              ( -                msg.channel.id in self.channel_cooldowns +                is_help_channel +                or msg.channel.id in self.channel_cooldowns                  or msg.channel.id in self.channel_whitelist              )              and not msg.author.bot diff --git a/bot/cogs/free.py b/bot/cogs/free.py deleted file mode 100644 index 33b55e79a..000000000 --- a/bot/cogs/free.py +++ /dev/null @@ -1,103 +0,0 @@ -import logging -from datetime import datetime -from operator import itemgetter - -from discord import Colour, Embed, Member, utils -from discord.ext.commands import Cog, Context, command - -from bot.bot import Bot -from bot.constants import Categories, Channels, Free, STAFF_ROLES -from bot.decorators import redirect_output - -log = logging.getLogger(__name__) - -TIMEOUT = Free.activity_timeout -RATE = Free.cooldown_rate -PER = Free.cooldown_per - - -class Free(Cog): -    """Tries to figure out which help channels are free.""" - -    PYTHON_HELP_ID = Categories.python_help - -    @command(name="free", aliases=('f',)) -    @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES) -    async def free(self, ctx: Context, user: Member = None, seek: int = 2) -> None: -        """ -        Lists free help channels by likeliness of availability. - -        seek is used only when this command is invoked in a help channel. -        You cannot override seek without mentioning a user first. - -        When seek is 2, we are avoiding considering the last active message -        in a channel to be the one that invoked this command. - -        When seek is 3 or more, a user has been mentioned on the assumption -        that they asked if the channel is free or they asked their question -        in an active channel, and we want the message before that happened. -        """ -        free_channels = [] -        python_help = utils.get(ctx.guild.categories, id=self.PYTHON_HELP_ID) - -        if user is not None and seek == 2: -            seek = 3 -        elif not 0 < seek < 10: -            seek = 3 - -        # Iterate through all the help channels -        # to check latest activity -        for channel in python_help.channels: -            # Seek further back in the help channel -            # the command was invoked in -            if channel.id == ctx.channel.id: -                messages = await channel.history(limit=seek).flatten() -                msg = messages[seek - 1] -            # Otherwise get last message -            else: -                msg = await channel.history(limit=1).next()  # noqa: B305 - -            inactive = (datetime.utcnow() - msg.created_at).seconds -            if inactive > TIMEOUT: -                free_channels.append((inactive, channel)) - -        embed = Embed() -        embed.colour = Colour.blurple() -        embed.title = "**Looking for a free help channel?**" - -        if user is not None: -            embed.description = f"**Hey {user.mention}!**\n\n" -        else: -            embed.description = "" - -        # Display all potentially inactive channels -        # in descending order of inactivity -        if free_channels: -            # Sort channels in descending order by seconds -            # Get position in list, inactivity, and channel object -            # For each channel, add to embed.description -            sorted_channels = sorted(free_channels, key=itemgetter(0), reverse=True) - -            for (inactive, channel) in sorted_channels[:3]: -                minutes, seconds = divmod(inactive, 60) -                if minutes > 59: -                    hours, minutes = divmod(minutes, 60) -                    embed.description += f"{channel.mention} **{hours}h {minutes}m {seconds}s** inactive\n" -                else: -                    embed.description += f"{channel.mention} **{minutes}m {seconds}s** inactive\n" - -            embed.set_footer(text="Please confirm these channels are free before posting") -        else: -            embed.description = ( -                "Doesn't look like any channels are available right now. " -                "You're welcome to check for yourself to be sure. " -                "If all channels are truly busy, please be patient " -                "as one will likely be available soon." -            ) - -        await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: -    """Load the Free cog.""" -    bot.add_cog(Free()) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py new file mode 100644 index 000000000..b820c7ad3 --- /dev/null +++ b/bot/cogs/help_channels.py @@ -0,0 +1,671 @@ +import asyncio +import bisect +import inspect +import json +import logging +import random +import typing as t +from collections import deque +from datetime import datetime +from pathlib import Path + +import discord +from discord.ext import commands + +from bot import constants +from bot.bot import Bot +from bot.decorators import with_role +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 + +AVAILABLE_TOPIC = """ +This channel is available. Feel free to ask a question in order to claim this channel! +""" + +IN_USE_TOPIC = """ +This channel is currently in use. If you'd like to discuss a different problem, please claim a new \ +channel from the Help: Available category. +""" + +DORMANT_TOPIC = """ +This channel is temporarily archived. If you'd like to ask a question, please use one of the \ +channels in the Help: Available category. +""" + +AVAILABLE_MSG = f""" +This help channel is now **available**, which means that you can claim it by simply typing your \ +question into it. Once claimed, the channel will move into the **Help: In Use** category, and will \ +be yours until it has been inactive for {constants.HelpChannels.idle_minutes} minutes. When that \ +happens, it will be set to **dormant** and moved into the **Help: Dormant** category. + +You may claim a new channel once every {constants.HelpChannels.claim_minutes} minutes. If you \ +currently cannot send a message in this channel, it means you are on cooldown and need to wait. + +Try to write the best question you can by providing a detailed description and telling us what \ +you've tried already. For more information on asking a good question, \ +check out our guide on [asking good questions]({ASKING_GUIDE_URL}). +""" + +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}). +""" + + +class TaskData(t.NamedTuple): +    """Data for a scheduled task.""" + +    wait_time: int +    callback: t.Awaitable + + +class HelpChannels(Scheduler, 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` + +    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 +        * 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): +        super().__init__() + +        self.bot = bot + +        # 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.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) + +    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) + +    @commands.command(name="dormant", aliases=["close"], enabled=False) +    @with_role(*constants.HelpChannels.cmd_whitelist) +    async def dormant_command(self, ctx: commands.Context) -> None: +        """Make the current in-use help channel dormant.""" +        log.trace("dormant command invoked; checking if the channel is in-use.") + +        if ctx.channel.category == self.in_use_category: +            self.cancel_task(ctx.channel.id) +            await self.move_to_dormant(ctx.channel) +        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_position(channel: discord.TextChannel, destination: discord.CategoryChannel) -> int: +        """Return alphabetical position for `channel` if moved to `destination`.""" +        log.trace(f"Getting alphabetical position for #{channel} ({channel.id}).") + +        # If the destination category is empty, use the first position +        if not destination.channels: +            position = 1 +        else: +            # Make a sorted list of channel names for bisect. +            channel_names = [c.name for c in destination.channels] + +            # Get location which would maintain sorted order if channel was inserted into the list. +            rank = bisect.bisect(channel_names, channel.name) + +            if rank == len(destination.channels): +                # Channel should be moved to the end of the category. +                position = destination.channels[-1].position + 1 +            else: +                # Channel should be moved to the position of its alphabetical successor. +                position = destination.channels[rank].position + +        log.trace( +            f"Position of #{channel} ({channel.id}) in {destination.name} will be {position} " +            f"(was {channel.position})." +        ) + +        return position + +    @staticmethod +    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 isinstance(channel, discord.TextChannel): +                yield channel + +    @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(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 + +    @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) + +        log.trace(f"Moving {missing} missing channels to the Available category.") + +        for _ in range(missing): +            await self.move_to_available() + +    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 self.try_get_channel( +                constants.Categories.help_available +            ) +            self.in_use_category = await self.try_get_channel(constants.Categories.help_in_use) +            self.dormant_category = await self.try_get_channel(constants.Categories.help_dormant) +        except discord.HTTPException: +            log.exception(f"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.reset_send_permissions() + +        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.dormant_command.enabled = True + +        await self.init_available() + +        log.info("Cog is ready!") +        self.ready.set() + +    @staticmethod +    def is_dormant_message(message: t.Optional[discord.Message]) -> bool: +        """Return True if the contents of the `message` match `DORMANT_MSG`.""" +        if not message or not message.embeds: +            return False + +        embed = message.embeds[0] +        return embed.description.strip() == DORMANT_MSG.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}).") + +        idle_seconds = constants.HelpChannels.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) +        else: +            # Cancel the existing task, if any. +            if has_task: +                self.cancel_task(channel.id) + +            data = TaskData(idle_seconds - time_elapsed, self.move_idle_channel(channel)) + +            log.info( +                f"#{channel} ({channel.id}) is still active; " +                f"scheduling it to be moved after {data.wait_time} seconds." +            ) + +            self.schedule_task(channel.id, data) + +    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 channel.edit( +            category=self.available_category, +            sync_permissions=True, +            topic=AVAILABLE_TOPIC, +        ) + +    async def move_to_dormant(self, channel: discord.TextChannel) -> None: +        """Make the `channel` dormant.""" +        log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") + +        await channel.edit( +            category=self.dormant_category, +            sync_permissions=True, +            topic=DORMANT_TOPIC, +            position=self.get_position(channel, self.dormant_category), +        ) + +        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) + +        log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") +        self.channel_queue.put_nowait(channel) + +    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 channel.edit( +            category=self.in_use_category, +            sync_permissions=True, +            topic=IN_USE_TOPIC, +            position=0, +        ) + +        timeout = constants.HelpChannels.idle_minutes * 60 + +        log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.") +        data = TaskData(timeout, self.move_idle_channel(channel)) +        self.schedule_task(channel.id, data) + +    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) + +            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." +            ) + +            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!") + +    @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 +        if channel.category and channel.category.id != constants.Categories.help_available: +            return  # Ignore messages outside the Available category. + +        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 channel.category and channel.category.id != 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 + +            await self.move_to_in_use(channel) +            await self.revoke_send_permissions(message.author) + +            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() + +    async def reset_send_permissions(self) -> None: +        """Reset send permissions for members with it set to False in the Available category.""" +        log.trace("Resetting send permissions in the Available category.") + +        for member, overwrite in self.available_category.overwrites.items(): +            if isinstance(member, discord.Member) and overwrite.send_messages is False: +                log.trace(f"Resetting send permissions for {member} ({member.id}).") +                await self.available_category.set_permissions(member, send_messages=None) + +    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.available_category.set_permissions(member, send_messages=False) + +        # Cancel the existing task, if any. +        # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). +        self.cancel_task(member.id, ignore_missing=True) + +        timeout = constants.HelpChannels.claim_minutes * 60 +        callback = self.available_category.set_permissions(member, overwrite=None) + +        log.trace(f"Scheduling {member}'s ({member.id}) send message permissions to be reinstated.") +        self.schedule_task(member.id, TaskData(timeout, callback)) + +    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(description=AVAILABLE_MSG) + +        msg = await self.get_last_message(channel) +        if self.is_dormant_message(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 try_get_channel(self, channel_id: int) -> discord.abc.GuildChannel: +        """Attempt to get or fetch a channel and return it.""" +        log.trace(f"Getting the channel {channel_id}.") + +        channel = self.bot.get_channel(channel_id) +        if not channel: +            log.debug(f"Channel {channel_id} is not in cache; fetching from API.") +            channel = await self.bot.fetch_channel(channel_id) + +        log.trace(f"Channel #{channel} ({channel_id}) retrieved.") +        return 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 + +    async def _scheduled_task(self, data: TaskData) -> None: +        """Await the `data.callback` coroutine after waiting for `data.wait_time` seconds.""" +        try: +            log.trace(f"Waiting {data.wait_time} seconds before awaiting callback.") +            await asyncio.sleep(data.wait_time) + +            # Use asyncio.shield to prevent callback from cancelling itself. +            # The parent task (_scheduled_task) will still get cancelled. +            log.trace("Done waiting; now awaiting the callback.") +            await asyncio.shield(data.callback) +        finally: +            if inspect.iscoroutine(data.callback): +                log.trace("Explicitly closing coroutine.") +                data.callback.close() + + +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/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index c63b4bab9..beef7a8ef 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -15,7 +15,7 @@ from discord.ext.commands import Cog, Context  from discord.utils import escape_markdown  from bot.bot import Bot -from bot.constants import Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs +from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs  from bot.utils.time import humanize_delta  log = logging.getLogger(__name__) @@ -188,6 +188,12 @@ class ModLog(Cog, name="ModLog"):              self._ignored[Event.guild_channel_update].remove(before.id)              return +        # Two channel updates are sent for a single edit: 1 for topic and 1 for category change. +        # TODO: remove once support is added for ignoring multiple occurrences for the same channel. +        help_categories = (Categories.help_available, Categories.help_dormant, Categories.help_in_use) +        if after.category and after.category.id in help_categories: +            return +          diff = DeepDiff(before, after)          changes = []          done = [] diff --git a/bot/constants.py b/bot/constants.py index 549e69c8f..60e3c4897 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -355,7 +355,9 @@ class Categories(metaclass=YAMLGetter):      section = "guild"      subsection = "categories" -    python_help: int +    help_available: int +    help_in_use: int +    help_dormant: int  class Channels(metaclass=YAMLGetter): @@ -373,14 +375,6 @@ class Channels(metaclass=YAMLGetter):      dev_core: int      dev_log: int      esoteric: int -    help_0: int -    help_1: int -    help_2: int -    help_3: int -    help_4: int -    help_5: int -    help_6: int -    help_7: int      helpers: int      message_log: int      meta: int @@ -531,6 +525,22 @@ class Free(metaclass=YAMLGetter):      cooldown_per: float +class HelpChannels(metaclass=YAMLGetter): +    section = 'help_channels' + +    enable: bool +    claim_minutes: int +    cmd_whitelist: List[int] +    idle_minutes: int +    max_available: int +    max_total_channels: int +    name_prefix: str +    notify: bool +    notify_channel: int +    notify_minutes: int +    notify_roles: List[int] + +  class Mention(metaclass=YAMLGetter):      section = 'mention' diff --git a/bot/resources/elements.json b/bot/resources/elements.json new file mode 100644 index 000000000..2dc9b6fd6 --- /dev/null +++ b/bot/resources/elements.json @@ -0,0 +1,120 @@ +[ +    "hydrogen", +    "helium", +    "lithium", +    "beryllium", +    "boron", +    "carbon", +    "nitrogen", +    "oxygen", +    "fluorine", +    "neon", +    "sodium", +    "magnesium", +    "aluminium", +    "silicon", +    "phosphorus", +    "sulfur", +    "chlorine", +    "argon", +    "potassium", +    "calcium", +    "scandium", +    "titanium", +    "vanadium", +    "chromium", +    "manganese", +    "iron", +    "cobalt", +    "nickel", +    "copper", +    "zinc", +    "gallium", +    "germanium", +    "arsenic", +    "selenium", +    "bromine", +    "krypton", +    "rubidium", +    "strontium", +    "yttrium", +    "zirconium", +    "niobium", +    "molybdenum", +    "technetium", +    "ruthenium", +    "rhodium", +    "palladium", +    "silver", +    "cadmium", +    "indium", +    "tin", +    "antimony", +    "tellurium", +    "iodine", +    "xenon", +    "caesium", +    "barium", +    "lanthanum", +    "cerium", +    "praseodymium", +    "neodymium", +    "promethium", +    "samarium", +    "europium", +    "gadolinium", +    "terbium", +    "dysprosium", +    "holmium", +    "erbium", +    "thulium", +    "ytterbium", +    "lutetium", +    "hafnium", +    "tantalum", +    "tungsten", +    "rhenium", +    "osmium", +    "iridium", +    "platinum", +    "gold", +    "mercury", +    "thallium", +    "lead", +    "bismuth", +    "polonium", +    "astatine", +    "radon", +    "francium", +    "radium", +    "actinium", +    "thorium", +    "protactinium", +    "uranium", +    "neptunium", +    "plutonium", +    "americium", +    "curium", +    "berkelium", +    "californium", +    "einsteinium", +    "fermium", +    "mendelevium", +    "nobelium", +    "lawrencium", +    "rutherfordium", +    "dubnium", +    "seaborgium", +    "bohrium", +    "hassium", +    "meitnerium", +    "darmstadtium", +    "roentgenium", +    "copernicium", +    "nihonium", +    "flerovium", +    "moscovium", +    "livermorium", +    "tennessine", +    "oganesson" +] diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 5760ec2d4..8b778a093 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -51,20 +51,32 @@ class Scheduler(metaclass=CogABCMeta):          self._scheduled_tasks[task_id] = task          log.debug(f"{self.cog_name}: scheduled task #{task_id} {id(task)}.") -    def cancel_task(self, task_id: t.Hashable) -> None: -        """Unschedule the task identified by `task_id`.""" +    def cancel_task(self, task_id: t.Hashable, ignore_missing: bool = False) -> None: +        """ +        Unschedule the task identified by `task_id`. + +        If `ignore_missing` is True, a warning will not be sent if a task isn't found. +        """          log.trace(f"{self.cog_name}: cancelling task #{task_id}...")          task = self._scheduled_tasks.get(task_id)          if not task: -            log.warning(f"{self.cog_name}: failed to unschedule {task_id} (no task found).") +            if not ignore_missing: +                log.warning(f"{self.cog_name}: failed to unschedule {task_id} (no task found).")              return -        task.cancel()          del self._scheduled_tasks[task_id] +        task.cancel()          log.debug(f"{self.cog_name}: unscheduled task #{task_id} {id(task)}.") +    def cancel_all(self) -> None: +        """Unschedule all known tasks.""" +        log.debug(f"{self.cog_name}: unscheduling all tasks") + +        for task_id in self._scheduled_tasks.copy(): +            self.cancel_task(task_id, ignore_missing=True) +      def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None:          """          Delete the task and raise its exception if one exists. @@ -98,6 +110,6 @@ class Scheduler(metaclass=CogABCMeta):              # Log the exception if one exists.              if exception:                  log.error( -                    f"{self.cog_name}: error in task #{task_id} {id(scheduled_task)}!", +                    f"{self.cog_name}: error in task #{task_id} {id(done_task)}!",                      exc_info=exception                  ) diff --git a/config-default.yml b/config-default.yml index a9578d9bb..70c31ebb5 100644 --- a/config-default.yml +++ b/config-default.yml @@ -111,7 +111,9 @@ guild:      id: 267624335836053506      categories: -        python_help:    356013061213126657 +        help_available:                     691405807388196926 +        help_in_use:                        356013061213126657 +        help_dormant:                       691405908919451718      channels:          announcements:                              354619224620138496 @@ -138,16 +140,6 @@ guild:          off_topic_1:    463035241142026251          off_topic_2:    463035268514185226 -        # Python Help -        help_0:         303906576991780866 -        help_1:         303906556754395136 -        help_2:         303906514266226689 -        help_3:         439702951246692352 -        help_4:         451312046647148554 -        help_5:         454941769734422538 -        help_6:         587375753306570782 -        help_7:         587375768556797982 -          # Special          bot_commands:       &BOT_CMD        267659945086812160          esoteric:                           470884583684964352 @@ -512,6 +504,42 @@ mention:      message_timeout: 300      reset_delay: 5 +help_channels: +    enable: true + +    # Minimum interval before allowing a certain user to claim a new help channel +    claim_minutes: 15 + +    # Roles which are allowed to use the command which makes channels dormant +    cmd_whitelist: +        - *HELPERS_ROLE + +    # Allowed duration of inactivity before making a channel dormant +    idle_minutes: 30 + +    # Maximum number of channels to put in the available category +    max_available: 2 + +    # Maximum number of channels across all 3 categories +    # Note Discord has a hard limit of 50 channels per category, so this shouldn't be > 50 +    max_total_channels: 32 + +    # Prefix for help channel names +    name_prefix: 'help-' + +    # Notify if more available channels are needed but there are no more dormant ones +    notify: true + +    # Channel in which to send notifications +    notify_channel: *HELPERS + +    # Minimum interval between helper notifications +    notify_minutes: 15 + +    # Mention these roles in notifications +    notify_roles: +        - *HELPERS_ROLE +  redirect_output:      delete_invocation: true      delete_delay: 15 | 
