diff --git a/README.md b/README.md index 6bbc282..d99d78c 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ | owner | `sync`, `cog_management`, `shutdown`, `say`, `invite`, `logs` | | general | `help`, `botinfo`, `serverinfo`, `ping`, `feedback`, `uptime` | | fun | `randomfact`, `coinflip`, `rps`, `8ball`, `minesweeper` | -| moderation | `kick`, `ban`, `nick`, `purge`, `hackban`, `warnings`, `archive` | +| moderation | `kick`, `ban`, `nick`, `purge`, `hackban`, `warnings`, `archive`, `timeout` | | sidestore | `sidestore`, `refresh`, `code`, `crash`, `pairing`, `server`, `half`, `sparse`, `afc`, `udid` | | idevice | `idevice`, `noapps`, `errorcode`, `developermode`, `mountddi` | | melonx | `melonx`, `transfer`, `mods`, `gamecrash`, `requirements`, `error`, `26` | diff --git a/cogs/melonx/gamecrash.py b/cogs/melonx/gamecrash.py index b2b1821..37e1db6 100644 --- a/cogs/melonx/gamecrash.py +++ b/cogs/melonx/gamecrash.py @@ -33,7 +33,7 @@ def crash_command(): view.add_item(discord.ui.Button( label="Edit Command", style=discord.ButtonStyle.secondary, - url="https://github.com/neoarz/Syntrel/blob/main/cogs/melonx/crash.py", + url="https://github.com/neoarz/Syntrel/blob/main/cogs/melonx/gamecrash.py", emoji="<:githubicon:1417717356846776340>" )) diff --git a/cogs/moderation/__init__.py b/cogs/moderation/__init__.py index 50ee84c..daec7b7 100644 --- a/cogs/moderation/__init__.py +++ b/cogs/moderation/__init__.py @@ -1,4 +1,5 @@ import discord +from discord import app_commands from discord.ext import commands from discord.ext.commands import Context @@ -9,6 +10,7 @@ from .warnings import warnings_command from .archive import archive_command from .hackban import hackban_command from .nick import nick_command +from .timeout import timeout_command class Moderation(commands.GroupCog, name="moderation"): def __init__(self, bot) -> None: @@ -22,7 +24,7 @@ class Moderation(commands.GroupCog, name="moderation"): description="Use `.moderation ` or `/moderation `.", color=0x7289DA ) - embed.add_field(name="Available", value="ban, kick, purge, warnings, archive, hackban, nick", inline=False) + embed.add_field(name="Available", value="ban, kick, purge, warnings, archive, hackban, nick, timeout", inline=False) await context.send(embed=embed) async def _invoke_hybrid(self, context: Context, name: str, **kwargs): @@ -42,6 +44,26 @@ class Moderation(commands.GroupCog, name="moderation"): content = context.message.content.strip().lower() return content.startswith(f"{prefix}{group} ") + async def timeout_duration_autocomplete( + self, + interaction: discord.Interaction, + current: str, + ) -> list[app_commands.Choice[str]]: + options = [ + ("60 secs", "60s"), + ("5 mins", "5m"), + ("10 mins", "10m"), + ("1 hour", "1h"), + ("1 day", "1d"), + ("1 week", "1w"), + ] + q = (current or "").lower() + results: list[app_commands.Choice[str]] = [] + for name, value in options: + if q in name.lower() or q in value.lower(): + results.append(app_commands.Choice(name=name, value=value)) + return results[:25] + @moderation_group.command(name="ban") async def moderation_group_ban(self, context: Context, user: discord.User, *, reason: str = "Not specified", delete_messages: str = "none"): await self._invoke_hybrid(context, "ban", user=user, reason=reason, delete_messages=delete_messages) @@ -70,6 +92,10 @@ class Moderation(commands.GroupCog, name="moderation"): async def moderation_group_nick(self, context: Context, user: discord.User, *, nickname: str = None): await self._invoke_hybrid(context, "nick", user=user, nickname=nickname) + @moderation_group.command(name="timeout") + async def moderation_group_timeout(self, context: Context, user: discord.User, duration: str, *, reason: str = "Not specified"): + await self._invoke_hybrid(context, "timeout", user=user, duration=duration, reason=reason) + @commands.check(_require_group_prefix) @commands.hybrid_command( name="ban", @@ -126,6 +152,20 @@ class Moderation(commands.GroupCog, name="moderation"): async def nick(self, context, user: discord.User, *, nickname: str = None): return await nick_command()(self, context, user=user, nickname=nickname) + @commands.check(_require_group_prefix) + @commands.hybrid_command( + name="timeout", + description="Timeout a user for a specified duration." + ) + @app_commands.describe( + user="The user that should be timed out.", + duration="Duration", + reason="The reason why the user should be timed out.", + ) + @app_commands.autocomplete(duration=timeout_duration_autocomplete) + async def timeout(self, context, user: discord.User, duration: str, *, reason: str = "Not specified"): + return await timeout_command()(self, context, user=user, duration=duration, reason=reason) + async def setup(bot) -> None: cog = Moderation(bot) await bot.add_cog(cog) @@ -137,3 +177,4 @@ async def setup(bot) -> None: bot.logger.info("Loaded extension 'moderation.archive'") bot.logger.info("Loaded extension 'moderation.hackban'") bot.logger.info("Loaded extension 'moderation.nick'") + bot.logger.info("Loaded extension 'moderation.timeout'") diff --git a/cogs/moderation/timeout.py b/cogs/moderation/timeout.py new file mode 100644 index 0000000..69fb37b --- /dev/null +++ b/cogs/moderation/timeout.py @@ -0,0 +1,176 @@ +import discord +from discord import app_commands +from discord.ext import commands +from datetime import timedelta + + +def timeout_command(): + DURATION_CHOICES = [ + app_commands.Choice(name="60 secs", value="60s"), + app_commands.Choice(name="5 mins", value="5m"), + app_commands.Choice(name="10 mins", value="10m"), + app_commands.Choice(name="1 hour", value="1h"), + app_commands.Choice(name="1 day", value="1d"), + app_commands.Choice(name="1 week", value="1w"), + ] + @commands.hybrid_command( + name="timeout", + description="Timeout a user for a specified duration.", + ) + @app_commands.describe( + user="The user that should be timed out.", + duration="Duration", + reason="The reason why the user should be timed out.", + ) + @app_commands.choices(duration=DURATION_CHOICES) + async def timeout( + self, context, user: discord.User, duration: str, *, reason: str = "Not specified" + ): + try: + member = context.guild.get_member(user.id) + if not member: + try: + member = await context.guild.fetch_member(user.id) + except discord.NotFound: + embed = discord.Embed( + title="Error!", + description="This user is not in the server.", + color=0xE02B2B, + ).set_author(name="Moderation", icon_url="https://yes.nighty.works/raw/CPKHQd.png") + await context.send(embed=embed, ephemeral=True) + return + + if not context.author.guild_permissions.moderate_members and context.author != context.guild.owner: + embed = discord.Embed( + title="Missing Permissions!", + description="You don't have the `Timeout Members` permission to use this command.", + color=0xE02B2B, + ).set_author(name="Moderation", icon_url="https://yes.nighty.works/raw/CPKHQd.png") + await context.send(embed=embed, ephemeral=True) + return + + if member and member.top_role >= context.guild.me.top_role: + embed = discord.Embed( + title="Cannot Timeout User", + description="This user has a higher or equal role to me. Make sure my role is above theirs.", + color=0xE02B2B, + ).set_author(name="Moderation", icon_url="https://yes.nighty.works/raw/CPKHQd.png") + await context.send(embed=embed, ephemeral=True) + return + + if member and context.author != context.guild.owner: + if member.top_role >= context.author.top_role: + embed = discord.Embed( + title="Cannot Timeout User", + description="You cannot timeout this user as they have a higher or equal role to you.", + color=0xE02B2B, + ).set_author(name="Moderation", icon_url="https://yes.nighty.works/raw/CPKHQd.png") + await context.send(embed=embed, ephemeral=True) + return + + seconds = _parse_duration_to_seconds(duration) + if seconds is None or seconds <= 0: + embed = discord.Embed( + title="Invalid Duration", + description="Choose one of: 60 secs, 5 mins, 10 mins, 1 hour, 1 day, 1 week.", + color=0xE02B2B, + ).set_author(name="Moderation", icon_url="https://yes.nighty.works/raw/CPKHQd.png") + await context.send(embed=embed, ephemeral=True) + return + + max_seconds = 28 * 24 * 60 * 60 + seconds = min(seconds, max_seconds) + + try: + timeout_delta = timedelta(seconds=seconds) + await member.timeout(timeout_delta, reason=reason) + + embed = discord.Embed( + title="Timeout", + description=f"**{user}** was timed out by **{context.author}**!", + color=0x7289DA, + ).set_author(name="Moderation", icon_url="https://yes.nighty.works/raw/CPKHQd.png") + embed.add_field(name="Reason:", value=reason) + embed.add_field(name="Duration:", value=_format_duration(duration), inline=False) + await context.send(embed=embed) + + except discord.Forbidden: + embed = discord.Embed( + title="Error!", + description="I don't have permission to timeout this user. Make sure my role is above theirs.", + color=0xE02B2B, + ).set_author(name="Moderation", icon_url="https://yes.nighty.works/raw/CPKHQd.png") + await context.send(embed=embed, ephemeral=True) + except discord.HTTPException as e: + embed = discord.Embed( + title="Error!", + description=f"Discord API error: {str(e)}", + color=0xE02B2B, + ).set_author(name="Moderation", icon_url="https://yes.nighty.works/raw/CPKHQd.png") + await context.send(embed=embed, ephemeral=True) + except Exception as e: + embed = discord.Embed( + title="Debug Error!", + description=f"Error type: {type(e).__name__}\nError message: {str(e)}", + color=0xE02B2B, + ).set_author(name="Moderation", icon_url="https://yes.nighty.works/raw/CPKHQd.png") + await context.send(embed=embed, ephemeral=True) + + except Exception: + embed = discord.Embed( + title="Error!", + description="An unexpected error occurred.", + color=0xE02B2B, + ).set_author(name="Moderation", icon_url="https://yes.nighty.works/raw/CPKHQd.png") + await context.send(embed=embed, ephemeral=True) + + @timeout.autocomplete("duration") + async def duration_autocomplete(interaction: discord.Interaction, current: str): + query = (current or "").lower() + filtered = [c for c in DURATION_CHOICES if query in c.name.lower() or query in c.value.lower()] + return filtered[:25] + + return timeout + + +def _parse_duration_to_seconds(duration: str): + try: + s = duration.strip().lower() + if s.endswith("s"): + return int(s[:-1]) + if s.endswith("m"): + return int(s[:-1]) * 60 + if s.endswith("h"): + return int(s[:-1]) * 60 * 60 + if s.endswith("d"): + return int(s[:-1]) * 60 * 60 * 24 + if s.endswith("w"): + return int(s[:-1]) * 60 * 60 * 24 * 7 + return int(s) + except Exception: + return None + + +def _format_duration(duration: str) -> str: + mapping = { + "s": "seconds", + "m": "minutes", + "h": "hours", + "d": "days", + "w": "weeks", + } + s = duration.strip().lower() + for suffix, word in mapping.items(): + if s.endswith(suffix): + try: + value = int(s[:-1]) + return f"{value} {word}" + except Exception: + return duration + try: + value = int(s) + return f"{value} seconds" + except Exception: + return duration + +