Exemple #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))
Exemple #2
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
Exemple #3
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)
Exemple #4
0
class Stream(commands.Cog):
    """Grant and revoke streaming permissions from members."""

    # Stores tasks to remove streaming permission
    # RedisCache[discord.Member.id, UtcPosixTimestamp]
    task_cache = RedisCache()

    def __init__(self, bot: Bot):
        self.bot = bot
        self.scheduler = Scheduler(self.__class__.__name__)
        self.reload_task = self.bot.loop.create_task(
            self._reload_tasks_from_redis())

    def cog_unload(self) -> None:
        """Cancel all scheduled tasks."""
        self.reload_task.cancel()
        self.reload_task.add_done_callback(
            lambda _: self.scheduler.cancel_all())

    async def _revoke_streaming_permission(self,
                                           member: discord.Member) -> None:
        """Remove the streaming permission from the given Member."""
        await self.task_cache.delete(member.id)
        await member.remove_roles(discord.Object(Roles.video),
                                  reason="Streaming access revoked")

    async def _reload_tasks_from_redis(self) -> None:
        """Reload outstanding tasks from redis on startup, delete the task if the member has since left the server."""
        await self.bot.wait_until_guild_available()
        items = await self.task_cache.items()
        for key, value in items:
            member = self.bot.get_guild(Guild.id).get_member(key)

            if not member:
                # Member isn't found in the cache
                try:
                    member = await self.bot.get_guild(Guild.id
                                                      ).fetch_member(key)
                except discord.errors.NotFound:
                    log.debug(
                        f"Member {key} left the guild before we could schedule "
                        "the revoking of their streaming permissions.")
                    await self.task_cache.delete(key)
                    continue
                except discord.HTTPException:
                    log.exception(
                        f"Exception while trying to retrieve member {key} from Discord."
                    )
                    continue

            revoke_time = Arrow.utcfromtimestamp(value)
            log.debug(
                f"Scheduling {member} ({member.id}) to have streaming permission revoked at {revoke_time}"
            )
            self.scheduler.schedule_at(
                revoke_time, key, self._revoke_streaming_permission(member))

    async def _suspend_stream(self, ctx: commands.Context,
                              member: discord.Member) -> None:
        """Suspend a member's stream."""
        await self.bot.wait_until_guild_available()
        voice_state = member.voice

        if not voice_state:
            return

        # If the user is streaming.
        if voice_state.self_stream:
            # End user's stream by moving them to AFK voice channel and back.
            original_vc = voice_state.channel
            await member.move_to(ctx.guild.afk_channel)
            await member.move_to(original_vc)

            # Notify.
            await ctx.send(f"{member.mention}'s stream has been suspended!")
            log.debug(
                f"Successfully suspended stream from {member} ({member.id}).")
            return

        log.debug(f"No stream found to suspend from {member} ({member.id}).")

    @commands.command(aliases=("streaming", ))
    @commands.has_any_role(*MODERATION_ROLES)
    async def stream(self,
                     ctx: commands.Context,
                     member: discord.Member,
                     duration: Expiry = None) -> None:
        """
        Temporarily grant streaming permissions to a member for a given duration.

        A unit of time should be appended to the duration.
        Units (∗case-sensitive):
        \u2003`y` - years
        \u2003`m` - months∗
        \u2003`w` - weeks
        \u2003`d` - days
        \u2003`h` - hours
        \u2003`M` - minutes∗
        \u2003`s` - seconds

        Alternatively, an ISO 8601 timestamp can be provided for the duration.
        """
        log.trace(
            f"Attempting to give temporary streaming permission to {member} ({member.id})."
        )

        if duration is None:
            # Use default duration and convert back to datetime as Embed.timestamp doesn't support Arrow
            duration = arrow.utcnow() + timedelta(
                minutes=VideoPermission.default_permission_duration)
            duration = duration.datetime
        elif duration.tzinfo is None:
            # Make duration tz-aware.
            # ISODateTime could already include tzinfo, this check is so it isn't overwritten.
            duration.replace(tzinfo=timezone.utc)

        # Check if the member already has streaming permission
        already_allowed = any(Roles.video == role.id for role in member.roles)
        if already_allowed:
            await ctx.send(
                f"{Emojis.cross_mark} {member.mention} can already stream.")
            log.debug(
                f"{member} ({member.id}) already has permission to stream.")
            return

        # Schedule task to remove streaming permission from Member and add it to task cache
        self.scheduler.schedule_at(duration, member.id,
                                   self._revoke_streaming_permission(member))
        await self.task_cache.set(member.id, duration.timestamp())

        await member.add_roles(discord.Object(Roles.video),
                               reason="Temporary streaming access granted")

        # Use embed as embed timestamps do timezone conversions.
        embed = discord.Embed(
            description=f"{Emojis.check_mark} {member.mention} can now stream.",
            colour=Colours.soft_green)
        embed.set_footer(
            text=f"Streaming permission has been given to {member} until")
        embed.timestamp = duration

        # Mention in content as mentions in embeds don't ping
        await ctx.send(content=member.mention, embed=embed)

        # Convert here for nicer logging
        revoke_time = format_infraction_with_duration(str(duration))
        log.debug(
            f"Successfully gave {member} ({member.id}) permission to stream until {revoke_time}."
        )

    @commands.command(aliases=("pstream", ))
    @commands.has_any_role(*MODERATION_ROLES)
    async def permanentstream(self, ctx: commands.Context,
                              member: discord.Member) -> None:
        """Permanently grants the given member the permission to stream."""
        log.trace(
            f"Attempting to give permanent streaming permission to {member} ({member.id})."
        )

        # Check if the member already has streaming permission
        if any(Roles.video == role.id for role in member.roles):
            if member.id in self.scheduler:
                # Member has temp permission, so cancel the task to revoke later and delete from cache
                self.scheduler.cancel(member.id)
                await self.task_cache.delete(member.id)

                await ctx.send(
                    f"{Emojis.check_mark} Permanently granted {member.mention} the permission to stream."
                )
                log.debug(
                    f"Successfully upgraded temporary streaming permission for {member} ({member.id}) to permanent."
                )
                return

            await ctx.send(
                f"{Emojis.cross_mark} This member can already stream.")
            log.debug(
                f"{member} ({member.id}) already had permanent streaming permission."
            )
            return

        await member.add_roles(discord.Object(Roles.video),
                               reason="Permanent streaming access granted")
        await ctx.send(
            f"{Emojis.check_mark} Permanently granted {member.mention} the permission to stream."
        )
        log.debug(
            f"Successfully gave {member} ({member.id}) permanent streaming permission."
        )

    @commands.command(aliases=("unstream", "rstream"))
    @commands.has_any_role(*MODERATION_ROLES)
    async def revokestream(self, ctx: commands.Context,
                           member: discord.Member) -> None:
        """Revoke the permission to stream from the given member."""
        log.trace(
            f"Attempting to remove streaming permission from {member} ({member.id})."
        )

        # Check if the member already has streaming permission
        if any(Roles.video == role.id for role in member.roles):
            if member.id in self.scheduler:
                # Member has temp permission, so cancel the task to revoke later and delete from cache
                self.scheduler.cancel(member.id)
                await self.task_cache.delete(member.id)
            await self._revoke_streaming_permission(member)

            await ctx.send(
                f"{Emojis.check_mark} Revoked the permission to stream from {member.mention}."
            )
            log.debug(
                f"Successfully revoked streaming permission from {member} ({member.id})."
            )

        else:
            await ctx.send(
                f"{Emojis.cross_mark} This member doesn't have video permissions to remove!"
            )
            log.debug(
                f"{member} ({member.id}) didn't have the streaming permission to remove!"
            )

        await self._suspend_stream(ctx, member)

    @commands.command(aliases=('lstream', ))
    @commands.has_any_role(*MODERATION_ROLES)
    async def liststream(self, ctx: commands.Context) -> None:
        """Lists all non-staff users who have permission to stream."""
        non_staff_members_with_stream = [
            member for member in ctx.guild.get_role(Roles.video).members
            if not any(role.id in STAFF_ROLES for role in member.roles)
        ]

        # List of tuples (UtcPosixTimestamp, str)
        # So that the list can be sorted on the UtcPosixTimestamp before the message is passed to the paginator.
        streamer_info = []
        for member in non_staff_members_with_stream:
            if revoke_time := await self.task_cache.get(member.id):
                # Member only has temporary streaming perms
                revoke_delta = Arrow.utcfromtimestamp(revoke_time).humanize()
                message = f"{member.mention} will have stream permissions revoked {revoke_delta}."
            else:
                message = f"{member.mention} has permanent streaming permissions."

            # If revoke_time is None use max timestamp to force sort to put them at the end
            streamer_info.append((revoke_time
                                  or Arrow.max.timestamp(), message))

        if streamer_info:
            # Sort based on duration left of streaming perms
            streamer_info.sort(key=itemgetter(0))

            # Only output the message in the pagination
            lines = [line[1] for line in streamer_info]
            embed = discord.Embed(
                title=
                f"Members with streaming permission (`{len(lines)}` total)",
                colour=Colours.soft_green)
            await LinePaginator.paginate(lines,
                                         ctx,
                                         embed,
                                         max_size=400,
                                         empty=False)
        else:
            await ctx.send("No members with stream permissions found.")
