diff options
author | 2023-03-20 14:17:49 +0200 | |
---|---|---|
committer | 2023-03-20 14:17:49 +0200 | |
commit | b5aabe45390bf2981cea87bf703ff3f19b61a6ff (patch) | |
tree | bd6584c93b0a8d2e9d00cf9d8e62389f017f25c7 | |
parent | Merge pull request #2470 from python-discord/fix-channel-blacklist (diff) | |
parent | Merge branch 'main' into mbaruh/timeout (diff) |
Merge pull request #2438 from python-discord/mbaruh/timeout
Migrate from role-based mutes to native timeouts
-rw-r--r-- | bot/constants.py | 13 | ||||
-rw-r--r-- | bot/exts/filters/antispam.py | 14 | ||||
-rw-r--r-- | bot/exts/moderation/infraction/_scheduler.py | 18 | ||||
-rw-r--r-- | bot/exts/moderation/infraction/_utils.py | 2 | ||||
-rw-r--r-- | bot/exts/moderation/infraction/infractions.py | 118 |
5 files changed, 100 insertions, 65 deletions
diff --git a/bot/constants.py b/bot/constants.py index 31a8b4d31..4553095f3 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -143,7 +143,7 @@ class _Roles(EnvConfig): contributors = 295488872404484098 help_cooldown = 699189276025421825 - muted = 277914926603829249 + muted = 277914926603829249 # TODO remove when no longer relevant. partners = 323426753857191936 python_community = 458226413825294336 voice_verified = 764802720779337729 @@ -334,11 +334,6 @@ class _Free(EnvConfig): Free = _Free() -class Punishment(BaseModel): - remove_after = 600 - role_id: int = Roles.muted - - class Rule(BaseModel): interval: int max: int @@ -369,7 +364,7 @@ class _AntiSpam(EnvConfig): clean_offending = True ping_everyone = True - punishment = Punishment() + remove_timeout_after = 600 rules = Rules() @@ -653,9 +648,9 @@ class _Icons(EnvConfig): token_removed = "https://cdn.discordapp.com/emojis/470326273298792469.png" user_ban = "https://cdn.discordapp.com/emojis/469952898026045441.png" - user_mute = "https://cdn.discordapp.com/emojis/472472640100106250.png" + user_timeout = "https://cdn.discordapp.com/emojis/472472640100106250.png" user_unban = "https://cdn.discordapp.com/emojis/469952898692808704.png" - user_unmute = "https://cdn.discordapp.com/emojis/472472639206719508.png" + user_untimeout = "https://cdn.discordapp.com/emojis/472472639206719508.png" user_update = "https://cdn.discordapp.com/emojis/469952898684551168.png" user_verified = "https://cdn.discordapp.com/emojis/470326274519334936.png" user_warn = "https://cdn.discordapp.com/emojis/470326274238447633.png" diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 4d2e67a31..70a9c00b8 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -8,7 +8,7 @@ from operator import attrgetter, itemgetter from typing import Dict, Iterable, List, Set import arrow -from discord import Colour, Member, Message, MessageType, NotFound, Object, TextChannel +from discord import Colour, Member, Message, MessageType, NotFound, TextChannel from discord.ext.commands import Cog from pydis_core.utils import scheduling @@ -123,8 +123,6 @@ class AntiSpam(Cog): def __init__(self, bot: Bot, validation_errors: Dict[str, str]) -> None: self.bot = bot self.validation_errors = validation_errors - role_id = AntiSpamConfig.punishment.role_id - self.muted_role = Object(role_id) self.expiration_date_converter = Duration() self.message_deletion_queue = dict() @@ -229,17 +227,17 @@ class AntiSpam(Cog): @lock.lock_arg("antispam.punish", "member", attrgetter("id")) async def punish(self, msg: Message, member: Member, reason: str) -> None: """Punishes the given member for triggering an antispam rule.""" - if not any(role.id == self.muted_role.id for role in member.roles): - remove_role_after = AntiSpamConfig.punishment.remove_after + if not member.is_timed_out(): + remove_timeout_after = AntiSpamConfig.remove_timeout_after # Get context and make sure the bot becomes the actor of infraction by patching the `author` attributes context = await self.bot.get_context(msg) context.author = self.bot.user - # Since we're going to invoke the tempmute command directly, we need to manually call the converter. - dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S") + # Since we're going to invoke the timeout command directly, we need to manually call the converter. + dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_timeout_after}S") await context.invoke( - self.bot.get_command('tempmute'), + self.bot.get_command('timeout'), member, dt_remove_role_after, reason=reason diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 63aac6340..a8af33dee 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -89,7 +89,7 @@ class InfractionScheduler: to prevent getting a RuntimeWarning if it is not used (e.g. in mocked tests). """ if infraction["expires_at"] is not None: - # Calculate the time remaining, in seconds, for the mute. + # Calculate the time remaining, in seconds, for the infraction. expiry = dateutil.parser.isoparse(infraction["expires_at"]) delta = (expiry - arrow.utcnow()).total_seconds() else: @@ -291,14 +291,14 @@ class InfractionScheduler: return not failed async def pardon_infraction( - self, - ctx: Context, - infr_type: str, - user: MemberOrUser, - pardon_reason: t.Optional[str] = None, - *, - send_msg: bool = True, - notify: bool = True + self, + ctx: Context, + infr_type: str, + user: MemberOrUser, + pardon_reason: t.Optional[str] = None, + *, + send_msg: bool = True, + notify: bool = True ) -> None: """ Prematurely end an infraction for a user and log the action in the mod log. diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 5e9fa75cc..18d296752 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -20,7 +20,7 @@ log = get_logger(__name__) INFRACTION_ICONS = { "ban": (Icons.user_ban, Icons.user_unban), "kick": (Icons.sign_out, None), - "mute": (Icons.user_mute, Icons.user_unmute), + "timeout": (Icons.user_timeout, Icons.user_untimeout), "note": (Icons.user_warn, None), "superstar": (Icons.superstarify, Icons.unsuperstarify), "warning": (Icons.user_warn, None), diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 60b4428b7..4ec9e41c7 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -1,21 +1,24 @@ import textwrap import typing as t +from datetime import timedelta import arrow import discord +from dateutil.relativedelta import relativedelta from discord import Member from discord.ext import commands from discord.ext.commands import Context, command from bot import constants from bot.bot import Bot -from bot.constants import Event +from bot.constants import Channels, Event from bot.converters import Age, Duration, DurationOrExpiry, MemberOrUser, UnambiguousMemberOrUser from bot.decorators import ensure_future_timestamp, respect_role_hierarchy from bot.exts.filters.filtering import AUTO_BAN_DURATION, AUTO_BAN_REASON from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler from bot.log import get_logger +from bot.utils.channel import is_mod_channel from bot.utils.members import get_or_fetch_member from bot.utils.messages import format_user @@ -27,6 +30,13 @@ if t.TYPE_CHECKING: from bot.exts.moderation.watchchannels.bigbrother import BigBrother +MAXIMUM_TIMEOUT_DAYS = timedelta(days=28) +TIMEOUT_CAP_MESSAGE = ( + f"The timeout for {{0}} can't be longer than {MAXIMUM_TIMEOUT_DAYS.days} days." + " I'll pretend that's what you meant." +) + + class Infractions(InfractionScheduler, commands.Cog): """Apply and pardon infractions on users for moderation purposes.""" @@ -34,30 +44,35 @@ class Infractions(InfractionScheduler, commands.Cog): category_description = "Server moderation tools." def __init__(self, bot: Bot): - super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning", "voice_mute"}) + super().__init__(bot, supported_infractions={"ban", "kick", "timeout", "note", "warning", "voice_mute"}) self.category = "Moderation" - self._muted_role = discord.Object(constants.Roles.muted) + self._muted_role = discord.Object(constants.Roles.muted) # TODO remove when no longer relevant. self._voice_verified_role = discord.Object(constants.Roles.voice_verified) @commands.Cog.listener() async def on_member_join(self, member: Member) -> None: - """Reapply active mute infractions for returning members.""" - active_mutes = await self.bot.api_client.get( + """ + Apply active timeout infractions for returning members. + + This is only needed for users who received the old role-mute, and are returning before it's ended. + TODO remove when no longer relevant. + """ + active_timeouts = await self.bot.api_client.get( "bot/infractions", params={ "active": "true", - "type": "mute", + "type": "timeout", "user__id": member.id } ) - if active_mutes: - reason = f"Re-applying active mute: {active_mutes[0]['id']}" + if active_timeouts and not member.is_timed_out(): + reason = f"Applying active timeout for returning member: {active_timeouts[0]['id']}" async def action() -> None: - await member.add_roles(self._muted_role, reason=reason) - await self.reapply_infraction(active_mutes[0], action) + await member.edit(timed_out_until=arrow.get(active_timeouts[0]["expires_at"]).datetime, reason=reason) + await self.reapply_infraction(active_timeouts[0], action) # region: Permanent infractions @@ -190,9 +205,9 @@ class Infractions(InfractionScheduler, commands.Cog): # endregion # region: Temporary infractions - @command(aliases=["mute"]) + @command(aliases=["mute", "tempmute"]) @ensure_future_timestamp(timestamp_arg=3) - async def tempmute( + async def timeout( self, ctx: Context, user: UnambiguousMemberOrUser, duration: t.Optional[DurationOrExpiry] = None, @@ -200,7 +215,7 @@ class Infractions(InfractionScheduler, commands.Cog): reason: t.Optional[str] = None ) -> None: """ - Temporarily mute a user for the given reason and duration. + Timeout a user for the given reason and duration. A unit of time should be appended to the duration. Units (∗case-sensitive): @@ -214,7 +229,7 @@ class Infractions(InfractionScheduler, commands.Cog): Alternatively, an ISO 8601 timestamp can be provided for the duration. - If no duration is given, a one hour duration is used by default. + If no duration is given, a one-hour duration is used by default. """ if not isinstance(user, Member): await ctx.send(":x: The user doesn't appear to be on the server.") @@ -222,7 +237,24 @@ class Infractions(InfractionScheduler, commands.Cog): if duration is None: duration = await Duration().convert(ctx, "1h") - await self.apply_mute(ctx, user, reason, duration_or_expiry=duration) + else: + now = arrow.utcnow() + if isinstance(duration, relativedelta): + duration += now + if duration > now + MAXIMUM_TIMEOUT_DAYS: + cap_message_for_user = TIMEOUT_CAP_MESSAGE.format(user.mention) + if is_mod_channel(ctx.channel): + await ctx.reply(f":warning: {cap_message_for_user}") + else: + await self.bot.get_channel(Channels.mods).send( + f":warning: {ctx.author.mention} {cap_message_for_user}" + ) + duration = now + MAXIMUM_TIMEOUT_DAYS - timedelta(minutes=1) # Duration cap is exclusive. + elif duration > now + MAXIMUM_TIMEOUT_DAYS - timedelta(minutes=1): + # Duration cap is exclusive. This is to still allow specifying "28d". + duration -= timedelta(minutes=1) + + await self.apply_timeout(ctx, user, reason, duration_or_expiry=duration) @command(aliases=("tban",)) @ensure_future_timestamp(timestamp_arg=3) @@ -337,16 +369,16 @@ class Infractions(InfractionScheduler, commands.Cog): # endregion # region: Remove infractions (un- commands) - @command() - async def unmute( + @command(aliases=("unmute",)) + async def untimeout( self, ctx: Context, user: UnambiguousMemberOrUser, *, pardon_reason: t.Optional[str] = None ) -> None: - """Prematurely end the active mute infraction for the user.""" - await self.pardon_infraction(ctx, "mute", user, pardon_reason) + """Prematurely end the active timeout infraction for the user.""" + await self.pardon_infraction(ctx, "timeout", user, pardon_reason) @command() async def unban(self, ctx: Context, user: UnambiguousMemberOrUser, *, pardon_reason: str) -> None: @@ -376,23 +408,28 @@ class Infractions(InfractionScheduler, commands.Cog): # endregion # region: Base apply functions - async def apply_mute(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: - """Apply a mute infraction with kwargs passed to `post_infraction`.""" - if active := await _utils.get_active_infraction(ctx, user, "mute", send_msg=False): + @respect_role_hierarchy(member_arg=2) + async def apply_timeout(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: + """Apply a timeout infraction with kwargs passed to `post_infraction`.""" + if isinstance(user, Member) and user.top_role >= ctx.me.top_role: + await ctx.send(":x: I can't timeout users above or equal to me in the role hierarchy.") + return None + + if active := await _utils.get_active_infraction(ctx, user, "timeout", send_msg=False): if active["actor"] != self.bot.user.id: await _utils.send_active_infraction_message(ctx, active) return - # Allow the current mute attempt to override an automatically triggered mute. + # Allow the current timeout attempt to override an automatically triggered timeout. log_text = await self.deactivate_infraction(active, notify=False) if "Failure" in log_text: await ctx.send( - f":x: can't override infraction **mute** for {user.mention}: " + f":x: can't override infraction **timeout** for {user.mention}: " f"failed to deactivate. {log_text['Failure']}" ) return - infraction = await _utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs) + infraction = await _utils.post_infraction(ctx, user, "timeout", reason, active=True, **kwargs) if infraction is None: return @@ -402,11 +439,11 @@ class Infractions(InfractionScheduler, commands.Cog): # Skip members that left the server if not isinstance(user, Member): return + duration_or_expiry = kwargs["duration_or_expiry"] + if isinstance(duration_or_expiry, relativedelta): + duration_or_expiry += arrow.utcnow() - await user.add_roles(self._muted_role, reason=reason) - - log.trace(f"Attempting to kick {user} from voice because they've been muted.") - await user.move_to(None, reason=reason) + await user.edit(timed_out_until=duration_or_expiry, reason=reason) await self.apply_infraction(ctx, infraction, user, action) @@ -522,7 +559,7 @@ class Infractions(InfractionScheduler, commands.Cog): # endregion # region: Base pardon functions - async def pardon_mute( + async def pardon_timeout( self, user_id: int, guild: discord.Guild, @@ -530,28 +567,33 @@ class Infractions(InfractionScheduler, commands.Cog): *, notify: bool = True ) -> t.Dict[str, str]: - """Remove a user's muted role, optionally DM them a notification, and return a log dict.""" + """Remove a user's timeout, optionally DM them a notification, and return a log dict.""" user = await get_or_fetch_member(guild, user_id) log_text = {} if user: - # Remove the muted role. + # Remove the timeout. self.mod_log.ignore(Event.member_update, user.id) - await user.remove_roles(self._muted_role, reason=reason) + if user.get_role(self._muted_role.id): + # Compatibility with existing role mutes. TODO remove when no longer relevant. + await user.remove_roles(self._muted_role, reason=reason) + if user.is_timed_out(): # Handle pardons via the command and any other obscure weirdness. + log.trace(f"Manually pardoning timeout for user {user.id}") + await user.edit(timed_out_until=None, reason=reason) if notify: # DM the user about the expiration. notified = await _utils.notify_pardon( user=user, - title="You have been unmuted", + title="Your timeout has ended", content="You may now send messages in the server.", - icon_url=_utils.INFRACTION_ICONS["mute"][1] + icon_url=_utils.INFRACTION_ICONS["timeout"][1] ) log_text["DM"] = "Sent" if notified else "**Failed**" log_text["Member"] = format_user(user) else: - log.info(f"Failed to unmute user {user_id}: user not found") + log.info(f"Failed to remove timeout from user {user_id}: user not found") log_text["Failure"] = "User was not found in the guild." return log_text @@ -610,8 +652,8 @@ class Infractions(InfractionScheduler, commands.Cog): user_id = infraction["user"] reason = f"Infraction #{infraction['id']} expired or was pardoned." - if infraction["type"] == "mute": - return await self.pardon_mute(user_id, guild, reason, notify=notify) + if infraction["type"] == "timeout": + return await self.pardon_timeout(user_id, guild, reason, notify=notify) elif infraction["type"] == "ban": return await self.pardon_ban(user_id, guild, reason) elif infraction["type"] == "voice_mute": |