aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/cogs/moderation/__init__.py4
-rw-r--r--bot/cogs/moderation/incidents.py333
-rw-r--r--bot/constants.py6
-rw-r--r--config-default.yml7
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: