Esempio n. 1
0
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))
Esempio n. 2
0
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))
Esempio n. 3
0
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
Esempio n. 4
0
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)
Esempio n. 5
0
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)
Esempio n. 6
0
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)
Esempio n. 7
0
File: _cog.py Progetto: lxnn/bot
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
Esempio n. 8
0
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)