Exemple #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)
Exemple #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)
Exemple #7
0
Fichier : _cog.py Projet : 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
Exemple #8
0
class Reminders(Cog):
    """Provide in-channel reminder functionality."""

    def __init__(self, bot: Bot):
        self.bot = bot
        self.scheduler = Scheduler(self.__class__.__name__)

        self.bot.loop.create_task(self.reschedule_reminders())

    async def reschedule_reminders(self) -> None:
        """Get all current reminders from the API and reschedule them."""
        await self.bot.wait_until_guild_available()
        response = await self.bot.api_client.get(
            'bot/reminders',
            params={'active': 'true'}
        )

        now = datetime.utcnow()

        for reminder in response:
            is_valid, *_ = self.ensure_valid_reminder(reminder, cancel_task=False)
            if not is_valid:
                continue

            remind_at = isoparse(reminder['expiration']).replace(tzinfo=None)

            # If the reminder is already overdue ...
            if remind_at < now:
                late = relativedelta(now, remind_at)
                await self.send_reminder(reminder, late)
            else:
                self.schedule_reminder(reminder)

    def ensure_valid_reminder(
        self,
        reminder: dict,
        cancel_task: bool = True
    ) -> t.Tuple[bool, discord.User, discord.TextChannel]:
        """Ensure reminder author and channel can be fetched otherwise delete the reminder."""
        user = self.bot.get_user(reminder['author'])
        channel = self.bot.get_channel(reminder['channel_id'])
        is_valid = True
        if not user or not channel:
            is_valid = False
            log.info(
                f"Reminder {reminder['id']} invalid: "
                f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}."
            )
            asyncio.create_task(self._delete_reminder(reminder['id'], cancel_task))

        return is_valid, user, channel

    @staticmethod
    async def _send_confirmation(
        ctx: Context,
        on_success: str,
        reminder_id: str,
        delivery_dt: t.Optional[datetime],
    ) -> None:
        """Send an embed confirming the reminder change was made successfully."""
        embed = discord.Embed()
        embed.colour = discord.Colour.green()
        embed.title = random.choice(POSITIVE_REPLIES)
        embed.description = on_success

        footer_str = f"ID: {reminder_id}"
        if delivery_dt:
            # Reminder deletion will have a `None` `delivery_dt`
            footer_str = f"{footer_str}, Due: {delivery_dt.strftime('%Y-%m-%dT%H:%M:%S')}"

        embed.set_footer(text=footer_str)

        await ctx.send(embed=embed)

    def schedule_reminder(self, reminder: dict) -> None:
        """A coroutine which sends the reminder once the time is reached, and cancels the running task."""
        reminder_id = reminder["id"]
        reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None)

        async def _remind() -> None:
            await self.send_reminder(reminder)

            log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).")
            await self._delete_reminder(reminder_id)

        self.scheduler.schedule_at(reminder_datetime, reminder_id, _remind())

    async def _delete_reminder(self, reminder_id: str, cancel_task: bool = True) -> None:
        """Delete a reminder from the database, given its ID, and cancel the running task."""
        await self.bot.api_client.delete('bot/reminders/' + str(reminder_id))

        if cancel_task:
            # Now we can remove it from the schedule list
            self.scheduler.cancel(reminder_id)

    async def _reschedule_reminder(self, reminder: dict) -> None:
        """Reschedule a reminder object."""
        log.trace(f"Cancelling old task #{reminder['id']}")
        self.scheduler.cancel(reminder["id"])

        log.trace(f"Scheduling new task #{reminder['id']}")
        self.schedule_reminder(reminder)

    async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None:
        """Send the reminder."""
        is_valid, user, channel = self.ensure_valid_reminder(reminder)
        if not is_valid:
            return

        embed = discord.Embed()
        embed.colour = discord.Colour.blurple()
        embed.set_author(
            icon_url=Icons.remind_blurple,
            name="It has arrived!"
        )

        embed.description = f"Here's your reminder: `{reminder['content']}`."

        if reminder.get("jump_url"):  # keep backward compatibility
            embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})"

        if late:
            embed.colour = discord.Colour.red()
            embed.set_author(
                icon_url=Icons.remind_red,
                name=f"Sorry it arrived {humanize_delta(late, max_units=2)} late!"
            )

        await channel.send(
            content=user.mention,
            embed=embed
        )
        await self._delete_reminder(reminder["id"])

    @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True)
    async def remind_group(self, ctx: Context, expiration: Duration, *, content: str) -> None:
        """Commands for managing your reminders."""
        await ctx.invoke(self.new_reminder, expiration=expiration, content=content)

    @remind_group.command(name="new", aliases=("add", "create"))
    async def new_reminder(self, ctx: Context, expiration: Duration, *, content: str) -> t.Optional[discord.Message]:
        """
        Set yourself a simple reminder.

        Expiration is parsed per: http://strftime.org/
        """
        embed = discord.Embed()

        # If the user is not staff, we need to verify whether or not to make a reminder at all.
        if without_role_check(ctx, *STAFF_ROLES):

            # If they don't have permission to set a reminder in this channel
            if ctx.channel.id not in WHITELISTED_CHANNELS:
                embed.colour = discord.Colour.red()
                embed.title = random.choice(NEGATIVE_REPLIES)
                embed.description = "Sorry, you can't do that here!"

                return await ctx.send(embed=embed)

            # Get their current active reminders
            active_reminders = await self.bot.api_client.get(
                'bot/reminders',
                params={
                    'author__id': str(ctx.author.id)
                }
            )

            # Let's limit this, so we don't get 10 000
            # reminders from kip or something like that :P
            if len(active_reminders) > MAXIMUM_REMINDERS:
                embed.colour = discord.Colour.red()
                embed.title = random.choice(NEGATIVE_REPLIES)
                embed.description = "You have too many active reminders!"

                return await ctx.send(embed=embed)

        # Now we can attempt to actually set the reminder.
        reminder = await self.bot.api_client.post(
            'bot/reminders',
            json={
                'author': ctx.author.id,
                'channel_id': ctx.message.channel.id,
                'jump_url': ctx.message.jump_url,
                'content': content,
                'expiration': expiration.isoformat()
            }
        )

        now = datetime.utcnow() - timedelta(seconds=1)
        humanized_delta = humanize_delta(relativedelta(expiration, now))

        # Confirm to the user that it worked.
        await self._send_confirmation(
            ctx,
            on_success=f"Your reminder will arrive in {humanized_delta}!",
            reminder_id=reminder["id"],
            delivery_dt=expiration,
        )

        self.schedule_reminder(reminder)

    @remind_group.command(name="list")
    async def list_reminders(self, ctx: Context) -> t.Optional[discord.Message]:
        """View a paginated embed of all reminders for your user."""
        # Get all the user's reminders from the database.
        data = await self.bot.api_client.get(
            'bot/reminders',
            params={'author__id': str(ctx.author.id)}
        )

        now = datetime.utcnow()

        # Make a list of tuples so it can be sorted by time.
        reminders = sorted(
            (
                (rem['content'], rem['expiration'], rem['id'])
                for rem in data
            ),
            key=itemgetter(1)
        )

        lines = []

        for content, remind_at, id_ in reminders:
            # Parse and humanize the time, make it pretty :D
            remind_datetime = isoparse(remind_at).replace(tzinfo=None)
            time = humanize_delta(relativedelta(remind_datetime, now))

            text = textwrap.dedent(f"""
            **Reminder #{id_}:** *expires in {time}* (ID: {id_})
            {content}
            """).strip()

            lines.append(text)

        embed = discord.Embed()
        embed.colour = discord.Colour.blurple()
        embed.title = f"Reminders for {ctx.author}"

        # Remind the user that they have no reminders :^)
        if not lines:
            embed.description = "No active reminders could be found."
            return await ctx.send(embed=embed)

        # Construct the embed and paginate it.
        embed.colour = discord.Colour.blurple()

        await LinePaginator.paginate(
            lines,
            ctx, embed,
            max_lines=3,
            empty=True
        )

    @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True)
    async def edit_reminder_group(self, ctx: Context) -> None:
        """Commands for modifying your current reminders."""
        await ctx.send_help(ctx.command)

    @edit_reminder_group.command(name="duration", aliases=("time",))
    async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Duration) -> None:
        """
         Edit one of your reminder's expiration.

        Expiration is parsed per: http://strftime.org/
        """
        # Send the request to update the reminder in the database
        reminder = await self.bot.api_client.patch(
            'bot/reminders/' + str(id_),
            json={'expiration': expiration.isoformat()}
        )

        # Send a confirmation message to the channel
        await self._send_confirmation(
            ctx,
            on_success="That reminder has been edited successfully!",
            reminder_id=id_,
            delivery_dt=expiration,
        )

        await self._reschedule_reminder(reminder)

    @edit_reminder_group.command(name="content", aliases=("reason",))
    async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str) -> None:
        """Edit one of your reminder's content."""
        # Send the request to update the reminder in the database
        reminder = await self.bot.api_client.patch(
            'bot/reminders/' + str(id_),
            json={'content': content}
        )

        # Parse the reminder expiration back into a datetime for the confirmation message
        expiration = isoparse(reminder['expiration']).replace(tzinfo=None)

        # Send a confirmation message to the channel
        await self._send_confirmation(
            ctx,
            on_success="That reminder has been edited successfully!",
            reminder_id=id_,
            delivery_dt=expiration,
        )
        await self._reschedule_reminder(reminder)

    @remind_group.command("delete", aliases=("remove", "cancel"))
    async def delete_reminder(self, ctx: Context, id_: int) -> None:
        """Delete one of your active reminders."""
        await self._delete_reminder(id_)
        await self._send_confirmation(
            ctx,
            on_success="That reminder has been deleted successfully!",
            reminder_id=id_,
            delivery_dt=None,
        )
