aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/constants.py13
-rw-r--r--bot/exts/filters/antispam.py14
-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.py118
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":