diff options
| -rw-r--r-- | bot/cogs/moderation/__init__.py | 4 | ||||
| -rw-r--r-- | bot/cogs/moderation/incidents.py | 333 | ||||
| -rw-r--r-- | bot/constants.py | 6 | ||||
| -rw-r--r-- | config-default.yml | 7 | 
4 files changed, 348 insertions, 2 deletions
| diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py index 6880ca1bd..4455705f7 100644 --- a/bot/cogs/moderation/__init__.py +++ b/bot/cogs/moderation/__init__.py @@ -1,4 +1,5 @@  from bot.bot import Bot +from .incidents import Incidents  from .infractions import Infractions  from .management import ModManagement  from .modlog import ModLog @@ -7,7 +8,8 @@ from .superstarify import Superstarify  def setup(bot: Bot) -> None: -    """Load the Infractions, ModManagement, ModLog, Silence, and Superstarify cogs.""" +    """Load the Incidents, Infractions, ModManagement, ModLog, Silence, and Superstarify cogs.""" +    bot.add_cog(Incidents(bot))      bot.add_cog(Infractions(bot))      bot.add_cog(ModLog(bot))      bot.add_cog(ModManagement(bot)) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py new file mode 100644 index 000000000..151584d38 --- /dev/null +++ b/bot/cogs/moderation/incidents.py @@ -0,0 +1,333 @@ +import asyncio +import logging +import typing as t +from enum import Enum + +import discord +from discord.ext.commands import Cog + +from bot.bot import Bot +from bot.constants import Channels, Emojis, Roles, Webhooks + +log = logging.getLogger(__name__) + + +class Signal(Enum): +    """ +    Recognized incident status signals. + +    This binds emoji to actions. The bot will only react to emoji linked here. +    All other signals are seen as invalid. +    """ + +    ACTIONED = Emojis.incident_actioned +    NOT_ACTIONED = Emojis.incident_unactioned +    INVESTIGATING = Emojis.incident_investigating + + +# Reactions from roles not listed here, or using emoji not listed here, will be removed +ALLOWED_ROLES: t.Set[int] = {Roles.moderators, Roles.admins, Roles.owners} +ALLOWED_EMOJI: t.Set[str] = {signal.value for signal in Signal} + + +def is_incident(message: discord.Message) -> bool: +    """True if `message` qualifies as an incident, False otherwise.""" +    conditions = ( +        message.channel.id == Channels.incidents,  # Message sent in #incidents +        not message.author.bot,                    # Not by a bot +        not message.content.startswith("#"),       # Doesn't start with a hash +        not message.pinned,                        # And isn't header +    ) +    return all(conditions) + + +def own_reactions(message: discord.Message) -> t.Set[str]: +    """Get the set of reactions placed on `message` by the bot itself.""" +    return {str(reaction.emoji) for reaction in message.reactions if reaction.me} + + +def has_signals(message: discord.Message) -> bool: +    """True if `message` already has all `Signal` reactions, False otherwise.""" +    missing_signals = ALLOWED_EMOJI - own_reactions(message)  # In `ALLOWED_EMOJI` but not in `own_reactions(message)` +    return not missing_signals + + +async def add_signals(incident: discord.Message) -> None: +    """ +    Add `Signal` member emoji to `incident` as reactions. + +    If the emoji has already been placed on `incident` by the bot, it will be skipped. +    """ +    existing_reacts = own_reactions(incident) + +    for signal_emoji in Signal: + +        # This will not raise, but it is a superfluous API call that can be avoided +        if signal_emoji.value in existing_reacts: +            log.debug(f"Skipping emoji as it's already been placed: {signal_emoji}") + +        else: +            log.debug(f"Adding reaction: {signal_emoji}") +            await incident.add_reaction(signal_emoji.value) + + +class Incidents(Cog): +    """ +    Automation for the #incidents channel. + +    This cog does not provide a command API, it only reacts to the following events. + +    On start-up: +        * Crawl #incidents and add missing `Signal` emoji where appropriate +        * This is to retro-actively add the available options for messages which +          were sent while the bot wasn't listening +        * Pinned messages and message starting with # do not qualify as incidents +        * See: `crawl_incidents` + +    On message: +        * Add `Signal` member emoji if message qualifies as an incident +        * Ignore messages starting with # +            * Use this if verbal communication is necessary +            * Each such message must be deleted manually once appropriate +        * See: `on_message` + +    On reaction: +        * Remove reaction if not permitted (`ALLOWED_EMOJI`, `ALLOWED_ROLES`) +        * If `Signal.ACTIONED` or `Signal.NOT_ACTIONED` were chosen, attempt to +          relay the incident message to #incidents-archive +        * If relay successful, delete original message +        * See: `on_raw_reaction_add` + +    Please refer to function docstrings for implementation details. +    """ + +    def __init__(self, bot: Bot) -> None: +        """Prepare `event_lock` and schedule `crawl_task` on start-up.""" +        self.bot = bot + +        self.event_lock = asyncio.Lock() +        self.crawl_task = self.bot.loop.create_task(self.crawl_incidents()) + +    async def crawl_incidents(self) -> None: +        """ +        Crawl #incidents and add missing emoji where necessary. + +        This is to catch-up should an incident be reported while the bot wasn't listening. +        After adding each reaction, we take a short break to avoid drowning in ratelimits. + +        Once this task is scheduled, listeners that change messages should await it. +        The crawl assumes that the channel history doesn't change as we go over it. +        """ +        await self.bot.wait_until_guild_available() +        incidents: discord.TextChannel = self.bot.get_channel(Channels.incidents) + +        # Limit the query at 50 as in practice, there should never be this many messages, +        # and if there are, something has likely gone very wrong +        limit = 50 + +        # Seconds to sleep after adding reactions to a message +        sleep = 2 + +        log.debug(f"Crawling messages in #incidents: {limit=}, {sleep=}") +        async for message in incidents.history(limit=limit): + +            if not is_incident(message): +                log.debug("Skipping message: not an incident") +                continue + +            if has_signals(message): +                log.debug("Skipping message: already has all signals") +                continue + +            await add_signals(message) +            await asyncio.sleep(sleep) + +        log.debug("Crawl task finished!") + +    async def archive(self, incident: discord.Message, outcome: Signal) -> bool: +        """ +        Relay `incident` to the #incidents-archive channel. + +        The following pieces of information are relayed: +            * Incident message content (clean, pingless) +            * Incident author name (as webhook author) +            * Incident author avatar (as webhook avatar) +            * Resolution signal (`outcome`) + +        Return True if the relay finishes successfully. If anything goes wrong, meaning +        not all information was relayed, return False. This signals that the original +        message is not safe to be deleted, as we will lose some information. +        """ +        log.debug(f"Archiving incident: {incident.id} with outcome: {outcome}") +        try: +            # First we try to grab the webhook +            webhook: discord.Webhook = await self.bot.fetch_webhook(Webhooks.incidents_archive) + +            # Now relay the incident +            message: discord.Message = await webhook.send( +                content=incident.clean_content,  # Clean content will prevent mentions from pinging +                username=incident.author.name, +                avatar_url=incident.author.avatar_url, +                wait=True,  # This makes the method return the sent Message object +            ) + +            # Finally add the `outcome` emoji +            await message.add_reaction(outcome.value) + +        except Exception as exc: +            log.exception("Failed to archive incident to #incidents-archive", exc_info=exc) +            return False + +        else: +            log.debug("Message archived successfully!") +            return True + +    def make_confirmation_task(self, incident: discord.Message, timeout: int = 5) -> asyncio.Task: +        """ +        Create a task to wait `timeout` seconds for `incident` to be deleted. + +        If `timeout` passes, this will raise `asyncio.TimeoutError`, signaling that we haven't +        been able to confirm that the message was deleted. +        """ +        log.debug(f"Confirmation task will wait {timeout=} seconds for {incident.id=} to be deleted") + +        def check(payload: discord.RawReactionActionEvent) -> bool: +            return payload.message_id == incident.id + +        coroutine = self.bot.wait_for(event="raw_message_delete", check=check, timeout=timeout) +        return self.bot.loop.create_task(coroutine) + +    async def process_event(self, reaction: str, incident: discord.Message, member: discord.Member) -> None: +        """ +        Process a `reaction_add` event in #incidents. + +        First, we check that the reaction is a recognized `Signal` member, and that it was sent by +        a permitted user (at least one role in `ALLOWED_ROLES`). If not, the reaction is removed. + +        If the reaction was either `Signal.ACTIONED` or `Signal.NOT_ACTIONED`, we attempt to relay +        the report to #incidents-archive. If successful, the original message is deleted. + +        We do not release `event_lock` until we receive the corresponding `message_delete` event. +        This ensures that if there is a racing event awaiting the lock, it will fail to find the +        message, and will abort. There is a `timeout` to ensure that this doesn't hold the lock +        forever should something go wrong. +        """ +        members_roles: t.Set[int] = {role.id for role in member.roles} +        if not members_roles & ALLOWED_ROLES:  # Intersection is truthy on at least 1 common element +            log.debug(f"Removing invalid reaction: user {member} is not permitted to send signals") +            await incident.remove_reaction(reaction, member) +            return + +        if reaction not in ALLOWED_EMOJI: +            log.debug(f"Removing invalid reaction: emoji {reaction} is not a valid signal") +            await incident.remove_reaction(reaction, member) +            return + +        # If we reach this point, we know that `emoji` is a `Signal` member +        signal = Signal(reaction) +        log.debug(f"Received signal: {signal}") + +        if signal not in (Signal.ACTIONED, Signal.NOT_ACTIONED): +            log.debug("Reaction was valid, but no action is currently defined for it") +            return + +        relay_successful = await self.archive(incident, signal) +        if not relay_successful: +            log.debug("Original message will not be deleted as we failed to relay it to the archive") +            return + +        timeout = 5  # Seconds +        confirmation_task = self.make_confirmation_task(incident, timeout) + +        log.debug("Deleting original message") +        await incident.delete() + +        log.debug(f"Awaiting deletion confirmation: {timeout=} seconds") +        try: +            await confirmation_task +        except asyncio.TimeoutError: +            log.warning(f"Did not receive incident deletion confirmation within {timeout} seconds!") +        else: +            log.debug("Deletion was confirmed") + +    async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: +        """ +        Get `discord.Message` for `message_id` from cache, or API. + +        We first look into the local cache to see if the message is present. + +        If not, we try to fetch the message from the API. This is necessary for messages +        which were sent before the bot's current session. + +        In an edge-case, it is also possible that the message was already deleted, and +        the API will respond with a 404. In such a case, None will be returned. +        This signals that the event for `message_id` should be ignored. +        """ +        await self.bot.wait_until_guild_available()  # First make sure that the cache is ready +        log.debug(f"Resolving message for: {message_id=}") +        message: discord.Message = self.bot._connection._get_message(message_id)  # noqa: Private attribute + +        if message is not None: +            log.debug("Message was found in cache") +            return message + +        log.debug("Message not found, attempting to fetch") +        try: +            message = await self.bot.get_channel(Channels.incidents).fetch_message(message_id) +        except Exception as exc: +            log.debug(f"Failed to fetch message: {exc}") +            return None +        else: +            log.debug("Message fetched successfully!") +            return message + +    @Cog.listener() +    async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> None: +        """ +        Pre-process `payload` and pass it to `process_event` if appropriate. + +        We abort instantly if `payload` doesn't relate to a message sent in #incidents, +        or if it was sent by a bot. + +        If `payload` relates to a message in #incidents, we first ensure that `crawl_task` has +        finished, to make sure we don't mutate channel state as we're crawling it. + +        Next, we acquire `event_lock` - to prevent racing, events are processed one at a time. + +        Once we have the lock, the `discord.Message` object for this event must be resolved. +        If the lock was previously held by an event which successfully relayed the incident, +        this will fail and we abort the current event. + +        Finally, with both the lock and the `discord.Message` instance in our hands, we delegate +        to `process_event` to handle the event. + +        The justification for using a raw listener is the need to receive events for messages +        which were not cached in the current session. As a result, a certain amount of +        complexity is introduced, but at the moment this doesn't appear to be avoidable. +        """ +        if payload.channel_id != Channels.incidents or payload.member.bot: +            return + +        log.debug(f"Received reaction add event in #incidents, waiting for crawler: {self.crawl_task.done()=}") +        await self.crawl_task + +        log.debug(f"Acquiring event lock: {self.event_lock.locked()=}") +        async with self.event_lock: +            message = await self.resolve_message(payload.message_id) + +            if message is None: +                log.debug("Listener will abort as related message does not exist!") +                return + +            if not is_incident(message): +                log.debug("Ignoring event for a non-incident message") +                return + +            await self.process_event(str(payload.emoji), message, payload.member) +            log.debug("Releasing event lock") + +    @Cog.listener() +    async def on_message(self, message: discord.Message) -> None: +        """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" +        if is_incident(message): +            await add_signals(message) diff --git a/bot/constants.py b/bot/constants.py index 470221369..24726c20d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -271,6 +271,10 @@ class Emojis(metaclass=YAMLGetter):      status_idle: str      status_dnd: str +    incident_actioned: str +    incident_unactioned: str +    incident_investigating: str +      failmail: str      trashcan: str @@ -398,6 +402,7 @@ class Channels(metaclass=YAMLGetter):      helpers: int      how_to_get_help: int      incidents: int +    incidents_archive: int      message_log: int      meta: int      mod_alerts: int @@ -426,6 +431,7 @@ class Webhooks(metaclass=YAMLGetter):      reddit: int      duck_pond: int      dev_log: int +    incidents_archive: int  class Roles(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index 3388e5f78..6b827b63d 100644 --- a/config-default.yml +++ b/config-default.yml @@ -38,6 +38,10 @@ style:          status_dnd:     "<:status_dnd:470326272082313216>"          status_offline: "<:status_offline:470326266537705472>" +        incident_actioned:      "<:incident_actioned:719645530128646266>" +        incident_unactioned:    "<:incident_unactioned:719645583245180960>" +        incident_investigating: "<:incident_investigating:719645658671480924>" +          failmail: "<:failmail:633660039931887616>"          trashcan: "<:trashcan:637136429717389331>" @@ -173,6 +177,7 @@ guild:          organisation:       &ORGANISATION   551789653284356126          staff_lounge:       &STAFF_LOUNGE   464905259261755392          incidents:                          714214212200562749 +        incidents_archive:                  720668923636351037          # Voice          admins_voice:       &ADMINS_VOICE   500734494840717332 @@ -251,7 +256,7 @@ guild:          duck_pond:                      637821475327311927          dev_log:                        680501655111729222          python_news:    &PYNEWS_WEBHOOK 704381182279942324 - +        incidents_archive:              720671599790915702  filter: | 