Exemple #9
0
class InfractionScheduler:
    """Handles the application, pardoning, and expiration of infractions."""
    def __init__(self, bot: Bot, supported_infractions: t.Container[str]):
        self.bot = bot
        self.scheduler = Scheduler(self.__class__.__name__)

        self.bot.loop.create_task(
            self.reschedule_infractions(supported_infractions))

    def cog_unload(self) -> None:
        """Cancel scheduled tasks."""
        self.scheduler.cancel_all()

    @property
    def mod_log(self) -> ModLog:
        """Get the currently loaded ModLog cog instance."""
        return self.bot.get_cog("ModLog")

    async def reschedule_infractions(
            self, supported_infractions: t.Container[str]) -> None:
        """Schedule expiration for previous infractions."""
        await self.bot.wait_until_guild_available()

        log.trace(f"Rescheduling infractions for {self.__class__.__name__}.")

        infractions = await self.bot.api_client.get('bot/infractions',
                                                    params={'active': 'true'})
        for infraction in infractions:
            if infraction["expires_at"] is not None and infraction[
                    "type"] in supported_infractions:
                self.schedule_expiration(infraction)

    async def reapply_infraction(self, infraction: utils.Infraction,
                                 apply_coro: t.Optional[t.Awaitable]) -> None:
        """Reapply an infraction if it's still active or deactivate it if less than 60 sec left."""
        # Calculate the time remaining, in seconds, for the mute.
        expiry = dateutil.parser.isoparse(
            infraction["expires_at"]).replace(tzinfo=None)
        delta = (expiry - datetime.utcnow()).total_seconds()

        # Mark as inactive if less than a minute remains.
        if delta < 60:
            log.info("Infraction will be deactivated instead of re-applied "
                     "because less than 1 minute remains.")
            await self.deactivate_infraction(infraction)
            return

        # Allowing mod log since this is a passive action that should be logged.
        await apply_coro
        log.info(
            f"Re-applied {infraction['type']} to user {infraction['user']} upon rejoining."
        )

    async def apply_infraction(
            self,
            ctx: Context,
            infraction: utils.Infraction,
            user: UserSnowflake,
            action_coro: t.Optional[t.Awaitable] = None) -> None:
        """Apply an infraction to the user, log the infraction, and optionally notify the user."""
        infr_type = infraction["type"]
        icon = utils.INFRACTION_ICONS[infr_type][0]
        reason = infraction["reason"]
        expiry = time.format_infraction_with_duration(infraction["expires_at"])
        id_ = infraction['id']

        log.trace(f"Applying {infr_type} infraction #{id_} to {user}.")

        # Default values for the confirmation message and mod log.
        confirm_msg = ":ok_hand: applied"

        # Specifying an expiry for a note or warning makes no sense.
        if infr_type in ("note", "warning"):
            expiry_msg = ""
        else:
            expiry_msg = f" until {expiry}" if expiry else " permanently"

        dm_result = ""
        dm_log_text = ""
        expiry_log_text = f"\nExpires: {expiry}" if expiry else ""
        log_title = "applied"
        log_content = None
        failed = False

        # DM the user about the infraction if it's not a shadow/hidden infraction.
        # This needs to happen before we apply the infraction, as the bot cannot
        # send DMs to user that it doesn't share a guild with. If we were to
        # apply kick/ban infractions first, this would mean that we'd make it
        # impossible for us to deliver a DM. See python-discord/bot#982.
        if not infraction["hidden"]:
            dm_result = f"{constants.Emojis.failmail} "
            dm_log_text = "\nDM: **Failed**"

            # Sometimes user is a discord.Object; make it a proper user.
            try:
                if not isinstance(user, (discord.Member, discord.User)):
                    user = await self.bot.fetch_user(user.id)
            except discord.HTTPException as e:
                log.error(
                    f"Failed to DM {user.id}: could not fetch user (status {e.status})"
                )
            else:
                # Accordingly display whether the user was successfully notified via DM.
                if await utils.notify_infraction(user, infr_type, expiry,
                                                 reason, icon):
                    dm_result = ":incoming_envelope: "
                    dm_log_text = "\nDM: Sent"

        end_msg = ""
        if infraction["actor"] == self.bot.user.id:
            log.trace(
                f"Infraction #{id_} actor is bot; including the reason in the confirmation message."
            )
            if reason:
                end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})"
        elif ctx.channel.id not in STAFF_CHANNELS:
            log.trace(
                f"Infraction #{id_} context is not in a staff channel; omitting infraction count."
            )
        else:
            log.trace(f"Fetching total infraction count for {user}.")

            infractions = await self.bot.api_client.get(
                "bot/infractions", params={"user__id": str(user.id)})
            total = len(infractions)
            end_msg = f" ({total} infraction{ngettext('', 's', total)} total)"

        # Execute the necessary actions to apply the infraction on Discord.
        if action_coro:
            log.trace(
                f"Awaiting the infraction #{id_} application action coroutine."
            )
            try:
                await action_coro
                if expiry:
                    # Schedule the expiration of the infraction.
                    self.schedule_expiration(infraction)
            except discord.HTTPException as e:
                # Accordingly display that applying the infraction failed.
                confirm_msg = ":x: failed to apply"
                expiry_msg = ""
                log_content = ctx.author.mention
                log_title = "failed to apply"

                log_msg = f"Failed to apply {infr_type} infraction #{id_} to {user}"
                if isinstance(e, discord.Forbidden):
                    log.warning(f"{log_msg}: bot lacks permissions.")
                else:
                    log.exception(log_msg)
                failed = True

        if failed:
            log.trace(
                f"Deleted infraction {infraction['id']} from database because applying infraction failed."
            )
            try:
                await self.bot.api_client.delete(f"bot/infractions/{id_}")
            except ResponseCodeError as e:
                confirm_msg += " and failed to delete"
                log_title += " and failed to delete"
                log.error(
                    f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}."
                )
            infr_message = ""
        else:
            infr_message = f" **{infr_type}** to {user.mention}{expiry_msg}{end_msg}"

        # Send a confirmation message to the invoking context.
        log.trace(f"Sending infraction #{id_} confirmation message.")
        await ctx.send(f"{dm_result}{confirm_msg}{infr_message}.")

        # Send a log message to the mod log.
        log.trace(f"Sending apply mod log for infraction #{id_}.")
        await self.mod_log.send_log_message(
            icon_url=icon,
            colour=Colours.soft_red,
            title=f"Infraction {log_title}: {infr_type}",
            thumbnail=user.avatar_url_as(static_format="png"),
            text=textwrap.dedent(f"""
                Member: {user.mention} (`{user.id}`)
                Actor: {ctx.message.author}{dm_log_text}{expiry_log_text}
                Reason: {reason}
            """),
            content=log_content,
            footer=f"ID {infraction['id']}")

        log.info(f"Applied {infr_type} infraction #{id_} to {user}.")

    async def pardon_infraction(self,
                                ctx: Context,
                                infr_type: str,
                                user: UserSnowflake,
                                send_msg: bool = True) -> None:
        """
        Prematurely end an infraction for a user and log the action in the mod log.

        If `send_msg` is True, then a pardoning confirmation message will be sent to
        the context channel.  Otherwise, no such message will be sent.
        """
        log.trace(f"Pardoning {infr_type} infraction for {user}.")

        # Check the current active infraction
        log.trace(f"Fetching active {infr_type} infractions for {user}.")
        response = await self.bot.api_client.get('bot/infractions',
                                                 params={
                                                     'active': 'true',
                                                     'type': infr_type,
                                                     'user__id': user.id
                                                 })

        if not response:
            log.debug(f"No active {infr_type} infraction found for {user}.")
            await ctx.send(
                f":x: There's no active {infr_type} infraction for user {user.mention}."
            )
            return

        # Deactivate the infraction and cancel its scheduled expiration task.
        log_text = await self.deactivate_infraction(response[0],
                                                    send_log=False)

        log_text["Member"] = f"{user.mention}(`{user.id}`)"
        log_text["Actor"] = str(ctx.message.author)
        log_content = None
        id_ = response[0]['id']
        footer = f"ID: {id_}"

        # If multiple active infractions were found, mark them as inactive in the database
        # and cancel their expiration tasks.
        if len(response) > 1:
            log.info(
                f"Found more than one active {infr_type} infraction for user {user.id}; "
                "deactivating the extra active infractions too.")

            footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}"

            log_note = f"Found multiple **active** {infr_type} infractions in the database."
            if "Note" in log_text:
                log_text["Note"] = f" {log_note}"
            else:
                log_text["Note"] = log_note

            # deactivate_infraction() is not called again because:
            #     1. Discord cannot store multiple active bans or assign multiples of the same role
            #     2. It would send a pardon DM for each active infraction, which is redundant
            for infraction in response[1:]:
                id_ = infraction['id']
                try:
                    # Mark infraction as inactive in the database.
                    await self.bot.api_client.patch(f"bot/infractions/{id_}",
                                                    json={"active": False})
                except ResponseCodeError:
                    log.exception(
                        f"Failed to deactivate infraction #{id_} ({infr_type})"
                    )
                    # This is simpler and cleaner than trying to concatenate all the errors.
                    log_text["Failure"] = "See bot's logs for details."

                # Cancel pending expiration task.
                if infraction["expires_at"] is not None:
                    self.scheduler.cancel(infraction["id"])

        # Accordingly display whether the user was successfully notified via DM.
        dm_emoji = ""
        if log_text.get("DM") == "Sent":
            dm_emoji = ":incoming_envelope: "
        elif "DM" in log_text:
            dm_emoji = f"{constants.Emojis.failmail} "

        # Accordingly display whether the pardon failed.
        if "Failure" in log_text:
            confirm_msg = ":x: failed to pardon"
            log_title = "pardon failed"
            log_content = ctx.author.mention

            log.warning(
                f"Failed to pardon {infr_type} infraction #{id_} for {user}.")
        else:
            confirm_msg = ":ok_hand: pardoned"
            log_title = "pardoned"

            log.info(f"Pardoned {infr_type} infraction #{id_} for {user}.")

        # Send a confirmation message to the invoking context.
        if send_msg:
            log.trace(
                f"Sending infraction #{id_} pardon confirmation message.")
            await ctx.send(
                f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. "
                f"{log_text.get('Failure', '')}")

        # Move reason to end of entry to avoid cutting out some keys
        log_text["Reason"] = log_text.pop("Reason")

        # Send a log message to the mod log.
        await self.mod_log.send_log_message(
            icon_url=utils.INFRACTION_ICONS[infr_type][1],
            colour=Colours.soft_green,
            title=f"Infraction {log_title}: {infr_type}",
            thumbnail=user.avatar_url_as(static_format="png"),
            text="\n".join(f"{k}: {v}" for k, v in log_text.items()),
            footer=footer,
            content=log_content,
        )

    async def deactivate_infraction(self,
                                    infraction: utils.Infraction,
                                    send_log: bool = True) -> t.Dict[str, str]:
        """
        Deactivate an active infraction and return a dictionary of lines to send in a mod log.

        The infraction is removed from Discord, marked as inactive in the database, and has its
        expiration task cancelled. If `send_log` is True, a mod log is sent for the
        deactivation of the infraction.

        Infractions of unsupported types will raise a ValueError.
        """
        guild = self.bot.get_guild(constants.Guild.id)
        mod_role = guild.get_role(constants.Roles.moderators)
        user_id = infraction["user"]
        actor = infraction["actor"]
        type_ = infraction["type"]
        id_ = infraction["id"]
        inserted_at = infraction["inserted_at"]
        expiry = infraction["expires_at"]

        log.info(f"Marking infraction #{id_} as inactive (expired).")

        expiry = dateutil.parser.isoparse(expiry).replace(
            tzinfo=None) if expiry else None
        created = time.format_infraction_with_duration(inserted_at, expiry)

        log_content = None
        log_text = {
            "Member": f"<@{user_id}>",
            "Actor": str(self.bot.get_user(actor) or actor),
            "Reason": infraction["reason"],
            "Created": created,
        }

        try:
            log.trace("Awaiting the pardon action coroutine.")
            returned_log = await self._pardon_action(infraction)

            if returned_log is not None:
                log_text = {
                    **log_text,
                    **returned_log
                }  # Merge the logs together
            else:
                raise ValueError(
                    f"Attempted to deactivate an unsupported infraction #{id_} ({type_})!"
                )
        except discord.Forbidden:
            log.warning(
                f"Failed to deactivate infraction #{id_} ({type_}): bot lacks permissions."
            )
            log_text[
                "Failure"] = "The bot lacks permissions to do this (role hierarchy?)"
            log_content = mod_role.mention
        except discord.HTTPException as e:
            log.exception(f"Failed to deactivate infraction #{id_} ({type_})")
            log_text[
                "Failure"] = f"HTTPException with status {e.status} and code {e.code}."
            log_content = mod_role.mention

        # Check if the user is currently being watched by Big Brother.
        try:
            log.trace(
                f"Determining if user {user_id} is currently being watched by Big Brother."
            )

            active_watch = await self.bot.api_client.get("bot/infractions",
                                                         params={
                                                             "active": "true",
                                                             "type": "watch",
                                                             "user__id":
                                                             user_id
                                                         })

            log_text["Watching"] = "Yes" if active_watch else "No"
        except ResponseCodeError:
            log.exception(f"Failed to fetch watch status for user {user_id}")
            log_text["Watching"] = "Unknown - failed to fetch watch status."

        try:
            # Mark infraction as inactive in the database.
            log.trace(
                f"Marking infraction #{id_} as inactive in the database.")
            await self.bot.api_client.patch(f"bot/infractions/{id_}",
                                            json={"active": False})
        except ResponseCodeError as e:
            log.exception(f"Failed to deactivate infraction #{id_} ({type_})")
            log_line = f"API request failed with code {e.status}."
            log_content = mod_role.mention

            # Append to an existing failure message if possible
            if "Failure" in log_text:
                log_text["Failure"] += f" {log_line}"
            else:
                log_text["Failure"] = log_line

        # Cancel the expiration task.
        if infraction["expires_at"] is not None:
            self.scheduler.cancel(infraction["id"])

        # Send a log message to the mod log.
        if send_log:
            log_title = "expiration failed" if "Failure" in log_text else "expired"

            user = self.bot.get_user(user_id)
            avatar = user.avatar_url_as(static_format="png") if user else None

            # Move reason to end so when reason is too long, this is not gonna cut out required items.
            log_text["Reason"] = log_text.pop("Reason")

            log.trace(f"Sending deactivation mod log for infraction #{id_}.")
            await self.mod_log.send_log_message(
                icon_url=utils.INFRACTION_ICONS[type_][1],
                colour=Colours.soft_green,
                title=f"Infraction {log_title}: {type_}",
                thumbnail=avatar,
                text="\n".join(f"{k}: {v}" for k, v in log_text.items()),
                footer=f"ID: {id_}",
                content=log_content,
            )

        return log_text

    @abstractmethod
    async def _pardon_action(
            self,
            infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]:
        """
        Execute deactivation steps specific to the infraction's type and return a log dict.

        If an infraction type is unsupported, return None instead.
        """
        raise NotImplementedError

    def schedule_expiration(self, infraction: utils.Infraction) -> None:
        """
        Marks an infraction expired after the delay from time of scheduling to time of expiration.

        At the time of expiration, the infraction is marked as inactive on the website and the
        expiration task is cancelled.
        """
        expiry = dateutil.parser.isoparse(
            infraction["expires_at"]).replace(tzinfo=None)
        self.scheduler.schedule_at(expiry, infraction["id"],
                                   self.deactivate_infraction(infraction))
