aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/cogs/moderation/infractions.py357
-rw-r--r--bot/cogs/moderation/scheduler.py368
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)