diff options
author | 2022-11-25 22:45:55 +0000 | |
---|---|---|
committer | 2022-11-25 22:45:55 +0000 | |
commit | 04bba4cc319485821bcd46949225e72ffe5c9603 (patch) | |
tree | 6c8430919368c64779b011a1c968b1927df8fdd7 | |
parent | Edited several tags (#2322) (diff) | |
parent | Pin the user's starter message on help post creation (diff) |
Merge pull request #2318 from python-discord/help-channel-rewrite
Update help channel system to use forum channels
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | bot/constants.py | 18 | ||||
-rw-r--r-- | bot/exts/help_channels/__init__.py | 40 | ||||
-rw-r--r-- | bot/exts/help_channels/_caches.py | 25 | ||||
-rw-r--r-- | bot/exts/help_channels/_channel.py | 285 | ||||
-rw-r--r-- | bot/exts/help_channels/_cog.py | 691 | ||||
-rw-r--r-- | bot/exts/help_channels/_message.py | 311 | ||||
-rw-r--r-- | bot/exts/help_channels/_name.py | 69 | ||||
-rw-r--r-- | bot/exts/help_channels/_stats.py | 50 | ||||
-rw-r--r-- | bot/exts/info/codeblock/_cog.py | 4 | ||||
-rw-r--r-- | bot/exts/moderation/modlog.py | 8 | ||||
-rw-r--r-- | bot/exts/utils/snekbox.py | 5 | ||||
-rw-r--r-- | bot/utils/channel.py | 9 | ||||
-rw-r--r-- | config-default.yml | 40 |
14 files changed, 301 insertions, 1256 deletions
diff --git a/.gitignore b/.gitignore index 177345908..6691dbea1 100644 --- a/.gitignore +++ b/.gitignore @@ -114,7 +114,7 @@ log.* !log.py # Custom user configuration -config.yml +*config.yml docker-compose.override.yml metricity-config.toml diff --git a/bot/constants.py b/bot/constants.py index ba7d53ea8..24862059e 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -387,9 +387,6 @@ class Categories(metaclass=YAMLGetter): section = "guild" subsection = "categories" - help_available: int - help_dormant: int - help_in_use: int moderators: int modmail: int voice: int @@ -416,8 +413,7 @@ class Channels(metaclass=YAMLGetter): meta: int python_general: int - cooldown: int - how_to_get_help: int + help_system_forum: int attachment_log: int filter_log: int @@ -620,18 +616,6 @@ class HelpChannels(metaclass=YAMLGetter): enable: bool cmd_whitelist: List[int] - idle_minutes_claimant: int - idle_minutes_others: int - deleted_idle_minutes: int - max_available: int - max_total_channels: int - name_prefix: str - notify_channel: int - notify_minutes: int - notify_none_remaining: bool - notify_none_remaining_roles: List[int] - notify_running_low: bool - notify_running_low_threshold: int class RedirectOutput(metaclass=YAMLGetter): diff --git a/bot/exts/help_channels/__init__.py b/bot/exts/help_channels/__init__.py index b9c940183..00b4a735b 100644 --- a/bot/exts/help_channels/__init__.py +++ b/bot/exts/help_channels/__init__.py @@ -1,40 +1,8 @@ -from bot import constants -from bot.bot import Bot -from bot.exts.help_channels._channel import MAX_CHANNELS_PER_CATEGORY -from bot.log import get_logger - -log = get_logger(__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." - ) +from bot.bot import Bot +from bot.exts.help_channels._cog import HelpForum async def setup(bot: Bot) -> None: - """Load the HelpChannels cog.""" - # Defer import to reduce side effects from importing the help_channels package. - from bot.exts.help_channels._cog import HelpChannels - try: - validate_config() - except ValueError as e: - log.error(f"HelpChannels cog will not be loaded due to misconfiguration: {e}") - else: - await bot.add_cog(HelpChannels(bot)) + """Load the HelpForum cog.""" + await bot.add_cog(HelpForum(bot)) diff --git a/bot/exts/help_channels/_caches.py b/bot/exts/help_channels/_caches.py index 937c4ab57..5d98f99d3 100644 --- a/bot/exts/help_channels/_caches.py +++ b/bot/exts/help_channels/_caches.py @@ -1,26 +1,5 @@ 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") - -# Stores the timestamp of the last message from the claimant of a help channel -# RedisCache[discord.TextChannel.id, UtcPosixTimestamp] -claimant_last_message_times = RedisCache(namespace="HelpChannels.claimant_last_message_times") - -# This cache maps a help channel to the timestamp of the last non-claimant message. -# This cache being empty for a given help channel indicates the question is unanswered. -# RedisCache[discord.TextChannel.id, UtcPosixTimestamp] -non_claimant_last_message_times = RedisCache(namespace="HelpChannels.non_claimant_last_message_times") - -# This cache keeps track of the dynamic message ID for -# the continuously updated message in the #How-to-get-help channel. -dynamic_message = RedisCache(namespace="HelpChannels.dynamic_message") - # This cache keeps track of who has help-dms on. # RedisCache[discord.User.id, bool] help_dm = RedisCache(namespace="HelpChannels.help_dm") @@ -29,3 +8,7 @@ help_dm = RedisCache(namespace="HelpChannels.help_dm") # serialise the set as a comma separated string to allow usage with redis # RedisCache[discord.TextChannel.id, str[set[discord.User.id]]] session_participants = RedisCache(namespace="HelpChannels.session_participants") + +# Stores posts that have had a non-claimant reply. +# Currently only used to determine whether the post was answered or not when collecting stats. +posts_with_non_claimant_messages = RedisCache(namespace="HelpChannels.posts_with_non_claimant_messages") diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index cfe774f4c..a191e1ed7 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -1,195 +1,148 @@ -import re -import typing as t -from datetime import timedelta -from enum import Enum +"""Contains all logic to handle changes to posts in the help forum.""" + +import textwrap -import arrow import discord -from arrow import Arrow +from botcore.utils import members import bot from bot import constants -from bot.exts.help_channels import _caches, _message +from bot.exts.help_channels import _stats from bot.log import get_logger -from bot.utils.channel import get_or_fetch_channel log = get_logger(__name__) -MAX_CHANNELS_PER_CATEGORY = 50 -EXCLUDED_CHANNELS = (constants.Channels.cooldown,) -CLAIMED_BY_RE = re.compile(r"Channel claimed by <@!?(?P<user_id>\d{17,20})>\.$") +ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" + +POST_TITLE = "Python help channel" +NEW_POST_MSG = f""" +**Remember to:** +• **Ask** your Python question, not if you can ask or if there's an expert who can help. +• **Show** a code sample as text (rather than a screenshot) and the error message, if you got one. +• **Explain** what you expect to happen and what actually happens. +For more tips, check out our guide on [asking good questions]({ASKING_GUIDE_URL}). +""" +POST_FOOTER = f"Closes after a period of inactivity, or when you send {constants.Bot.prefix}close." -class ClosingReason(Enum): - """All possible closing reasons for help channels.""" +DORMANT_MSG = f""" +This help channel has been marked as **dormant** and locked. \ +It is no longer possible to send messages in this channel. - COMMAND = "command" - LATEST_MESSAGE = "auto.latest_message" - CLAIMANT_TIMEOUT = "auto.claimant_timeout" - OTHER_TIMEOUT = "auto.other_timeout" - DELETED = "auto.deleted" - CLEANUP = "auto.cleanup" +If your question wasn't answered yet, you can create a new post in <#{constants.Channels.help_system_forum}>. \ +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})**. +""" -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}).") +def is_help_forum_post(channel: discord.abc.GuildChannel) -> bool: + """Return True if `channel` is a post in the help forum.""" + log.trace(f"Checking if #{channel} is a help channel.") + return getattr(channel, "parent_id", None) == constants.Channels.help_system_forum - # This is faster than using category.channels because the latter sorts them. - for channel in category.guild.channels: - if channel.category_id == category.id and not is_excluded_channel(channel): - yield channel +async def _close_help_thread(closed_thread: discord.Thread, closed_on: _stats.ClosingReason) -> None: + """Close the help thread and record stats.""" + embed = discord.Embed(description=DORMANT_MSG) + await closed_thread.send(embed=embed) + await closed_thread.edit(archived=True, locked=True, reason="Locked a dormant help channel") -async def get_closing_time(channel: discord.TextChannel, init_done: bool) -> t.Tuple[Arrow, ClosingReason]: - """ - Return the time at which the given help `channel` should be closed along with the reason. + _stats.report_post_count() + await _stats.report_complete_session(closed_thread, closed_on) - `init_done` is True if the cog has finished loading and False otherwise. + poster = closed_thread.owner + cooldown_role = closed_thread.guild.get_role(constants.Roles.help_cooldown) + await members.handle_role_change(poster, poster.remove_roles, cooldown_role) - The time is calculated as follows: - * If `init_done` is True or the cached time for the claimant's last message is unavailable, - add the configured `idle_minutes_claimant` to the time the most recent message was sent. - * If the help session is empty (see `is_empty`), do the above but with `deleted_idle_minutes`. - * If either of the above is attempted but the channel is completely empty, close the channel - immediately. - * Otherwise, retrieve the times of the claimant's and non-claimant's last messages from the - cache. Add the configured `idle_minutes_claimant` and idle_minutes_others`, respectively, and - choose the time which is furthest in the future. - """ - log.trace(f"Getting the closing time for #{channel} ({channel.id}).") - - is_empty = await _message.is_empty(channel) - if is_empty: - idle_minutes_claimant = constants.HelpChannels.deleted_idle_minutes - else: - idle_minutes_claimant = constants.HelpChannels.idle_minutes_claimant - - claimant_time = await _caches.claimant_last_message_times.get(channel.id) - - # The current session lacks messages, the cog is still starting, or the cache is empty. - if is_empty or not init_done or claimant_time is None: - msg = await _message.get_last_message(channel) - if not msg: - log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages, closing now.") - return Arrow.min, ClosingReason.DELETED - - # Use the greatest offset to avoid the possibility of prematurely closing the channel. - time = Arrow.fromdatetime(msg.created_at) + timedelta(minutes=idle_minutes_claimant) - reason = ClosingReason.DELETED if is_empty else ClosingReason.LATEST_MESSAGE - return time, reason - - claimant_time = Arrow.utcfromtimestamp(claimant_time) - others_time = await _caches.non_claimant_last_message_times.get(channel.id) - - if others_time: - others_time = Arrow.utcfromtimestamp(others_time) - else: - # The help session hasn't received any answers (messages from non-claimants) yet. - # Set to min value so it isn't considered when calculating the closing time. - others_time = Arrow.min - - # Offset the cached times by the configured values. - others_time += timedelta(minutes=constants.HelpChannels.idle_minutes_others) - claimant_time += timedelta(minutes=idle_minutes_claimant) - - # Use the time which is the furthest into the future. - if claimant_time >= others_time: - closing_time = claimant_time - reason = ClosingReason.CLAIMANT_TIMEOUT - else: - closing_time = others_time - reason = ClosingReason.OTHER_TIMEOUT - - log.trace(f"#{channel} ({channel.id}) should be closed at {closing_time} due to {reason}.") - return closing_time, reason - - -async def get_in_use_time(channel_id: int) -> t.Optional[timedelta]: - """Return the duration `channel_id` has been in use. Return None if it's not in use.""" - log.trace(f"Calculating in use time for channel {channel_id}.") - - claimed_timestamp = await _caches.claim_times.get(channel_id) - if claimed_timestamp: - claimed = Arrow.utcfromtimestamp(claimed_timestamp) - return arrow.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 - - -async def move_to_bottom(channel: discord.TextChannel, category_id: int, **options) -> None: - """ - Move the `channel` to the bottom position of `category` and edit channel attributes. - - To ensure "stable sorting", we use the `bulk_channel_update` endpoint and provide the current - positions of the other channels in the category as-is. This should make sure that the channel - really ends up at the bottom of the category. - - If `options` are provided, the channel will be edited after the move is completed. This is the - same order of operations that `discord.TextChannel.edit` uses. For information on available - options, see the documentation on `discord.TextChannel.edit`. While possible, position-related - options should be avoided, as it may interfere with the category move we perform. - """ - # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. - category = await get_or_fetch_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, - } +async def send_opened_post_message(thread: discord.Thread) -> None: + """Send the opener message in the new help post.""" + embed = discord.Embed( + color=constants.Colours.bright_green, + description=NEW_POST_MSG, + ) + embed.set_author(name=POST_TITLE) + embed.set_footer(text=POST_FOOTER) + await thread.send(embed=embed) + + +async def send_opened_post_dm(thread: discord.Thread) -> None: + """Send the opener a DM message with a jump link to their new post.""" + embed = discord.Embed( + title="Help channel opened", + description=f"You opened {thread.mention}.", + colour=constants.Colours.bright_green, + timestamp=thread.created_at, + ) + embed.set_thumbnail(url=constants.Icons.green_questionmark) + message = thread.starter_message + if not message: + try: + message = await thread.fetch_message(thread.id) + except discord.HTTPException: + log.warning(f"Could not fetch message for thread {thread.name} ({thread.id})") + return + + formatted_message = textwrap.shorten(message.content, width=100, placeholder="...") + embed.add_field(name="Your message", value=formatted_message, inline=False) + embed.add_field( + name="Conversation", + value=f"[Jump to message!]({message.jump_url})", + inline=False, ) - # We use d.py's method to ensure our request is processed by d.py's rate limit manager - await bot.instance.http.bulk_channel_update(category.guild.id, payload) + try: + await thread.owner.send(embed=embed) + log.trace(f"Sent DM to {thread.owner} ({thread.owner_id}) after posting in help forum.") + except discord.errors.Forbidden: + log.trace( + f"Ignoring to send DM to {thread.owner} ({thread.owner_id}) after posting in help forum: DMs disabled.", + ) - # Now that the channel is moved, we can edit the other attributes - if options: - await channel.edit(**options) +async def help_thread_opened(opened_thread: discord.Thread, *, reopen: bool = False) -> None: + """Apply new post logic to a new help forum post.""" + _stats.report_post_count() -async def ensure_cached_claimant(channel: discord.TextChannel) -> None: - """ - Ensure there is a claimant cached for each help channel. + if not isinstance(opened_thread.owner, discord.Member): + log.debug(f"{opened_thread.owner_id} isn't a member. Closing post.") + await _close_help_thread(opened_thread, _stats.ClosingReason.CLEANUP) + return - Check the redis cache first, return early if there is already a claimant cached. - If there isn't an entry in redis, search for the "Claimed by X." embed in channel history. - Stopping early if we discover a dormant message first. + if opened_thread.starter_message: + # To cover the case where the user deletes their starter message before code execution reaches this line. + await opened_thread.starter_message.pin() + + await send_opened_post_message(opened_thread) + await send_opened_post_dm(opened_thread) + + cooldown_role = opened_thread.guild.get_role(constants.Roles.help_cooldown) + await members.handle_role_change(opened_thread.owner, opened_thread.owner.add_roles, cooldown_role) - If a claimant could not be found, send a warning to #helpers and set the claimant to the bot. - """ - if await _caches.claimants.get(channel.id): - return - async for message in channel.history(limit=1000): - if message.author.id != bot.instance.user.id: - # We only care about bot messages +async def help_thread_closed(closed_thread: discord.Thread) -> None: + """Apply archive logic to a manually closed help forum post.""" + await _close_help_thread(closed_thread, _stats.ClosingReason.COMMAND) + + +async def help_thread_archived(archived_thread: discord.Thread) -> None: + """Apply archive logic to an archived help forum post.""" + async for thread_update in archived_thread.guild.audit_logs(limit=50, action=discord.AuditLogAction.thread_update): + if thread_update.target.id != archived_thread.id: continue - if message.embeds: - if _message._match_bot_embed(message, _message.DORMANT_MSG): - log.info("Hit the dormant message embed before finding a claimant in %s (%d).", channel, channel.id) - break - # Only set the claimant if the first embed matches the claimed channel embed regex - description = message.embeds[0].description - if (description is not None) and (match := CLAIMED_BY_RE.match(description)): - await _caches.claimants.set(channel.id, int(match.group("user_id"))) - return - - await bot.instance.get_channel(constants.Channels.helpers).send( - f"I couldn't find a claimant for {channel.mention} in that last 1000 messages. " - "Please use your helper powers to close the channel if/when appropriate." - ) - await _caches.claimants.set(channel.id, bot.instance.user.id) + + # Don't apply close logic if the post was archived by the bot, as it + # would have been done so via _close_help_thread. + if thread_update.user.id == bot.instance.user.id: + return + + await _close_help_thread(archived_thread, _stats.ClosingReason.INACTIVE) + + +async def help_thread_deleted(deleted_thread_event: discord.RawThreadDeleteEvent) -> None: + """Record appropriate stats when a help thread is deleted.""" + _stats.report_post_count() + cached_thread = deleted_thread_event.thread + if cached_thread and not cached_thread.archived: + # If the thread is in the bot's cache, and it was not archived before deleting, report a complete session. + await _stats.report_complete_session(cached_thread, _stats.ClosingReason.DELETED) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 31a33f8af..50f8416fc 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -1,666 +1,151 @@ -import asyncio -import random +"""Contains the Cog that receives discord.py events and defers most actions to other files in the module.""" + import typing as t -from datetime import timedelta -from operator import attrgetter -import arrow import discord -import discord.abc -from botcore.utils import members, scheduling from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.constants import Channels, RedirectOutput -from bot.exts.help_channels import _caches, _channel, _message, _name, _stats +from bot.exts.help_channels import _caches, _channel, _message from bot.log import get_logger -from bot.utils import channel as channel_utils, lock log = get_logger(__name__) -NAMESPACE = "help" -HELP_CHANNEL_TOPIC = """ -This is a Python help channel. You can claim your own help channel in the Python Help: Available category. -""" -AVAILABLE_HELP_CHANNELS = "**Currently available help channel(s):** {available}" +if t.TYPE_CHECKING: + from bot.exts.filters.filtering import Filtering -class HelpChannels(commands.Cog): +class HelpForum(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_other` minutes since the last user message, or - - `constants.HelpChannels.idle_minutes_claimant` minutes since the last claimant message. - * 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 + Manage the help channel forum of the guild. - Dormant Category + This system uses Discord's native forum channel feature to handle most of the logic. - * Contains channels which aren't in use - * Channels are used to refill the Available category - - Help channels are named after the foods in `bot/resources/foods.json`. + The purpose of this cog is to add additional features, such as stats collection, old post locking + and helpful automated messages. """ def __init__(self, bot: Bot): self.bot = bot - self.scheduler = scheduling.Scheduler(self.__class__.__name__) - - self.guild: discord.Guild = None - self.cooldown_role: discord.Role = None - - # 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 - - # Notifications - # Using a very old date so that we don't have to use Optional typing. - self.last_none_remaining_notification = arrow.get('1815-12-10T18:00:00.00000+00:00') - self.last_running_low_notification = arrow.get('1815-12-10T18:00:00.00000+00:00') - - self.dynamic_message: t.Optional[int] = None - self.available_help_channels: t.Set[discord.TextChannel] = set() - - # Asyncio stuff - self.queue_tasks: t.List[asyncio.Task] = [] - self.init_done = False - - async 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") - - log.trace("Cog unload: cancelling the channel queue tasks") - for task in self.queue_tasks: - task.cancel() - - self.scheduler.cancel_all() - - @lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id")) - @lock.lock_arg(NAMESPACE, "message", attrgetter("author.id")) - @lock.lock_arg(f"{NAMESPACE}.unclaim", "message", attrgetter("author.id"), wait=True) - async def claim_channel(self, message: discord.Message) -> None: - """ - Claim the channel in which the question `message` was sent. - - Send an embed stating the claimant, move the channel to the In Use category, and pin the `message`. - Add a cooldown to the claimant to prevent them from asking another question. - Lastly, make a new channel available. - """ - log.info(f"Channel #{message.channel} was claimed by `{message.author.id}`.") - - try: - await self.move_to_in_use(message.channel) - except discord.DiscordServerError: - try: - await message.channel.send( - "The bot encountered a Discord API error while trying to move this channel, please try again later." - ) - except Exception as e: - log.warning("Error occurred while sending fail claim message:", exc_info=e) - log.info( - "500 error from Discord when moving #%s (%d) to in-use for %s (%d). Cancelling claim.", - message.channel.name, - message.channel.id, - message.author.name, - message.author.id, - ) - self.bot.stats.incr("help.failed_claims.500_on_move") - return - - embed = discord.Embed( - description=f"Channel claimed by {message.author.mention}.", - color=constants.Colours.bright_green, - ) - await message.channel.send(embed=embed) - - # Handle odd edge case of `message.author` not being a `discord.Member` (see bot#1839) - if not isinstance(message.author, discord.Member): - log.debug(f"{message.author} ({message.author.id}) isn't a member. Not giving cooldown role or sending DM.") - else: - await members.handle_role_change(message.author, message.author.add_roles, self.cooldown_role) - - try: - await _message.dm_on_open(message) - except Exception as e: - log.warning("Error occurred while sending DM:", exc_info=e) - - await _message.pin(message) - - # Add user with channel for dormant check. - await _caches.claimants.set(message.channel.id, message.author.id) - - self.bot.stats.incr("help.claimed") - - # datetime.timestamp() would assume it's local, despite d.py giving a (naïve) UTC time. - timestamp = arrow.Arrow.fromdatetime(message.created_at).timestamp() - - await _caches.claim_times.set(message.channel.id, timestamp) - await _caches.claimant_last_message_times.set(message.channel.id, timestamp) - # Delete to indicate that the help session has yet to receive an answer. - await _caches.non_claimant_last_message_times.delete(message.channel.id) - - # Removing the help channel from the dynamic message, and editing/sending that message. - self.available_help_channels.remove(message.channel) - - # Not awaited because it may indefinitely hold the lock while waiting for a channel. - scheduling.create_task(self.move_to_available(), name=f"help_claim_{message.id}") - - def create_channel_queue(self) -> asyncio.Queue: - """ - Return a queue of dormant channels to use for getting the next available channel. - - The channels are added to the queue in a random order. - """ - log.trace("Creating the channel queue.") - - channels = list(_channel.get_category_channels(self.dormant_category)) - random.shuffle(channels) - - log.trace("Populating the channel queue with channels.") - queue = asyncio.Queue() - for channel in channels: - queue.put_nowait(channel) - - return queue - - async def create_dormant(self) -> t.Optional[discord.TextChannel]: - """ - Create and return a new channel in the Dormant category. - - The new channel will sync its permission overwrites with the category. - - Return None if no more channel names are available. - """ - log.trace("Getting a name for a new dormant channel.") - - try: - name = self.name_queue.popleft() - except IndexError: - log.debug("No more names available for new dormant channels.") - return None - - log.debug(f"Creating a new dormant channel named {name}.") - return await self.dormant_category.create_text_channel(name, topic=HELP_CHANNEL_TOPIC) + self.help_forum_channel_id = constants.Channels.help_system_forum async def close_check(self, ctx: commands.Context) -> bool: - """Return True if the channel is in use and the user is the claimant or has a whitelisted role.""" - if ctx.channel.category != self.in_use_category: - log.debug(f"{ctx.author} invoked command 'close' outside an in-use help channel") + """Return True if the channel is a help post, and the user is the claimant or has a whitelisted role.""" + if not _channel.is_help_forum_post(ctx.channel): return False - if await _caches.claimants.get(ctx.channel.id) == ctx.author.id: + if ctx.author.id == ctx.channel.owner_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 post_with_disallowed_title_check(self, post: discord.Thread) -> None: + """Check if the given post has a bad word, alerting moderators if it does.""" + filter_cog: Filtering | None = self.bot.get_cog("Filtering") + if filter_cog and (match := filter_cog.get_name_match(post.name)): + mod_alerts = self.bot.get_channel(constants.Channels.mod_alerts) + await mod_alerts.send( + f"<@&{constants.Roles.moderators}>\n" + f"<@{post.owner_id}> ({post.owner_id}) opened the thread {post.mention} ({post.id}), " + "which triggered the token filter with its name!\n" + f"**Match:** {match.group()}" + ) + + @commands.group(name="help-forum", aliases=("hf",)) + async def help_forum_group(self, ctx: commands.Context) -> None: + """A group of commands that help manage our help forum system.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @help_forum_group.command(name="close", root_aliases=("close", "dormant", "solved")) async def close_command(self, ctx: commands.Context) -> None: """ - Make the current in-use help channel dormant. + Make the help post this command was called in dormant. May only be invoked by the channel's claimant or by staff. """ # Don't use a discord.py check because the check needs to fail silently. if await self.close_check(ctx): log.info(f"Close command invoked by {ctx.author} in #{ctx.channel}.") - await self.unclaim_channel(ctx.channel, closed_on=_channel.ClosingReason.COMMAND) - - 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.") - last_notification = await _message.notify_none_remaining(self.last_none_remaining_notification) - - if last_notification: - self.last_none_remaining_notification = last_notification - - channel = await self.wait_for_dormant_channel() # Blocks until a new channel is available - - else: - last_notification = await _message.notify_running_low( - self.channel_queue.qsize(), - self.last_running_low_notification - ) - - if last_notification: - self.last_running_low_notification = last_notification - - return channel - - async def init_available(self) -> None: - """Initialise the Available category with channels.""" - log.trace("Initialising the Available category with channels.") - - channels = list(_channel.get_category_channels(self.available_category)) - missing = constants.HelpChannels.max_available - len(channels) - - # If we've got less than `max_available` channel available, we should add some. - if missing > 0: - log.trace(f"Moving {missing} missing channels to the Available category.") - for _ in range(missing): - await self.move_to_available() - - # If for some reason we have more than `max_available` channels available, - # we should move the superfluous ones over to dormant. - elif missing < 0: - log.trace(f"Moving {abs(missing)} superfluous available channels over to the Dormant category.") - for channel in channels[:abs(missing)]: - await self.unclaim_channel(channel, closed_on=_channel.ClosingReason.CLEANUP) - - self.available_help_channels = set(_channel.get_category_channels(self.available_category)) - - # Getting channels that need to be included in the dynamic message. - await self.update_available_help_channels() - log.trace("Dynamic available help message updated.") - - 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.get_or_fetch_channel( - constants.Categories.help_available - ) - self.in_use_category = await channel_utils.get_or_fetch_channel( - constants.Categories.help_in_use - ) - self.dormant_category = await channel_utils.get_or_fetch_channel( - constants.Categories.help_dormant - ) - except discord.HTTPException: - log.exception("Failed to get a category; cog will be removed") - await self.bot.remove_cog(self.qualified_name) - - async def cog_load(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.") - self.guild = self.bot.get_guild(constants.Guild.id) - self.cooldown_role = self.guild.get_role(constants.Roles.help_cooldown) - - await self.init_categories() - - self.channel_queue = self.create_channel_queue() - self.name_queue = _name.create_name_queue( - self.available_category, - self.in_use_category, - self.dormant_category, - ) - - log.trace("Moving or rescheduling in-use channels.") - for channel in _channel.get_category_channels(self.in_use_category): - await _channel.ensure_cached_claimant(channel) - 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 - - # Acquiring the dynamic message ID, if it exists within the cache. - log.trace("Attempting to fetch How-to-get-help dynamic message ID.") - self.dynamic_message = await _caches.dynamic_message.get("message_id") - - await self.init_available() - _stats.report_counts() - - self.init_done = True - log.info("Cog is ready!") - - 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}).") - - closing_time, closed_on = await _channel.get_closing_time(channel, self.init_done) + await _channel.help_thread_closed(ctx.channel) - # Closing time is in the past. - # Add 1 second due to POSIX timestamps being lower resolution than datetime objects. - if closing_time < (arrow.utcnow() + timedelta(seconds=1)): - log.info( - f"#{channel} ({channel.id}) is idle past {closing_time} " - f"and will be made dormant. Reason: {closed_on.value}" - ) - - await self.unclaim_channel(channel, closed_on=closed_on) - else: - # Cancel the existing task, if any. - if has_task: - self.scheduler.cancel(channel.id) - - delay = (closing_time - arrow.utcnow()).seconds - 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_available(self) -> None: - """Make a channel available.""" - log.trace("Making a channel available.") - - channel = await self.get_available_candidate() - channel_str = f"#{channel} ({channel.id})" - log.info(f"Making {channel_str} available.") - - await _message.send_available_message(channel) - - log.trace(f"Moving {channel_str} to the Available category.") - - # Unpin any previously stuck pins - log.trace(f"Looking for pins stuck in {channel_str}.") - if stuck_pins := await _message.unpin_all(channel): - log.debug(f"Removed {stuck_pins} stuck pins from {channel_str}.") - - await _channel.move_to_bottom( - channel=channel, - category_id=constants.Categories.help_available, - ) - - # Adding the help channel to the dynamic message, and editing/sending that message. - self.available_help_channels.add(channel) - await self.update_available_help_channels() - - _stats.report_counts() - - async def move_to_dormant(self, channel: discord.TextChannel) -> None: - """Make the `channel` dormant.""" - log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") - await _channel.move_to_bottom( - channel=channel, - category_id=constants.Categories.help_dormant, - ) - - log.trace(f"Sending dormant message for #{channel} ({channel.id}).") - embed = discord.Embed( - description=_message.DORMANT_MSG.format( - dormant=self.dormant_category.name, - available=self.available_category.name, - ) - ) - await channel.send(embed=embed) - - log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") - self.channel_queue.put_nowait(channel) - - _stats.report_counts() - - @lock.lock_arg(f"{NAMESPACE}.unclaim", "channel") - async def unclaim_channel(self, channel: discord.TextChannel, *, closed_on: _channel.ClosingReason) -> None: + @help_forum_group.command(name="dm", root_aliases=("helpdm",)) + async def help_dm_command( + self, + ctx: commands.Context, + state_bool: bool, + ) -> None: """ - Unclaim an in-use help `channel` to make it dormant. - - Unpin the claimant's question message and move the channel to the Dormant category. - Remove the cooldown role from the channel claimant if they have no other channels claimed. - Cancel the scheduled cooldown role removal task. + Allows user to toggle "Helping" DMs. - `closed_on` is the reason that the channel was closed. See _channel.ClosingReason for possible values. + If this is set to on the user will receive a dm for the channel they are participating in. + If this is set to off the user will not receive a dm for channel that they are participating in. """ - claimant_id = await _caches.claimants.get(channel.id) - _unclaim_channel = self._unclaim_channel - - # It could be possible that there is no claimant cached. In such case, it'd be useless and - # possibly incorrect to lock on None. Therefore, the lock is applied conditionally. - if claimant_id is not None: - decorator = lock.lock_arg(f"{NAMESPACE}.unclaim", "claimant_id", wait=True) - _unclaim_channel = decorator(_unclaim_channel) - - return await _unclaim_channel(channel, claimant_id, closed_on) + state_str = "ON" if state_bool else "OFF" - async def _unclaim_channel( - self, - channel: discord.TextChannel, - claimant_id: t.Optional[int], - closed_on: _channel.ClosingReason - ) -> None: - """Actual implementation of `unclaim_channel`. See that for full documentation.""" - await _caches.claimants.delete(channel.id) - await _caches.session_participants.delete(channel.id) + if state_bool == await _caches.help_dm.get(ctx.author.id, False): + await ctx.send(f"{constants.Emojis.cross_mark} {ctx.author.mention} Help DMs are already {state_str}") + return - if not claimant_id: - log.info("No claimant given when un-claiming %s (%d). Skipping role removal.", channel, channel.id) + if state_bool: + await _caches.help_dm.set(ctx.author.id, True) else: - claimant = await members.get_or_fetch_member(self.guild, claimant_id) - if claimant is None: - log.info(f"{claimant_id} left the guild during their help session; the cooldown role won't be removed") - else: - await members.handle_role_change(claimant, claimant.remove_roles, self.cooldown_role) - - await _message.unpin_all(channel) - await _stats.report_complete_session(channel.id, closed_on) - await self.move_to_dormant(channel) - - # Cancel the task that makes the channel dormant only if called by the close command. - # In other cases, the task is either already done or not-existent. - if closed_on == _channel.ClosingReason.COMMAND: - self.scheduler.cancel(channel.id) - - async def move_to_in_use(self, channel: discord.TextChannel) -> None: - """Make a channel in-use and schedule it to be made dormant.""" - log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") + await _caches.help_dm.delete(ctx.author.id) + await ctx.send(f"{constants.Emojis.ok_hand} {ctx.author.mention} Help DMs {state_str}!") - await _channel.move_to_bottom( - channel=channel, - category_id=constants.Categories.help_in_use, - ) + @help_forum_group.command(name="title", root_aliases=("title",)) + async def rename_help_post(self, ctx: commands.Context, *, title: str) -> None: + """Rename the help post to the provided title.""" + if not _channel.is_help_forum_post(ctx.channel): + # Silently fail in channels other than help posts + return - timeout = constants.HelpChannels.idle_minutes_claimant * 60 + if not await commands.has_any_role(constants.Roles.helpers).predicate(ctx): + # Silently fail for non-helpers + return - 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)) - _stats.report_counts() + await ctx.channel.edit(name=title) @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. + async def on_thread_create(self, thread: discord.Thread) -> None: + """Defer application of new post logic for posts the help forum to the _channel helper.""" + if thread.parent_id != self.help_forum_channel_id: + return - if channel_utils.is_in_category(message.channel, constants.Categories.help_available): - if not _channel.is_excluded_channel(message.channel): - await self.claim_channel(message) + await _channel.help_thread_opened(thread) - elif channel_utils.is_in_category(message.channel, constants.Categories.help_in_use): - await self.notify_session_participants(message) - await _message.update_message_caches(message) + await self.post_with_disallowed_title_check(thread) @commands.Cog.listener() - async def on_message_delete(self, msg: discord.Message) -> None: - """ - Reschedule an in-use channel to become dormant sooner if the channel is empty. - - The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`. - """ - if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use): - return - - if not await _message.is_empty(msg.channel): + async def on_thread_update(self, before: discord.Thread, after: discord.Thread) -> None: + """Defer application archive logic for posts in the help forum to the _channel helper.""" + if after.parent_id != self.help_forum_channel_id: return + if not before.archived and after.archived: + await _channel.help_thread_archived(after) + if before.name != after.name: + await self.post_with_disallowed_title_check(after) - log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") - - # Cancel existing dormant task before scheduling new. - self.scheduler.cancel(msg.channel.id) - - delay = constants.HelpChannels.deleted_idle_minutes * 60 - self.scheduler.schedule_later(delay, msg.channel.id, self.move_idle_channel(msg.channel)) - - async def wait_for_dormant_channel(self) -> discord.TextChannel: - """Wait for a dormant channel to become available in the queue and return it.""" - log.trace("Waiting for a dormant channel.") - - task = scheduling.create_task(self.channel_queue.get()) - self.queue_tasks.append(task) - channel = await task - - log.trace(f"Channel #{channel} ({channel.id}) finally retrieved from the queue.") - self.queue_tasks.remove(task) - - return channel - - async def update_available_help_channels(self) -> None: - """Updates the dynamic message within #how-to-get-help for available help channels.""" - available_channels = AVAILABLE_HELP_CHANNELS.format( - available=", ".join( - c.mention for c in sorted(self.available_help_channels, key=attrgetter("position")) - ) or None - ) - - if self.dynamic_message is not None: - try: - log.trace("Help channels have changed, dynamic message has been edited.") - await discord.PartialMessage( - channel=self.bot.get_channel(constants.Channels.how_to_get_help), - id=self.dynamic_message, - ).edit(content=available_channels) - except discord.NotFound: - pass - else: - return - - log.trace("Dynamic message could not be edited or found. Creating a new one.") - new_dynamic_message = await self.bot.get_channel(constants.Channels.how_to_get_help).send(available_channels) - self.dynamic_message = new_dynamic_message.id - await _caches.dynamic_message.set("message_id", self.dynamic_message) - - @staticmethod - def _serialise_session_participants(participants: set[int]) -> str: - """Convert a set to a comma separated string.""" - return ','.join(str(p) for p in participants) - - @staticmethod - def _deserialise_session_participants(s: str) -> set[int]: - """Convert a comma separated string into a set.""" - return set(int(user_id) for user_id in s.split(",") if user_id != "") - - @lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id")) - @lock.lock_arg(NAMESPACE, "message", attrgetter("author.id")) - async def notify_session_participants(self, message: discord.Message) -> None: - """ - Check if the message author meets the requirements to be notified. - - If they meet the requirements they are notified. - """ - if await _caches.claimants.get(message.channel.id) == message.author.id: - return # Ignore messages sent by claimants - - if not await _caches.help_dm.get(message.author.id): - return # Ignore message if user is opted out of help dms - - if (await self.bot.get_context(message)).command == self.close_command: - return # Ignore messages that are closing the channel - - session_participants = self._deserialise_session_participants( - await _caches.session_participants.get(message.channel.id) or "" - ) - - if message.author.id not in session_participants: - session_participants.add(message.author.id) - - embed = discord.Embed( - title="Currently Helping", - description=f"You're currently helping in {message.channel.mention}", - color=constants.Colours.bright_green, - timestamp=message.created_at - ) - embed.add_field(name="Conversation", value=f"[Jump to message]({message.jump_url})") - - try: - await message.author.send(embed=embed) - except discord.Forbidden: - log.trace( - f"Failed to send helpdm message to {message.author.id}. DMs Closed/Blocked. " - "Removing user from helpdm." - ) - bot_commands_channel = self.bot.get_channel(Channels.bot_commands) - await _caches.help_dm.delete(message.author.id) - await bot_commands_channel.send( - f"{message.author.mention} {constants.Emojis.cross_mark} " - "To receive updates on help channels you're active in, enable your DMs.", - delete_after=RedirectOutput.delete_delay - ) - return - - await _caches.session_participants.set( - message.channel.id, - self._serialise_session_participants(session_participants) - ) - - @commands.command(name="helpdm") - async def helpdm_command( - self, - ctx: commands.Context, - state_bool: bool - ) -> None: - """ - Allows user to toggle "Helping" dms. - - If this is set to on the user will receive a dm for the channel they are participating in. + @commands.Cog.listener() + async def on_raw_thread_delete(self, deleted_thread_event: discord.RawThreadDeleteEvent) -> None: + """Defer application of new post logic for posts the help forum to the _channel helper.""" + if deleted_thread_event.parent_id == self.help_forum_channel_id: + await _channel.help_thread_deleted(deleted_thread_event) - If this is set to off the user will not receive a dm for channel that they are participating in. - """ - state_str = "ON" if state_bool else "OFF" + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Defer application of new message logic for messages in the help forum to the _message helper.""" + if not _channel.is_help_forum_post(message.channel): + return None - if state_bool == await _caches.help_dm.get(ctx.author.id, False): - await ctx.send(f"{constants.Emojis.cross_mark} {ctx.author.mention} Help DMs are already {state_str}") - return + await _message.notify_session_participants(message) - if state_bool: - await _caches.help_dm.set(ctx.author.id, True) - else: - await _caches.help_dm.delete(ctx.author.id) - await ctx.send(f"{constants.Emojis.ok_hand} {ctx.author.mention} Help DMs {state_str}!") + if message.author.id != message.channel.owner_id: + await _caches.posts_with_non_claimant_messages.set(message.channel.id, "sentinel") diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index 00d57ea40..98bfe59b8 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -1,286 +1,73 @@ -import textwrap -import typing as t +from operator import attrgetter -import arrow import discord -from arrow import Arrow import bot from bot import constants from bot.exts.help_channels import _caches from bot.log import get_logger +from bot.utils import lock log = get_logger(__name__) +NAMESPACE = "help" -ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" -AVAILABLE_MSG = f""" -Send your question here to claim the channel. +def _serialise_session_participants(participants: set[int]) -> str: + """Convert a set to a comma separated string.""" + return ','.join(str(p) for p in participants) -**Remember to:** -• **Ask** your Python question, not if you can ask or if there's an expert who can help. -• **Show** a code sample as text (rather than a screenshot) and the error message, if you got one. -• **Explain** what you expect to happen and what actually happens. -For more tips, check out our guide on [asking good questions]({ASKING_GUIDE_URL}). -""" +def _deserialise_session_participants(s: str) -> set[int]: + """Convert a comma separated string into a set.""" + return set(int(user_id) for user_id in s.split(",") if user_id != "") -AVAILABLE_TITLE = "Available help channel" -AVAILABLE_FOOTER = f"Closes after a period of inactivity, or when you send {constants.Bot.prefix}close." - -DORMANT_MSG = f""" -This help channel has been marked as **dormant**, and has been moved into the **{{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 \ -**{{available}}** category by simply asking your question again. Consider rephrasing the \ -question to maximize your chance of getting a good answer. If you're not sure how, have a look \ -through our guide for **[asking a good question]({ASKING_GUIDE_URL})**. -""" - - -async def update_message_caches(message: discord.Message) -> None: - """Checks the source of new content in a help channel and updates the appropriate cache.""" - channel = message.channel - - log.trace(f"Checking if #{channel} ({channel.id}) has had a reply.") - - 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 - - # datetime.timestamp() would assume it's local, despite d.py giving a (naïve) UTC time. - timestamp = Arrow.fromdatetime(message.created_at).timestamp() - - # Overwrite the appropriate last message cache depending on the author of the message - if message.author.id == claimant_id: - await _caches.claimant_last_message_times.set(channel.id, timestamp) - else: - await _caches.non_claimant_last_message_times.set(channel.id, timestamp) - - -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}).") - - async for message in channel.history(limit=1): - return message - - 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 dm_on_open(message: discord.Message) -> None: - """ - DM claimant with a link to the claimed channel's first message, with a 100 letter preview of the message. - - Does nothing if the user has DMs disabled. - """ - embed = discord.Embed( - title="Help channel opened", - description=f"You claimed {message.channel.mention}.", - colour=bot.constants.Colours.bright_green, - timestamp=message.created_at, - ) - - embed.set_thumbnail(url=constants.Icons.green_questionmark) - formatted_message = textwrap.shorten(message.content, width=100, placeholder="...") - if formatted_message: - embed.add_field(name="Your message", value=formatted_message, inline=False) - embed.add_field( - name="Conversation", - value=f"[Jump to message!]({message.jump_url})", - inline=False, - ) - - try: - await message.author.send(embed=embed) - log.trace(f"Sent DM to {message.author.id} after claiming help channel.") - except discord.errors.Forbidden: - log.trace( - f"Ignoring to send DM to {message.author.id} after claiming help channel: DMs disabled." - ) - - -async def notify_none_remaining(last_notification: Arrow) -> t.Optional[Arrow]: - """ - Send a pinging message in `channel` notifying about there being no dormant channels remaining. - - If a notification was sent, return the time at which the message was sent. - Otherwise, return None. - - Configuration: - * `HelpChannels.notify_minutes` - minimum interval between notifications - * `HelpChannels.notify_none_remaining` - toggle none_remaining notifications - * `HelpChannels.notify_none_remaining_roles` - roles mentioned in notifications - """ - if not constants.HelpChannels.notify_none_remaining: - return None - - if (arrow.utcnow() - last_notification).total_seconds() < (constants.HelpChannels.notify_minutes * 60): - log.trace("Did not send none_remaining notification as it hasn't been enough time since the last one.") - return None - - log.trace("Notifying about lack of channels.") - - mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_none_remaining_roles) - allowed_roles = [discord.Object(id_) for id_ in constants.HelpChannels.notify_none_remaining_roles] - - channel = bot.instance.get_channel(constants.HelpChannels.notify_channel) - if channel is None: - log.trace("Did not send none_remaining notification as the notification channel couldn't be gathered.") - - try: - await channel.send( - f"{mentions} A new available help channel is needed but there " - "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) - ) - 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!") - else: - bot.instance.stats.incr("help.out_of_channel_alerts") - return arrow.utcnow() - - -async def notify_running_low(number_of_channels_left: int, last_notification: Arrow) -> t.Optional[Arrow]: [email protected]_arg(NAMESPACE, "message", attrgetter("channel.id")) [email protected]_arg(NAMESPACE, "message", attrgetter("author.id")) +async def notify_session_participants(message: discord.Message) -> None: """ - Send a non-pinging message in `channel` notifying about there being a low amount of dormant channels. - - This will include the number of dormant channels left `number_of_channels_left` + Check if the message author meets the requirements to be notified. - If a notification was sent, return the time at which the message was sent. - Otherwise, return None. - - Configuration: - * `HelpChannels.notify_minutes` - minimum interval between notifications - * `HelpChannels.notify_running_low` - toggle running_low notifications - * `HelpChannels.notify_running_low_threshold` - minimum amount of channels to trigger running_low notifications + If they meet the requirements they are notified. """ - if not constants.HelpChannels.notify_running_low: - return None - - if number_of_channels_left > constants.HelpChannels.notify_running_low_threshold: - log.trace("Did not send notify_running_low notification as the threshold was not met.") - return None - - if (arrow.utcnow() - last_notification).total_seconds() < (constants.HelpChannels.notify_minutes * 60): - log.trace("Did not send notify_running_low notification as it hasn't been enough time since the last one.") - return None - - log.trace("Notifying about getting close to no dormant channels.") - - channel = bot.instance.get_channel(constants.HelpChannels.notify_channel) - if channel is None: - log.trace("Did not send notify_running notification as the notification channel couldn't be gathered.") - - try: - if number_of_channels_left == 1: - message = f"There is only {number_of_channels_left} dormant channel left. " - else: - message = f"There are only {number_of_channels_left} dormant channels left. " - message += "Consider participating in some help channels so that we don't run out." - await channel.send(message) - except Exception: - # Handle it here cause this feature isn't critical for the functionality of the system. - log.exception("Failed to send notification about running low of dormant channels!") - else: - bot.instance.stats.incr("help.running_low_alerts") - return arrow.utcnow() - - -async def pin(message: discord.Message) -> None: - """Pin an initial question `message`.""" - await _pin_wrapper(message, pin=True) + if message.channel.owner_id == message.author.id: + return # Ignore messages sent by claimants + if not await _caches.help_dm.get(message.author.id): + return # Ignore message if user is opted out of help dms -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, + session_participants = _deserialise_session_participants( + await _caches.session_participants.get(message.channel.id) or "", ) - 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_all(channel: discord.TextChannel) -> int: - """Unpin all pinned messages in `channel` and return the amount of unpinned messages.""" - count = 0 - for message in await channel.pins(): - if await _pin_wrapper(message, pin=False): - count += 1 - - return count - - -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 None: - 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() + if message.author.id not in session_participants: + session_participants.add(message.author.id) -async def _pin_wrapper(message: discord.Message, *, pin: bool) -> bool: - """ - Pin `message` if `pin` is True or unpin if it's False. - - Return True if successful and False otherwise. - """ - channel_str = f"#{message.channel} ({message.channel.id})" - func = message.pin if pin else message.unpin - - try: - await func() - except discord.HTTPException as e: - if e.code == 10008: - log.debug(f"Message {message.id} in {channel_str} doesn't exist; can't {func.__name__}.") - else: - log.exception( - f"Error {func.__name__}ning message {message.id} in {channel_str}: " - f"{e.status} ({e.code})" + embed = discord.Embed( + title="Currently Helping", + description=f"You're currently helping in {message.channel.mention}", + color=constants.Colours.bright_green, + timestamp=message.created_at, + ) + embed.add_field(name="Conversation", value=f"[Jump to message]({message.jump_url})") + + try: + await message.author.send(embed=embed) + except discord.Forbidden: + log.trace( + f"Failed to send help dm message to {message.author.id}. DMs Closed/Blocked. " + "Removing user from help dm." + ) + await _caches.help_dm.delete(message.author.id) + bot_commands_channel = bot.instance.get_channel(constants.Channels.bot_commands) + await bot_commands_channel.send( + f"{message.author.mention} {constants.Emojis.cross_mark} " + "To receive updates on help channels you're active in, enable your DMs.", + delete_after=constants.RedirectOutput.delete_delay, ) - return False - else: - log.trace(f"{func.__name__.capitalize()}ned message {message.id} in {channel_str}.") - return True + return + + await _caches.session_participants.set( + message.channel.id, + _serialise_session_participants(session_participants), + ) diff --git a/bot/exts/help_channels/_name.py b/bot/exts/help_channels/_name.py deleted file mode 100644 index a9d9b2df1..000000000 --- a/bot/exts/help_channels/_name.py +++ /dev/null @@ -1,69 +0,0 @@ -import json -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 -from bot.log import get_logger - -log = get_logger(__name__) - - -def create_name_queue(*categories: discord.CategoryChannel) -> deque: - """ - Return a queue of food names to use for creating new channels. - - Skip names that are already in use by channels in `categories`. - """ - log.trace("Creating the food 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 food 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} food names from JSON.") - - with Path("bot/resources/foods.json").open(encoding="utf-8") as foods_file: - all_names = json.load(foods_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/_stats.py b/bot/exts/help_channels/_stats.py index 4698c26de..8ab93f19d 100644 --- a/bot/exts/help_channels/_stats.py +++ b/bot/exts/help_channels/_stats.py @@ -1,40 +1,44 @@ -from more_itertools import ilen +from enum import Enum + +import arrow +import discord import bot from bot import constants -from bot.exts.help_channels import _caches, _channel +from bot.exts.help_channels import _caches from bot.log import get_logger log = get_logger(__name__) -def report_counts() -> None: - """Report channel count stats of each help category.""" - for name in ("in_use", "available", "dormant"): - id_ = getattr(constants.Categories, f"help_{name}") - category = bot.instance.get_channel(id_) +class ClosingReason(Enum): + """All possible closing reasons for help channels.""" + + COMMAND = "command" + INACTIVE = "auto.inactive" + DELETED = "auto.deleted" + CLEANUP = "auto.cleanup" + - if category: - total = ilen(_channel.get_category_channels(category)) - bot.instance.stats.gauge(f"help.total.{name}", total) - else: - log.warning(f"Couldn't find category {name!r} to track channel count stats.") +def report_post_count() -> None: + """Report post count stats of the help forum.""" + help_forum = bot.instance.get_channel(constants.Channels.help_system_forum) + bot.instance.stats.gauge("help_forum.total.in_use", len(help_forum.threads)) -async def report_complete_session(channel_id: int, closed_on: _channel.ClosingReason) -> None: +async def report_complete_session(help_session_post: discord.Thread, closed_on: ClosingReason) -> None: """ - Report stats for a completed help session channel `channel_id`. + Report stats for a completed help session post `help_session_post`. - `closed_on` is the reason why the channel was closed. See `_channel.ClosingReason` for possible reasons. + `closed_on` is the reason why the post was closed. See `ClosingReason` for possible reasons. """ - bot.instance.stats.incr(f"help.dormant_calls.{closed_on.value}") + bot.instance.stats.incr(f"help_forum.dormant_calls.{closed_on.value}") - in_use_time = await _channel.get_in_use_time(channel_id) - if in_use_time: - bot.instance.stats.timing("help.in_use_time", in_use_time) + open_time = discord.utils.snowflake_time(help_session_post.id) + in_use_time = arrow.utcnow() - open_time + bot.instance.stats.timing("help_forum.in_use_time", in_use_time) - non_claimant_last_message_time = await _caches.non_claimant_last_message_times.get(channel_id) - if non_claimant_last_message_time is None: - bot.instance.stats.incr("help.sessions.unanswered") + if await _caches.posts_with_non_claimant_messages.get(help_session_post.id): + bot.instance.stats.incr("help_forum.sessions.answered") else: - bot.instance.stats.incr("help.sessions.answered") + bot.instance.stats.incr("help_forum.sessions.unanswered") diff --git a/bot/exts/info/codeblock/_cog.py b/bot/exts/info/codeblock/_cog.py index 9027105d9..0605a26e7 100644 --- a/bot/exts/info/codeblock/_cog.py +++ b/bot/exts/info/codeblock/_cog.py @@ -10,10 +10,10 @@ from bot import constants from bot.bot import Bot from bot.exts.filters.token_remover import TokenRemover from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE +from bot.exts.help_channels._channel import is_help_forum_post from bot.exts.info.codeblock._instructions import get_instructions from bot.log import get_logger from bot.utils import has_lines -from bot.utils.channel import is_help_channel from bot.utils.messages import wait_for_deletion log = get_logger(__name__) @@ -98,7 +98,7 @@ class CodeBlockCog(Cog, name="Code Block"): """Return True if `channel` is a help channel, may be on a cooldown, or is whitelisted.""" log.trace(f"Checking if #{channel} qualifies for code block detection.") return ( - is_help_channel(channel) + is_help_forum_post(channel) or channel.id in self.channel_cooldowns or channel.id in constants.CodeBlock.channel_whitelist ) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index efa87ce25..a1ed714be 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -16,7 +16,7 @@ from discord.utils import escape_markdown, format_dt, snowflake_time from sentry_sdk import add_breadcrumb from bot.bot import Bot -from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, Roles, URLs +from bot.constants import Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, Roles, URLs from bot.log import get_logger from bot.utils import time from bot.utils.messages import format_user @@ -209,12 +209,6 @@ class ModLog(Cog, name="ModLog"): self._ignored[Event.guild_channel_update].remove(before.id) return - # Two channel updates are sent for a single edit: 1 for topic and 1 for category change. - # TODO: remove once support is added for ignoring multiple occurrences for the same channel. - help_categories = (Categories.help_available, Categories.help_dormant, Categories.help_in_use) - if after.category and after.category.id in help_categories: - return - diff = DeepDiff(before, after) changes = [] done = [] diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index 5e217a288..190956959 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -13,8 +13,9 @@ from discord import AllowedMentions, HTTPException, Interaction, Message, NotFou from discord.ext.commands import Cog, Command, Context, Converter, command, guild_only from bot.bot import Bot -from bot.constants import Categories, Channels, MODERATION_ROLES, Roles, URLs +from bot.constants import Channels, MODERATION_ROLES, Roles, URLs from bot.decorators import redirect_output +from bot.exts.help_channels._channel import is_help_forum_post from bot.log import get_logger from bot.utils import send_to_paste_service from bot.utils.lock import LockedResourceError, lock_arg @@ -456,7 +457,7 @@ class Snekbox(Cog): else: self.bot.stats.incr("snekbox_usages.roles.developers") - if ctx.channel.category_id == Categories.help_in_use: + if is_help_forum_post(ctx.channel): self.bot.stats.incr("snekbox_usages.channels.help") elif ctx.channel.id == Channels.bot_commands: self.bot.stats.incr("snekbox_usages.channels.bot_commands") diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 954a10e56..821a3732a 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -4,20 +4,11 @@ import discord import bot from bot import constants -from bot.constants import Categories from bot.log import get_logger log = get_logger(__name__) -def is_help_channel(channel: discord.TextChannel) -> bool: - """Return True if `channel` is in one of the help categories (excluding dormant).""" - log.trace(f"Checking if #{channel} is a help channel.") - categories = (Categories.help_available, Categories.help_in_use) - - return any(is_in_category(channel, category) for category in categories) - - def is_mod_channel(channel: Union[discord.TextChannel, discord.Thread]) -> bool: """True if channel, or channel.parent for threads, is considered a mod channel.""" if isinstance(channel, discord.Thread): diff --git a/config-default.yml b/config-default.yml index a5f4a5bda..c9d043ff7 100644 --- a/config-default.yml +++ b/config-default.yml @@ -139,9 +139,6 @@ guild: invite: "https://discord.gg/python" categories: - help_available: 691405807388196926 - help_dormant: 691405908919451718 - help_in_use: 696958401460043776 logs: &LOGS 468520609152892958 moderators: &MODS_CATEGORY 749736277464842262 modmail: &MODMAIL 714494672835444826 @@ -169,9 +166,8 @@ guild: meta: 429409067623251969 python_general: &PY_GENERAL 267624335836053506 - # Python Help: Available - cooldown: 720603994149486673 - how_to_get_help: 704250143020417084 + # Python Help + help_system_forum: 1035199133436354600 # Topical discord_bots: 343944376055103488 @@ -501,38 +497,6 @@ help_channels: cmd_whitelist: - *HELPERS_ROLE - # Allowed duration of inactivity by claimant before making a channel dormant - idle_minutes_claimant: 30 - - # Allowed duration of inactivity by others before making a channel dormant - # `idle_minutes_claimant` must also be met, before a channel is closed - idle_minutes_others: 10 - - # Allowed duration of inactivity when channel is empty (due to deleted messages) - # before message making a channel dormant - deleted_idle_minutes: 5 - - # Maximum number of channels to put in the available category - max_available: 3 - - # Maximum number of channels across all 3 categories - # Note Discord has a hard limit of 50 channels per category, so this shouldn't be > 50 - max_total_channels: 42 - - # Prefix for help channel names - name_prefix: 'help-' - - notify_channel: *HELPERS # Channel in which to send notifications messages - notify_minutes: 15 # Minimum interval between none_remaining or running_low notifications - - notify_none_remaining: true # Pinging notification for the Helper role when no dormant channels remain - notify_none_remaining_roles: # Mention these roles in the none_remaining notification - - *HELPERS_ROLE - - notify_running_low: true # Non-pinging notification which is triggered when the channel count is equal or less than the threshold - notify_running_low_threshold: 4 # The amount of channels at which a running_low notification will be sent - - redirect_output: delete_delay: 15 delete_invocation: true |