Exemple #10
0
class Stream(commands.Cog):
    """Grant and revoke streaming permissions from members."""

    # Stores tasks to remove streaming permission
    # RedisCache[discord.Member.id, UtcPosixTimestamp]
    task_cache = RedisCache()

    def __init__(self, bot: Bot):
        self.bot = bot
        self.scheduler = Scheduler(self.__class__.__name__)
        self.reload_task = self.bot.loop.create_task(
            self._reload_tasks_from_redis())

    def cog_unload(self) -> None:
        """Cancel all scheduled tasks."""
        self.reload_task.cancel()
        self.reload_task.add_done_callback(
            lambda _: self.scheduler.cancel_all())

    async def _revoke_streaming_permission(self,
                                           member: discord.Member) -> None:
        """Remove the streaming permission from the given Member."""
        await self.task_cache.delete(member.id)
        await member.remove_roles(discord.Object(Roles.video),
                                  reason="Streaming access revoked")

    async def _reload_tasks_from_redis(self) -> None:
        """Reload outstanding tasks from redis on startup, delete the task if the member has since left the server."""
        await self.bot.wait_until_guild_available()
        items = await self.task_cache.items()
        for key, value in items:
            member = self.bot.get_guild(Guild.id).get_member(key)

            if not member:
                # Member isn't found in the cache
                try:
                    member = await self.bot.get_guild(Guild.id
                                                      ).fetch_member(key)
                except discord.errors.NotFound:
                    log.debug(
                        f"Member {key} left the guild before we could schedule "
                        "the revoking of their streaming permissions.")
                    await self.task_cache.delete(key)
                    continue
                except discord.HTTPException:
                    log.exception(
                        f"Exception while trying to retrieve member {key} from Discord."
                    )
                    continue

            revoke_time = Arrow.utcfromtimestamp(value)
            log.debug(
                f"Scheduling {member} ({member.id}) to have streaming permission revoked at {revoke_time}"
            )
            self.scheduler.schedule_at(
                revoke_time, key, self._revoke_streaming_permission(member))

    @commands.command(aliases=("streaming", ))
    @commands.has_any_role(*STAFF_ROLES)
    async def stream(self,
                     ctx: commands.Context,
                     member: discord.Member,
                     duration: Expiry = None) -> None:
        """
        Temporarily grant streaming permissions to a member for a given duration.

        A unit of time should be appended to the duration.
        Units (∗case-sensitive):
        \u2003`y` - years
        \u2003`m` - months∗
        \u2003`w` - weeks
        \u2003`d` - days
        \u2003`h` - hours
        \u2003`M` - minutes∗
        \u2003`s` - seconds

        Alternatively, an ISO 8601 timestamp can be provided for the duration.
        """
        log.trace(
            f"Attempting to give temporary streaming permission to {member} ({member.id})."
        )

        if duration is None:
            # Use default duration and convert back to datetime as Embed.timestamp doesn't support Arrow
            duration = arrow.utcnow() + timedelta(
                minutes=VideoPermission.default_permission_duration)
            duration = duration.datetime
        elif duration.tzinfo is None:
            # Make duration tz-aware.
            # ISODateTime could already include tzinfo, this check is so it isn't overwritten.
            duration.replace(tzinfo=timezone.utc)

        # Check if the member already has streaming permission
        already_allowed = any(Roles.video == role.id for role in member.roles)
        if already_allowed:
            await ctx.send(
                f"{Emojis.cross_mark} {member.mention} can already stream.")
            log.debug(
                f"{member} ({member.id}) already has permission to stream.")
            return

        # Schedule task to remove streaming permission from Member and add it to task cache
        self.scheduler.schedule_at(duration, member.id,
                                   self._revoke_streaming_permission(member))
        await self.task_cache.set(member.id, duration.timestamp())

        await member.add_roles(discord.Object(Roles.video),
                               reason="Temporary streaming access granted")

        # Use embed as embed timestamps do timezone conversions.
        embed = discord.Embed(
            description=f"{Emojis.check_mark} {member.mention} can now stream.",
            colour=Colours.soft_green)
        embed.set_footer(
            text=f"Streaming permission has been given to {member} until")
        embed.timestamp = duration

        # Mention in content as mentions in embeds don't ping
        await ctx.send(content=member.mention, embed=embed)

        # Convert here for nicer logging
        revoke_time = format_infraction_with_duration(str(duration))
        log.debug(
            f"Successfully gave {member} ({member.id}) permission to stream until {revoke_time}."
        )

    @commands.command(aliases=("pstream", ))
    @commands.has_any_role(*STAFF_ROLES)
    async def permanentstream(self, ctx: commands.Context,
                              member: discord.Member) -> None:
        """Permanently grants the given member the permission to stream."""
        log.trace(
            f"Attempting to give permanent streaming permission to {member} ({member.id})."
        )

        # Check if the member already has streaming permission
        if any(Roles.video == role.id for role in member.roles):
            if member.id in self.scheduler:
                # Member has temp permission, so cancel the task to revoke later and delete from cache
                self.scheduler.cancel(member.id)
                await self.task_cache.delete(member.id)

                await ctx.send(
                    f"{Emojis.check_mark} Permanently granted {member.mention} the permission to stream."
                )
                log.debug(
                    f"Successfully upgraded temporary streaming permission for {member} ({member.id}) to permanent."
                )
                return

            await ctx.send(
                f"{Emojis.cross_mark} This member can already stream.")
            log.debug(
                f"{member} ({member.id}) already had permanent streaming permission."
            )
            return

        await member.add_roles(discord.Object(Roles.video),
                               reason="Permanent streaming access granted")
        await ctx.send(
            f"{Emojis.check_mark} Permanently granted {member.mention} the permission to stream."
        )
        log.debug(
            f"Successfully gave {member} ({member.id}) permanent streaming permission."
        )

    @commands.command(aliases=("unstream", "rstream"))
    @commands.has_any_role(*STAFF_ROLES)
    async def revokestream(self, ctx: commands.Context,
                           member: discord.Member) -> None:
        """Revoke the permission to stream from the given member."""
        log.trace(
            f"Attempting to remove streaming permission from {member} ({member.id})."
        )

        # Check if the member already has streaming permission
        if any(Roles.video == role.id for role in member.roles):
            if member.id in self.scheduler:
                # Member has temp permission, so cancel the task to revoke later and delete from cache
                self.scheduler.cancel(member.id)
                await self.task_cache.delete(member.id)
            await self._revoke_streaming_permission(member)

            await ctx.send(
                f"{Emojis.check_mark} Revoked the permission to stream from {member.mention}."
            )
            log.debug(
                f"Successfully revoked streaming permission from {member} ({member.id})."
            )
            return

        await ctx.send(
            f"{Emojis.cross_mark} This member doesn't have video permissions to remove!"
        )
        log.debug(
            f"{member} ({member.id}) didn't have the streaming permission to remove!"
        )
