From da4befba66dcfdba1179468dd2dbed50a8e574ef Mon Sep 17 00:00:00 2001 From: pushiscool Date: Mon, 25 May 2026 02:37:04 -0400 Subject: [PATCH 1/3] Add selfmute command --- src/cogs/moderation/commands.py | 80 ++++++++++++++++++++++++++++++++ tests/test_moderation_helpers.py | 12 +++++ 2 files changed, 92 insertions(+) diff --git a/src/cogs/moderation/commands.py b/src/cogs/moderation/commands.py index 042bd92..de76bfd 100644 --- a/src/cogs/moderation/commands.py +++ b/src/cogs/moderation/commands.py @@ -21,6 +21,19 @@ conf = load_optional_config() COMMAND_GUILD_IDS = get_command_guild_ids(conf) +MIN_SELFMUTE_SECONDS = 600 +MAX_SELFMUTE_SECONDS = 604800 + + +def validate_selfmute_duration(duration: str): + duration_seconds = convert_time(duration) + if isinstance(duration_seconds, str): + return None, duration_seconds + if duration_seconds < MIN_SELFMUTE_SECONDS: + return None, "Selfmute must be at least 10 minutes." + if duration_seconds > MAX_SELFMUTE_SECONDS: + return None, "Selfmute cannot be longer than 168 hours." + return duration_seconds, None class Infraction: def __init__( @@ -382,6 +395,73 @@ async def mute( mute_embed.set_footer(text=f"Muted by {interaction.user.display_name}", icon_url=interaction.user.display_avatar.url) await interaction.followup.send(embed=mute_embed) + @slash_command( + name="selfmute", + description="Mute yourself from 10 minutes up to 168 hours.", + guild_ids=COMMAND_GUILD_IDS, + ) + async def selfmute( + self, + interaction: Interaction, + duration: str = SlashOption( + name="duration", + description="Selfmute duration from 10m to 168h. Format: 5h9m2s", + required=True, + ), + ): + duration_seconds, error = validate_selfmute_duration(duration) + if error: + await interaction.response.send_message(error, ephemeral=True) + return + + member = interaction.user + if not hasattr(member, "timeout"): + await interaction.response.send_message("This command can only be used in a server.", ephemeral=True) + return + + time_until = timedelta(seconds=duration_seconds) + now = datetime.now(timezone.utc) + unmute_time = now + time_until + reason = f"Selfmute requested by {member} ({member.id})" + + try: + await member.timeout(timeout=time_until, reason=reason) + except Forbidden: + await interaction.response.send_message("I do not have permission to mute you.", ephemeral=True) + return + except nextcord.HTTPException: + await interaction.response.send_message("Failed to apply selfmute.", ephemeral=True) + return + + embed = Embed( + title="Selfmute Enabled", + description=( + f"You have selfmuted until " + f"()." + ), + color=self.bot.colors.get("light_orange", Color.orange()), + timestamp=now, + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + + logs_channel = nextcord.utils.get(interaction.guild.text_channels, name="logs") if interaction.guild else None + if logs_channel: + log_embed = Embed( + title="Selfmute Logged", + description=( + f"User: {member.mention} (`{member.id}`)\n" + f"Duration: {duration}\n" + f"Will be unmuted at: " + f"()" + ), + color=self.bot.colors.get("light_orange", Color.orange()), + timestamp=now, + ) + try: + await logs_channel.send(embed=log_embed) + except Forbidden: + pass + @slash_command( name="unmute", description="Unmute a member.", diff --git a/tests/test_moderation_helpers.py b/tests/test_moderation_helpers.py index 95f3700..338aef9 100644 --- a/tests/test_moderation_helpers.py +++ b/tests/test_moderation_helpers.py @@ -26,3 +26,15 @@ def test_to_snowflake_parses_mentions_and_ints(): assert to_snowflake("<@123456789012345678>") == 123456789012345678 assert to_snowflake("not-a-user") is None assert to_snowflake(None) is None + +from cogs.moderation.commands import validate_selfmute_duration + + +def test_validate_selfmute_duration_accepts_bounds(): + assert validate_selfmute_duration("10m") == (600, None) + assert validate_selfmute_duration("168h") == (604800, None) + + +def test_validate_selfmute_duration_rejects_out_of_range(): + assert validate_selfmute_duration("9m")[0] is None + assert validate_selfmute_duration("169h")[0] is None From f150aebad376ade10237bc10fd845f4a65a3c54b Mon Sep 17 00:00:00 2001 From: pushiscool Date: Mon, 25 May 2026 02:42:16 -0400 Subject: [PATCH 2/3] Fix mod role check with missing config role IDs --- src/cogs/moderation/infraction.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/cogs/moderation/infraction.py b/src/cogs/moderation/infraction.py index d6c4f1c..c7d1096 100644 --- a/src/cogs/moderation/infraction.py +++ b/src/cogs/moderation/infraction.py @@ -30,8 +30,12 @@ def __init__(self, bot: APBot) -> None: def has_mod_role(self, member: Member) -> bool: allowed_role_names = {"Trial Chat Moderator", "Chat Moderator", "Admin"} allowed_role_ids = { - conf.get("bot_staff_role_id"), - conf.get("special_perms_role_id"), + role_id + for role_id in ( + conf.get("bot_staff_role_id"), + conf.get("special_perms_role_id"), + ) + if role_id is not None } perms = getattr(member, "guild_permissions", None) From 59c38c568cd4f0e405bcb34ecd9c372e5a72be3b Mon Sep 17 00:00:00 2001 From: pushiscool Date: Mon, 25 May 2026 15:23:22 -0400 Subject: [PATCH 3/3] Harden selfmute command --- src/cogs/moderation/commands.py | 41 +++++++++++++++++++++++++++++--- tests/test_moderation_helpers.py | 28 +++++++++++++++++++++- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/src/cogs/moderation/commands.py b/src/cogs/moderation/commands.py index de76bfd..fd31ff8 100644 --- a/src/cogs/moderation/commands.py +++ b/src/cogs/moderation/commands.py @@ -25,6 +25,24 @@ MAX_SELFMUTE_SECONDS = 604800 +def format_duration_seconds(duration_seconds: int) -> str: + days, rem = divmod(duration_seconds, 86400) + hours, rem = divmod(rem, 3600) + minutes, seconds = divmod(rem, 60) + + parts = [] + if days: + parts.append(f"{days}d") + if hours: + parts.append(f"{hours}h") + if minutes: + parts.append(f"{minutes}m") + if seconds: + parts.append(f"{seconds}s") + + return " ".join(parts) if parts else "0s" + + def validate_selfmute_duration(duration: str): duration_seconds = convert_time(duration) if isinstance(duration_seconds, str): @@ -35,6 +53,17 @@ def validate_selfmute_duration(duration: str): return None, "Selfmute cannot be longer than 168 hours." return duration_seconds, None + +def current_timeout_ends_after(member, unmute_time: datetime) -> bool: + timeout_until = getattr(member, "communication_disabled_until", None) + if timeout_until is None: + return False + + if timeout_until.tzinfo is None: + timeout_until = timeout_until.replace(tzinfo=timezone.utc) + + return timeout_until.astimezone(timezone.utc) > unmute_time + class Infraction: def __init__( self, @@ -424,6 +453,13 @@ async def selfmute( unmute_time = now + time_until reason = f"Selfmute requested by {member} ({member.id})" + if current_timeout_ends_after(member, unmute_time): + await interaction.response.send_message( + "You are already muted longer than that, so I will not shorten your current timeout.", + ephemeral=True, + ) + return + try: await member.timeout(timeout=time_until, reason=reason) except Forbidden: @@ -450,7 +486,7 @@ async def selfmute( title="Selfmute Logged", description=( f"User: {member.mention} (`{member.id}`)\n" - f"Duration: {duration}\n" + f"Duration: {format_duration_seconds(duration_seconds)}\n" f"Will be unmuted at: " f"()" ), @@ -459,7 +495,7 @@ async def selfmute( ) try: await logs_channel.send(embed=log_embed) - except Forbidden: + except (Forbidden, nextcord.HTTPException): pass @slash_command( @@ -1096,4 +1132,3 @@ async def restrict( async def setup(bot: APBot) -> None: bot.add_cog(ModerationCommands(bot)) - diff --git a/tests/test_moderation_helpers.py b/tests/test_moderation_helpers.py index 338aef9..3c474d4 100644 --- a/tests/test_moderation_helpers.py +++ b/tests/test_moderation_helpers.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from types import SimpleNamespace from cogs.moderation.commands import ModerationCommands @@ -27,14 +28,39 @@ def test_to_snowflake_parses_mentions_and_ints(): assert to_snowflake("not-a-user") is None assert to_snowflake(None) is None -from cogs.moderation.commands import validate_selfmute_duration +from cogs.moderation.commands import ( + current_timeout_ends_after, + format_duration_seconds, + validate_selfmute_duration, +) def test_validate_selfmute_duration_accepts_bounds(): assert validate_selfmute_duration("10m") == (600, None) assert validate_selfmute_duration("168h") == (604800, None) + assert validate_selfmute_duration("1w") == (604800, None) def test_validate_selfmute_duration_rejects_out_of_range(): assert validate_selfmute_duration("9m")[0] is None assert validate_selfmute_duration("169h")[0] is None + + +def test_current_timeout_ends_after_prevents_shortening_existing_timeout(): + unmute_time = datetime(2026, 5, 25, 12, 0, tzinfo=timezone.utc) + member = SimpleNamespace(communication_disabled_until=datetime(2026, 5, 25, 13, 0, tzinfo=timezone.utc)) + + assert current_timeout_ends_after(member, unmute_time) is True + + +def test_current_timeout_ends_after_allows_longer_selfmute(): + unmute_time = datetime(2026, 5, 25, 12, 0, tzinfo=timezone.utc) + member = SimpleNamespace(communication_disabled_until=datetime(2026, 5, 25, 11, 0, tzinfo=timezone.utc)) + + assert current_timeout_ends_after(member, unmute_time) is False + + +def test_format_duration_seconds(): + assert format_duration_seconds(600) == "10m" + assert format_duration_seconds(604800) == "7d" + assert format_duration_seconds(90061) == "1d 1h 1m 1s"