async def revoke_send_permissions(member: discord.Member, scheduler: Scheduler) -> None: """ Disallow `member` to send messages in the Available category for a certain time. The time until permissions are reinstated can be configured with `HelpChannels.claim_minutes`. """ log.trace( f"Revoking {member}'s ({member.id}) send message permissions in the Available category." ) await add_cooldown_role(member) # Cancel the existing task, if any. # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). if member.id in scheduler: scheduler.cancel(member.id) delay = constants.HelpChannels.claim_minutes * 60 scheduler.schedule_later(delay, member.id, remove_cooldown_role(member))
async def check_cooldowns(scheduler: Scheduler) -> None: """Remove expired cooldowns and re-schedule active ones.""" log.trace("Checking all cooldowns to remove or re-schedule them.") guild = bot.instance.get_guild(constants.Guild.id) cooldown = constants.HelpChannels.claim_minutes * 60 for channel_id, member_id in await _caches.claimants.items(): member = guild.get_member(member_id) if not member: continue # Member probably left the guild. in_use_time = await _channel.get_in_use_time(channel_id) if not in_use_time or in_use_time.seconds > cooldown: # Remove the role if no claim time could be retrieved or if the cooldown expired. # Since the channel is in the claimants cache, it is definitely strange for a time # to not exist. However, it isn't a reason to keep the user stuck with a cooldown. await remove_cooldown_role(member) else: # The member is still on a cooldown; re-schedule it for the remaining time. delay = cooldown - in_use_time.seconds scheduler.schedule_later(delay, member.id, remove_cooldown_role(member))
class HelpChannels(commands.Cog): """ Manage the help channel system of the guild. The system is based on a 3-category system: Available Category * Contains channels which are ready to be occupied by someone who needs help * Will always contain `constants.HelpChannels.max_available` channels; refilled automatically from the pool of dormant channels * Prioritise using the channels which have been dormant for the longest amount of time * If there are no more dormant channels, the bot will automatically create a new one * If there are no dormant channels to move, helpers will be notified (see `notify()`) * When a channel becomes available, the dormant embed will be edited to show `AVAILABLE_MSG` * User can only claim a channel at an interval `constants.HelpChannels.claim_minutes` * To keep track of cooldowns, user which claimed a channel will have a temporary role In Use Category * Contains all channels which are occupied by someone needing help * Channel moves to dormant category after `constants.HelpChannels.idle_minutes` of being idle * Command can prematurely mark a channel as dormant * Channel claimant is allowed to use the command * Allowed roles for the command are configurable with `constants.HelpChannels.cmd_whitelist` * When a channel becomes dormant, an embed with `DORMANT_MSG` will be sent Dormant Category * Contains channels which aren't in use * Channels are used to refill the Available category Help channels are named after the chemical elements in `bot/resources/elements.json`. """ # This cache tracks which channels are claimed by which members. # RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] help_channel_claimants = RedisCache() # This cache maps a help channel to whether it has had any # activity other than the original claimant. True being no other # activity and False being other activity. # RedisCache[discord.TextChannel.id, bool] unanswered = RedisCache() # This dictionary maps a help channel to the time it was claimed # RedisCache[discord.TextChannel.id, UtcPosixTimestamp] claim_times = RedisCache() # This cache maps a help channel to original question message in same channel. # RedisCache[discord.TextChannel.id, discord.Message.id] question_messages = RedisCache() def __init__(self, bot: Bot): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) # Categories self.available_category: discord.CategoryChannel = None self.in_use_category: discord.CategoryChannel = None self.dormant_category: discord.CategoryChannel = None # Queues self.channel_queue: asyncio.Queue[discord.TextChannel] = None self.name_queue: t.Deque[str] = None self.name_positions = self.get_names() self.last_notification: t.Optional[datetime] = None # Asyncio stuff self.queue_tasks: t.List[asyncio.Task] = [] self.ready = asyncio.Event() self.on_message_lock = asyncio.Lock() self.init_task = self.bot.loop.create_task(self.init_cog()) def cog_unload(self) -> None: """Cancel the init task and scheduled tasks when the cog unloads.""" log.trace("Cog unload: cancelling the init_cog task") self.init_task.cancel() log.trace("Cog unload: cancelling the channel queue tasks") for task in self.queue_tasks: task.cancel() self.scheduler.cancel_all() def create_channel_queue(self) -> asyncio.Queue: """ Return a queue of dormant channels to use for getting the next available channel. The channels are added to the queue in a random order. """ log.trace("Creating the channel queue.") channels = list(self.get_category_channels(self.dormant_category)) random.shuffle(channels) log.trace("Populating the channel queue with channels.") queue = asyncio.Queue() for channel in channels: queue.put_nowait(channel) return queue async def create_dormant(self) -> t.Optional[discord.TextChannel]: """ Create and return a new channel in the Dormant category. The new channel will sync its permission overwrites with the category. Return None if no more channel names are available. """ log.trace("Getting a name for a new dormant channel.") try: name = self.name_queue.popleft() except IndexError: log.debug("No more names available for new dormant channels.") return None log.debug(f"Creating a new dormant channel named {name}.") return await self.dormant_category.create_text_channel( name, topic=HELP_CHANNEL_TOPIC) def create_name_queue(self) -> deque: """Return a queue of element names to use for creating new channels.""" log.trace("Creating the chemical element name queue.") used_names = self.get_used_names() log.trace("Determining the available names.") available_names = (name for name in self.name_positions if name not in used_names) log.trace("Populating the name queue with names.") return deque(available_names) async def dormant_check(self, ctx: commands.Context) -> bool: """Return True if the user is the help channel claimant or passes the role check.""" if await self.help_channel_claimants.get(ctx.channel.id ) == ctx.author.id: log.trace( f"{ctx.author} is the help channel claimant, passing the check for dormant." ) self.bot.stats.incr("help.dormant_invoke.claimant") return True log.trace( f"{ctx.author} is not the help channel claimant, checking roles.") has_role = await commands.has_any_role( *constants.HelpChannels.cmd_whitelist).predicate(ctx) if has_role: self.bot.stats.incr("help.dormant_invoke.staff") return has_role @commands.command(name="close", aliases=["dormant", "solved"], enabled=False) async def close_command(self, ctx: commands.Context) -> None: """ Make the current in-use help channel dormant. Make the channel dormant if the user passes the `dormant_check`, delete the message that invoked this, and reset the send permissions cooldown for the user who started the session. """ log.trace("close command invoked; checking if the channel is in-use.") if ctx.channel.category == self.in_use_category: if await self.dormant_check(ctx): await self.remove_cooldown_role(ctx.author) # Ignore missing task when cooldown has passed but the channel still isn't dormant. if ctx.author.id in self.scheduler: self.scheduler.cancel(ctx.author.id) await self.move_to_dormant(ctx.channel, "command") self.scheduler.cancel(ctx.channel.id) else: log.debug( f"{ctx.author} invoked command 'dormant' outside an in-use help channel" ) async def get_available_candidate(self) -> discord.TextChannel: """ Return a dormant channel to turn into an available channel. If no channel is available, wait indefinitely until one becomes available. """ log.trace("Getting an available channel candidate.") try: channel = self.channel_queue.get_nowait() except asyncio.QueueEmpty: log.info( "No candidate channels in the queue; creating a new channel.") channel = await self.create_dormant() if not channel: log.info( "Couldn't create a candidate channel; waiting to get one from the queue." ) await self.notify() channel = await self.wait_for_dormant_channel() return channel @staticmethod def get_clean_channel_name(channel: discord.TextChannel) -> str: """Return a clean channel name without status emojis prefix.""" prefix = constants.HelpChannels.name_prefix try: # Try to remove the status prefix using the index of the channel prefix name = channel.name[channel.name.index(prefix):] log.trace(f"The clean name for `{channel}` is `{name}`") except ValueError: # If, for some reason, the channel name does not contain "help-" fall back gracefully log.info( f"Can't get clean name because `{channel}` isn't prefixed by `{prefix}`." ) name = channel.name return name @staticmethod def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: """Check if a channel should be excluded from the help channel system.""" return not isinstance( channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS def get_category_channels( self, category: discord.CategoryChannel ) -> t.Iterable[discord.TextChannel]: """Yield the text channels of the `category` in an unsorted manner.""" log.trace( f"Getting text channels in the category '{category}' ({category.id})." ) # This is faster than using category.channels because the latter sorts them. for channel in self.bot.get_guild(constants.Guild.id).channels: if channel.category_id == category.id and not self.is_excluded_channel( channel): yield channel async def get_in_use_time(self, channel_id: int) -> t.Optional[timedelta]: """Return the duration `channel_id` has been in use. Return None if it's not in use.""" log.trace(f"Calculating in use time for channel {channel_id}.") claimed_timestamp = await self.claim_times.get(channel_id) if claimed_timestamp: claimed = datetime.utcfromtimestamp(claimed_timestamp) return datetime.utcnow() - claimed @staticmethod def get_names() -> t.List[str]: """ Return a truncated list of prefixed element names. The amount of names is configured with `HelpChannels.max_total_channels`. The prefix is configured with `HelpChannels.name_prefix`. """ count = constants.HelpChannels.max_total_channels prefix = constants.HelpChannels.name_prefix log.trace(f"Getting the first {count} element names from JSON.") with Path("bot/resources/elements.json").open( encoding="utf-8") as elements_file: all_names = json.load(elements_file) if prefix: return [prefix + name for name in all_names[:count]] else: return all_names[:count] def get_used_names(self) -> t.Set[str]: """Return channel names which are already being used.""" log.trace("Getting channel names which are already being used.") names = set() for cat in (self.available_category, self.in_use_category, self.dormant_category): for channel in self.get_category_channels(cat): names.add(self.get_clean_channel_name(channel)) if len(names) > MAX_CHANNELS_PER_CATEGORY: log.warning( f"Too many help channels ({len(names)}) already exist! " f"Discord only supports {MAX_CHANNELS_PER_CATEGORY} in a category." ) log.trace(f"Got {len(names)} used names: {names}") return names @classmethod async def get_idle_time(cls, channel: discord.TextChannel) -> t.Optional[int]: """ Return the time elapsed, in seconds, since the last message sent in the `channel`. Return None if the channel has no messages. """ log.trace(f"Getting the idle time for #{channel} ({channel.id}).") msg = await cls.get_last_message(channel) if not msg: log.debug( f"No idle time available; #{channel} ({channel.id}) has no messages." ) return None idle_time = (datetime.utcnow() - msg.created_at).seconds log.trace( f"#{channel} ({channel.id}) has been idle for {idle_time} seconds." ) return idle_time @staticmethod async def get_last_message( channel: discord.TextChannel) -> t.Optional[discord.Message]: """Return the last message sent in the channel or None if no messages exist.""" log.trace(f"Getting the last message in #{channel} ({channel.id}).") try: return await channel.history(limit=1).next() # noqa: B305 except discord.NoMoreItems: log.debug( f"No last message available; #{channel} ({channel.id}) has no messages." ) return None async def init_available(self) -> None: """Initialise the Available category with channels.""" log.trace("Initialising the Available category with channels.") channels = list(self.get_category_channels(self.available_category)) missing = constants.HelpChannels.max_available - len(channels) # If we've got less than `max_available` channel available, we should add some. if missing > 0: log.trace( f"Moving {missing} missing channels to the Available category." ) for _ in range(missing): await self.move_to_available() # If for some reason we have more than `max_available` channels available, # we should move the superfluous ones over to dormant. elif missing < 0: log.trace( f"Moving {abs(missing)} superfluous available channels over to the Dormant category." ) for channel in channels[:abs(missing)]: await self.move_to_dormant(channel, "auto") async def init_categories(self) -> None: """Get the help category objects. Remove the cog if retrieval fails.""" log.trace( "Getting the CategoryChannel objects for the help categories.") try: self.available_category = await channel_utils.try_get_channel( constants.Categories.help_available, self.bot) self.in_use_category = await channel_utils.try_get_channel( constants.Categories.help_in_use, self.bot) self.dormant_category = await channel_utils.try_get_channel( constants.Categories.help_dormant, self.bot) except discord.HTTPException: log.exception("Failed to get a category; cog will be removed") self.bot.remove_cog(self.qualified_name) async def init_cog(self) -> None: """Initialise the help channel system.""" log.trace( "Waiting for the guild to be available before initialisation.") await self.bot.wait_until_guild_available() log.trace("Initialising the cog.") await self.init_categories() await self.check_cooldowns() self.channel_queue = self.create_channel_queue() self.name_queue = self.create_name_queue() log.trace("Moving or rescheduling in-use channels.") for channel in self.get_category_channels(self.in_use_category): await self.move_idle_channel(channel, has_task=False) # Prevent the command from being used until ready. # The ready event wasn't used because channels could change categories between the time # the command is invoked and the cog is ready (e.g. if move_idle_channel wasn't called yet). # This may confuse users. So would potentially long delays for the cog to become ready. self.close_command.enabled = True await self.init_available() log.info("Cog is ready!") self.ready.set() self.report_stats() def report_stats(self) -> None: """Report the channel count stats.""" total_in_use = sum( 1 for _ in self.get_category_channels(self.in_use_category)) total_available = sum( 1 for _ in self.get_category_channels(self.available_category)) total_dormant = sum( 1 for _ in self.get_category_channels(self.dormant_category)) self.bot.stats.gauge("help.total.in_use", total_in_use) self.bot.stats.gauge("help.total.available", total_available) self.bot.stats.gauge("help.total.dormant", total_dormant) @staticmethod def is_claimant(member: discord.Member) -> bool: """Return True if `member` has the 'Help Cooldown' role.""" return any(constants.Roles.help_cooldown == role.id for role in member.roles) def match_bot_embed(self, message: t.Optional[discord.Message], description: str) -> bool: """Return `True` if the bot's `message`'s embed description matches `description`.""" if not message or not message.embeds: return False bot_msg_desc = message.embeds[0].description if bot_msg_desc is discord.Embed.Empty: log.trace("Last message was a bot embed but it was empty.") return False return message.author == self.bot.user and bot_msg_desc.strip( ) == description.strip() async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None: """ Make the `channel` dormant if idle or schedule the move if still active. If `has_task` is True and rescheduling is required, the extant task to make the channel dormant will first be cancelled. """ log.trace(f"Handling in-use channel #{channel} ({channel.id}).") if not await self.is_empty(channel): idle_seconds = constants.HelpChannels.idle_minutes * 60 else: idle_seconds = constants.HelpChannels.deleted_idle_minutes * 60 time_elapsed = await self.get_idle_time(channel) if time_elapsed is None or time_elapsed >= idle_seconds: log.info( f"#{channel} ({channel.id}) is idle longer than {idle_seconds} seconds " f"and will be made dormant.") await self.move_to_dormant(channel, "auto") else: # Cancel the existing task, if any. if has_task: self.scheduler.cancel(channel.id) delay = idle_seconds - time_elapsed log.info(f"#{channel} ({channel.id}) is still active; " f"scheduling it to be moved after {delay} seconds.") self.scheduler.schedule_later(delay, channel.id, self.move_idle_channel(channel)) async def move_to_bottom_position(self, channel: discord.TextChannel, category_id: int, **options) -> None: """ Move the `channel` to the bottom position of `category` and edit channel attributes. To ensure "stable sorting", we use the `bulk_channel_update` endpoint and provide the current positions of the other channels in the category as-is. This should make sure that the channel really ends up at the bottom of the category. If `options` are provided, the channel will be edited after the move is completed. This is the same order of operations that `discord.TextChannel.edit` uses. For information on available options, see the documentation on `discord.TextChannel.edit`. While possible, position-related options should be avoided, as it may interfere with the category move we perform. """ # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. category = await channel_utils.try_get_channel(category_id, self.bot) payload = [{ "id": c.id, "position": c.position } for c in category.channels] # Calculate the bottom position based on the current highest position in the category. If the # category is currently empty, we simply use the current position of the channel to avoid making # unnecessary changes to positions in the guild. bottom_position = payload[-1][ "position"] + 1 if payload else channel.position payload.append({ "id": channel.id, "position": bottom_position, "parent_id": category.id, "lock_permissions": True, }) # We use d.py's method to ensure our request is processed by d.py's rate limit manager await self.bot.http.bulk_channel_update(category.guild.id, payload) # Now that the channel is moved, we can edit the other attributes if options: await channel.edit(**options) async def move_to_available(self) -> None: """Make a channel available.""" log.trace("Making a channel available.") channel = await self.get_available_candidate() log.info(f"Making #{channel} ({channel.id}) available.") await self.send_available_message(channel) log.trace( f"Moving #{channel} ({channel.id}) to the Available category.") await self.move_to_bottom_position( channel=channel, category_id=constants.Categories.help_available, ) self.report_stats() async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None: """ Make the `channel` dormant. A caller argument is provided for metrics. """ log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") await self.help_channel_claimants.delete(channel.id) await self.move_to_bottom_position( channel=channel, category_id=constants.Categories.help_dormant, ) self.bot.stats.incr(f"help.dormant_calls.{caller}") in_use_time = await self.get_in_use_time(channel.id) if in_use_time: self.bot.stats.timing("help.in_use_time", in_use_time) unanswered = await self.unanswered.get(channel.id) if unanswered: self.bot.stats.incr("help.sessions.unanswered") elif unanswered is not None: self.bot.stats.incr("help.sessions.answered") log.trace( f"Position of #{channel} ({channel.id}) is actually {channel.position}." ) log.trace(f"Sending dormant message for #{channel} ({channel.id}).") embed = discord.Embed(description=DORMANT_MSG) await channel.send(embed=embed) await self.unpin(channel) log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") self.channel_queue.put_nowait(channel) self.report_stats() async def move_to_in_use(self, channel: discord.TextChannel) -> None: """Make a channel in-use and schedule it to be made dormant.""" log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") await self.move_to_bottom_position( channel=channel, category_id=constants.Categories.help_in_use, ) timeout = constants.HelpChannels.idle_minutes * 60 log.trace( f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec." ) self.scheduler.schedule_later(timeout, channel.id, self.move_idle_channel(channel)) self.report_stats() async def notify(self) -> None: """ Send a message notifying about a lack of available help channels. Configuration: * `HelpChannels.notify` - toggle notifications * `HelpChannels.notify_channel` - destination channel for notifications * `HelpChannels.notify_minutes` - minimum interval between notifications * `HelpChannels.notify_roles` - roles mentioned in notifications """ if not constants.HelpChannels.notify: return log.trace("Notifying about lack of channels.") if self.last_notification: elapsed = (datetime.utcnow() - self.last_notification).seconds minimum_interval = constants.HelpChannels.notify_minutes * 60 should_send = elapsed >= minimum_interval else: should_send = True if not should_send: log.trace( "Notification not sent because it's too recent since the previous one." ) return try: log.trace("Sending notification message.") channel = self.bot.get_channel( constants.HelpChannels.notify_channel) mentions = " ".join( f"<@&{role}>" for role in constants.HelpChannels.notify_roles) allowed_roles = [ discord.Object(id_) for id_ in constants.HelpChannels.notify_roles ] message = await channel.send( f"{mentions} A new available help channel is needed but there " f"are no more dormant ones. Consider freeing up some in-use channels manually by " f"using the `{constants.Bot.prefix}dormant` command within the channels.", allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles)) self.bot.stats.incr("help.out_of_channel_alerts") self.last_notification = message.created_at except Exception: # Handle it here cause this feature isn't critical for the functionality of the system. log.exception( "Failed to send notification about lack of dormant channels!") async def check_for_answer(self, message: discord.Message) -> None: """Checks for whether new content in a help channel comes from non-claimants.""" channel = message.channel # Confirm the channel is an in use help channel if channel_utils.is_in_category(channel, constants.Categories.help_in_use): log.trace( f"Checking if #{channel} ({channel.id}) has been answered.") # Check if there is an entry in unanswered if await self.unanswered.contains(channel.id): claimant_id = await self.help_channel_claimants.get(channel.id) if not claimant_id: # The mapping for this channel doesn't exist, we can't do anything. return # Check the message did not come from the claimant if claimant_id != message.author.id: # Mark the channel as answered await self.unanswered.set(channel.id, False) @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: """Move an available channel to the In Use category and replace it with a dormant one.""" if message.author.bot: return # Ignore messages sent by bots. channel = message.channel await self.check_for_answer(message) is_available = channel_utils.is_in_category( channel, constants.Categories.help_available) if not is_available or self.is_excluded_channel(channel): return # Ignore messages outside the Available category or in excluded channels. log.trace( "Waiting for the cog to be ready before processing messages.") await self.ready.wait() log.trace( "Acquiring lock to prevent a channel from being processed twice..." ) async with self.on_message_lock: log.trace(f"on_message lock acquired for {message.id}.") if not channel_utils.is_in_category( channel, constants.Categories.help_available): log.debug( f"Message {message.id} will not make #{channel} ({channel.id}) in-use " f"because another message in the channel already triggered that." ) return log.info( f"Channel #{channel} was claimed by `{message.author.id}`.") await self.move_to_in_use(channel) await self.revoke_send_permissions(message.author) await self.pin(message) # Add user with channel for dormant check. await self.help_channel_claimants.set(channel.id, message.author.id) self.bot.stats.incr("help.claimed") # Must use a timezone-aware datetime to ensure a correct POSIX timestamp. timestamp = datetime.now(timezone.utc).timestamp() await self.claim_times.set(channel.id, timestamp) await self.unanswered.set(channel.id, True) log.trace(f"Releasing on_message lock for {message.id}.") # Move a dormant channel to the Available category to fill in the gap. # This is done last and outside the lock because it may wait indefinitely for a channel to # be put in the queue. await self.move_to_available() @commands.Cog.listener() async def on_message_delete(self, msg: discord.Message) -> None: """ Reschedule an in-use channel to become dormant sooner if the channel is empty. The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`. """ if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use): return if not await self.is_empty(msg.channel): return log.info( f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task." ) # Cancel existing dormant task before scheduling new. self.scheduler.cancel(msg.channel.id) delay = constants.HelpChannels.deleted_idle_minutes * 60 self.scheduler.schedule_later(delay, msg.channel.id, self.move_idle_channel(msg.channel)) async def is_empty(self, channel: discord.TextChannel) -> bool: """Return True if there's an AVAILABLE_MSG and the messages leading up are bot messages.""" log.trace(f"Checking if #{channel} ({channel.id}) is empty.") # A limit of 100 results in a single API call. # If AVAILABLE_MSG isn't found within 100 messages, then assume the channel is not empty. # Not gonna do an extensive search for it cause it's too expensive. async for msg in channel.history(limit=100): if not msg.author.bot: log.trace(f"#{channel} ({channel.id}) has a non-bot message.") return False if self.match_bot_embed(msg, AVAILABLE_MSG): log.trace( f"#{channel} ({channel.id}) has the available message embed." ) return True return False async def check_cooldowns(self) -> None: """Remove expired cooldowns and re-schedule active ones.""" log.trace("Checking all cooldowns to remove or re-schedule them.") guild = self.bot.get_guild(constants.Guild.id) cooldown = constants.HelpChannels.claim_minutes * 60 for channel_id, member_id in await self.help_channel_claimants.items(): member = guild.get_member(member_id) if not member: continue # Member probably left the guild. in_use_time = await self.get_in_use_time(channel_id) if not in_use_time or in_use_time.seconds > cooldown: # Remove the role if no claim time could be retrieved or if the cooldown expired. # Since the channel is in the claimants cache, it is definitely strange for a time # to not exist. However, it isn't a reason to keep the user stuck with a cooldown. await self.remove_cooldown_role(member) else: # The member is still on a cooldown; re-schedule it for the remaining time. delay = cooldown - in_use_time.seconds self.scheduler.schedule_later( delay, member.id, self.remove_cooldown_role(member)) async def add_cooldown_role(self, member: discord.Member) -> None: """Add the help cooldown role to `member`.""" log.trace(f"Adding cooldown role for {member} ({member.id}).") await self._change_cooldown_role(member, member.add_roles) async def remove_cooldown_role(self, member: discord.Member) -> None: """Remove the help cooldown role from `member`.""" log.trace(f"Removing cooldown role for {member} ({member.id}).") await self._change_cooldown_role(member, member.remove_roles) async def _change_cooldown_role(self, member: discord.Member, coro_func: CoroutineFunc) -> None: """ Change `member`'s cooldown role via awaiting `coro_func` and handle errors. `coro_func` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. """ guild = self.bot.get_guild(constants.Guild.id) role = guild.get_role(constants.Roles.help_cooldown) if role is None: log.warning( f"Help cooldown role ({constants.Roles.help_cooldown}) could not be found!" ) return try: await coro_func(role) except discord.NotFound: log.debug( f"Failed to change role for {member} ({member.id}): member not found" ) except discord.Forbidden: log.debug(f"Forbidden to change role for {member} ({member.id}); " f"possibly due to role hierarchy") except discord.HTTPException as e: log.error( f"Failed to change role for {member} ({member.id}): {e.status} {e.code}" ) async def revoke_send_permissions(self, member: discord.Member) -> None: """ Disallow `member` to send messages in the Available category for a certain time. The time until permissions are reinstated can be configured with `HelpChannels.claim_minutes`. """ log.trace( f"Revoking {member}'s ({member.id}) send message permissions in the Available category." ) await self.add_cooldown_role(member) # Cancel the existing task, if any. # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). if member.id in self.scheduler: self.scheduler.cancel(member.id) delay = constants.HelpChannels.claim_minutes * 60 self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) async def send_available_message(self, channel: discord.TextChannel) -> None: """Send the available message by editing a dormant message or sending a new message.""" channel_info = f"#{channel} ({channel.id})" log.trace(f"Sending available message in {channel_info}.") embed = discord.Embed(description=AVAILABLE_MSG) msg = await self.get_last_message(channel) if self.match_bot_embed(msg, DORMANT_MSG): log.trace( f"Found dormant message {msg.id} in {channel_info}; editing it." ) await msg.edit(embed=embed) else: log.trace( f"Dormant message not found in {channel_info}; sending a new message." ) await channel.send(embed=embed) async def pin_wrapper(self, msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool: """ Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False. Return True if successful and False otherwise. """ channel_str = f"#{channel} ({channel.id})" if pin: func = self.bot.http.pin_message verb = "pin" else: func = self.bot.http.unpin_message verb = "unpin" try: await func(channel.id, msg_id) except discord.HTTPException as e: if e.code == 10008: log.debug( f"Message {msg_id} in {channel_str} doesn't exist; can't {verb}." ) else: log.exception( f"Error {verb}ning message {msg_id} in {channel_str}: {e.status} ({e.code})" ) return False else: log.trace( f"{verb.capitalize()}ned message {msg_id} in {channel_str}.") return True async def pin(self, message: discord.Message) -> None: """Pin an initial question `message` and store it in a cache.""" if await self.pin_wrapper(message.id, message.channel, pin=True): await self.question_messages.set(message.channel.id, message.id) async def unpin(self, channel: discord.TextChannel) -> None: """Unpin the initial question message sent in `channel`.""" msg_id = await self.question_messages.pop(channel.id) if msg_id is None: log.debug( f"#{channel} ({channel.id}) doesn't have a message pinned.") else: await self.pin_wrapper(msg_id, channel, pin=False) async def wait_for_dormant_channel(self) -> discord.TextChannel: """Wait for a dormant channel to become available in the queue and return it.""" log.trace("Waiting for a dormant channel.") task = asyncio.create_task(self.channel_queue.get()) self.queue_tasks.append(task) channel = await task log.trace( f"Channel #{channel} ({channel.id}) finally retrieved from the queue." ) self.queue_tasks.remove(task) return channel
class DocCog(commands.Cog): """A set of commands for querying & displaying documentation.""" def __init__(self, bot: Bot): # Contains URLs to documentation home pages. # Used to calculate inventory diffs on refreshes and to display all currently stored inventories. self.base_urls = {} self.bot = bot self.doc_symbols: Dict[str, DocItem] = { } # Maps symbol names to objects containing their metadata. self.item_fetcher = _batch_parser.BatchParser() # Maps a conflicting symbol name to a list of the new, disambiguated names created from conflicts with the name. self.renamed_symbols = defaultdict(list) self.inventory_scheduler = Scheduler(self.__class__.__name__) self.refresh_event = asyncio.Event() self.refresh_event.set() self.symbol_get_event = SharedEvent() self.init_refresh_task = self.bot.loop.create_task( self.init_refresh_inventory(), name="Doc inventory init") @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) async def init_refresh_inventory(self) -> None: """Refresh documentation inventory on cog initialization.""" await self.bot.wait_until_guild_available() await self.refresh_inventories() def update_single(self, package_name: str, base_url: str, inventory: InventoryDict) -> None: """ Build the inventory for a single package. Where: * `package_name` is the package name to use in logs and when qualifying symbols * `base_url` is the root documentation URL for the specified package, used to build absolute paths that link to specific symbols * `package` is the content of a intersphinx inventory. """ self.base_urls[package_name] = base_url for group, items in inventory.items(): for symbol_name, relative_doc_url in items: # e.g. get 'class' from 'py:class' group_name = group.split(":")[1] symbol_name = self.ensure_unique_symbol_name( package_name, group_name, symbol_name, ) relative_url_path, _, symbol_id = relative_doc_url.partition( "#") # Intern fields that have shared content so we're not storing unique strings for every object doc_item = DocItem( package_name, sys.intern(group_name), base_url, sys.intern(relative_url_path), symbol_id, ) self.doc_symbols[symbol_name] = doc_item self.item_fetcher.add_item(doc_item) log.trace(f"Fetched inventory for {package_name}.") async def update_or_reschedule_inventory( self, api_package_name: str, base_url: str, inventory_url: str, ) -> None: """ Update the cog's inventories, or reschedule this method to execute again if the remote inventory is unreachable. The first attempt is rescheduled to execute in `FETCH_RESCHEDULE_DELAY.first` minutes, the subsequent attempts in `FETCH_RESCHEDULE_DELAY.repeated` minutes. """ package = await fetch_inventory(inventory_url) if not package: if api_package_name in self.inventory_scheduler: self.inventory_scheduler.cancel(api_package_name) delay = FETCH_RESCHEDULE_DELAY.repeated else: delay = FETCH_RESCHEDULE_DELAY.first log.info( f"Failed to fetch inventory; attempting again in {delay} minutes." ) self.inventory_scheduler.schedule_later( delay * 60, api_package_name, self.update_or_reschedule_inventory(api_package_name, base_url, inventory_url), ) else: self.update_single(api_package_name, base_url, package) def ensure_unique_symbol_name(self, package_name: str, group_name: str, symbol_name: str) -> str: """ Ensure `symbol_name` doesn't overwrite an another symbol in `doc_symbols`. For conflicts, rename either the current symbol or the existing symbol with which it conflicts. Store the new name in `renamed_symbols` and return the name to use for the symbol. If the existing symbol was renamed or there was no conflict, the returned name is equivalent to `symbol_name`. """ if (item := self.doc_symbols.get(symbol_name)) is None: return symbol_name # There's no conflict so it's fine to simply use the given symbol name. def rename(prefix: str, *, rename_extant: bool = False) -> str: new_name = f"{prefix}.{symbol_name}" if new_name in self.doc_symbols: # If there's still a conflict, qualify the name further. if rename_extant: new_name = f"{item.package}.{item.group}.{symbol_name}" else: new_name = f"{package_name}.{group_name}.{symbol_name}" self.renamed_symbols[symbol_name].append(new_name) if rename_extant: # Instead of renaming the current symbol, rename the symbol with which it conflicts. self.doc_symbols[new_name] = self.doc_symbols[symbol_name] return symbol_name else: return new_name # Certain groups are added as prefixes to disambiguate the symbols. if group_name in FORCE_PREFIX_GROUPS: return rename(group_name) # The existing symbol with which the current symbol conflicts should have a group prefix. # It currently doesn't have the group prefix because it's only added once there's a conflict. elif item.group in FORCE_PREFIX_GROUPS: return rename(item.group, rename_extant=True) elif package_name in PRIORITY_PACKAGES: return rename(item.package, rename_extant=True) # If we can't specially handle the symbol through its group or package, # fall back to prepending its package name to the front. else: return rename(package_name)
class Silence(commands.Cog): """Commands for stopping channel messages for `everyone` role in a channel.""" # Maps muted channel IDs to their previous overwrites for send_message and add_reactions. # Overwrites are stored as JSON. previous_overwrites = RedisCache() # Maps muted channel IDs to POSIX timestamps of when they'll be unsilenced. # A timestamp equal to -1 means it's indefinite. unsilence_timestamps = RedisCache() def __init__(self, bot: Bot): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) self._init_task = scheduling.create_task(self._async_init(), event_loop=self.bot.loop) async def _async_init(self) -> None: """Set instance attributes once the guild is available and reschedule unsilences.""" await self.bot.wait_until_guild_available() guild = self.bot.get_guild(constants.Guild.id) self._everyone_role = guild.default_role self._verified_voice_role = guild.get_role( constants.Roles.voice_verified) self._mod_alerts_channel = self.bot.get_channel( constants.Channels.mod_alerts) self.notifier = SilenceNotifier( self.bot.get_channel(constants.Channels.mod_log)) await self._reschedule() async def send_message(self, message: str, source_channel: TextChannel, target_channel: TextOrVoiceChannel, *, alert_target: bool = False) -> None: """Helper function to send message confirmation to `source_channel`, and notification to `target_channel`.""" # Reply to invocation channel source_reply = message if source_channel != target_channel: source_reply = source_reply.format(channel=target_channel.mention) else: source_reply = source_reply.format(channel="current channel") await source_channel.send(source_reply) # Reply to target channel if alert_target: if isinstance(target_channel, VoiceChannel): voice_chat = self.bot.get_channel( VOICE_CHANNELS.get(target_channel.id)) if voice_chat and source_channel != voice_chat: await voice_chat.send( message.format(channel=target_channel.mention)) elif source_channel != target_channel: await target_channel.send( message.format(channel="current channel")) @commands.command(aliases=("hush", )) @lock(LOCK_NAMESPACE, _select_lock_channel, raise_error=True) async def silence(self, ctx: Context, duration_or_channel: typing.Union[ TextOrVoiceChannel, HushDurationConverter] = None, duration: HushDurationConverter = 10, *, kick: bool = False) -> None: """ Silence the current channel for `duration` minutes or `forever`. Duration is capped at 15 minutes, passing forever makes the silence indefinite. Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. Passing a voice channel will attempt to move members out of the channel and back to force sync permissions. If `kick` is True, members will not be added back to the voice channel, and members will be unable to rejoin. """ await self._init_task channel, duration = self.parse_silence_args(ctx, duration_or_channel, duration) channel_info = f"#{channel} ({channel.id})" log.debug(f"{ctx.author} is silencing channel {channel_info}.") # Since threads don't have specific overrides, we cannot silence them individually. # The parent channel has to be muted or the thread should be archived. if isinstance(channel, Thread): await ctx.send(":x: Threads cannot be silenced.") return if not await self._set_silence_overwrites(channel, kick=kick): log.info( f"Tried to silence channel {channel_info} but the channel was already silenced." ) await self.send_message(MSG_SILENCE_FAIL, ctx.channel, channel, alert_target=False) return if isinstance(channel, VoiceChannel): if kick: await self._kick_voice_members(channel) else: await self._force_voice_sync(channel) await self._schedule_unsilence(ctx, channel, duration) if duration is None: self.notifier.add_channel(channel) log.info(f"Silenced {channel_info} indefinitely.") await self.send_message(MSG_SILENCE_PERMANENT, ctx.channel, channel, alert_target=True) else: log.info(f"Silenced {channel_info} for {duration} minute(s).") formatted_message = MSG_SILENCE_SUCCESS.format(duration=duration) await self.send_message(formatted_message, ctx.channel, channel, alert_target=True) @staticmethod def parse_silence_args( ctx: Context, duration_or_channel: typing.Union[TextOrVoiceChannel, int], duration: HushDurationConverter ) -> typing.Tuple[TextOrVoiceChannel, Optional[int]]: """Helper method to parse the arguments of the silence command.""" if duration_or_channel: if isinstance(duration_or_channel, (TextChannel, VoiceChannel)): channel = duration_or_channel else: channel = ctx.channel duration = duration_or_channel else: channel = ctx.channel if duration == -1: duration = None return channel, duration async def _set_silence_overwrites(self, channel: TextOrVoiceChannel, *, kick: bool = False) -> bool: """Set silence permission overwrites for `channel` and return True if successful.""" # Get the original channel overwrites if isinstance(channel, TextChannel): role = self._everyone_role overwrite = channel.overwrites_for(role) prev_overwrites = dict( send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions, create_private_threads=overwrite.create_private_threads, create_public_threads=overwrite.create_public_threads, send_messages_in_threads=overwrite.send_messages_in_threads) else: role = self._verified_voice_role overwrite = channel.overwrites_for(role) prev_overwrites = dict(speak=overwrite.speak) if kick: prev_overwrites.update(connect=overwrite.connect) # Stop if channel was already silenced if channel.id in self.scheduler or all( val is False for val in prev_overwrites.values()): return False # Set new permissions, store overwrite.update(**dict.fromkeys(prev_overwrites, False)) await channel.set_permissions(role, overwrite=overwrite) await self.previous_overwrites.set(channel.id, json.dumps(prev_overwrites)) return True async def _schedule_unsilence(self, ctx: Context, channel: TextOrVoiceChannel, duration: Optional[int]) -> None: """Schedule `ctx.channel` to be unsilenced if `duration` is not None.""" if duration is None: await self.unsilence_timestamps.set(channel.id, -1) else: self.scheduler.schedule_later( duration * 60, channel.id, ctx.invoke(self.unsilence, channel=channel)) unsilence_time = datetime.now(tz=timezone.utc) + timedelta( minutes=duration) await self.unsilence_timestamps.set(channel.id, unsilence_time.timestamp()) @commands.command(aliases=("unhush", )) async def unsilence(self, ctx: Context, *, channel: TextOrVoiceChannel = None) -> None: """ Unsilence the given channel if given, else the current one. If the channel was silenced indefinitely, notifications for the channel will stop. """ await self._init_task if channel is None: channel = ctx.channel log.debug( f"Unsilencing channel #{channel} from {ctx.author}'s command.") await self._unsilence_wrapper(channel, ctx) @lock_arg(LOCK_NAMESPACE, "channel", raise_error=True) async def _unsilence_wrapper(self, channel: TextOrVoiceChannel, ctx: Optional[Context] = None) -> None: """ Unsilence `channel` and send a success/failure message to ctx.channel. If ctx is None or not passed, `channel` is used in its place. If `channel` and ctx.channel are the same, only one message is sent. """ msg_channel = channel if ctx is not None: msg_channel = ctx.channel if not await self._unsilence(channel): if isinstance(channel, VoiceChannel): overwrite = channel.overwrites_for(self._verified_voice_role) has_channel_overwrites = overwrite.speak is False else: overwrite = channel.overwrites_for(self._everyone_role) has_channel_overwrites = overwrite.send_messages is False or overwrite.add_reactions is False # Send fail message to muted channel or voice chat channel, and invocation channel if has_channel_overwrites: await self.send_message(MSG_UNSILENCE_MANUAL, msg_channel, channel, alert_target=False) else: await self.send_message(MSG_UNSILENCE_FAIL, msg_channel, channel, alert_target=False) else: await self.send_message(MSG_UNSILENCE_SUCCESS, msg_channel, channel, alert_target=True) async def _unsilence(self, channel: TextOrVoiceChannel) -> bool: """ Unsilence `channel`. If `channel` has a silence task scheduled or has its previous overwrites cached, unsilence it, cancel the task, and remove it from the notifier. Notify admins if it has a task but not cached overwrites. Return `True` if channel permissions were changed, `False` otherwise. """ # Get stored overwrites, and return if channel is unsilenced prev_overwrites = await self.previous_overwrites.get(channel.id) if channel.id not in self.scheduler and prev_overwrites is None: log.info( f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced." ) return False # Select the role based on channel type, and get current overwrites if isinstance(channel, TextChannel): role = self._everyone_role overwrite = channel.overwrites_for(role) permissions = "`Send Messages` and `Add Reactions`" else: role = self._verified_voice_role overwrite = channel.overwrites_for(role) permissions = "`Speak` and `Connect`" # Check if old overwrites were not stored if prev_overwrites is None: log.info( f"Missing previous overwrites for #{channel} ({channel.id}); defaulting to None." ) overwrite.update(send_messages=None, add_reactions=None, create_private_threads=None, create_public_threads=None, send_messages_in_threads=None, speak=None, connect=None) else: overwrite.update(**json.loads(prev_overwrites)) # Update Permissions await channel.set_permissions(role, overwrite=overwrite) if isinstance(channel, VoiceChannel): await self._force_voice_sync(channel) log.info(f"Unsilenced channel #{channel} ({channel.id}).") self.scheduler.cancel(channel.id) self.notifier.remove_channel(channel) await self.previous_overwrites.delete(channel.id) await self.unsilence_timestamps.delete(channel.id) # Alert Admin team if old overwrites were not available if prev_overwrites is None: await self._mod_alerts_channel.send( f"<@&{constants.Roles.admins}> Restored overwrites with default values after unsilencing " f"{channel.mention}. Please check that the {permissions} " f"overwrites for {role.mention} are at their desired values.") return True @staticmethod async def _get_afk_channel(guild: Guild) -> VoiceChannel: """Get a guild's AFK channel, or create one if it does not exist.""" afk_channel = guild.afk_channel if afk_channel is None: overwrites = { guild.default_role: PermissionOverwrite(speak=False, connect=False, view_channel=False) } afk_channel = await guild.create_voice_channel( "mute-temp", overwrites=overwrites) log.info( f"Failed to get afk-channel, created #{afk_channel} ({afk_channel.id})" ) return afk_channel @staticmethod async def _kick_voice_members(channel: VoiceChannel) -> None: """Remove all non-staff members from a voice channel.""" log.debug( f"Removing all non staff members from #{channel.name} ({channel.id})." ) for member in channel.members: # Skip staff if any(role.id in constants.MODERATION_ROLES for role in member.roles): continue try: await member.move_to( None, reason="Kicking member from voice channel.") log.trace(f"Kicked {member.name} from voice channel.") except Exception as e: log.debug(f"Failed to move {member.name}. Reason: {e}") continue log.debug("Removed all members.") async def _force_voice_sync(self, channel: VoiceChannel) -> None: """ Move all non-staff members from `channel` to a temporary channel and back to force toggle role mute. Permission modification has to happen before this function. """ # Obtain temporary channel delete_channel = channel.guild.afk_channel is None afk_channel = await self._get_afk_channel(channel.guild) try: # Move all members to temporary channel and back for member in channel.members: # Skip staff if any(role.id in constants.MODERATION_ROLES for role in member.roles): continue try: await member.move_to(afk_channel, reason="Muting VC member.") log.trace(f"Moved {member.name} to afk channel.") await member.move_to(channel, reason="Muting VC member.") log.trace( f"Moved {member.name} to original voice channel.") except Exception as e: log.debug(f"Failed to move {member.name}. Reason: {e}") continue finally: # Delete VC channel if it was created. if delete_channel: await afk_channel.delete( reason="Deleting temporary mute channel.") async def _reschedule(self) -> None: """Reschedule unsilencing of active silences and add permanent ones to the notifier.""" for channel_id, timestamp in await self.unsilence_timestamps.items(): channel = self.bot.get_channel(channel_id) if channel is None: log.info( f"Can't reschedule silence for {channel_id}: channel not found." ) continue if timestamp == -1: log.info( f"Adding permanent silence for #{channel} ({channel.id}) to the notifier." ) self.notifier.add_channel(channel) continue dt = datetime.fromtimestamp(timestamp, tz=timezone.utc) delta = (dt - datetime.now(tz=timezone.utc)).total_seconds() if delta <= 0: # Suppress the error since it's not being invoked by a user via the command. with suppress(LockedResourceError): await self._unsilence_wrapper(channel) else: log.info( f"Rescheduling silence for #{channel} ({channel.id}).") self.scheduler.schedule_later(delta, channel_id, self._unsilence_wrapper(channel)) def cog_unload(self) -> None: """Cancel the init task and scheduled tasks.""" # It's important to wait for _init_task (specifically for _reschedule) to be cancelled # before cancelling scheduled tasks. Otherwise, it's possible for _reschedule to schedule # more tasks after cancel_all has finished, despite _init_task.cancel being called first. # This is cause cancel() on its own doesn't block until the task is cancelled. self._init_task.cancel() self._init_task.add_done_callback( lambda _: self.scheduler.cancel_all()) # This cannot be static (must have a __func__ attribute). async def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" return await commands.has_any_role(*constants.MODERATION_ROLES ).predicate(ctx)
class Silence(commands.Cog): """Commands for stopping channel messages for `verified` role in a channel.""" def __init__(self, bot: Bot): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) self.muted_channels = set() self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) self._get_instance_vars_event = asyncio.Event() async def _get_instance_vars(self) -> None: """Get instance variables after they're available to get from the guild.""" await self.bot.wait_until_guild_available() guild = self.bot.get_guild(Guild.id) self._verified_role = guild.get_role(Roles.verified) self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) self._mod_log_channel = self.bot.get_channel(Channels.mod_log) self.notifier = SilenceNotifier(self._mod_log_channel) self._get_instance_vars_event.set() @commands.command(aliases=("hush",)) async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None: """ Silence the current channel for `duration` minutes or `forever`. Duration is capped at 15 minutes, passing forever makes the silence indefinite. Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. """ await self._get_instance_vars_event.wait() log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): await ctx.send(f"{Emojis.cross_mark} current channel is already silenced.") return if duration is None: await ctx.send(f"{Emojis.check_mark} silenced current channel indefinitely.") return await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context) -> None: """ Unsilence the current channel. If the channel was silenced indefinitely, notifications for the channel will stop. """ await self._get_instance_vars_event.wait() log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") if not await self._unsilence(ctx.channel): await ctx.send(f"{Emojis.cross_mark} current channel was not silenced.") else: await ctx.send(f"{Emojis.check_mark} unsilenced current channel.") async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool: """ Silence `channel` for `self._verified_role`. If `persistent` is `True` add `channel` to notifier. `duration` is only used for logging; if None is passed `persistent` should be True to not log None. Return `True` if channel permissions were changed, `False` otherwise. """ current_overwrite = channel.overwrites_for(self._verified_role) if current_overwrite.send_messages is False: log.info(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") return False await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=False)) self.muted_channels.add(channel) if persistent: log.info(f"Silenced #{channel} ({channel.id}) indefinitely.") self.notifier.add_channel(channel) return True log.info(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).") return True async def _unsilence(self, channel: TextChannel) -> bool: """ Unsilence `channel`. Check if `channel` is silenced through a `PermissionOverwrite`, if it is unsilence it and remove it from the notifier. Return `True` if channel permissions were changed, `False` otherwise. """ current_overwrite = channel.overwrites_for(self._verified_role) if current_overwrite.send_messages is False: await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=None)) log.info(f"Unsilenced channel #{channel} ({channel.id}).") self.scheduler.cancel(channel.id) self.notifier.remove_channel(channel) self.muted_channels.discard(channel) return True log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") return False def cog_unload(self) -> None: """Send alert with silenced channels on unload.""" if self.muted_channels: channels_string = ''.join(channel.mention for channel in self.muted_channels) message = f"<@&{Roles.moderators}> channels left silenced on cog unload: {channels_string}" asyncio.create_task(self._mod_alerts_channel.send(message)) # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" return with_role_check(ctx, *MODERATION_ROLES)
class HelpChannels(commands.Cog): """ Manage the help channel system of the guild. The system is based on a 3-category system: Available Category * Contains channels which are ready to be occupied by someone who needs help * Will always contain `constants.HelpChannels.max_available` channels; refilled automatically from the pool of dormant channels * Prioritise using the channels which have been dormant for the longest amount of time * If there are no more dormant channels, the bot will automatically create a new one * If there are no dormant channels to move, helpers will be notified (see `notify()`) * When a channel becomes available, the dormant embed will be edited to show `AVAILABLE_MSG` * User can only claim a channel at an interval `constants.HelpChannels.claim_minutes` * To keep track of cooldowns, user which claimed a channel will have a temporary role In Use Category * Contains all channels which are occupied by someone needing help * Channel moves to dormant category after `constants.HelpChannels.idle_minutes` of being idle * Command can prematurely mark a channel as dormant * Channel claimant is allowed to use the command * Allowed roles for the command are configurable with `constants.HelpChannels.cmd_whitelist` * When a channel becomes dormant, an embed with `DORMANT_MSG` will be sent Dormant Category * Contains channels which aren't in use * Channels are used to refill the Available category Help channels are named after the chemical elements in `bot/resources/elements.json`. """ def __init__(self, bot: Bot): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) # Categories self.available_category: discord.CategoryChannel = None self.in_use_category: discord.CategoryChannel = None self.dormant_category: discord.CategoryChannel = None # Queues self.channel_queue: asyncio.Queue[discord.TextChannel] = None self.name_queue: t.Deque[str] = None self.last_notification: t.Optional[datetime] = None # Asyncio stuff self.queue_tasks: t.List[asyncio.Task] = [] self.on_message_lock = asyncio.Lock() self.init_task = self.bot.loop.create_task(self.init_cog()) def cog_unload(self) -> None: """Cancel the init task and scheduled tasks when the cog unloads.""" log.trace("Cog unload: cancelling the init_cog task") self.init_task.cancel() log.trace("Cog unload: cancelling the channel queue tasks") for task in self.queue_tasks: task.cancel() self.scheduler.cancel_all() def create_channel_queue(self) -> asyncio.Queue: """ Return a queue of dormant channels to use for getting the next available channel. The channels are added to the queue in a random order. """ log.trace("Creating the channel queue.") channels = list(_channel.get_category_channels(self.dormant_category)) random.shuffle(channels) log.trace("Populating the channel queue with channels.") queue = asyncio.Queue() for channel in channels: queue.put_nowait(channel) return queue async def create_dormant(self) -> t.Optional[discord.TextChannel]: """ Create and return a new channel in the Dormant category. The new channel will sync its permission overwrites with the category. Return None if no more channel names are available. """ log.trace("Getting a name for a new dormant channel.") try: name = self.name_queue.popleft() except IndexError: log.debug("No more names available for new dormant channels.") return None log.debug(f"Creating a new dormant channel named {name}.") return await self.dormant_category.create_text_channel(name, topic=HELP_CHANNEL_TOPIC) async def dormant_check(self, ctx: commands.Context) -> bool: """Return True if the user is the help channel claimant or passes the role check.""" if await _caches.claimants.get(ctx.channel.id) == ctx.author.id: log.trace(f"{ctx.author} is the help channel claimant, passing the check for dormant.") self.bot.stats.incr("help.dormant_invoke.claimant") return True log.trace(f"{ctx.author} is not the help channel claimant, checking roles.") has_role = await commands.has_any_role(*constants.HelpChannels.cmd_whitelist).predicate(ctx) if has_role: self.bot.stats.incr("help.dormant_invoke.staff") return has_role @commands.command(name="close", aliases=["dormant", "solved"], enabled=False) async def close_command(self, ctx: commands.Context) -> None: """ Make the current in-use help channel dormant. Make the channel dormant if the user passes the `dormant_check`, delete the message that invoked this, and reset the send permissions cooldown for the user who started the session. """ log.trace("close command invoked; checking if the channel is in-use.") if ctx.channel.category == self.in_use_category: if await self.dormant_check(ctx): await _cooldown.remove_cooldown_role(ctx.author) # Ignore missing task when cooldown has passed but the channel still isn't dormant. if ctx.author.id in self.scheduler: self.scheduler.cancel(ctx.author.id) await self.move_to_dormant(ctx.channel, "command") self.scheduler.cancel(ctx.channel.id) else: log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") async def get_available_candidate(self) -> discord.TextChannel: """ Return a dormant channel to turn into an available channel. If no channel is available, wait indefinitely until one becomes available. """ log.trace("Getting an available channel candidate.") try: channel = self.channel_queue.get_nowait() except asyncio.QueueEmpty: log.info("No candidate channels in the queue; creating a new channel.") channel = await self.create_dormant() if not channel: log.info("Couldn't create a candidate channel; waiting to get one from the queue.") notify_channel = self.bot.get_channel(constants.HelpChannels.notify_channel) last_notification = await _message.notify(notify_channel, self.last_notification) if last_notification: self.last_notification = last_notification self.bot.stats.incr("help.out_of_channel_alerts") channel = await self.wait_for_dormant_channel() return channel async def init_available(self) -> None: """Initialise the Available category with channels.""" log.trace("Initialising the Available category with channels.") channels = list(_channel.get_category_channels(self.available_category)) missing = constants.HelpChannels.max_available - len(channels) # If we've got less than `max_available` channel available, we should add some. if missing > 0: log.trace(f"Moving {missing} missing channels to the Available category.") for _ in range(missing): await self.move_to_available() # If for some reason we have more than `max_available` channels available, # we should move the superfluous ones over to dormant. elif missing < 0: log.trace(f"Moving {abs(missing)} superfluous available channels over to the Dormant category.") for channel in channels[:abs(missing)]: await self.move_to_dormant(channel, "auto") async def init_categories(self) -> None: """Get the help category objects. Remove the cog if retrieval fails.""" log.trace("Getting the CategoryChannel objects for the help categories.") try: self.available_category = await channel_utils.try_get_channel( constants.Categories.help_available ) self.in_use_category = await channel_utils.try_get_channel( constants.Categories.help_in_use ) self.dormant_category = await channel_utils.try_get_channel( constants.Categories.help_dormant ) except discord.HTTPException: log.exception("Failed to get a category; cog will be removed") self.bot.remove_cog(self.qualified_name) async def init_cog(self) -> None: """Initialise the help channel system.""" log.trace("Waiting for the guild to be available before initialisation.") await self.bot.wait_until_guild_available() log.trace("Initialising the cog.") await self.init_categories() await _cooldown.check_cooldowns(self.scheduler) self.channel_queue = self.create_channel_queue() self.name_queue = _name.create_name_queue( self.available_category, self.in_use_category, self.dormant_category, ) log.trace("Moving or rescheduling in-use channels.") for channel in _channel.get_category_channels(self.in_use_category): await self.move_idle_channel(channel, has_task=False) # Prevent the command from being used until ready. # The ready event wasn't used because channels could change categories between the time # the command is invoked and the cog is ready (e.g. if move_idle_channel wasn't called yet). # This may confuse users. So would potentially long delays for the cog to become ready. self.close_command.enabled = True await self.init_available() self.report_stats() log.info("Cog is ready!") def report_stats(self) -> None: """Report the channel count stats.""" total_in_use = sum(1 for _ in _channel.get_category_channels(self.in_use_category)) total_available = sum(1 for _ in _channel.get_category_channels(self.available_category)) total_dormant = sum(1 for _ in _channel.get_category_channels(self.dormant_category)) self.bot.stats.gauge("help.total.in_use", total_in_use) self.bot.stats.gauge("help.total.available", total_available) self.bot.stats.gauge("help.total.dormant", total_dormant) async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None: """ Make the `channel` dormant if idle or schedule the move if still active. If `has_task` is True and rescheduling is required, the extant task to make the channel dormant will first be cancelled. """ log.trace(f"Handling in-use channel #{channel} ({channel.id}).") if not await _message.is_empty(channel): idle_seconds = constants.HelpChannels.idle_minutes * 60 else: idle_seconds = constants.HelpChannels.deleted_idle_minutes * 60 time_elapsed = await _channel.get_idle_time(channel) if time_elapsed is None or time_elapsed >= idle_seconds: log.info( f"#{channel} ({channel.id}) is idle longer than {idle_seconds} seconds " f"and will be made dormant." ) await self.move_to_dormant(channel, "auto") else: # Cancel the existing task, if any. if has_task: self.scheduler.cancel(channel.id) delay = idle_seconds - time_elapsed log.info( f"#{channel} ({channel.id}) is still active; " f"scheduling it to be moved after {delay} seconds." ) self.scheduler.schedule_later(delay, channel.id, self.move_idle_channel(channel)) async def move_to_bottom_position(self, channel: discord.TextChannel, category_id: int, **options) -> None: """ Move the `channel` to the bottom position of `category` and edit channel attributes. To ensure "stable sorting", we use the `bulk_channel_update` endpoint and provide the current positions of the other channels in the category as-is. This should make sure that the channel really ends up at the bottom of the category. If `options` are provided, the channel will be edited after the move is completed. This is the same order of operations that `discord.TextChannel.edit` uses. For information on available options, see the documentation on `discord.TextChannel.edit`. While possible, position-related options should be avoided, as it may interfere with the category move we perform. """ # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. category = await channel_utils.try_get_channel(category_id) payload = [{"id": c.id, "position": c.position} for c in category.channels] # Calculate the bottom position based on the current highest position in the category. If the # category is currently empty, we simply use the current position of the channel to avoid making # unnecessary changes to positions in the guild. bottom_position = payload[-1]["position"] + 1 if payload else channel.position payload.append( { "id": channel.id, "position": bottom_position, "parent_id": category.id, "lock_permissions": True, } ) # We use d.py's method to ensure our request is processed by d.py's rate limit manager await self.bot.http.bulk_channel_update(category.guild.id, payload) # Now that the channel is moved, we can edit the other attributes if options: await channel.edit(**options) async def move_to_available(self) -> None: """Make a channel available.""" log.trace("Making a channel available.") channel = await self.get_available_candidate() log.info(f"Making #{channel} ({channel.id}) available.") await _message.send_available_message(channel) log.trace(f"Moving #{channel} ({channel.id}) to the Available category.") await self.move_to_bottom_position( channel=channel, category_id=constants.Categories.help_available, ) self.report_stats() async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None: """ Make the `channel` dormant. A caller argument is provided for metrics. """ log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") await _caches.claimants.delete(channel.id) await self.move_to_bottom_position( channel=channel, category_id=constants.Categories.help_dormant, ) self.bot.stats.incr(f"help.dormant_calls.{caller}") in_use_time = await _channel.get_in_use_time(channel.id) if in_use_time: self.bot.stats.timing("help.in_use_time", in_use_time) unanswered = await _caches.unanswered.get(channel.id) if unanswered: self.bot.stats.incr("help.sessions.unanswered") elif unanswered is not None: self.bot.stats.incr("help.sessions.answered") log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") log.trace(f"Sending dormant message for #{channel} ({channel.id}).") embed = discord.Embed(description=_message.DORMANT_MSG) await channel.send(embed=embed) await _message.unpin(channel) log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") self.channel_queue.put_nowait(channel) self.report_stats() async def move_to_in_use(self, channel: discord.TextChannel) -> None: """Make a channel in-use and schedule it to be made dormant.""" log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") await self.move_to_bottom_position( channel=channel, category_id=constants.Categories.help_in_use, ) timeout = constants.HelpChannels.idle_minutes * 60 log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.") self.scheduler.schedule_later(timeout, channel.id, self.move_idle_channel(channel)) self.report_stats() @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: """Move an available channel to the In Use category and replace it with a dormant one.""" if message.author.bot: return # Ignore messages sent by bots. channel = message.channel await _message.check_for_answer(message) is_available = channel_utils.is_in_category(channel, constants.Categories.help_available) if not is_available or _channel.is_excluded_channel(channel): return # Ignore messages outside the Available category or in excluded channels. log.trace("Waiting for the cog to be ready before processing messages.") await self.init_task log.trace("Acquiring lock to prevent a channel from being processed twice...") async with self.on_message_lock: log.trace(f"on_message lock acquired for {message.id}.") if not channel_utils.is_in_category(channel, constants.Categories.help_available): log.debug( f"Message {message.id} will not make #{channel} ({channel.id}) in-use " f"because another message in the channel already triggered that." ) return log.info(f"Channel #{channel} was claimed by `{message.author.id}`.") await self.move_to_in_use(channel) await _cooldown.revoke_send_permissions(message.author, self.scheduler) await _message.pin(message) # Add user with channel for dormant check. await _caches.claimants.set(channel.id, message.author.id) self.bot.stats.incr("help.claimed") # Must use a timezone-aware datetime to ensure a correct POSIX timestamp. timestamp = datetime.now(timezone.utc).timestamp() await _caches.claim_times.set(channel.id, timestamp) await _caches.unanswered.set(channel.id, True) log.trace(f"Releasing on_message lock for {message.id}.") # Move a dormant channel to the Available category to fill in the gap. # This is done last and outside the lock because it may wait indefinitely for a channel to # be put in the queue. await self.move_to_available() @commands.Cog.listener() async def on_message_delete(self, msg: discord.Message) -> None: """ Reschedule an in-use channel to become dormant sooner if the channel is empty. The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`. """ if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use): return if not await _message.is_empty(msg.channel): return log.trace("Waiting for the cog to be ready before processing deleted messages.") await self.init_task log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") # Cancel existing dormant task before scheduling new. self.scheduler.cancel(msg.channel.id) delay = constants.HelpChannels.deleted_idle_minutes * 60 self.scheduler.schedule_later(delay, msg.channel.id, self.move_idle_channel(msg.channel)) async def wait_for_dormant_channel(self) -> discord.TextChannel: """Wait for a dormant channel to become available in the queue and return it.""" log.trace("Waiting for a dormant channel.") task = asyncio.create_task(self.channel_queue.get()) self.queue_tasks.append(task) channel = await task log.trace(f"Channel #{channel} ({channel.id}) finally retrieved from the queue.") self.queue_tasks.remove(task) return channel
class Silence(commands.Cog): """Commands for stopping channel messages for `verified` role in a channel.""" # Maps muted channel IDs to their previous overwrites for send_message and add_reactions. # Overwrites are stored as JSON. previous_overwrites = RedisCache() # Maps muted channel IDs to POSIX timestamps of when they'll be unsilenced. # A timestamp equal to -1 means it's indefinite. unsilence_timestamps = RedisCache() def __init__(self, bot: Bot): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) self._init_task = self.bot.loop.create_task(self._async_init()) async def _async_init(self) -> None: """Set instance attributes once the guild is available and reschedule unsilences.""" await self.bot.wait_until_guild_available() guild = self.bot.get_guild(Guild.id) self._verified_role = guild.get_role(Roles.verified) self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) self.notifier = SilenceNotifier(self.bot.get_channel(Channels.mod_log)) await self._reschedule() @commands.command(aliases=("hush", )) @lock_arg(LOCK_NAMESPACE, "ctx", attrgetter("channel"), raise_error=True) async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None: """ Silence the current channel for `duration` minutes or `forever`. Duration is capped at 15 minutes, passing forever makes the silence indefinite. Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. """ await self._init_task channel_info = f"#{ctx.channel} ({ctx.channel.id})" log.debug(f"{ctx.author} is silencing channel {channel_info}.") if not await self._set_silence_overwrites(ctx.channel): log.info( f"Tried to silence channel {channel_info} but the channel was already silenced." ) await ctx.send(MSG_SILENCE_FAIL) return await self._schedule_unsilence(ctx, duration) if duration is None: self.notifier.add_channel(ctx.channel) log.info(f"Silenced {channel_info} indefinitely.") await ctx.send(MSG_SILENCE_PERMANENT) else: log.info(f"Silenced {channel_info} for {duration} minute(s).") await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration)) @commands.command(aliases=("unhush", )) async def unsilence(self, ctx: Context) -> None: """ Unsilence the current channel. If the channel was silenced indefinitely, notifications for the channel will stop. """ await self._init_task log.debug( f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") await self._unsilence_wrapper(ctx.channel) @lock_arg(LOCK_NAMESPACE, "channel", raise_error=True) async def _unsilence_wrapper(self, channel: TextChannel) -> None: """Unsilence `channel` and send a success/failure message.""" if not await self._unsilence(channel): overwrite = channel.overwrites_for(self._verified_role) if overwrite.send_messages is False or overwrite.add_reactions is False: await channel.send(MSG_UNSILENCE_MANUAL) else: await channel.send(MSG_UNSILENCE_FAIL) else: await channel.send(MSG_UNSILENCE_SUCCESS) async def _set_silence_overwrites(self, channel: TextChannel) -> bool: """Set silence permission overwrites for `channel` and return True if successful.""" overwrite = channel.overwrites_for(self._verified_role) prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) if channel.id in self.scheduler or all( val is False for val in prev_overwrites.values()): return False overwrite.update(send_messages=False, add_reactions=False) await channel.set_permissions(self._verified_role, overwrite=overwrite) await self.previous_overwrites.set(channel.id, json.dumps(prev_overwrites)) return True async def _schedule_unsilence(self, ctx: Context, duration: Optional[int]) -> None: """Schedule `ctx.channel` to be unsilenced if `duration` is not None.""" if duration is None: await self.unsilence_timestamps.set(ctx.channel.id, -1) else: self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) unsilence_time = datetime.now(tz=timezone.utc) + timedelta( minutes=duration) await self.unsilence_timestamps.set(ctx.channel.id, unsilence_time.timestamp()) async def _unsilence(self, channel: TextChannel) -> bool: """ Unsilence `channel`. If `channel` has a silence task scheduled or has its previous overwrites cached, unsilence it, cancel the task, and remove it from the notifier. Notify admins if it has a task but not cached overwrites. Return `True` if channel permissions were changed, `False` otherwise. """ prev_overwrites = await self.previous_overwrites.get(channel.id) if channel.id not in self.scheduler and prev_overwrites is None: log.info( f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced." ) return False overwrite = channel.overwrites_for(self._verified_role) if prev_overwrites is None: log.info( f"Missing previous overwrites for #{channel} ({channel.id}); defaulting to None." ) overwrite.update(send_messages=None, add_reactions=None) else: overwrite.update(**json.loads(prev_overwrites)) await channel.set_permissions(self._verified_role, overwrite=overwrite) log.info(f"Unsilenced channel #{channel} ({channel.id}).") self.scheduler.cancel(channel.id) self.notifier.remove_channel(channel) await self.previous_overwrites.delete(channel.id) await self.unsilence_timestamps.delete(channel.id) if prev_overwrites is None: await self._mod_alerts_channel.send( f"<@&{Roles.admins}> Restored overwrites with default values after unsilencing " f"{channel.mention}. Please check that the `Send Messages` and `Add Reactions` " f"overwrites for {self._verified_role.mention} are at their desired values." ) return True async def _reschedule(self) -> None: """Reschedule unsilencing of active silences and add permanent ones to the notifier.""" for channel_id, timestamp in await self.unsilence_timestamps.items(): channel = self.bot.get_channel(channel_id) if channel is None: log.info( f"Can't reschedule silence for {channel_id}: channel not found." ) continue if timestamp == -1: log.info( f"Adding permanent silence for #{channel} ({channel.id}) to the notifier." ) self.notifier.add_channel(channel) continue dt = datetime.fromtimestamp(timestamp, tz=timezone.utc) delta = (dt - datetime.now(tz=timezone.utc)).total_seconds() if delta <= 0: # Suppress the error since it's not being invoked by a user via the command. with suppress(LockedResourceError): await self._unsilence_wrapper(channel) else: log.info( f"Rescheduling silence for #{channel} ({channel.id}).") self.scheduler.schedule_later(delta, channel_id, self._unsilence_wrapper(channel)) def cog_unload(self) -> None: """Cancel the init task and scheduled tasks.""" # It's important to wait for _init_task (specifically for _reschedule) to be cancelled # before cancelling scheduled tasks. Otherwise, it's possible for _reschedule to schedule # more tasks after cancel_all has finished, despite _init_task.cancel being called first. # This is cause cancel() on its own doesn't block until the task is cancelled. self._init_task.cancel() self._init_task.add_done_callback( lambda _: self.scheduler.cancel_all()) # This cannot be static (must have a __func__ attribute). async def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" return await commands.has_any_role(*MODERATION_ROLES).predicate(ctx)