Exemple #11
0
class ModPings(Cog):
    """Commands for a moderator to turn moderator pings on and off."""

    # RedisCache[discord.Member.id, 'Naïve ISO 8601 string']
    # The cache's keys are mods who have pings off.
    # The cache's values are the times when the role should be re-applied to them, stored in ISO format.
    pings_off_mods = RedisCache()

    def __init__(self, bot: Bot):
        self.bot = bot
        self._role_scheduler = Scheduler(self.__class__.__name__)

        self.guild = None
        self.moderators_role = None

        self.reschedule_task = self.bot.loop.create_task(self.reschedule_roles(), name="mod-pings-reschedule")

    async def reschedule_roles(self) -> None:
        """Reschedule moderators role re-apply times."""
        await self.bot.wait_until_guild_available()
        self.guild = self.bot.get_guild(Guild.id)
        self.moderators_role = self.guild.get_role(Roles.moderators)

        mod_team = self.guild.get_role(Roles.mod_team)
        pings_on = self.moderators_role.members
        pings_off = await self.pings_off_mods.to_dict()

        log.trace("Applying the moderators role to the mod team where necessary.")
        for mod in mod_team.members:
            if mod in pings_on:  # Make sure that on-duty mods aren't in the cache.
                if mod in pings_off:
                    await self.pings_off_mods.delete(mod.id)
                continue

            # Keep the role off only for those in the cache.
            if mod.id not in pings_off:
                await self.reapply_role(mod)
            else:
                expiry = isoparse(pings_off[mod.id]).replace(tzinfo=None)
                self._role_scheduler.schedule_at(expiry, mod.id, self.reapply_role(mod))

    async def reapply_role(self, mod: Member) -> None:
        """Reapply the moderator's role to the given moderator."""
        log.trace(f"Re-applying role to mod with ID {mod.id}.")
        await mod.add_roles(self.moderators_role, reason="Pings off period expired.")

    @group(name='modpings', aliases=('modping',), invoke_without_command=True)
    @has_any_role(*MODERATION_ROLES)
    async def modpings_group(self, ctx: Context) -> None:
        """Allow the removal and re-addition of the pingable moderators role."""
        await ctx.send_help(ctx.command)

    @modpings_group.command(name='off')
    @has_any_role(*MODERATION_ROLES)
    async def off_command(self, ctx: Context, duration: Expiry) -> None:
        """
        Temporarily removes the pingable moderators role for a set amount of time.

        A unit of time should be appended to the duration.
        Units (∗case-sensitive):
        \u2003`y` - years
        \u2003`m` - months∗
        \u2003`w` - weeks
        \u2003`d` - days
        \u2003`h` - hours
        \u2003`M` - minutes∗
        \u2003`s` - seconds

        Alternatively, an ISO 8601 timestamp can be provided for the duration.

        The duration cannot be longer than 30 days.
        """
        duration: datetime.datetime
        delta = duration - datetime.datetime.utcnow()
        if delta > datetime.timedelta(days=30):
            await ctx.send(":x: Cannot remove the role for longer than 30 days.")
            return

        mod = ctx.author

        until_date = duration.replace(microsecond=0).isoformat()  # Looks noisy with microseconds.
        await mod.remove_roles(self.moderators_role, reason=f"Turned pings off until {until_date}.")

        await self.pings_off_mods.set(mod.id, duration.isoformat())

        # Allow rescheduling the task without cancelling it separately via the `on` command.
        if mod.id in self._role_scheduler:
            self._role_scheduler.cancel(mod.id)
        self._role_scheduler.schedule_at(duration, mod.id, self.reapply_role(mod))

        await ctx.send(f"{Emojis.check_mark} Moderators role has been removed until {until_date}.")

    @modpings_group.command(name='on')
    @has_any_role(*MODERATION_ROLES)
    async def on_command(self, ctx: Context) -> None:
        """Re-apply the pingable moderators role."""
        mod = ctx.author
        if mod in self.moderators_role.members:
            await ctx.send(":question: You already have the role.")
            return

        await mod.add_roles(self.moderators_role, reason="Pings off period canceled.")

        await self.pings_off_mods.delete(mod.id)

        # We assume the task exists. Lack of it may indicate a bug.
        self._role_scheduler.cancel(mod.id)

        await ctx.send(f"{Emojis.check_mark} Moderators role has been re-applied.")

    def cog_unload(self) -> None:
        """Cancel role tasks when the cog unloads."""
        log.trace("Cog unload: canceling role tasks.")
        self.reschedule_task.cancel()
        self._role_scheduler.cancel_all()
