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: |