diff options
| author | 2020-10-16 14:01:20 -0700 | |
|---|---|---|
| committer | 2020-11-20 15:57:02 -0800 | |
| commit | e30d69f18dbfefefbc4034d744d0fad5198567c1 (patch) | |
| tree | cae9689db5e2bd908ce7d8eddfdc72b697cac671 | |
| parent | Help channels: move pin functions to a separate module (diff) | |
Help channels: move message functions to message module
| -rw-r--r-- | bot/exts/help_channels/_cog.py | 197 | ||||
| -rw-r--r-- | 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. |