Exemple #12
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)
Exemple #13
0
class Stream(commands.Cog):
    """Grant and revoke streaming permissions from users."""

    # Stores tasks to remove streaming permission
    # User id : timestamp relation
    task_cache = RedisCache()

    def __init__(self, bot: Bot):
        self.bot = bot
        self.scheduler = Scheduler(self.__class__.__name__)
        self.reload_task = self.bot.loop.create_task(
            self._reload_tasks_from_redis())

    async def _remove_streaming_permission(
            self, schedule_user: discord.Member) -> None:
        """Remove streaming permission from Member."""
        await self._delete_from_redis(schedule_user.id)
        await schedule_user.remove_roles(discord.Object(Roles.video),
                                         reason="Streaming access revoked")

    async def _add_to_redis_cache(self, user_id: int,
                                  timestamp: float) -> None:
        """Adds 'task' to redis cache."""
        await self.task_cache.set(user_id, timestamp)

    async def _reload_tasks_from_redis(self) -> None:
        await self.bot.wait_until_guild_available()
        items = await self.task_cache.items()
        for key, value in items:
            member = await self.bot.get_guild(Guild.id).fetch_member(key)
            self.scheduler.schedule_at(
                datetime.datetime.utcfromtimestamp(value), key,
                self._remove_streaming_permission(member))

    async def _delete_from_redis(self, key: str) -> None:
        await self.task_cache.delete(key)

    @commands.command(aliases=("streaming", ))
    @commands.has_any_role(*STAFF_ROLES)
    async def stream(self,
                     ctx: commands.Context,
                     user: discord.Member,
                     duration: Expiry = None,
                     *_) -> None:
        """
        Temporarily grant streaming permissions to a user for a given duration.

        A unit of time should be appended to the duration.
        Units (∗case-sensitive):
        \u2003`y` - years
        \u2003`m` - months∗
        \u2003`w` - weeks
        \u2003`d` - days
        \u2003`h` - hours
        \u2003`M` - minutes∗
        \u2003`s` - seconds

        Alternatively, an ISO 8601 timestamp can be provided for the duration.
        """
        # if duration is none then calculate default duration
        if duration is None:
            now = datetime.datetime.utcnow()
            duration = now + datetime.timedelta(
                minutes=VideoPermission.default_permission_duration)

        # Check if user already has streaming permission
        already_allowed = any(Roles.video == role.id for role in user.roles)
        if already_allowed:
            await ctx.send(f"{Emojis.cross_mark} This user can already stream."
                           )
            return

        # Schedule task to remove streaming permission from Member and add it to task cache
        self.scheduler.schedule_at(duration, user.id,
                                   self._remove_streaming_permission(user))
        await self._add_to_redis_cache(user.id, duration.timestamp())
        await user.add_roles(discord.Object(Roles.video),
                             reason="Temporary streaming access granted")
        duration = format_infraction_with_duration(str(duration))
        await ctx.send(
            f"{Emojis.check_mark} {user.mention} can now stream until {duration}."
        )

    @commands.command(aliases=("pstream", ))
    @commands.has_any_role(*STAFF_ROLES)
    async def permanentstream(self, ctx: commands.Context,
                              user: discord.Member, *_) -> None:
        """Permanently give user a streaming permission."""
        # Check if user already has streaming permission
        already_allowed = any(Roles.video == role.id for role in user.roles)
        if already_allowed:
            if user.id in self.scheduler:
                self.scheduler.cancel(user.id)
                await self._delete_from_redis(user.id)
                await ctx.send(
                    f"{Emojis.check_mark} Moved temporary permission to permanent"
                )
                return
            await ctx.send(f"{Emojis.cross_mark} This user can already stream."
                           )
            return

        await user.add_roles(discord.Object(Roles.video),
                             reason="Permanent streaming access granted")
        await ctx.send(
            f"{Emojis.check_mark} {user.mention} can now stream forever")

    @commands.command(aliases=("unstream", ))
    @commands.has_any_role(*STAFF_ROLES)
    async def revokestream(self, ctx: commands.Context,
                           user: discord.Member) -> None:
        """Take away streaming permission from a user."""
        # Check if user has the streaming permission to begin with
        allowed = any(Roles.video == role.id for role in user.roles)
        if allowed:
            # Cancel scheduled task to take away streaming permission to avoid errors
            if user.id in self.scheduler:
                self.scheduler.cancel(user.id)
            await self._remove_streaming_permission(user)
            await ctx.send(
                f"{Emojis.check_mark} Streaming permission taken from {user.display_name}."
            )
        else:
            await ctx.send(
                f"{Emojis.cross_mark} This user already can't stream.")

    def cog_unload(self) -> None:
        """Cancel all scheduled tasks."""
        self.reload_task.cancel()
        self.reload_task.add_done_callback(
            lambda _: self.scheduler.cancel_all())
