aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar mbaruh <[email protected]>2023-03-04 21:25:43 +0200
committerGravatar mbaruh <[email protected]>2023-03-05 23:41:57 +0200
commitfdece3cc21f1317762535907ee50d04da0d9e1ec (patch)
treed91cf723ed5b21ad10d55c9bfdb29a5778a3f72c
parentMerge pull request #2428 from python-discord/dependabot/pip/redis-4.4.2 (diff)
Migrate from role-based mutes to native timeouts
- Makes use of the native timeout instead of adding the Muted role. - Renames all references to the "mute" infraction to "timeout", except in command aliases for ease of transition. - Maintains support for the old functionality (pardoning users with the muted role, applying timeout to users who rejoin and are not yet timed out because they originally had the role). This can be removed (the relevant parts are marked with TODOs) after there are no longer users with the old mute.
-rw-r--r--bot/constants.py6
-rw-r--r--bot/exts/filters/antispam.py10
-rw-r--r--bot/exts/moderation/infraction/_scheduler.py18
-rw-r--r--bot/exts/moderation/infraction/_utils.py2
-rw-r--r--bot/exts/moderation/infraction/infractions.py99
-rw-r--r--config-default.yml6
6 files changed, 85 insertions, 56 deletions
diff --git a/bot/constants.py b/bot/constants.py
index f1fb5471f..7e8e7591a 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -356,9 +356,9 @@ class Icons(metaclass=YAMLGetter):
token_removed: str
user_ban: str
- user_mute: str
+ user_timeout: str
user_unban: str
- user_unmute: str
+ user_untimeout: str
user_update: str
user_verified: str
user_warn: str
@@ -493,7 +493,7 @@ class Roles(metaclass=YAMLGetter):
contributors: int
help_cooldown: int
- muted: int
+ muted: int # TODO remove when no longer relevant.
partners: int
python_community: int
sprinters: int
diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py
index d7783292d..5473889f3 100644
--- a/bot/exts/filters/antispam.py
+++ b/bot/exts/filters/antispam.py
@@ -228,17 +228,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.punishment['remove_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 9b8e67ec5..c04cf7933 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:
@@ -283,14 +283,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 c2ef80461..662bd4cd4 100644
--- a/bot/exts/moderation/infraction/_utils.py
+++ b/bot/exts/moderation/infraction/_utils.py
@@ -19,7 +19,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..96e4eb642 100644
--- a/bot/exts/moderation/infraction/infractions.py
+++ b/bot/exts/moderation/infraction/infractions.py
@@ -1,8 +1,10 @@
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
@@ -27,6 +29,9 @@ if t.TYPE_CHECKING:
from bot.exts.moderation.watchchannels.bigbrother import BigBrother
+MAXIMUM_TIMEOUT_DAYS = timedelta(days=28)
+
+
class Infractions(InfractionScheduler, commands.Cog):
"""Apply and pardon infractions on users for moderation purposes."""
@@ -34,30 +39,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 +200,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 +210,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 +224,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 +232,18 @@ 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:
+ await ctx.send(f":x: A timeout cannot be longer than {MAXIMUM_TIMEOUT_DAYS.days} days.")
+ return
+ 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 +358,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 +397,23 @@ 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):
+ 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 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,10 +423,13 @@ 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)
+ await user.edit(timed_out_until=duration_or_expiry, reason=reason)
- log.trace(f"Attempting to kick {user} from voice because they've been muted.")
+ log.trace(f"Attempting to kick {user} from voice because they've been timed out.")
await user.move_to(None, reason=reason)
await self.apply_infraction(ctx, infraction, user, action)
@@ -522,7 +546,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 +554,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 +639,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":
diff --git a/config-default.yml b/config-default.yml
index de0f7e4e8..9088fae34 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -122,9 +122,9 @@ style:
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"
@@ -275,7 +275,7 @@ guild:
contributors: 295488872404484098
help_cooldown: 699189276025421825
- muted: &MUTED_ROLE 277914926603829249
+ muted: &MUTED_ROLE 277914926603829249 # TODO remove when no longer relevant.
partners: &PY_PARTNER_ROLE 323426753857191936
python_community: &PY_COMMUNITY_ROLE 458226413825294336
sprinters: &SPRINTERS 758422482289426471