diff options
| -rw-r--r-- | bot/cogs/moderation/infractions.py | 357 | ||||
| -rw-r--r-- | bot/cogs/moderation/scheduler.py | 368 |
2 files changed, 372 insertions, 353 deletions
diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 997ffe524..9d65dfa8a 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -1,8 +1,6 @@ import logging -import textwrap import typing as t from datetime import datetime -from gettext import ngettext import dateutil.parser import discord @@ -11,14 +9,11 @@ from discord.ext import commands from discord.ext.commands import Context, command from bot import constants -from bot.api import ResponseCodeError -from bot.constants import Colours, Event, STAFF_CHANNELS +from bot.constants import Event from bot.decorators import respect_role_hierarchy -from bot.utils import time from bot.utils.checks import with_role_check -from bot.utils.scheduling import Scheduler from . import utils -from .modlog import ModLog +from .scheduler import InfractionScheduler from .utils import MemberObject log = logging.getLogger(__name__) @@ -26,38 +21,18 @@ log = logging.getLogger(__name__) MemberConverter = t.Union[utils.UserTypes, utils.proxy_user] -class Infractions(Scheduler, commands.Cog): +class Infractions(InfractionScheduler, commands.Cog): """Apply and pardon infractions on users for moderation purposes.""" category = "Moderation" category_description = "Server moderation tools." def __init__(self, bot: commands.Bot): - super().__init__() + super().__init__(bot) - self.bot = bot self.category = "Moderation" self._muted_role = discord.Object(constants.Roles.muted) - self.bot.loop.create_task(self.reschedule_infractions()) - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - async def reschedule_infractions(self) -> None: - """Schedule expiration for previous infractions.""" - await self.bot.wait_until_ready() - - infractions = await self.bot.api_client.get( - 'bot/infractions', - params={'active': 'true'} - ) - for infraction in infractions: - if infraction["expires_at"] is not None: - self.schedule_task(self.bot.loop, infraction["id"], infraction) - @commands.Cog.listener() async def on_member_join(self, member: Member) -> None: """Reapply active mute infractions for returning members.""" @@ -278,330 +253,6 @@ class Infractions(Scheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user, action) # endregion - # region: Utility functions - - async def _scheduled_task(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. - """ - _id = infraction["id"] - - expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) - await time.wait_until(expiry) - - log.debug(f"Marking infraction {_id} as inactive (expired).") - await self.deactivate_infraction(infraction) - - 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. - - Supported infraction types are mute and ban. Other types will raise a ValueError. - """ - guild = self.bot.get_guild(constants.Guild.id) - mod_role = guild.get_role(constants.Roles.moderator) - user_id = infraction["user"] - _type = infraction["type"] - _id = infraction["id"] - reason = f"Infraction #{_id} expired or was pardoned." - - log.debug(f"Marking infraction #{_id} as inactive (expired).") - - log_content = None - log_text = { - "Member": str(user_id), - "Actor": str(self.bot.user), - "Reason": infraction["reason"] - } - - try: - if _type == "mute": - user = guild.get_member(user_id) - if user: - # Remove the muted role. - self.mod_log.ignore(Event.member_update, user.id) - await user.remove_roles(self._muted_role, reason=reason) - - # DM the user about the expiration. - notified = await utils.notify_pardon( - user=user, - title="You have been unmuted.", - content="You may now send messages in the server.", - icon_url=utils.INFRACTION_ICONS["mute"][1] - ) - - log_text["Member"] = f"{user.mention}(`{user.id}`)" - log_text["DM"] = "Sent" if notified else "**Failed**" - else: - log.info(f"Failed to unmute user {user_id}: user not found") - log_text["Failure"] = "User was not found in the guild." - elif _type == "ban": - user = discord.Object(user_id) - self.mod_log.ignore(Event.member_unban, user_id) - try: - await guild.unban(user, reason=reason) - except discord.NotFound: - log.info(f"Failed to unban user {user_id}: no active ban found on Discord") - log_text["Note"] = "No active ban found on Discord." - 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"] = f"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 code {e.code}." - log_content = mod_role.mention - - # Check if the user is currently being watched by Big Brother. - try: - 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. - 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.cancel_task(infraction["id"]) - - # Send a log message to the mod log. - if send_log: - log_title = f"expiration failed" if "Failure" in log_text else "expired" - - await self.mod_log.send_log_message( - icon_url=utils.INFRACTION_ICONS[_type][1], - colour=Colours.soft_green, - title=f"Infraction {log_title}: {_type}", - text="\n".join(f"{k}: {v}" for k, v in log_text.items()), - footer=f"ID: {_id}", - content=log_content, - ) - - return log_text - - async def apply_infraction( - self, - ctx: Context, - infraction: utils.Infraction, - user: MemberObject, - 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 = infraction["expires_at"] - - if expiry: - expiry = time.format_infraction(expiry) - - # Default values for the confirmation message and mod log. - confirm_msg = f":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"Expires: {expiry}" if expiry else "" - log_title = "applied" - log_content = None - - # DM the user about the infraction if it's not a shadow/hidden infraction. - if not infraction["hidden"]: - # Sometimes user is a discord.Object; make it a proper user. - await self.bot.fetch_user(user.id) - - # 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" - else: - dm_log_text = "\nDM: **Failed**" - log_content = ctx.author.mention - - if infraction["actor"] == self.bot.user.id: - end_msg = f" (reason: {infraction['reason']})" - elif ctx.channel.id not in STAFF_CHANNELS: - end_msg = '' - else: - 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: - try: - await action_coro - if expiry: - # Schedule the expiration of the infraction. - self.schedule_task(ctx.bot.loop, infraction["id"], infraction) - except discord.Forbidden: - # Accordingly display that applying the infraction failed. - confirm_msg = f":x: failed to apply" - expiry_msg = "" - log_content = ctx.author.mention - log_title = "failed to apply" - - # Send a confirmation message to the invoking context. - await ctx.send( - f"{dm_result}{confirm_msg} **{infr_type}** to {user.mention}{expiry_msg}{end_msg}." - ) - - # Send a log message to the mod log. - 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} - Reason: {reason} - {expiry_log_text} - """), - content=log_content, - footer=f"ID {infraction['id']}" - ) - - async def pardon_infraction(self, ctx: Context, infr_type: str, user: MemberObject) -> None: - """Prematurely end an infraction for a user and log the action in the mod log.""" - # Check the current active infraction - response = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': infr_type, - 'user__id': user.id - } - ) - - if not response: - 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 - footer = f"ID: {response[0]['id']}" - - # If multiple active infractions were found, mark them as inactive in the database - # and cancel their expiration tasks. - if len(response) > 1: - log.warning(f"Found more than one active {infr_type} infraction for user {user.id}") - - 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.cancel_task(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: - # Mention the actor because the DM failed to send. - log_content = ctx.author.mention - - # 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 - else: - confirm_msg = f":ok_hand: pardoned" - log_title = "pardoned" - - # Send a confirmation message to the invoking context. - await ctx.send( - f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " - f"{log_text.get('Failure', '')}" - ) - - # 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, - ) - - # endregion # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py new file mode 100644 index 000000000..408e9943e --- /dev/null +++ b/bot/cogs/moderation/scheduler.py @@ -0,0 +1,368 @@ +import logging +import textwrap +import typing as t +from gettext import ngettext + +import dateutil.parser +import discord +from discord.ext import commands +from discord.ext.commands import Context + +from bot import constants +from bot.api import ResponseCodeError +from bot.constants import Colours, Event, STAFF_CHANNELS +from bot.utils import time +from bot.utils.scheduling import Scheduler +from . import utils +from .modlog import ModLog +from .utils import MemberObject + +log = logging.getLogger(__name__) + + +class InfractionScheduler(Scheduler): + """Handles the application, pardoning, and expiration of infractions.""" + + def __init__(self, bot: commands.Bot): + super().__init__() + + self.bot = bot + self.bot.loop.create_task(self.reschedule_infractions()) + + @property + def mod_log(self) -> ModLog: + """Get the currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + async def reschedule_infractions(self) -> None: + """Schedule expiration for previous infractions.""" + await self.bot.wait_until_ready() + + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={'active': 'true'} + ) + for infraction in infractions: + if infraction["expires_at"] is not None: + self.schedule_task(self.bot.loop, infraction["id"], infraction) + + async def apply_infraction( + self, + ctx: Context, + infraction: utils.Infraction, + user: MemberObject, + 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 = infraction["expires_at"] + + if expiry: + expiry = time.format_infraction(expiry) + + # Default values for the confirmation message and mod log. + confirm_msg = f":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"Expires: {expiry}" if expiry else "" + log_title = "applied" + log_content = None + + # DM the user about the infraction if it's not a shadow/hidden infraction. + if not infraction["hidden"]: + # Sometimes user is a discord.Object; make it a proper user. + await self.bot.fetch_user(user.id) + + # 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" + else: + dm_log_text = "\nDM: **Failed**" + log_content = ctx.author.mention + + if infraction["actor"] == self.bot.user.id: + end_msg = f" (reason: {infraction['reason']})" + elif ctx.channel.id not in STAFF_CHANNELS: + end_msg = "" + else: + 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: + try: + await action_coro + if expiry: + # Schedule the expiration of the infraction. + self.schedule_task(ctx.bot.loop, infraction["id"], infraction) + except discord.Forbidden: + # Accordingly display that applying the infraction failed. + confirm_msg = f":x: failed to apply" + expiry_msg = "" + log_content = ctx.author.mention + log_title = "failed to apply" + + # Send a confirmation message to the invoking context. + await ctx.send( + f"{dm_result}{confirm_msg} **{infr_type}** to {user.mention}{expiry_msg}{end_msg}." + ) + + # Send a log message to the mod log. + 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} + Reason: {reason} + {expiry_log_text} + """), + content=log_content, + footer=f"ID {infraction['id']}" + ) + + async def pardon_infraction(self, ctx: Context, infr_type: str, user: MemberObject) -> None: + """Prematurely end an infraction for a user and log the action in the mod log.""" + # Check the current active infraction + response = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': infr_type, + 'user__id': user.id + } + ) + + if not response: + 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 + footer = f"ID: {response[0]['id']}" + + # If multiple active infractions were found, mark them as inactive in the database + # and cancel their expiration tasks. + if len(response) > 1: + log.warning(f"Found more than one active {infr_type} infraction for user {user.id}") + + 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.cancel_task(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: + # Mention the actor because the DM failed to send. + log_content = ctx.author.mention + + # 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 + else: + confirm_msg = f":ok_hand: pardoned" + log_title = "pardoned" + + # Send a confirmation message to the invoking context. + await ctx.send( + f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " + f"{log_text.get('Failure', '')}" + ) + + # 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. + + Supported infraction types are mute and ban. Other types will raise a ValueError. + """ + guild = self.bot.get_guild(constants.Guild.id) + mod_role = guild.get_role(constants.Roles.moderator) + user_id = infraction["user"] + _type = infraction["type"] + _id = infraction["id"] + reason = f"Infraction #{_id} expired or was pardoned." + + log.debug(f"Marking infraction #{_id} as inactive (expired).") + + log_content = None + log_text = { + "Member": str(user_id), + "Actor": str(self.bot.user), + "Reason": infraction["reason"] + } + + try: + if _type == "mute": + user = guild.get_member(user_id) + if user: + # Remove the muted role. + self.mod_log.ignore(Event.member_update, user.id) + await user.remove_roles(self._muted_role, reason=reason) + + # DM the user about the expiration. + notified = await utils.notify_pardon( + user=user, + title="You have been unmuted.", + content="You may now send messages in the server.", + icon_url=utils.INFRACTION_ICONS["mute"][1] + ) + + log_text["Member"] = f"{user.mention}(`{user.id}`)" + log_text["DM"] = "Sent" if notified else "**Failed**" + else: + log.info(f"Failed to unmute user {user_id}: user not found") + log_text["Failure"] = "User was not found in the guild." + elif _type == "ban": + user = discord.Object(user_id) + self.mod_log.ignore(Event.member_unban, user_id) + try: + await guild.unban(user, reason=reason) + except discord.NotFound: + log.info(f"Failed to unban user {user_id}: no active ban found on Discord") + log_text["Note"] = "No active ban found on Discord." + 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"] = f"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 code {e.code}." + log_content = mod_role.mention + + # Check if the user is currently being watched by Big Brother. + try: + 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. + 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.cancel_task(infraction["id"]) + + # Send a log message to the mod log. + if send_log: + log_title = f"expiration failed" if "Failure" in log_text else "expired" + + await self.mod_log.send_log_message( + icon_url=utils.INFRACTION_ICONS[_type][1], + colour=Colours.soft_green, + title=f"Infraction {log_title}: {_type}", + text="\n".join(f"{k}: {v}" for k, v in log_text.items()), + footer=f"ID: {_id}", + content=log_content, + ) + + return log_text + + async def _scheduled_task(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. + """ + _id = infraction["id"] + + expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) + await time.wait_until(expiry) + + log.debug(f"Marking infraction {_id} as inactive (expired).") + await self.deactivate_infraction(infraction) |