Exemple #14
0
class Reviewer:
    """Schedules, formats, and publishes reviews of helper nominees."""
    def __init__(self, name: str, bot: Bot, pool: 'TalentPool'):
        self.bot = bot
        self._pool = pool
        self._review_scheduler = Scheduler(name)

    def __contains__(self, user_id: int) -> bool:
        """Return True if the user with ID user_id is scheduled for review, False otherwise."""
        return user_id in self._review_scheduler

    async def reschedule_reviews(self) -> None:
        """Reschedule all active nominations to be reviewed at the appropriate time."""
        log.trace("Rescheduling reviews")
        await self.bot.wait_until_guild_available()
        # TODO Once the watch channel is removed, this can be done in a smarter way, e.g create a sync function.
        await self._pool.fetch_user_cache()

        for user_id, user_data in self._pool.watched_users.items():
            if not user_data["reviewed"]:
                self.schedule_review(user_id)

    def schedule_review(self, user_id: int) -> None:
        """Schedules a single user for review."""
        log.trace(f"Scheduling review of user with ID {user_id}")

        user_data = self._pool.watched_users[user_id]
        inserted_at = isoparse(user_data['inserted_at']).replace(tzinfo=None)
        review_at = inserted_at + timedelta(days=MAX_DAYS_IN_POOL)

        # If it's over a day overdue, it's probably an old nomination and shouldn't be automatically reviewed.
        if datetime.utcnow() - review_at < timedelta(days=1):
            self._review_scheduler.schedule_at(
                review_at, user_id,
                self.post_review(user_id, update_database=True))

    async def post_review(self, user_id: int, update_database: bool) -> None:
        """Format a generic review of a user and post it to the nomination voting channel."""
        log.trace(f"Posting the review of {user_id}")

        nomination = self._pool.watched_users[user_id]
        if not nomination:
            log.trace(
                f"There doesn't appear to be an active nomination for {user_id}"
            )
            return

        guild = self.bot.get_guild(Guild.id)
        channel = guild.get_channel(Channels.nomination_voting)
        member = guild.get_member(user_id)

        if update_database:
            await self.bot.api_client.patch(
                f"{self._pool.api_endpoint}/{nomination['id']}",
                json={"reviewed": True})

        if not member:
            await channel.send(
                f"I tried to review the user with ID `{user_id}`, but they don't appear to be on the server 😔"
            )
            return

        opening = f"<@&{Roles.moderators}> <@&{Roles.admins}>\n{member.mention} ({member}) for Helper!"

        current_nominations = "\n\n".join(
            f"**<@{entry['actor']}>:** {entry['reason'] or '*no reason given*'}"
            for entry in nomination['entries'])
        current_nominations = f"**Nominated by:**\n{current_nominations}"

        review_body = await self._construct_review_body(member)

        seen_emoji = self._random_ducky(guild)
        vote_request = (
            "*Refer to their nomination and infraction histories for further details*.\n"
            f"*Please react {seen_emoji} if you've seen this post."
            " Then react 👍 for approval, or 👎 for disapproval*.")

        review = "\n\n".join(part for part in (opening, current_nominations,
                                               review_body, vote_request))

        message = (await self._bulk_send(channel, review))[-1]
        for reaction in (seen_emoji, "👍", "👎"):
            await message.add_reaction(reaction)

    async def _construct_review_body(self, member: Member) -> str:
        """Formats the body of the nomination, with details of activity, infractions, and previous nominations."""
        activity = await self._activity_review(member)
        infractions = await self._infractions_review(member)
        prev_nominations = await self._previous_nominations_review(member)

        body = f"{activity}\n\n{infractions}"
        if prev_nominations:
            body += f"\n\n{prev_nominations}"
        return body

    async def _activity_review(self, member: Member) -> str:
        """
        Format the activity of the nominee.

        Adds details on how long they've been on the server, their total message count,
        and the channels they're the most active in.
        """
        log.trace(f"Fetching the metricity data for {member.id}'s review")
        try:
            user_activity = await self.bot.api_client.get(
                f"bot/users/{member.id}/metricity_review_data")
        except ResponseCodeError as e:
            if e.status == 404:
                log.trace(
                    f"The user {member.id} seems to have no activity logged in Metricity."
                )
                messages = "no"
                channels = ""
            else:
                log.trace(
                    f"An unexpected error occured while fetching information of user {member.id}."
                )
                raise
        else:
            log.trace(f"Activity found for {member.id}, formatting review.")
            messages = user_activity["total_messages"]
            # Making this part flexible to the amount of expected and returned channels.
            first_channel = user_activity["top_channel_activity"][0]
            channels = f", with {first_channel[1]} messages in {first_channel[0]}"

            if len(user_activity["top_channel_activity"]) > 1:
                channels += ", " + ", ".join(
                    f"{count} in {channel}" for channel, count in
                    user_activity["top_channel_activity"][1:-1])
                last_channel = user_activity["top_channel_activity"][-1]
                channels += f", and {last_channel[1]} in {last_channel[0]}"

        time_on_server = humanize_delta(relativedelta(datetime.utcnow(),
                                                      member.joined_at),
                                        max_units=2)
        review = (
            f"{member.name} has been on the server for **{time_on_server}**"
            f" and has **{messages} messages**{channels}.")

        return review

    async def _infractions_review(self, member: Member) -> str:
        """
        Formats the review of the nominee's infractions, if any.

        The infractions are listed by type and amount, and it is stated how long ago the last one was issued.
        """
        log.trace(f"Fetching the infraction data for {member.id}'s review")
        infraction_list = await self.bot.api_client.get(
            'bot/infractions/expanded',
            params={
                'user__id': str(member.id),
                'ordering': '-inserted_at'
            })

        log.trace(
            f"{len(infraction_list)} infractions found for {member.id}, formatting review."
        )
        if not infraction_list:
            return "They have no infractions."

        # Count the amount of each type of infraction.
        infr_stats = list(
            Counter(infr["type"] for infr in infraction_list).items())

        # Format into a sentence.
        if len(infr_stats) == 1:
            infr_type, count = infr_stats[0]
            infractions = f"{count} {self._format_infr_name(infr_type, count)}"
        else:  # We already made sure they have infractions.
            infractions = ", ".join(
                f"{count} {self._format_infr_name(infr_type, count)}"
                for infr_type, count in infr_stats[:-1])
            last_infr, last_count = infr_stats[-1]
            infractions += f", and {last_count} {self._format_infr_name(last_infr, last_count)}"

        infractions = f"**{infractions}**"

        # Show when the last one was issued.
        if len(infraction_list) == 1:
            infractions += ", issued "
        else:
            infractions += ", with the last infraction issued "

        # Infractions were ordered by time since insertion descending.
        infractions += get_time_delta(infraction_list[0]['inserted_at'])

        return f"They have {infractions}."

    @staticmethod
    def _format_infr_name(infr_type: str, count: int) -> str:
        """
        Format the infraction type in a way readable in a sentence.

        Underscores are replaced with spaces, as well as *attempting* to show the appropriate plural form if necessary.
        This function by no means covers all rules of grammar.
        """
        formatted = infr_type.replace("_", " ")
        if count > 1:
            if infr_type.endswith(('ch', 'sh')):
                formatted += "e"
            formatted += "s"

        return formatted

    async def _previous_nominations_review(self,
                                           member: Member) -> Optional[str]:
        """
        Formats the review of the nominee's previous nominations.

        The number of previous nominations and unnominations are shown, as well as the reason the last one ended.
        """
        log.trace(
            f"Fetching the nomination history data for {member.id}'s review")
        history = await self.bot.api_client.get(self._pool.api_endpoint,
                                                params={
                                                    "user__id": str(member.id),
                                                    "active": "false",
                                                    "ordering": "-inserted_at"
                                                })

        log.trace(
            f"{len(history)} previous nominations found for {member.id}, formatting review."
        )
        if not history:
            return

        num_entries = sum(len(nomination["entries"]) for nomination in history)

        nomination_times = f"{num_entries} times" if num_entries > 1 else "once"
        rejection_times = f"{len(history)} times" if len(
            history) > 1 else "once"
        end_time = time_since(isoparse(
            history[0]['ended_at']).replace(tzinfo=None),
                              max_units=2)

        review = (
            f"They were nominated **{nomination_times}** before"
            f", but their nomination was called off **{rejection_times}**."
            f"\nThe last one ended {end_time} with the reason: {history[0]['end_reason']}"
        )

        return review

    @staticmethod
    def _random_ducky(guild: Guild) -> Union[Emoji, str]:
        """Picks a random ducky emoji to be used to mark the vote as seen. If no duckies found returns 👀."""
        duckies = [
            emoji for emoji in guild.emojis if emoji.name.startswith("ducky")
        ]
        if not duckies:
            return "👀"
        return random.choice(duckies)

    @staticmethod
    async def _bulk_send(channel: TextChannel, text: str) -> List[Message]:
        """
        Split a text into several if necessary, and post them to the channel.

        Returns the resulting message objects.
        """
        messages = textwrap.wrap(text,
                                 width=MAX_MESSAGE_SIZE,
                                 replace_whitespace=False)
        log.trace(
            f"The provided string will be sent to the channel {channel.id} as {len(messages)} messages."
        )

        results = []
        for message in messages:
            await asyncio.sleep(1)
            results.append(await channel.send(message))

        return results

    async def mark_reviewed(self, ctx: Context, user_id: int) -> bool:
        """
        Mark an active nomination as reviewed, updating the database and canceling the review task.

        Returns True if the user was successfully marked as reviewed, False otherwise.
        """
        log.trace(f"Updating user {user_id} as reviewed")
        await self._pool.fetch_user_cache()
        if user_id not in self._pool.watched_users:
            log.trace(f"Can't find a nominated user with id {user_id}")
            await ctx.send(
                f"❌ Can't find a currently nominated user with id `{user_id}`")
            return False

        nomination = self._pool.watched_users[user_id]
        if nomination["reviewed"]:
            await ctx.send(
                "❌ This nomination was already reviewed, but here's a cookie 🍪"
            )
            return False

        await self.bot.api_client.patch(
            f"{self._pool.api_endpoint}/{nomination['id']}",
            json={"reviewed": True})
        if user_id in self._review_scheduler:
            self._review_scheduler.cancel(user_id)

        return True

    def cancel(self, user_id: int) -> None:
        """
        Cancels the review of the nominee with ID `user_id`.

        It's important to note that this applies only until reschedule_reviews is called again.
        To permanently cancel someone's review, either remove them from the pool, or use mark_reviewed.
        """
        log.trace(f"Canceling the review of user {user_id}.")
        self._review_scheduler.cancel(user_id)

    def cancel_all(self) -> None:
        """
        Cancels all reviews.

        It's important to note that this applies only until reschedule_reviews is called again.
        To permanently cancel someone's review, either remove them from the pool, or use mark_reviewed.
        """
        log.trace("Canceling all reviews.")
        self._review_scheduler.cancel_all()
