From ed68051a47e67d8e60498a866a8c3c54840aa6fb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 11:39:18 -0700 Subject: Help channels: move to a subpackage --- bot/exts/help_channels.py | 940 ------------------------------------- bot/exts/help_channels/__init__.py | 17 + bot/exts/help_channels/_cog.py | 930 ++++++++++++++++++++++++++++++++++++ 3 files changed, 947 insertions(+), 940 deletions(-) delete mode 100644 bot/exts/help_channels.py create mode 100644 bot/exts/help_channels/__init__.py create mode 100644 bot/exts/help_channels/_cog.py 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..38444b707 --- /dev/null +++ b/bot/exts/help_channels/__init__.py @@ -0,0 +1,17 @@ +import logging + +from bot.bot import Bot + +log = logging.getLogger(__name__) + + +def setup(bot: Bot) -> None: + """Load the HelpChannels cog.""" + # Defer import to reduce side effects from importing the sync package. + from bot.exts.help_channels import _cog + try: + _cog.validate_config() + except ValueError as e: + log.error(f"HelpChannels cog will not be loaded due to misconfiguration: {e}") + else: + bot.add_cog(_cog.HelpChannels(bot)) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py new file mode 100644 index 000000000..5e2a7dd71 --- /dev/null +++ b/bot/exts/help_channels/_cog.py @@ -0,0 +1,930 @@ +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." + ) -- cgit v1.2.3 From a1b2e8e9bff23ad5c9c8db8fa35116e7ce6a4965 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 11:44:23 -0700 Subject: Help channels: remove get_clean_channel_name Emoji are no longer used in channel names due to harsher rate limits for renaming channels. Therefore, the function is obsolete. --- bot/exts/help_channels/_cog.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 5e2a7dd71..00dd36304 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -251,21 +251,6 @@ class HelpChannels(commands.Cog): 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.""" @@ -317,7 +302,7 @@ class HelpChannels(commands.Cog): 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)) + names.add(channel.name) if len(names) > MAX_CHANNELS_PER_CATEGORY: log.warning( -- cgit v1.2.3 From abd6953a0f1567b6ff25d971a97eed966f469743 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 12:10:46 -0700 Subject: Help channels: move name and channel funcs to separate modules --- bot/exts/help_channels/_channels.py | 26 +++++++++++ bot/exts/help_channels/_cog.py | 93 ++++++------------------------------- bot/exts/help_channels/_names.py | 69 +++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 79 deletions(-) create mode 100644 bot/exts/help_channels/_channels.py create mode 100644 bot/exts/help_channels/_names.py diff --git a/bot/exts/help_channels/_channels.py b/bot/exts/help_channels/_channels.py new file mode 100644 index 000000000..047f41e89 --- /dev/null +++ b/bot/exts/help_channels/_channels.py @@ -0,0 +1,26 @@ +import logging +import typing as t + +import discord + +from bot import constants + +log = logging.getLogger(__name__) + +MAX_CHANNELS_PER_CATEGORY = 50 +EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, 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 + + +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 index 00dd36304..1db597e6c 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -1,11 +1,8 @@ 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 @@ -14,14 +11,14 @@ from discord.ext import commands from bot import constants from bot.bot import Bot +from bot.exts.help_channels import _channels +from bot.exts.help_channels._names import MAX_CHANNELS_PER_CATEGORY, create_name_queue 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. @@ -123,7 +120,6 @@ class HelpChannels(commands.Cog): 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 @@ -151,7 +147,7 @@ class HelpChannels(commands.Cog): """ log.trace("Creating the channel queue.") - channels = list(self.get_category_channels(self.dormant_category)) + channels = list(_channels.get_category_channels(self.dormant_category)) random.shuffle(channels) log.trace("Populating the channel queue with channels.") @@ -180,18 +176,6 @@ class HelpChannels(commands.Cog): log.debug(f"Creating a new dormant channel named {name}.") return await self.dormant_category.create_text_channel(name, topic=HELP_CHANNEL_TOPIC) - 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: @@ -251,20 +235,6 @@ class HelpChannels(commands.Cog): return channel - @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}.") @@ -274,45 +244,6 @@ class HelpChannels(commands.Cog): 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(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]: """ @@ -347,7 +278,7 @@ class HelpChannels(commands.Cog): """Initialise the Available category with channels.""" log.trace("Initialising the Available category with channels.") - channels = list(self.get_category_channels(self.available_category)) + channels = list(_channels.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. @@ -391,10 +322,14 @@ class HelpChannels(commands.Cog): await self.check_cooldowns() self.channel_queue = self.create_channel_queue() - self.name_queue = self.create_name_queue() + self.name_queue = create_name_queue( + self.available_category, + self.in_use_category, + self.dormant_category, + ) log.trace("Moving or rescheduling in-use channels.") - for channel in self.get_category_channels(self.in_use_category): + for channel in _channels.get_category_channels(self.in_use_category): await self.move_idle_channel(channel, has_task=False) # Prevent the command from being used until ready. @@ -412,9 +347,9 @@ class HelpChannels(commands.Cog): 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)) + total_in_use = sum(1 for _ in _channels.get_category_channels(self.in_use_category)) + total_available = sum(1 for _ in _channels.get_category_channels(self.available_category)) + total_dormant = sum(1 for _ in _channels.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) @@ -660,7 +595,7 @@ class HelpChannels(commands.Cog): 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): + if not is_available or _channels.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.") diff --git a/bot/exts/help_channels/_names.py b/bot/exts/help_channels/_names.py new file mode 100644 index 000000000..9959c105e --- /dev/null +++ b/bot/exts/help_channels/_names.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._channels 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 -- cgit v1.2.3 From 0cc221a6169bd12ddcc605eb02f2785716d2446e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 12:14:19 -0700 Subject: Help channels: move validation code to __init__.py --- bot/exts/help_channels/__init__.py | 32 ++++++++++++++++++++++++++++---- bot/exts/help_channels/_cog.py | 24 +----------------------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/bot/exts/help_channels/__init__.py b/bot/exts/help_channels/__init__.py index 38444b707..6ed94ebda 100644 --- a/bot/exts/help_channels/__init__.py +++ b/bot/exts/help_channels/__init__.py @@ -1,17 +1,41 @@ import logging +from bot import constants from bot.bot import Bot +from bot.exts.help_channels._channels 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 sync package. - from bot.exts.help_channels import _cog + # Defer import to reduce side effects from importing the help_channels package. + from bot.exts.help_channels._cog import HelpChannels try: - _cog.validate_config() + validate_config() except ValueError as e: log.error(f"HelpChannels cog will not be loaded due to misconfiguration: {e}") else: - bot.add_cog(_cog.HelpChannels(bot)) + bot.add_cog(HelpChannels(bot)) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 1db597e6c..d8fb3b830 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -12,7 +12,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot from bot.exts.help_channels import _channels -from bot.exts.help_channels._names import MAX_CHANNELS_PER_CATEGORY, create_name_queue +from bot.exts.help_channels._names import create_name_queue from bot.utils import channel as channel_utils from bot.utils.scheduling import Scheduler @@ -826,25 +826,3 @@ class HelpChannels(commands.Cog): 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." - ) -- cgit v1.2.3 From aa7eff22759e18bcbe0373e5397eb225f563e001 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 12:18:19 -0700 Subject: Help channels: rename modules to use singular tense Plural names imply the modules contain homogenous content. For example, "channels" implies the module contains multiple kinds of channels. --- bot/exts/help_channels/__init__.py | 2 +- bot/exts/help_channels/_channel.py | 26 ++++++++++++++ bot/exts/help_channels/_channels.py | 26 -------------- bot/exts/help_channels/_cog.py | 18 +++++----- bot/exts/help_channels/_name.py | 69 +++++++++++++++++++++++++++++++++++++ bot/exts/help_channels/_names.py | 69 ------------------------------------- 6 files changed, 105 insertions(+), 105 deletions(-) create mode 100644 bot/exts/help_channels/_channel.py delete mode 100644 bot/exts/help_channels/_channels.py create mode 100644 bot/exts/help_channels/_name.py delete mode 100644 bot/exts/help_channels/_names.py diff --git a/bot/exts/help_channels/__init__.py b/bot/exts/help_channels/__init__.py index 6ed94ebda..781f40449 100644 --- a/bot/exts/help_channels/__init__.py +++ b/bot/exts/help_channels/__init__.py @@ -2,7 +2,7 @@ import logging from bot import constants from bot.bot import Bot -from bot.exts.help_channels._channels import MAX_CHANNELS_PER_CATEGORY +from bot.exts.help_channels._channel import MAX_CHANNELS_PER_CATEGORY log = logging.getLogger(__name__) diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py new file mode 100644 index 000000000..047f41e89 --- /dev/null +++ b/bot/exts/help_channels/_channel.py @@ -0,0 +1,26 @@ +import logging +import typing as t + +import discord + +from bot import constants + +log = logging.getLogger(__name__) + +MAX_CHANNELS_PER_CATEGORY = 50 +EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, 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 + + +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/_channels.py b/bot/exts/help_channels/_channels.py deleted file mode 100644 index 047f41e89..000000000 --- a/bot/exts/help_channels/_channels.py +++ /dev/null @@ -1,26 +0,0 @@ -import logging -import typing as t - -import discord - -from bot import constants - -log = logging.getLogger(__name__) - -MAX_CHANNELS_PER_CATEGORY = 50 -EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, 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 - - -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 index d8fb3b830..e58660af8 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -11,8 +11,8 @@ from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.exts.help_channels import _channels -from bot.exts.help_channels._names import create_name_queue +from bot.exts.help_channels import _channel +from bot.exts.help_channels._name import create_name_queue from bot.utils import channel as channel_utils from bot.utils.scheduling import Scheduler @@ -147,7 +147,7 @@ class HelpChannels(commands.Cog): """ log.trace("Creating the channel queue.") - channels = list(_channels.get_category_channels(self.dormant_category)) + channels = list(_channel.get_category_channels(self.dormant_category)) random.shuffle(channels) log.trace("Populating the channel queue with channels.") @@ -278,7 +278,7 @@ class HelpChannels(commands.Cog): """Initialise the Available category with channels.""" log.trace("Initialising the Available category with channels.") - channels = list(_channels.get_category_channels(self.available_category)) + 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. @@ -329,7 +329,7 @@ class HelpChannels(commands.Cog): ) log.trace("Moving or rescheduling in-use channels.") - for channel in _channels.get_category_channels(self.in_use_category): + 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. @@ -347,9 +347,9 @@ class HelpChannels(commands.Cog): def report_stats(self) -> None: """Report the channel count stats.""" - total_in_use = sum(1 for _ in _channels.get_category_channels(self.in_use_category)) - total_available = sum(1 for _ in _channels.get_category_channels(self.available_category)) - total_dormant = sum(1 for _ in _channels.get_category_channels(self.dormant_category)) + 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) @@ -595,7 +595,7 @@ class HelpChannels(commands.Cog): await self.check_for_answer(message) is_available = channel_utils.is_in_category(channel, constants.Categories.help_available) - if not is_available or _channels.is_excluded_channel(channel): + 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.") 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/help_channels/_names.py b/bot/exts/help_channels/_names.py deleted file mode 100644 index 9959c105e..000000000 --- a/bot/exts/help_channels/_names.py +++ /dev/null @@ -1,69 +0,0 @@ -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._channels 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 -- cgit v1.2.3 From e7d6b2ba81e3609bd52e2d0c4c9d999e7deb14e8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 13:31:13 -0700 Subject: Help channels: move pin functions to a separate module --- bot/exts/help_channels/_cog.py | 50 ++-------------------------------- bot/exts/help_channels/_message.py | 56 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 47 deletions(-) create mode 100644 bot/exts/help_channels/_message.py diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index e58660af8..b3d720b24 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -12,6 +12,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot from bot.exts.help_channels import _channel +from bot.exts.help_channels._message import pin, unpin from bot.exts.help_channels._name import create_name_queue from bot.utils import channel as channel_utils from bot.utils.scheduling import Scheduler @@ -103,10 +104,6 @@ class HelpChannels(commands.Cog): # 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__) @@ -495,7 +492,7 @@ class HelpChannels(commands.Cog): embed = discord.Embed(description=DORMANT_MSG) await channel.send(embed=embed) - await self.unpin(channel) + await unpin(channel) log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") self.channel_queue.put_nowait(channel) @@ -616,7 +613,7 @@ class HelpChannels(commands.Cog): await self.move_to_in_use(channel) await self.revoke_send_permissions(message.author) - await self.pin(message) + await pin(message) # Add user with channel for dormant check. await self.help_channel_claimants.set(channel.id, message.author.id) @@ -773,47 +770,6 @@ class HelpChannels(commands.Cog): 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.") diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py new file mode 100644 index 000000000..e593aacc9 --- /dev/null +++ b/bot/exts/help_channels/_message.py @@ -0,0 +1,56 @@ +import logging + +import discord +from async_rediscache import RedisCache + +import bot + +log = logging.getLogger(__name__) + +# 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") + + +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 _question_messages.set(message.channel.id, message.id) + + +async def unpin(channel: discord.TextChannel) -> None: + """Unpin the initial question message sent in `channel`.""" + msg_id = await _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) + + +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 -- cgit v1.2.3 From e30d69f18dbfefefbc4034d744d0fad5198567c1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 14:01:20 -0700 Subject: Help channels: move message functions to message module --- bot/exts/help_channels/_cog.py | 197 ++++--------------------------------- bot/exts/help_channels/_message.py | 172 ++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 178 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index b3d720b24..174c40096 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -11,47 +11,17 @@ from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.exts.help_channels import _channel -from bot.exts.help_channels._message import pin, unpin +from bot.exts.help_channels import _channel, _message from bot.exts.help_channels._name import create_name_queue 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/" - 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] @@ -94,12 +64,6 @@ class HelpChannels(commands.Cog): # 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() @@ -227,7 +191,12 @@ class HelpChannels(commands.Cog): if not channel: log.info("Couldn't create a candidate channel; waiting to get one from the queue.") - await self.notify() + 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 @@ -241,8 +210,8 @@ class HelpChannels(commands.Cog): claimed = datetime.utcfromtimestamp(claimed_timestamp) return datetime.utcnow() - claimed - @classmethod - async def get_idle_time(cls, channel: discord.TextChannel) -> t.Optional[int]: + @staticmethod + 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`. @@ -250,7 +219,7 @@ class HelpChannels(commands.Cog): """ log.trace(f"Getting the idle time for #{channel} ({channel.id}).") - msg = await cls.get_last_message(channel) + 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 @@ -260,17 +229,6 @@ class HelpChannels(commands.Cog): 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.") @@ -357,17 +315,6 @@ class HelpChannels(commands.Cog): """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. @@ -377,7 +324,7 @@ class HelpChannels(commands.Cog): """ log.trace(f"Handling in-use channel #{channel} ({channel.id}).") - if not await self.is_empty(channel): + if not await _message.is_empty(channel): idle_seconds = constants.HelpChannels.idle_minutes * 60 else: idle_seconds = constants.HelpChannels.deleted_idle_minutes * 60 @@ -450,7 +397,7 @@ class HelpChannels(commands.Cog): channel = await self.get_available_candidate() log.info(f"Making #{channel} ({channel.id}) available.") - await self.send_available_message(channel) + await _message.send_available_message(channel) log.trace(f"Moving #{channel} ({channel.id}) to the Available category.") @@ -481,7 +428,7 @@ class HelpChannels(commands.Cog): if in_use_time: self.bot.stats.timing("help.in_use_time", in_use_time) - unanswered = await self.unanswered.get(channel.id) + unanswered = await _message.unanswered.get(channel.id) if unanswered: self.bot.stats.incr("help.sessions.unanswered") elif unanswered is not None: @@ -489,10 +436,10 @@ class HelpChannels(commands.Cog): 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) + embed = discord.Embed(description=_message.DORMANT_MSG) await channel.send(embed=embed) - await unpin(channel) + await _message.unpin(channel) log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") self.channel_queue.put_nowait(channel) @@ -513,74 +460,6 @@ class HelpChannels(commands.Cog): 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.""" @@ -589,7 +468,7 @@ class HelpChannels(commands.Cog): channel = message.channel - await self.check_for_answer(message) + 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): @@ -613,7 +492,7 @@ class HelpChannels(commands.Cog): await self.move_to_in_use(channel) await self.revoke_send_permissions(message.author) - await pin(message) + await _message.pin(message) # Add user with channel for dormant check. await self.help_channel_claimants.set(channel.id, message.author.id) @@ -624,7 +503,7 @@ class HelpChannels(commands.Cog): timestamp = datetime.now(timezone.utc).timestamp() await self.claim_times.set(channel.id, timestamp) - await self.unanswered.set(channel.id, True) + await _message.unanswered.set(channel.id, True) log.trace(f"Releasing on_message lock for {message.id}.") @@ -643,7 +522,7 @@ class HelpChannels(commands.Cog): if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use): return - if not await self.is_empty(msg.channel): + if not await _message.is_empty(msg.channel): return log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") @@ -654,24 +533,6 @@ class HelpChannels(commands.Cog): 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.") @@ -750,26 +611,6 @@ class HelpChannels(commands.Cog): 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 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.") diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index e593aacc9..eaf8b0ab5 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -1,23 +1,183 @@ import logging +import typing as t +from datetime import datetime import discord from async_rediscache import RedisCache import bot +from bot import constants +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})**. +""" + +# 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") + +# 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(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") +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 unanswered.contains(channel.id): + claimant_id = await _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 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. + + 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 _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 _question_messages.pop(channel.id) @@ -27,6 +187,18 @@ async def unpin(channel: discord.TextChannel) -> None: 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. -- cgit v1.2.3 From 44fe885de67135dd16a44539fc97d8e7fc543400 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 18 Oct 2020 18:56:08 -0700 Subject: Help channels: move time functions to channel module --- bot/exts/help_channels/_channel.py | 36 ++++++++++++++++++++++++++++++++++++ bot/exts/help_channels/_cog.py | 34 +++------------------------------- 2 files changed, 39 insertions(+), 31 deletions(-) diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index 047f41e89..93c0c7fc9 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -1,15 +1,22 @@ import logging import typing as t +from datetime import datetime, timedelta import discord +from async_rediscache import RedisCache from bot import constants +from bot.exts.help_channels import _message log = logging.getLogger(__name__) MAX_CHANNELS_PER_CATEGORY = 50 EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) +# This dictionary maps a help channel to the time it was claimed +# RedisCache[discord.TextChannel.id, UtcPosixTimestamp] +_claim_times = RedisCache(namespace="HelpChannels.claim_times") + def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: """Yield the text channels of the `category` in an unsorted manner.""" @@ -21,6 +28,35 @@ def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[disco 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 _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 index 174c40096..390528fde 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -2,7 +2,7 @@ import asyncio import logging import random import typing as t -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone import discord import discord.abc @@ -201,34 +201,6 @@ class HelpChannels(commands.Cog): return 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 - 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 init_available(self) -> None: """Initialise the Available category with channels.""" log.trace("Initialising the Available category with channels.") @@ -329,7 +301,7 @@ class HelpChannels(commands.Cog): else: idle_seconds = constants.HelpChannels.deleted_idle_minutes * 60 - time_elapsed = await self.get_idle_time(channel) + time_elapsed = await _channel.get_idle_time(channel) if time_elapsed is None or time_elapsed >= idle_seconds: log.info( @@ -424,7 +396,7 @@ class HelpChannels(commands.Cog): self.bot.stats.incr(f"help.dormant_calls.{caller}") - in_use_time = await self.get_in_use_time(channel.id) + 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) -- cgit v1.2.3 From 3843ae1546a1a1cbf7ca05756eee223bf7c2f317 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 18 Oct 2020 10:40:17 -0700 Subject: Help channels: move cooldown/role functions to cooldown module --- bot/exts/help_channels/_cog.py | 88 ++----------------------------- bot/exts/help_channels/_cooldown.py | 100 ++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 84 deletions(-) create mode 100644 bot/exts/help_channels/_cooldown.py diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 390528fde..169238937 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -11,7 +11,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.exts.help_channels import _channel, _message +from bot.exts.help_channels import _channel, _cooldown, _message from bot.exts.help_channels._name import create_name_queue from bot.utils import channel as channel_utils from bot.utils.scheduling import Scheduler @@ -22,8 +22,6 @@ HELP_CHANNEL_TOPIC = """ This is a Python help channel. You can claim your own help channel in the Python Help: Available category. """ -CoroutineFunc = t.Callable[..., t.Coroutine] - class HelpChannels(commands.Cog): """ @@ -164,7 +162,7 @@ class HelpChannels(commands.Cog): 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) + 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: @@ -246,7 +244,7 @@ class HelpChannels(commands.Cog): log.trace("Initialising the cog.") await self.init_categories() - await self.check_cooldowns() + await _cooldown.check_cooldowns(self.scheduler) self.channel_queue = self.create_channel_queue() self.name_queue = create_name_queue( @@ -462,7 +460,7 @@ class HelpChannels(commands.Cog): 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 _cooldown.revoke_send_permissions(message.author, self.scheduler) await _message.pin(message) @@ -505,84 +503,6 @@ class HelpChannels(commands.Cog): delay = constants.HelpChannels.deleted_idle_minutes * 60 self.scheduler.schedule_later(delay, msg.channel.id, self.move_idle_channel(msg.channel)) - 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 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.") diff --git a/bot/exts/help_channels/_cooldown.py b/bot/exts/help_channels/_cooldown.py new file mode 100644 index 000000000..c4fd4b662 --- /dev/null +++ b/bot/exts/help_channels/_cooldown.py @@ -0,0 +1,100 @@ +import logging +from typing import Callable, Coroutine + +import discord +from async_rediscache import RedisCache + +import bot +from bot import constants +from bot.exts.help_channels import _channel +from bot.utils.scheduling import Scheduler + +log = logging.getLogger(__name__) +CoroutineFunc = Callable[..., Coroutine] + +# 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(namespace="HelpChannels.help_channel_claimants") + + +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 _help_channel_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}") -- cgit v1.2.3 From b9593039a1663c983b84970dc8832900ce9e4cbb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 18 Oct 2020 18:58:43 -0700 Subject: Help channels: remove obsolete function --- bot/exts/help_channels/_cog.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 169238937..5c4a0d972 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -280,11 +280,6 @@ class HelpChannels(commands.Cog): 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) - 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. -- cgit v1.2.3 From 2eb56e4f863475307eafa070d22e71d061641f6a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 19 Oct 2020 13:08:05 -0700 Subject: Help channels: replace ready event with awaiting the init task The event is redundant since awaiting the task accomplishes the same thing. If the task is already done, the await will finish immediately. If the task gets cancelled, the error is raised but discord.py suppress it in both commands and event listeners. --- bot/exts/help_channels/_cog.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 5c4a0d972..bea1f65e6 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -83,7 +83,6 @@ class HelpChannels(commands.Cog): # 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()) @@ -264,11 +263,9 @@ class HelpChannels(commands.Cog): self.close_command.enabled = True await self.init_available() + self.report_stats() log.info("Cog is ready!") - self.ready.set() - - self.report_stats() def report_stats(self) -> None: """Report the channel count stats.""" @@ -439,8 +436,9 @@ class HelpChannels(commands.Cog): 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.ready.wait() + if not self.init_task.done(): + 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: -- cgit v1.2.3 From 9debf8d649e0f63753f0f486cd8b7490d90c324c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 19 Oct 2020 13:08:29 -0700 Subject: Help channels: wait for cog to be ready in deleted msg listener --- bot/exts/help_channels/_cog.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index bea1f65e6..29570bab3 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -488,6 +488,10 @@ class HelpChannels(commands.Cog): if not await _message.is_empty(msg.channel): return + if not self.init_task.done(): + 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. -- cgit v1.2.3 From 21f6a9a5f799c88f746b2bbda250ec1a6bb8f845 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 20 Oct 2020 12:31:40 -0700 Subject: Help channels: move all caches to a separate module Some need to be shared among modules, so it became redundant to redefine them in each module. --- bot/exts/help_channels/_caches.py | 19 +++++++++++++++++++ bot/exts/help_channels/_channel.py | 9 ++------- bot/exts/help_channels/_cog.py | 23 +++++++---------------- bot/exts/help_channels/_cooldown.py | 9 ++------- bot/exts/help_channels/_message.py | 26 ++++++-------------------- 5 files changed, 36 insertions(+), 50 deletions(-) create mode 100644 bot/exts/help_channels/_caches.py 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 index 93c0c7fc9..d6d6f1245 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -3,20 +3,15 @@ import typing as t from datetime import datetime, timedelta import discord -from async_rediscache import RedisCache from bot import constants -from bot.exts.help_channels import _message +from bot.exts.help_channels import _caches, _message log = logging.getLogger(__name__) MAX_CHANNELS_PER_CATEGORY = 50 EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) -# This dictionary maps a help channel to the time it was claimed -# RedisCache[discord.TextChannel.id, UtcPosixTimestamp] -_claim_times = RedisCache(namespace="HelpChannels.claim_times") - def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: """Yield the text channels of the `category` in an unsorted manner.""" @@ -51,7 +46,7 @@ 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 _claim_times.get(channel_id) + claimed_timestamp = await _caches.claim_times.get(channel_id) if claimed_timestamp: claimed = datetime.utcfromtimestamp(claimed_timestamp) return datetime.utcnow() - claimed diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 29570bab3..a17213323 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -6,12 +6,11 @@ from datetime import datetime, timezone 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.exts.help_channels import _channel, _cooldown, _message +from bot.exts.help_channels import _caches, _channel, _cooldown, _message from bot.exts.help_channels._name import create_name_queue from bot.utils import channel as channel_utils from bot.utils.scheduling import Scheduler @@ -58,14 +57,6 @@ class HelpChannels(commands.Cog): 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 dictionary maps a help channel to the time it was claimed - # RedisCache[discord.TextChannel.id, UtcPosixTimestamp] - claim_times = RedisCache() - def __init__(self, bot: Bot): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) @@ -136,7 +127,7 @@ class HelpChannels(commands.Cog): 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: + 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 @@ -378,7 +369,7 @@ class HelpChannels(commands.Cog): """ log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") - await self.help_channel_claimants.delete(channel.id) + await _caches.claimants.delete(channel.id) await self.move_to_bottom_position( channel=channel, category_id=constants.Categories.help_dormant, @@ -390,7 +381,7 @@ class HelpChannels(commands.Cog): if in_use_time: self.bot.stats.timing("help.in_use_time", in_use_time) - unanswered = await _message.unanswered.get(channel.id) + unanswered = await _caches.unanswered.get(channel.id) if unanswered: self.bot.stats.incr("help.sessions.unanswered") elif unanswered is not None: @@ -458,15 +449,15 @@ class HelpChannels(commands.Cog): await _message.pin(message) # Add user with channel for dormant check. - await self.help_channel_claimants.set(channel.id, message.author.id) + 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 self.claim_times.set(channel.id, timestamp) + await _caches.claim_times.set(channel.id, timestamp) - await _message.unanswered.set(channel.id, True) + await _caches.unanswered.set(channel.id, True) log.trace(f"Releasing on_message lock for {message.id}.") diff --git a/bot/exts/help_channels/_cooldown.py b/bot/exts/help_channels/_cooldown.py index c4fd4b662..c5c39297f 100644 --- a/bot/exts/help_channels/_cooldown.py +++ b/bot/exts/help_channels/_cooldown.py @@ -2,20 +2,15 @@ import logging from typing import Callable, Coroutine import discord -from async_rediscache import RedisCache import bot from bot import constants -from bot.exts.help_channels import _channel +from bot.exts.help_channels import _caches, _channel from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) CoroutineFunc = Callable[..., Coroutine] -# 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(namespace="HelpChannels.help_channel_claimants") - async def add_cooldown_role(member: discord.Member) -> None: """Add the help cooldown role to `member`.""" @@ -29,7 +24,7 @@ async def check_cooldowns(scheduler: Scheduler) -> None: guild = bot.instance.get_guild(constants.Guild.id) cooldown = constants.HelpChannels.claim_minutes * 60 - for channel_id, member_id in await _help_channel_claimants.items(): + 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. diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index eaf8b0ab5..8b058d5aa 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -3,10 +3,10 @@ import typing as t from datetime import datetime import discord -from async_rediscache import RedisCache 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__) @@ -40,20 +40,6 @@ question to maximize your chance of getting a good answer. If you're not sure ho through our guide for **[asking a good question]({ASKING_GUIDE_URL})**. """ -# 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") - -# 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(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") - async def check_for_answer(message: discord.Message) -> None: """Checks for whether new content in a help channel comes from non-claimants.""" @@ -64,8 +50,8 @@ async def check_for_answer(message: discord.Message) -> None: log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") # Check if there is an entry in unanswered - if await unanswered.contains(channel.id): - claimant_id = await _help_channel_claimants.get(channel.id) + 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 @@ -73,7 +59,7 @@ async def check_for_answer(message: discord.Message) -> None: # Check the message did not come from the claimant if claimant_id != message.author.id: # Mark the channel as answered - await unanswered.set(channel.id, False) + await _caches.unanswered.set(channel.id, False) async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]: @@ -154,7 +140,7 @@ async def notify(channel: discord.TextChannel, last_notification: t.Optional[dat 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 _question_messages.set(message.channel.id, message.id) + await _caches.question_messages.set(message.channel.id, message.id) async def send_available_message(channel: discord.TextChannel) -> None: @@ -180,7 +166,7 @@ async def send_available_message(channel: discord.TextChannel) -> None: async def unpin(channel: discord.TextChannel) -> None: """Unpin the initial question message sent in `channel`.""" - msg_id = await _question_messages.pop(channel.id) + 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: -- cgit v1.2.3 From 47d65fca599d138098d5021d403db78e4abc0e7a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 20 Nov 2020 16:42:24 -0800 Subject: Help channels: merge 2 imports into 1 The import was an outlier compared to how the other modules were imported. It's nicer to keep the imports consistent. --- bot/exts/help_channels/_cog.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index a17213323..638d00e4a 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -10,8 +10,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.exts.help_channels import _caches, _channel, _cooldown, _message -from bot.exts.help_channels._name import create_name_queue +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 @@ -237,7 +236,7 @@ class HelpChannels(commands.Cog): await _cooldown.check_cooldowns(self.scheduler) self.channel_queue = self.create_channel_queue() - self.name_queue = create_name_queue( + self.name_queue = _name.create_name_queue( self.available_category, self.in_use_category, self.dormant_category, -- cgit v1.2.3 From 0242b4ed4145edf3e3e6ea6ebcef62aaff77d7ec Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Nov 2020 13:57:55 -0800 Subject: Help channels: remove how_to_get_help from excluded channels The channel as moved out of this category. Delete the constant too since it isn't used anywhere else. Keep the excluded channels a tuple to conveniently support excluding multiple channels in the future. --- bot/constants.py | 1 - bot/exts/help_channels/_channel.py | 2 +- config-default.yml | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 6bb6aacd2..fb280b042 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -406,7 +406,6 @@ class Channels(metaclass=YAMLGetter): dm_log: int esoteric: int helpers: int - how_to_get_help: int incidents: int incidents_archive: int mailing_lists: int diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index d6d6f1245..e717d7af8 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -10,7 +10,7 @@ from bot.exts.help_channels import _caches, _message log = logging.getLogger(__name__) MAX_CHANNELS_PER_CATEGORY = 50 -EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) +EXCLUDED_CHANNELS = (constants.Channels.cooldown,) def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: diff --git a/config-default.yml b/config-default.yml index 60eb437af..82023aae1 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 -- cgit v1.2.3 From d9f87cc867a93d5f2d14d16eaac86ccb5adef447 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Nov 2020 14:07:29 -0800 Subject: Help channels: don't check if task is done before awaiting Awaiting a done task is effectively a no-op, so it's redundant to check if the task is done before awaiting it. Furthermore, a task is also considered done if it was cancelled or an exception was raised. Therefore, avoiding awaiting in such cases doesn't allow the errors to be propagated and incorrectly allows the awaiter to keep executing. --- bot/exts/help_channels/_cog.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 638d00e4a..e22d4663e 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -426,9 +426,8 @@ class HelpChannels(commands.Cog): if not is_available or _channel.is_excluded_channel(channel): return # Ignore messages outside the Available category or in excluded channels. - if not self.init_task.done(): - log.trace("Waiting for the cog to be ready before processing messages.") - await self.init_task + 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: @@ -478,9 +477,8 @@ class HelpChannels(commands.Cog): if not await _message.is_empty(msg.channel): return - if not self.init_task.done(): - log.trace("Waiting for the cog to be ready before processing deleted messages.") - await self.init_task + 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.") -- cgit v1.2.3 From e5b073c5ee9c7c887a193ec05d813d259eca58ee Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Nov 2020 14:11:40 -0800 Subject: Help channels: document the return value of notify() --- bot/exts/help_channels/_message.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index 8b058d5aa..2bbd4bdd6 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -96,6 +96,9 @@ async def notify(channel: discord.TextChannel, last_notification: t.Optional[dat """ 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 -- cgit v1.2.3 From bff4cd34bbacc7229a3fa2cf5421276c433c8b65 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Nov 2020 14:12:57 -0800 Subject: Update help channels directory for code owners --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5f5386222..843f86b71 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,7 +8,7 @@ bot/exts/info/codeblock/** @MarkKoz bot/exts/utils/extensions.py @MarkKoz bot/exts/utils/snekbox.py @MarkKoz -bot/exts/help_channels.py @MarkKoz +bot/exts/help_channels/** @MarkKoz # Utils bot/utils/extensions.py @MarkKoz -- cgit v1.2.3 From a8891d43484d602d49800ad5a8a39505758a86ba Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Thu, 26 Nov 2020 22:16:31 +0000 Subject: Update CODEOWNERS --- .github/CODEOWNERS | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5f5386222..0d7572e38 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,5 @@ -# Request Joe and Dennis for any PR -* @jb3 @Den4200 +# Request Dennis for any PR +* @Den4200 # Extensions **/bot/exts/backend/sync/** @MarkKoz @@ -24,3 +24,7 @@ tests/bot/exts/test_cogs.py @MarkKoz .github/workflows/** @MarkKoz Dockerfile @MarkKoz docker-compose.yml @MarkKoz + +# Statistics +bot/async_stats.py @jb3 +bot/exts/info/stats.py @jb3 -- cgit v1.2.3 From a7dd1c56dad3e2aa3c1304b6f9cc5bd63150ad91 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 27 Nov 2020 18:18:22 +0100 Subject: Add @Akarys42 to the codeowners --- .github/CODEOWNERS | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 86df4db8d..272fb2ffe 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -7,24 +7,31 @@ **/bot/exts/moderation/*silence.py @MarkKoz bot/exts/info/codeblock/** @MarkKoz bot/exts/utils/extensions.py @MarkKoz -bot/exts/utils/snekbox.py @MarkKoz -bot/exts/help_channels/** @MarkKoz +bot/exts/utils/snekbox.py @MarkKoz @Akarys42 +bot/exts/help_channels/** @MarkKoz @Akarys42 +bot/exts/moderation/** @Akarys42 +bot/exts/info/** @Akarys42 # 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 -Dockerfile @MarkKoz -docker-compose.yml @MarkKoz +.github/workflows/** @MarkKoz @Akarys42 +Dockerfile @MarkKoz @Akarys42 +docker-compose.yml @MarkKoz @Akarys42 + +# Tools +Pipfile* @Akarys42 # Statistics -bot/async_stats.py @jb3 -bot/exts/info/stats.py @jb3 +bot/async_stats.py @jb3 +bot/exts/info/stats.py @jb3 -- cgit v1.2.3 From 1cde79faa2675638ede0435fbf4cd2911b8a76ba Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 27 Nov 2020 23:39:13 +0100 Subject: Add myself to CODEOWNERS for CI files --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 272fb2ffe..495a6e9e2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -25,7 +25,7 @@ tests/bot/exts/test_cogs.py @MarkKoz tests/** @Akarys42 # CI & Docker -.github/workflows/** @MarkKoz @Akarys42 +.github/workflows/** @MarkKoz @Akarys42 @SebastiaanZ Dockerfile @MarkKoz @Akarys42 docker-compose.yml @MarkKoz @Akarys42 -- cgit v1.2.3 From 67fbb71f7954f4edc2f43b8f1069e3c366dcca4d Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Sat, 28 Nov 2020 16:58:58 +0200 Subject: Add myself to CODEOWNERS --- .github/CODEOWNERS | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 495a6e9e2..642676078 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -9,8 +9,9 @@ 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 -bot/exts/info/** @Akarys42 +bot/exts/moderation/** @Akarys42 @mbaruh +bot/exts/info/** @Akarys42 @mbaruh +bot/exts/filters/** @mbaruh # Utils bot/utils/extensions.py @MarkKoz -- cgit v1.2.3