diff --git a/src/cogs/moderation/commands.py b/src/cogs/moderation/commands.py index 042bd92..fd31ff8 100644 --- a/src/cogs/moderation/commands.py +++ b/src/cogs/moderation/commands.py @@ -21,6 +21,48 @@ conf = load_optional_config() COMMAND_GUILD_IDS = get_command_guild_ids(conf) +MIN_SELFMUTE_SECONDS = 600 +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): + 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 + + +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__( @@ -382,6 +424,80 @@ 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})" + + 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: + 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: {format_duration_seconds(duration_seconds)}\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, nextcord.HTTPException): + pass + @slash_command( name="unmute", description="Unmute a member.", @@ -1016,4 +1132,3 @@ async def restrict( async def setup(bot: APBot) -> None: bot.add_cog(ModerationCommands(bot)) - 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) diff --git a/tests/test_moderation_helpers.py b/tests/test_moderation_helpers.py index 95f3700..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 @@ -26,3 +27,40 @@ 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 ( + 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"