Exemple #15
0
class ModPings(Cog):
    """Commands for a moderator to turn moderator pings on and off."""

    # RedisCache[discord.Member.id, 'Naïve ISO 8601 string']
    # The cache's keys are mods who have pings off.
    # The cache's values are the times when the role should be re-applied to them, stored in ISO format.
    pings_off_mods = RedisCache()

    # RedisCache[discord.Member.id, 'start timestamp|total worktime in seconds']
    # The cache's keys are mod's ID
    # The cache's values are their pings on schedule timestamp and the total seconds (work time) until pings off
    modpings_schedule = RedisCache()

    def __init__(self, bot: Bot):
        self.bot = bot
        self._role_scheduler = Scheduler("ModPingsOnOff")
        self._modpings_scheduler = Scheduler("ModPingsSchedule")

        self.guild = None
        self.moderators_role = None

        self.modpings_schedule_task = scheduling.create_task(
            self.reschedule_modpings_schedule(), event_loop=self.bot.loop)
        self.reschedule_task = scheduling.create_task(
            self.reschedule_roles(),
            name="mod-pings-reschedule",
            event_loop=self.bot.loop,
        )

    async def reschedule_roles(self) -> None:
        """Reschedule moderators role re-apply times."""
        await self.bot.wait_until_guild_available()
        self.guild = self.bot.get_guild(Guild.id)
        self.moderators_role = self.guild.get_role(Roles.moderators)

        mod_team = self.guild.get_role(Roles.mod_team)
        pings_on = self.moderators_role.members
        pings_off = await self.pings_off_mods.to_dict()

        log.trace(
            "Applying the moderators role to the mod team where necessary.")
        for mod in mod_team.members:
            if mod in pings_on:  # Make sure that on-duty mods aren't in the cache.
                if mod.id in pings_off:
                    await self.pings_off_mods.delete(mod.id)
                continue

            # Keep the role off only for those in the cache.
            if mod.id not in pings_off:
                await self.reapply_role(mod)
            else:
                expiry = isoparse(pings_off[mod.id])
                self._role_scheduler.schedule_at(expiry, mod.id,
                                                 self.reapply_role(mod))

    async def reschedule_modpings_schedule(self) -> None:
        """Reschedule moderators schedule ping."""
        await self.bot.wait_until_guild_available()
        schedule_cache = await self.modpings_schedule.to_dict()

        log.info(
            "Scheduling modpings schedule for applicable moderators found in cache."
        )
        for mod_id, schedule in schedule_cache.items():
            start_timestamp, work_time = schedule.split("|")
            start = datetime.datetime.fromtimestamp(float(start_timestamp))

            mod = await self.bot.fetch_user(mod_id)
            self._modpings_scheduler.schedule_at(
                start, mod_id, self.add_role_schedule(mod, work_time, start))

    async def remove_role_schedule(self, mod: Member, work_time: int,
                                   schedule_start: datetime.datetime) -> None:
        """Removes the moderator's role to the given moderator."""
        log.trace(f"Removing moderator role from mod with ID {mod.id}")
        await mod.remove_roles(self.moderators_role,
                               reason="Moderator schedule time expired.")

        # Remove the task before scheduling it again
        self._modpings_scheduler.cancel(mod.id)

        # Add the task again
        log.trace(
            f"Adding mod pings schedule task again for mod with ID {mod.id}")
        schedule_start += datetime.timedelta(days=1)
        self._modpings_scheduler.schedule_at(
            schedule_start, mod.id,
            self.add_role_schedule(mod, work_time, schedule_start))

    async def add_role_schedule(self, mod: Member, work_time: int,
                                schedule_start: datetime.datetime) -> None:
        """Adds the moderator's role to the given moderator."""
        # If the moderator has pings off, then skip adding role
        if mod.id in await self.pings_off_mods.to_dict():
            log.trace(
                f"Skipping adding moderator role to mod with ID {mod.id} - found in pings off cache."
            )
        else:
            log.trace(f"Applying moderator role to mod with ID {mod.id}")
            await mod.add_roles(self.moderators_role,
                                reason="Moderator scheduled time started!")

        log.trace(
            f"Sleeping for {work_time} seconds, worktime for mod with ID {mod.id}"
        )
        await asyncio.sleep(work_time)
        await self.remove_role_schedule(mod, work_time, schedule_start)

    async def reapply_role(self, mod: Member) -> None:
        """Reapply the moderator's role to the given moderator."""
        log.trace(f"Re-applying role to mod with ID {mod.id}.")
        await mod.add_roles(self.moderators_role,
                            reason="Pings off period expired.")
        await self.pings_off_mods.delete(mod.id)

    @group(name='modpings', aliases=('modping', ), invoke_without_command=True)
    @has_any_role(*MODERATION_ROLES)
    async def modpings_group(self, ctx: Context) -> None:
        """Allow the removal and re-addition of the pingable moderators role."""
        await ctx.send_help(ctx.command)

    @modpings_group.command(name='off')
    @has_any_role(*MODERATION_ROLES)
    async def off_command(self, ctx: Context, duration: Expiry) -> None:
        """
        Temporarily removes the pingable moderators role for a set amount of time.

        A unit of time should be appended to the duration.
        Units (∗case-sensitive):
        \u2003`y` - years
        \u2003`m` - months∗
        \u2003`w` - weeks
        \u2003`d` - days
        \u2003`h` - hours
        \u2003`M` - minutes∗
        \u2003`s` - seconds

        Alternatively, an ISO 8601 timestamp can be provided for the duration.

        The duration cannot be longer than 30 days.
        """
        delta = duration - arrow.utcnow()
        if delta > datetime.timedelta(days=30):
            await ctx.send(
                ":x: Cannot remove the role for longer than 30 days.")
            return

        mod = ctx.author

        until_date = duration.replace(
            microsecond=0).isoformat()  # Looks noisy with microseconds.
        await mod.remove_roles(self.moderators_role,
                               reason=f"Turned pings off until {until_date}.")

        await self.pings_off_mods.set(mod.id, duration.isoformat())

        # Allow rescheduling the task without cancelling it separately via the `on` command.
        if mod.id in self._role_scheduler:
            self._role_scheduler.cancel(mod.id)
        self._role_scheduler.schedule_at(duration, mod.id,
                                         self.reapply_role(mod))

        embed = Embed(timestamp=duration, colour=Colours.bright_green)
        embed.set_footer(text="Moderators role has been removed until",
                         icon_url=Icons.green_checkmark)
        await ctx.send(embed=embed)

    @modpings_group.command(name='on')
    @has_any_role(*MODERATION_ROLES)
    async def on_command(self, ctx: Context) -> None:
        """Re-apply the pingable moderators role."""
        mod = ctx.author
        if mod in self.moderators_role.members:
            await ctx.send(":question: You already have the role.")
            return

        await mod.add_roles(self.moderators_role,
                            reason="Pings off period canceled.")

        await self.pings_off_mods.delete(mod.id)

        # We assume the task exists. Lack of it may indicate a bug.
        self._role_scheduler.cancel(mod.id)

        await ctx.send(
            f"{Emojis.check_mark} Moderators role has been re-applied.")

    @modpings_group.group(name='schedule',
                          aliases=('s', ),
                          invoke_without_command=True)
    @has_any_role(*MODERATION_ROLES)
    async def schedule_modpings(self, ctx: Context, start: str,
                                end: str) -> None:
        """Schedule modpings role to be added at <start> and removed at <end> everyday at UTC time!"""
        start, end = dateutil_parse(start), dateutil_parse(end)

        if end < start:
            end += datetime.timedelta(days=1)

        if (end - start) > datetime.timedelta(hours=MAXIMUM_WORK_LIMIT):
            await ctx.send(
                f":x: {ctx.author.mention} You can't have the modpings role for"
                f" more than {MAXIMUM_WORK_LIMIT} hours!")
            return

        if start < datetime.datetime.utcnow():
            # The datetime has already gone for the day, so make it tomorrow
            # otherwise the scheduler would schedule it immediately
            start += datetime.timedelta(days=1)

        work_time = (end - start).total_seconds()

        await self.modpings_schedule.set(ctx.author.id,
                                         f"{start.timestamp()}|{work_time}")

        if ctx.author.id in self._modpings_scheduler:
            self._modpings_scheduler.cancel(ctx.author.id)

        self._modpings_scheduler.schedule_at(
            start, ctx.author.id,
            self.add_role_schedule(ctx.author, work_time, start))

        await ctx.send(
            f"{Emojis.ok_hand} {ctx.author.mention} Scheduled mod pings from "
            f"{discord_timestamp(start, TimestampFormats.TIME)} to "
            f"{discord_timestamp(end, TimestampFormats.TIME)}!")

    @schedule_modpings.command(name='delete', aliases=('del', 'd'))
    async def modpings_schedule_delete(self, ctx: Context) -> None:
        """Delete your modpings schedule."""
        self._modpings_scheduler.cancel(ctx.author.id)
        await self.modpings_schedule.delete(ctx.author.id)
        await ctx.send(
            f"{Emojis.ok_hand} {ctx.author.mention} Deleted your modpings schedule!"
        )

    def cog_unload(self) -> None:
        """Cancel role tasks when the cog unloads."""
        log.trace("Cog unload: canceling role tasks.")
        self.reschedule_task.cancel()
        self._role_scheduler.cancel_all()

        self.modpings_schedule_task.cancel()
        self._modpings_scheduler.cancel_all()