diff --git a/.env.example b/.env.example index b5134bd..ab715c7 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,27 @@ +# +# Required +# + +# If in production mode: + +# DEV=False PROD_DATABASE_URL="" -# PROD_SUPABASE_DB_PASSWORD="" PROD_TOKEN="" -DEV=True +# If in development mode: + +# DEV=True DEV_DATABASE_URL="" -# DEV_SUPABASE_DB_PASSWORD="" DEV_TOKEN="" -TZ="UTC" -DISCORD_GUILD= +# +# Optional +# -SUPABASE_KEY="" SENTRY_URL="" -# PROD_COG_IGNORE_LIST="kick,ban" -# DEV_COG_IGNORE_LIST="mail, git" +PROD_COG_IGNORE_LIST= +DEV_COG_IGNORE_LIST= GITHUB_APP_ID= GITHUB_CLIENT_ID="" diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..60e8c36 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,42 @@ +--- +name: Bug Report +about: Create a report to help us improve +title: "[BUG] - " +labels: "type: bug" +assignees: '' + +--- + +## Describe the Bug + +A clear and concise description of what the bug is. + +## To Reproduce + +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +Please include code snippets or screenshots where applicable. + +## Expected Behavior + +A clear and concise description of what you expected to happen. + +## Screenshots + +If applicable, add screenshots to help explain your problem. + +## Environment (please complete the following information) + +- OS: [e.g. Ubuntu 20.04] +- Python version: [e.g. Python 3.12] +- Discord.py version: [e.g. 2.0.0] +- Tux version: [e.g. v1.0.0] + +## Additional Context + +Add any other context about the problem here, such as logs, configuration files, etc. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..5e84edd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,24 @@ +--- +name: Feature Request +about: Suggest an idea for this project +title: "[FEATURE] - " +labels: "type: feature-request" +assignees: '' + +--- + +## Is your feature request related to a problem? Please describe + +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +## Describe the solution you'd like + +A clear and concise description of what you want to happen. + +## Describe alternatives you've considered + +A clear and concise description of any alternative solutions or features you've considered. + +## Additional Context + +Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..de4ddeb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,40 @@ +# Pull Request Template + +## Description + +Please include a summary of the changes and the related issue. Please also include relevant motivation and context. List any dependencies that are required for this change. + +Fixes # (issue) + +## Type of Change + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update + +## Checklist + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published in downstream modules + +## How Has This Been Tested? + +Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. + +- [ ] Test A +- [ ] Test B + +## Screenshots (if applicable) + +Please add screenshots to help explain your changes. + +## Additional Information + +Please add any other information that is important to this PR. diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 6c8c1a1..7fa2eda 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: 3.11 + python-version: 3.12 # Install Ruff - name: Install Ruff @@ -30,4 +30,4 @@ jobs: run: ruff format && ruff check . --fix - uses: stefanzweifel/git-auto-commit-action@v5 with: - commit_message: '[Fix] Linting and formatting via Ruff' + commit_message: 'fix: Linting and formatting via Ruff' diff --git a/assets/emojis/snippetban.png b/assets/emojis/snippetban.png new file mode 100644 index 0000000..39f0b12 Binary files /dev/null and b/assets/emojis/snippetban.png differ diff --git a/assets/emojis/snippetunban.png b/assets/emojis/snippetunban.png new file mode 100644 index 0000000..62134b6 Binary files /dev/null and b/assets/emojis/snippetunban.png differ diff --git a/assets/roles/text-editors/ed.png b/assets/roles/text-editors/ed.png index 2405d00..0f428c5 100644 Binary files a/assets/roles/text-editors/ed.png and b/assets/roles/text-editors/ed.png differ diff --git a/poetry.lock b/poetry.lock index 3a85505..a24e6d9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1127,13 +1127,13 @@ pyyaml = ">=5.1" [[package]] name = "mkdocs-material" -version = "9.5.32" +version = "9.5.33" description = "Documentation that simply works" optional = false python-versions = ">=3.8" files = [ - {file = "mkdocs_material-9.5.32-py3-none-any.whl", hash = "sha256:f3704f46b63d31b3cd35c0055a72280bed825786eccaf19c655b44e0cd2c6b3f"}, - {file = "mkdocs_material-9.5.32.tar.gz", hash = "sha256:38ed66e6d6768dde4edde022554553e48b2db0d26d1320b19e2e2b9da0be1120"}, + {file = "mkdocs_material-9.5.33-py3-none-any.whl", hash = "sha256:dbc79cf0fdc6e2c366aa987de8b0c9d4e2bb9f156e7466786ba2fd0f9bf7ffca"}, + {file = "mkdocs_material-9.5.33.tar.gz", hash = "sha256:d23a8b5e3243c9b2f29cdfe83051104a8024b767312dc8fde05ebe91ad55d89d"}, ] [package.dependencies] diff --git a/tux/cogs/guild/config.py b/tux/cogs/guild/config.py index 9ffa518..ebbf769 100644 --- a/tux/cogs/guild/config.py +++ b/tux/cogs/guild/config.py @@ -17,7 +17,9 @@ class Config(commands.Cog): self.bot = bot self.db = DatabaseController().guild_config - @app_commands.command(name="config_set_logs") + config = app_commands.Group(name="config", description="Configure Tux for your server.") + + @config.command(name="set_logs") @app_commands.guild_only() # @checks.ac_has_pl(7) @app_commands.checks.has_permissions(administrator=True) @@ -45,7 +47,7 @@ class Config(commands.Cog): await interaction.response.send_message(view=view, ephemeral=True) - @app_commands.command(name="config_set_channels") + @config.command(name="set_channels") @app_commands.guild_only() # @checks.ac_has_pl(7) @app_commands.checks.has_permissions(administrator=True) @@ -65,7 +67,7 @@ class Config(commands.Cog): view = ConfigSetChannels() await interaction.response.send_message(view=view, ephemeral=True) - @app_commands.command(name="config_set_perms") + @config.command(name="set_perms") @app_commands.describe(setting="Which permission level to configure") @app_commands.choices( setting=[ @@ -116,7 +118,7 @@ class Config(commands.Cog): delete_after=30, ) - @app_commands.command(name="config_set_roles") + @config.command(name="set_roles") @app_commands.guild_only() # @checks.ac_has_pl(7) @app_commands.describe(setting="Which role to configure") @@ -158,13 +160,45 @@ class Config(commands.Cog): delete_after=30, ) - @app_commands.command(name="config_get_roles") + @config.command(name="get_roles") @app_commands.guild_only() # @checks.ac_has_pl(7) @app_commands.checks.has_permissions(administrator=True) async def config_get_roles( self, interaction: discord.Interaction, + ) -> None: + """ + Get the basic roles for the guild. + + Parameters + ---------- + interaction : discord.Interaction + The discord interaction object. + """ + + if interaction.guild is None: + return + + embed = discord.Embed( + title="Config - Roles", + color=discord.Color.blue(), + timestamp=discord.utils.utcnow(), + ) + + jail_role_id = await self.db.get_jail_role_id(interaction.guild.id) + jail_role = f"<@&{jail_role_id}>" if jail_role_id else "Not set" + embed.add_field(name="Jail Role", value=jail_role, inline=False) + + await interaction.response.send_message(embed=embed, ephemeral=True, delete_after=30) + + @config.command(name="get_perms") + @app_commands.guild_only() + # @checks.ac_has_pl(7) + @app_commands.checks.has_permissions(administrator=True) + async def config_get_perms( + self, + interaction: discord.Interaction, ) -> None: """ Get the roles for each permission level. @@ -192,7 +226,7 @@ class Config(commands.Cog): await interaction.response.send_message(embed=embed, ephemeral=True, delete_after=30) - @app_commands.command(name="config_get_channels") + @config.command(name="get_channels") @app_commands.guild_only() # @checks.ac_has_pl(7) @app_commands.checks.has_permissions(administrator=True) @@ -232,7 +266,7 @@ class Config(commands.Cog): await interaction.response.send_message(embed=embed, ephemeral=True, delete_after=30) - @app_commands.command(name="config_get_logs") + @config.command(name="get_logs") @app_commands.guild_only() # @checks.ac_has_pl(7) @app_commands.checks.has_permissions(administrator=True) diff --git a/tux/cogs/guild/setup.py b/tux/cogs/guild/setup.py new file mode 100644 index 0000000..9507fb4 --- /dev/null +++ b/tux/cogs/guild/setup.py @@ -0,0 +1,85 @@ +import discord +from discord import app_commands +from discord.ext import commands + +from tux.database.controllers import DatabaseController +from tux.utils import checks + + +class Setup(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + self.db = DatabaseController() + self.config = DatabaseController().guild_config + + setup = app_commands.Group(name="setup", description="Set up Tux for your server.") + + @setup.command(name="jail") + @commands.guild_only() + @checks.ac_has_pl(7) + async def setup_jail(self, interaction: discord.Interaction) -> None: + """ + Set up the jail role channel permissions for the server. + + Parameters + ---------- + interaction : discord.Interaction + The discord interaction object. + """ + + if interaction.guild is None: + return + + jail_role_id = await self.config.get_guild_config_field_value(interaction.guild.id, "jail_role_id") + if not jail_role_id: + await interaction.response.send_message("No jail role has been set up for this server.", ephemeral=True) + return + + jail_role = interaction.guild.get_role(jail_role_id) + if not jail_role: + await interaction.response.send_message("The jail role has been deleted.", ephemeral=True) + return + + jail_channel_id = await self.config.get_guild_config_field_value(interaction.guild.id, "jail_channel_id") + if not jail_channel_id: + await interaction.response.send_message("No jail channel has been set up for this server.", ephemeral=True) + return + + await interaction.response.defer(ephemeral=True) + + await self._set_permissions_for_channels(interaction, jail_role, jail_channel_id) + + await interaction.edit_original_response( + content="Permissions have been set up for the jail role.", + ) + + async def _set_permissions_for_channels( + self, + interaction: discord.Interaction, + jail_role: discord.Role, + jail_channel_id: int, + ) -> None: + if interaction.guild is None: + return + + for channel in interaction.guild.channels: + if not isinstance(channel, discord.TextChannel | discord.VoiceChannel | discord.ForumChannel): + continue + + if ( + jail_role in channel.overwrites + and channel.overwrites[jail_role].send_messages is False + and channel.overwrites[jail_role].read_messages is False + and channel.id != jail_channel_id + ): + continue + + await channel.set_permissions(jail_role, send_messages=False, read_messages=False) + if channel.id == jail_channel_id: + await channel.set_permissions(jail_role, send_messages=True, read_messages=True) + + await interaction.edit_original_response(content=f"Setting up permissions for {channel.name}.") + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Setup(bot)) diff --git a/tux/cogs/moderation/__init__.py b/tux/cogs/moderation/__init__.py index dff5908..e19932c 100644 --- a/tux/cogs/moderation/__init__.py +++ b/tux/cogs/moderation/__init__.py @@ -4,7 +4,9 @@ import discord from discord.ext import commands from loguru import logger +from prisma.enums import CaseType from tux.database.controllers import DatabaseController +from tux.utils.constants import Constants as CONST from tux.utils.embeds import create_embed_footer, create_error_embed @@ -14,7 +16,7 @@ class ModerationCogBase(commands.Cog): self.db = DatabaseController() self.config = DatabaseController().guild_config - async def create_embed( + def create_embed( self, ctx: commands.Context[commands.Bot], title: str, @@ -22,6 +24,7 @@ class ModerationCogBase(commands.Cog): color: int, icon_url: str, timestamp: datetime | None = None, + thumbnail_url: str | None = None, ) -> discord.Embed: """ Create an embed for moderation actions. @@ -49,6 +52,7 @@ class ModerationCogBase(commands.Cog): embed = discord.Embed(color=color, timestamp=timestamp or ctx.message.created_at) embed.set_author(name=title, icon_url=icon_url) + embed.set_thumbnail(url=thumbnail_url) footer_text, footer_icon_url = create_embed_footer(ctx) embed.set_footer(text=footer_text, icon_url=footer_icon_url) @@ -166,3 +170,42 @@ class ModerationCogBase(commands.Cog): return False return True + + async def handle_case_response( + self, + ctx: commands.Context[commands.Bot], + case_type: CaseType, + case_id: int | None, + reason: str, + target: discord.Member | discord.User, + duration: str | None = None, + ): + moderator = ctx.author + + fields = [ + ("Moderator", f"__{moderator}__\n`{moderator.id}`", True), + ("Target", f"__{target}__\n`{target.id}`", True), + ("Reason", f"> {reason}", False), + ] + + if case_id is not None: + embed = self.create_embed( + ctx, + title=f"Case #{case_id} ({duration} {case_type})" if duration else f"Case #{case_id} ({case_type})", + fields=fields, + color=CONST.EMBED_COLORS["CASE"], + icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"], + ) + embed.set_thumbnail(url=target.avatar) + + else: + embed = self.create_embed( + ctx, + title=f"Case #0 ({duration} {case_type})" if duration else f"Case #0 ({case_type})", + fields=fields, + color=CONST.EMBED_COLORS["CASE"], + icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"], + ) + + await self.send_embed(ctx, embed, log_type="mod") + await ctx.send(embed=embed, delete_after=30, ephemeral=True) diff --git a/tux/cogs/moderation/ban.py b/tux/cogs/moderation/ban.py index 747bba9..bcad88d 100644 --- a/tux/cogs/moderation/ban.py +++ b/tux/cogs/moderation/ban.py @@ -3,10 +3,8 @@ from discord.ext import commands from loguru import logger from prisma.enums import CaseType -from prisma.models import Case from tux.utils import checks -from tux.utils.constants import Constants as CONST -from tux.utils.flags import BanFlags +from tux.utils.flags import BanFlags, generate_usage from . import ModerationCogBase @@ -14,12 +12,9 @@ from . import ModerationCogBase class Ban(ModerationCogBase): def __init__(self, bot: commands.Bot) -> None: super().__init__(bot) + self.ban.usage = generate_usage(self.ban, BanFlags) - @commands.hybrid_command( - name="ban", - aliases=["b"], - usage="ban [target] [reason] ", - ) + @commands.hybrid_command(name="ban", aliases=["b"]) @commands.guild_only() @checks.has_pl(3) async def ban( @@ -48,6 +43,7 @@ class Ban(ModerationCogBase): discord.HTTPException If an error occurs while banning the user. """ + if ctx.guild is None: logger.warning("Ban command used outside of a guild context.") return @@ -74,48 +70,7 @@ class Ban(ModerationCogBase): guild_id=ctx.guild.id, ) - await self.handle_case_response(ctx, case, "created", flags.reason, target) - - async def handle_case_response( - self, - ctx: commands.Context[commands.Bot], - case: Case | None, - action: str, - reason: str, - target: discord.Member | discord.User, - previous_reason: str | None = None, - ) -> None: - moderator = ctx.author - - fields = [ - ("Moderator", f"__{moderator}__\n`{moderator.id}`", True), - ("Target", f"__{target}__\n`{target.id}`", True), - ("Reason", f"> {reason}", False), - ] - - if previous_reason: - fields.append(("Previous Reason", f"> {previous_reason}", False)) - - if case is not None: - embed = await self.create_embed( - ctx, - title=f"Case #{case.case_number} ({case.case_type}) {action}", - fields=fields, - color=CONST.EMBED_COLORS["CASE"], - icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"], - ) - embed.set_thumbnail(url=target.avatar) - else: - embed = await self.create_embed( - ctx, - title=f"Case {action} ({CaseType.BAN})", - fields=fields, - color=CONST.EMBED_COLORS["CASE"], - icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"], - ) - - await self.send_embed(ctx, embed, log_type="mod") - await ctx.send(embed=embed, delete_after=30, ephemeral=True) + await self.handle_case_response(ctx, CaseType.BAN, case.case_id, flags.reason, target) async def setup(bot: commands.Bot) -> None: diff --git a/tux/cogs/moderation/cases.py b/tux/cogs/moderation/cases.py index a53e89a..2f513a5 100644 --- a/tux/cogs/moderation/cases.py +++ b/tux/cogs/moderation/cases.py @@ -23,8 +23,8 @@ emojis: dict[str, int] = { "timeout": 1268115809083981886, "warn": 1268115764498399264, "jail": 1268115750392954880, - "snippetban": 1275782294363312172, # Placeholder - "snippetunban": 1275782294363312172, # Placeholder + "snippetban": 1277174953950576681, + "snippetunban": 1277174953292337222, } @@ -269,7 +269,7 @@ class Cases(ModerationCogBase): fields = self._create_case_fields(moderator, target, reason) - embed = await self.create_embed( + embed = self.create_embed( ctx, title=f"Case #{case.case_number} ({case.case_type}) {action}", fields=fields, diff --git a/tux/cogs/moderation/jail.py b/tux/cogs/moderation/jail.py index 6a4222a..695369c 100644 --- a/tux/cogs/moderation/jail.py +++ b/tux/cogs/moderation/jail.py @@ -1,13 +1,10 @@ import discord -from discord import app_commands from discord.ext import commands from loguru import logger from prisma.enums import CaseType -from prisma.models import Case from tux.utils import checks -from tux.utils.constants import Constants as CONST -from tux.utils.flags import JailFlags +from tux.utils.flags import JailFlags, generate_usage from . import ModerationCogBase @@ -15,83 +12,15 @@ from . import ModerationCogBase class Jail(ModerationCogBase): def __init__(self, bot: commands.Bot) -> None: super().__init__(bot) - - @app_commands.command( - name="setup_jail", - ) - @commands.guild_only() - @checks.ac_has_pl(7) - async def setup_jail(self, interaction: discord.Interaction) -> None: - """ - Set up the jail role channel permissions for the server. - - Parameters - ---------- - interaction : discord.Interaction - The discord interaction object. - """ - - if interaction.guild is None: - return - - jail_role_id = await self.config.get_guild_config_field_value(interaction.guild.id, "jail_role_id") - if not jail_role_id: - await interaction.response.send_message("No jail role has been set up for this server.", ephemeral=True) - return - - jail_role = interaction.guild.get_role(jail_role_id) - if not jail_role: - await interaction.response.send_message("The jail role has been deleted.", ephemeral=True) - return - - jail_channel_id = await self.config.get_guild_config_field_value(interaction.guild.id, "jail_channel_id") - if not jail_channel_id: - await interaction.response.send_message("No jail channel has been set up for this server.", ephemeral=True) - return - - await interaction.response.defer(ephemeral=True) - - await self._set_permissions_for_channels(interaction, jail_role, jail_channel_id) - - await interaction.edit_original_response( - content="Permissions have been set up for the jail role.", - ) - - async def _set_permissions_for_channels( - self, - interaction: discord.Interaction, - jail_role: discord.Role, - jail_channel_id: int, - ) -> None: - if interaction.guild is None: - return - - for channel in interaction.guild.channels: - if not isinstance(channel, discord.TextChannel | discord.VoiceChannel | discord.ForumChannel): - continue - - if ( - jail_role in channel.overwrites - and channel.overwrites[jail_role].send_messages is False - and channel.overwrites[jail_role].read_messages is False - and channel.id != jail_channel_id - ): - continue - - await channel.set_permissions(jail_role, send_messages=False, read_messages=False) - if channel.id == jail_channel_id: - await channel.set_permissions(jail_role, send_messages=True, read_messages=True) - - await interaction.edit_original_response(content=f"Setting up permissions for {channel.name}.") + self.jail.usage = generate_usage(self.jail, JailFlags) @commands.hybrid_command( name="jail", aliases=["j"], - usage="jail [target] [reason] ", ) @commands.guild_only() @checks.has_pl(2) - async def jail( + async def jail( # noqa: PLR0911 self, ctx: commands.Context[commands.Bot], target: discord.Member, @@ -137,38 +66,55 @@ class Jail(ModerationCogBase): case_target_roles = [role.id for role in target_roles] - await self._jail_user(ctx, target, flags, jail_role, target_roles) - - case = await self._insert_jail_case(ctx, target, flags.reason, case_target_roles) - - await self.handle_case_response(ctx, case, "created", flags.reason, target) - - async def _insert_jail_case( - self, - ctx: commands.Context[commands.Bot], - target: discord.Member, - reason: str, - case_target_roles: list[int] | None = None, - ) -> Case | None: - if not ctx.guild: - logger.warning("Jail command used outside of a guild context.") - return None - try: - return await self.db.case.insert_case( + case = await self.db.case.insert_case( case_target_id=target.id, case_moderator_id=ctx.author.id, case_type=CaseType.JAIL, - case_reason=reason, + case_reason=flags.reason, guild_id=ctx.guild.id, case_target_roles=case_target_roles, ) - except Exception as e: - logger.error(f"Failed to insert jail case for {target}. {e}") - await ctx.send(f"Failed to insert jail case for {target}. {e}", delete_after=30, ephemeral=True) - return None - def _get_manageable_roles(self, target: discord.Member, jail_role: discord.Role) -> list[discord.Role]: + except Exception as e: + logger.error(f"Failed to jail {target}. {e}") + await ctx.send(f"Failed to jail {target}. {e}", delete_after=30, ephemeral=True) + return + + try: + if target_roles: + await target.remove_roles(*target_roles, reason=flags.reason, atomic=False) + await target.add_roles(jail_role, reason=flags.reason) + + except (discord.Forbidden, discord.HTTPException) as e: + logger.error(f"Failed to jail {target}. {e}") + await ctx.send(f"Failed to jail {target}. {e}", delete_after=30, ephemeral=True) + return + + await self.send_dm(ctx, flags.silent, target, flags.reason, "jailed") + await self.handle_case_response(ctx, CaseType.JAIL, case.case_id, flags.reason, target) + + def _get_manageable_roles( + self, + target: discord.Member, + jail_role: discord.Role, + ) -> list[discord.Role]: + """ + Get the roles that can be managed by the bot. + + Parameters + ---------- + target : discord.Member + The member to jail. + jail_role : discord.Role + The jail role. + + Returns + ------- + list[discord.Role] + The roles that can be managed by the bot. + """ + return [ role for role in target.roles @@ -182,70 +128,6 @@ class Jail(ModerationCogBase): and role.is_assignable() ] - async def _jail_user( - self, - ctx: commands.Context[commands.Bot], - target: discord.Member, - flags: JailFlags, - jail_role: discord.Role, - target_roles: list[discord.Role], - ) -> None: - try: - await self.send_dm(ctx, flags.silent, target, flags.reason, "jailed") - - if target_roles: - await target.remove_roles(*target_roles, reason=flags.reason, atomic=False) - await target.add_roles(jail_role, reason=flags.reason) - - except (discord.Forbidden, discord.HTTPException) as e: - logger.error(f"Failed to jail {target}. {e}") - await ctx.send(f"Failed to jail {target}. {e}", delete_after=30, ephemeral=True) - return - - async def handle_case_response( - self, - ctx: commands.Context[commands.Bot], - case: Case | None, - action: str, - reason: str, - target: discord.Member | discord.User, - previous_reason: str | None = None, - ) -> None: - fields = [ - ("Moderator", f"__{ctx.author}__\n`{ctx.author.id}`", True), - ("Target", f"__{target}__\n`{target.id}`", True), - ("Reason", f"> {reason}", False), - ] - - if previous_reason: - fields.append(("Previous Reason", f"> {previous_reason}", False)) - - embed = await self._create_case_embed(ctx, case, action, fields, target) - - await self.send_embed(ctx, embed, log_type="mod") - await ctx.send(embed=embed, delete_after=30, ephemeral=True) - - async def _create_case_embed( - self, - ctx: commands.Context[commands.Bot], - case: Case | None, - action: str, - fields: list[tuple[str, str, bool]], - target: discord.Member | discord.User, - ) -> discord.Embed: - title = f"Case #{case.case_number} ({case.case_type}) {action}" if case else f"Case {action} ({CaseType.JAIL})" - - embed = await self.create_embed( - ctx, - title=title, - fields=fields, - color=CONST.EMBED_COLORS["CASE"], - icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"], - ) - - embed.set_thumbnail(url=target.avatar) - return embed - async def setup(bot: commands.Bot) -> None: await bot.add_cog(Jail(bot)) diff --git a/tux/cogs/moderation/kick.py b/tux/cogs/moderation/kick.py index 697a51f..b2d06c1 100644 --- a/tux/cogs/moderation/kick.py +++ b/tux/cogs/moderation/kick.py @@ -3,10 +3,8 @@ from discord.ext import commands from loguru import logger from prisma.enums import CaseType -from prisma.models import Case from tux.utils import checks -from tux.utils.constants import Constants as CONST -from tux.utils.flags import KickFlags +from tux.utils.flags import KickFlags, generate_usage from . import ModerationCogBase @@ -14,11 +12,11 @@ from . import ModerationCogBase class Kick(ModerationCogBase): def __init__(self, bot: commands.Bot) -> None: super().__init__(bot) + self.kick.usage = generate_usage(self.kick, KickFlags) @commands.hybrid_command( name="kick", aliases=["k"], - usage="kick [target] [reason] ", ) @commands.guild_only() @checks.has_pl(2) @@ -75,48 +73,7 @@ class Kick(ModerationCogBase): guild_id=ctx.guild.id, ) - await self.handle_case_response(ctx, case, "created", flags.reason, target) - - async def handle_case_response( - self, - ctx: commands.Context[commands.Bot], - case: Case | None, - action: str, - reason: str, - target: discord.Member | discord.User, - previous_reason: str | None = None, - ) -> None: - moderator = ctx.author - - fields = [ - ("Moderator", f"__{moderator}__\n`{moderator.id}`", True), - ("Target", f"__{target}__\n`{target.id}`", True), - ("Reason", f"> {reason}", False), - ] - - if previous_reason: - fields.append(("Previous Reason", f"> {previous_reason}", False)) - - if case is not None: - embed = await self.create_embed( - ctx, - title=f"Case #{case.case_number} {action} ({case.case_type})", - fields=fields, - color=CONST.EMBED_COLORS["CASE"], - icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"], - ) - embed.set_thumbnail(url=target.avatar) - else: - embed = await self.create_embed( - ctx, - title=f"Case {action} ({CaseType.KICK})", - fields=fields, - color=CONST.EMBED_COLORS["CASE"], - icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"], - ) - - await self.send_embed(ctx, embed, log_type="mod") - await ctx.send(embed=embed, delete_after=30, ephemeral=True) + await self.handle_case_response(ctx, CaseType.KICK, case.case_id, flags.reason, target) async def setup(bot: commands.Bot) -> None: diff --git a/tux/cogs/moderation/snippetban.py b/tux/cogs/moderation/snippetban.py index 2555f4f..e2d71f4 100644 --- a/tux/cogs/moderation/snippetban.py +++ b/tux/cogs/moderation/snippetban.py @@ -3,11 +3,9 @@ from discord.ext import commands from loguru import logger from prisma.enums import CaseType -from prisma.models import Case from tux.database.controllers.case import CaseController from tux.utils import checks -from tux.utils.constants import Constants as CONST -from tux.utils.flags import SnippetBanFlags +from tux.utils.flags import SnippetBanFlags, generate_usage from . import ModerationCogBase @@ -16,11 +14,11 @@ class SnippetBan(ModerationCogBase): def __init__(self, bot: commands.Bot) -> None: super().__init__(bot) self.case_controller = CaseController() + self.snippet_ban.usage = generate_usage(self.snippet_ban, SnippetBanFlags) @commands.hybrid_command( name="snippetban", aliases=["sb"], - usage="snippetban [target]", ) @commands.guild_only() @checks.has_pl(3) @@ -49,60 +47,25 @@ class SnippetBan(ModerationCogBase): return if await self.is_snippetbanned(ctx.guild.id, target.id): - await ctx.send("User is already snippet banned.", delete_after=30) + await ctx.send("User is already snippet banned.", delete_after=30, ephemeral=True) return - case = await self.db.case.insert_case( - case_target_id=target.id, - case_moderator_id=ctx.author.id, - case_type=CaseType.SNIPPETBAN, - case_reason=flags.reason, - guild_id=ctx.guild.id, - ) - - await self.send_dm(ctx, flags.silent, target, flags.reason, "Snippet banned") - await self.handle_case_response(ctx, case, "created", flags.reason, target) - - async def handle_case_response( - self, - ctx: commands.Context[commands.Bot], - case: Case | None, - action: str, - reason: str, - target: discord.Member | discord.User, - previous_reason: str | None = None, - ) -> None: - moderator = ctx.author - - fields = [ - ("Moderator", f"__{moderator}__\n`{moderator.id}`", True), - ("Target", f"__{target}__\n`{target.id}`", True), - ("Reason", f"> {reason}", False), - ] - - if previous_reason: - fields.append(("Previous Reason", f"> {previous_reason}", False)) - - if case is not None: - embed = await self.create_embed( - ctx, - title=f"Case #{case.case_number} ({case.case_type}) {action}", - fields=fields, - color=CONST.EMBED_COLORS["CASE"], - icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"], - ) - embed.set_thumbnail(url=target.avatar) - else: - embed = await self.create_embed( - ctx, - title=f"Case {action} ({CaseType.SNIPPETBAN})", - fields=fields, - color=CONST.EMBED_COLORS["CASE"], - icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"], + try: + case = await self.db.case.insert_case( + case_target_id=target.id, + case_moderator_id=ctx.author.id, + case_type=CaseType.SNIPPETBAN, + case_reason=flags.reason, + guild_id=ctx.guild.id, ) - await self.send_embed(ctx, embed, log_type="mod") - await ctx.send(embed=embed, delete_after=30, ephemeral=True) + except Exception as e: + logger.error(f"Failed to ban {target}. {e}") + await ctx.send(f"Failed to ban {target}. {e}", delete_after=30) + return + + await self.send_dm(ctx, flags.silent, target, flags.reason, "snippet banned") + await self.handle_case_response(ctx, CaseType.SNIPPETBAN, case.case_id, flags.reason, target) async def is_snippetbanned(self, guild_id: int, user_id: int) -> bool: """ diff --git a/tux/cogs/moderation/snippetunban.py b/tux/cogs/moderation/snippetunban.py index d44ee50..f553b5b 100644 --- a/tux/cogs/moderation/snippetunban.py +++ b/tux/cogs/moderation/snippetunban.py @@ -3,11 +3,9 @@ from discord.ext import commands from loguru import logger from prisma.enums import CaseType -from prisma.models import Case from tux.database.controllers.case import CaseController from tux.utils import checks -from tux.utils.constants import Constants as CONST -from tux.utils.flags import SnippetUnbanFlags +from tux.utils.flags import SnippetUnbanFlags, generate_usage from . import ModerationCogBase @@ -16,11 +14,11 @@ class SnippetUnban(ModerationCogBase): def __init__(self, bot: commands.Bot) -> None: super().__init__(bot) self.case_controller = CaseController() + self.snippet_unban.usage = generate_usage(self.snippet_unban, SnippetUnbanFlags) @commands.hybrid_command( name="snippetunban", aliases=["sub"], - usage="snippetunban [target]", ) @commands.guild_only() @checks.has_pl(3) @@ -48,62 +46,26 @@ class SnippetUnban(ModerationCogBase): logger.warning("Snippet ban command used outside of a guild context.") return - # Check if the user is already snippet banned if not await self.is_snippetbanned(ctx.guild.id, target.id): - await ctx.send("User is not snippet banned.", delete_after=30) + await ctx.send("User is not snippet banned.", delete_after=30, ephemeral=True) return - case = await self.db.case.insert_case( - case_target_id=target.id, - case_moderator_id=ctx.author.id, - case_type=CaseType.SNIPPETUNBAN, - case_reason=flags.reason, - guild_id=ctx.guild.id, - ) - - await self.send_dm(ctx, flags.silent, target, flags.reason, "Snippet unbanned") - await self.handle_case_response(ctx, case, "created", flags.reason, target) - - async def handle_case_response( - self, - ctx: commands.Context[commands.Bot], - case: Case | None, - action: str, - reason: str, - target: discord.Member | discord.User, - previous_reason: str | None = None, - ) -> None: - moderator = ctx.author - - fields = [ - ("Moderator", f"__{moderator}__\n`{moderator.id}`", True), - ("Target", f"__{target}__\n`{target.id}`", True), - ("Reason", f"> {reason}", False), - ] - - if previous_reason: - fields.append(("Previous Reason", f"> {previous_reason}", False)) - - if case is not None: - embed = await self.create_embed( - ctx, - title=f"Case #{case.case_number} ({case.case_type}) {action}", - fields=fields, - color=CONST.EMBED_COLORS["CASE"], - icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"], - ) - embed.set_thumbnail(url=target.avatar) - else: - embed = await self.create_embed( - ctx, - title=f"Case {action} ({CaseType.SNIPPETUNBAN})", - fields=fields, - color=CONST.EMBED_COLORS["CASE"], - icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"], + try: + case = await self.db.case.insert_case( + case_target_id=target.id, + case_moderator_id=ctx.author.id, + case_type=CaseType.SNIPPETUNBAN, + case_reason=flags.reason, + guild_id=ctx.guild.id, ) - await self.send_embed(ctx, embed, log_type="mod") - await ctx.send(embed=embed, delete_after=30, ephemeral=True) + except Exception as e: + logger.error(f"Failed to snippet unban {target}. {e}") + await ctx.send(f"Failed to snippet unban {target}. {e}", delete_after=30, ephemeral=True) + return + + await self.send_dm(ctx, flags.silent, target, flags.reason, "snippet unbanned") + await self.handle_case_response(ctx, CaseType.SNIPPETUNBAN, case.case_id, flags.reason, target) async def is_snippetbanned(self, guild_id: int, user_id: int) -> bool: """ diff --git a/tux/cogs/moderation/timeout.py b/tux/cogs/moderation/timeout.py index fd4620f..b2c686c 100644 --- a/tux/cogs/moderation/timeout.py +++ b/tux/cogs/moderation/timeout.py @@ -1,67 +1,25 @@ -import re -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime import discord from discord.ext import commands from loguru import logger from prisma.enums import CaseType -from prisma.models import Case from tux.utils import checks -from tux.utils.constants import Constants as CONST -from tux.utils.flags import TimeoutFlags +from tux.utils.flags import TimeoutFlags, generate_usage +from tux.utils.functions import parse_time_string from . import ModerationCogBase -def parse_time_string(time_str: str) -> timedelta: - """ - Convert a string representation of time (e.g., '60s', '1m', '2h', '10d') - into a datetime.timedelta object. - - Parameters - time_str (str): The string representation of time. - - Returns - timedelta: Corresponding timedelta object. - """ - - # Define regex pattern to parse time strings - time_pattern = re.compile(r"^(?P\d+)(?P[smhdw])$") - - # Match the input string with the pattern - match = time_pattern.match(time_str) - - if not match: - msg = f"Invalid time format: '{time_str}'" - raise ValueError(msg) - - # Extract the value and unit from the pattern match - value = int(match["value"]) - unit = match["unit"] - - # Define the mapping of units to keyword arguments for timedelta - unit_map = {"s": "seconds", "m": "minutes", "h": "hours", "d": "days", "w": "weeks"} - - # Check if the unit is in the map - if unit not in unit_map: - msg = f"Unknown time unit: '{unit}'" - raise ValueError(msg) - - # Create the timedelta with the appropriate keyword argument - kwargs = {unit_map[unit]: value} - - return timedelta(**kwargs) - - class Timeout(ModerationCogBase): def __init__(self, bot: commands.Bot) -> None: super().__init__(bot) + self.timeout.usage = generate_usage(self.timeout, TimeoutFlags) @commands.hybrid_command( name="timeout", aliases=["t", "to", "mute"], - usage="timeout [target] [duration] [reason]", ) @commands.guild_only() @checks.has_pl(2) @@ -106,7 +64,6 @@ class Timeout(ModerationCogBase): duration = parse_time_string(flags.duration) try: - await self.send_dm(ctx, flags.silent, target, flags.reason, f"timed out for {flags.duration}") await target.timeout(duration, reason=flags.reason) except discord.DiscordException as e: @@ -122,49 +79,8 @@ class Timeout(ModerationCogBase): guild_id=ctx.guild.id, ) - await self.handle_case_response(ctx, flags, case, "created", flags.reason, target) - - async def handle_case_response( - self, - ctx: commands.Context[commands.Bot], - flags: TimeoutFlags, - case: Case | None, - action: str, - reason: str, - target: discord.Member | discord.User, - previous_reason: str | None = None, - ) -> None: - moderator = ctx.author - - fields = [ - ("Moderator", f"__{moderator}__\n`{moderator.id}`", True), - ("Target", f"__{target}__\n`{target.id}`", True), - ("Reason", f"> {reason}", False), - ] - - if previous_reason: - fields.append(("Previous Reason", f"> {previous_reason}", False)) - - if case is not None: - embed = await self.create_embed( - ctx, - title=f"Case #{case.case_number} {action} ({flags.duration} {case.case_type})", - fields=fields, - color=CONST.EMBED_COLORS["CASE"], - icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"], - ) - embed.set_thumbnail(url=target.avatar) - else: - embed = await self.create_embed( - ctx, - title=f"Case #0 {action} ({flags.duration} {CaseType.TIMEOUT})", - fields=fields, - color=CONST.EMBED_COLORS["CASE"], - icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"], - ) - - await self.send_embed(ctx, embed, log_type="mod") - await ctx.send(embed=embed, delete_after=30, ephemeral=True) + await self.send_dm(ctx, flags.silent, target, flags.reason, f"timed out for {flags.duration}") + await self.handle_case_response(ctx, CaseType.TIMEOUT, case.case_id, flags.reason, target, flags.duration) async def setup(bot: commands.Bot) -> None: diff --git a/tux/cogs/moderation/unban.py b/tux/cogs/moderation/unban.py index 0eadebb..f459b29 100644 --- a/tux/cogs/moderation/unban.py +++ b/tux/cogs/moderation/unban.py @@ -3,10 +3,8 @@ from discord.ext import commands from loguru import logger from prisma.enums import CaseType -from prisma.models import Case from tux.utils import checks -from tux.utils.constants import Constants as CONST -from tux.utils.flags import UnbanFlags +from tux.utils.flags import UnbanFlags, generate_usage from . import ModerationCogBase @@ -14,17 +12,18 @@ from . import ModerationCogBase class Unban(ModerationCogBase): def __init__(self, bot: commands.Bot) -> None: super().__init__(bot) + self.unban.usage = generate_usage(self.unban, UnbanFlags) @commands.hybrid_command( name="unban", aliases=["ub"], - usage="unban [username_or_id] [reason]", ) @commands.guild_only() @checks.has_pl(3) async def unban( self, ctx: commands.Context[commands.Bot], + username_or_id: str, *, flags: UnbanFlags, ) -> None: @@ -35,10 +34,10 @@ class Unban(ModerationCogBase): ---------- ctx : commands.Context[commands.Bot] The context object for the command. - target : discord.Member - The member to unban. + username_or_id : str + The username or ID of the user to unban. flags : UnbanFlags - The flags for the command (username_or_id: str, reason: str). + The flags for the command (reason: str). Raises ------ @@ -54,7 +53,7 @@ class Unban(ModerationCogBase): # Get the list of banned users in the guild banned_users = [ban.user async for ban in ctx.guild.bans()] - user = await commands.UserConverter().convert(ctx, flags.username_or_id) + user = await commands.UserConverter().convert(ctx, username_or_id) if user not in banned_users: await ctx.send(f"{user} was not found in the guild ban list.", delete_after=30, ephemeral=True) @@ -76,48 +75,7 @@ class Unban(ModerationCogBase): case_reason=flags.reason, ) - await self.handle_case_response(ctx, case, "created", flags.reason, user) - - async def handle_case_response( - self, - ctx: commands.Context[commands.Bot], - case: Case | None, - action: str, - reason: str, - target: discord.Member | discord.User, - previous_reason: str | None = None, - ) -> None: - moderator = ctx.author - - fields = [ - ("Moderator", f"__{moderator}__\n`{moderator.id}`", True), - ("Target", f"__{target}__\n`{target.id}`", True), - ("Reason", f"> {reason}", False), - ] - - if previous_reason: - fields.append(("Previous Reason", f"> {previous_reason}", False)) - - if case is not None: - embed = await self.create_embed( - ctx, - title=f"Case #{case.case_number} ({case.case_type}) {action}", - fields=fields, - color=CONST.EMBED_COLORS["CASE"], - icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"], - ) - embed.set_thumbnail(url=target.avatar) - else: - embed = await self.create_embed( - ctx, - title=f"Case {action} ({CaseType.UNBAN})", - fields=fields, - color=CONST.EMBED_COLORS["CASE"], - icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"], - ) - - await self.send_embed(ctx, embed, log_type="mod") - await ctx.send(embed=embed, delete_after=30, ephemeral=True) + await self.handle_case_response(ctx, CaseType.UNBAN, case.case_id, flags.reason, user) async def setup(bot: commands.Bot) -> None: diff --git a/tux/cogs/moderation/unjail.py b/tux/cogs/moderation/unjail.py index ff4b191..0c87372 100644 --- a/tux/cogs/moderation/unjail.py +++ b/tux/cogs/moderation/unjail.py @@ -3,10 +3,8 @@ from discord.ext import commands from loguru import logger from prisma.enums import CaseType -from prisma.models import Case from tux.utils import checks -from tux.utils.constants import Constants as CONST -from tux.utils.flags import UnjailFlags +from tux.utils.flags import UnjailFlags, generate_usage from . import ModerationCogBase @@ -14,15 +12,15 @@ from . import ModerationCogBase class Unjail(ModerationCogBase): def __init__(self, bot: commands.Bot) -> None: super().__init__(bot) + self.unjail.usage = generate_usage(self.unjail, UnjailFlags) @commands.hybrid_command( name="unjail", aliases=["uj"], - usage="unjail [target] [reason] ", ) @commands.guild_only() @checks.has_pl(2) - async def unjail( + async def unjail( # noqa: PLR0911 self, ctx: commands.Context[commands.Bot], target: discord.Member, @@ -51,151 +49,55 @@ class Unjail(ModerationCogBase): if not await self.check_conditions(ctx, target, moderator, "unjail"): return - jail_role = await self._get_jail_role(ctx) - if not jail_role: + jail_role_id = await self.config.get_jail_role_id(ctx.guild.id) + jail_role = ctx.guild.get_role(jail_role_id) if jail_role_id else None + + jail_channel_id = await self.config.get_jail_channel_id(ctx.guild.id) + + if not all([jail_role_id, jail_role, jail_channel_id]): + error_msgs = { + not jail_role_id: "No jail role has been set up for this server.", + not jail_role: "The jail role has been deleted.", + not jail_channel_id: "No jail channel has been set up for this server.", + } + + for condition, msg in error_msgs.items(): + if condition: + await ctx.send(msg, delete_after=30, ephemeral=True) return if jail_role not in target.roles: await ctx.send("The member is not jailed.", delete_after=30, ephemeral=True) return - if not await self._check_jail_channel(ctx): - return - case = await self.db.case.get_last_jail_case_by_target_id(ctx.guild.id, target.id) if not case: await ctx.send("No jail case found for this member.", delete_after=30, ephemeral=True) return - await self._unjail_user(ctx, target, jail_role, case, flags.reason) - - unjail_case = await self._insert_unjail_case(ctx, target, flags.reason) - - await self.handle_case_response(ctx, unjail_case, "created", flags.reason, target) - - async def _get_jail_role(self, ctx: commands.Context[commands.Bot]) -> discord.Role | None: - if ctx.guild is None: - logger.warning("Unjail command used outside of a guild context.") - return None - - jail_role_id = await self.config.get_jail_role_id(ctx.guild.id) - if not jail_role_id: - await ctx.send("No jail role has been set up for this server.", delete_after=30, ephemeral=True) - return None - - jail_role = ctx.guild.get_role(jail_role_id) - if not jail_role: - await ctx.send("The jail role has been deleted.", delete_after=30, ephemeral=True) - return None - - return jail_role - - async def _check_jail_channel(self, ctx: commands.Context[commands.Bot]) -> bool: - if ctx.guild is None: - logger.warning("Unjail command used outside of a guild context.") - return False - - jail_channel_id = await self.config.get_jail_channel_id(ctx.guild.id) - if not jail_channel_id: - await ctx.send("No jail channel has been set up for this server.", delete_after=30, ephemeral=True) - return False - - return True - - async def _unjail_user( - self, - ctx: commands.Context[commands.Bot], - target: discord.Member, - jail_role: discord.Role, - case: Case, - reason: str, - ) -> None: - if ctx.guild is None: - logger.warning("Unjail command used outside of a guild context.") - return - try: - await target.remove_roles(jail_role, reason=reason) - previous_roles = [await commands.RoleConverter().convert(ctx, str(role)) for role in case.case_target_roles] - if previous_roles: - await target.add_roles(*previous_roles, reason=reason, atomic=False) + await target.remove_roles(jail_role, reason=flags.reason, atomic=True) + await target.add_roles(*previous_roles, reason=flags.reason, atomic=True) else: await ctx.send("No previous roles found for the member.", delete_after=30, ephemeral=True) + return except (discord.Forbidden, discord.HTTPException) as e: logger.error(f"Failed to unjail member {target}. {e}") await ctx.send(f"Failed to unjail member {target}. {e}", delete_after=30, ephemeral=True) + return - async def _insert_unjail_case( - self, - ctx: commands.Context[commands.Bot], - target: discord.Member, - reason: str, - ) -> Case | None: - if not ctx.guild: - logger.warning("Unjail command used outside of a guild context.") - return None - - try: - return await self.db.case.insert_case( - guild_id=ctx.guild.id, - case_target_id=target.id, - case_moderator_id=ctx.author.id, - case_type=CaseType.UNJAIL, - case_reason=reason, - ) - - except Exception as e: - logger.error(f"Failed to insert unjail case for {target}. {e}") - await ctx.send(f"Failed to insert unjail case for {target}. {e}", delete_after=30, ephemeral=True) - return None - - async def handle_case_response( - self, - ctx: commands.Context[commands.Bot], - case: Case | None, - action: str, - reason: str, - target: discord.Member | discord.User, - previous_reason: str | None = None, - ) -> None: - fields = [ - ("Moderator", f"__{ctx.author}__\n`{ctx.author.id}`", True), - ("Target", f"__{target}__\n`{target.id}`", True), - ("Reason", f"> {reason}", False), - ] - - if previous_reason: - fields.append(("Previous Reason", f"> {previous_reason}", False)) - - embed = await self._create_case_embed(ctx, case, action, fields, target) - await self.send_embed(ctx, embed, log_type="mod") - await ctx.send(embed=embed, delete_after=30, ephemeral=True) - - async def _create_case_embed( - self, - ctx: commands.Context[commands.Bot], - case: Case | None, - action: str, - fields: list[tuple[str, str, bool]], - target: discord.Member | discord.User, - ) -> discord.Embed: - title = ( - f"Case #{case.case_number} ({case.case_type}) {action}" if case else f"Case {action} ({CaseType.UNJAIL})" + unjail_case = await self.db.case.insert_case( + guild_id=ctx.guild.id, + case_target_id=target.id, + case_moderator_id=ctx.author.id, + case_type=CaseType.UNJAIL, + case_reason=flags.reason, ) - embed = await self.create_embed( - ctx, - title=title, - fields=fields, - color=CONST.EMBED_COLORS["CASE"], - icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"], - ) - - embed.set_thumbnail(url=target.avatar) - return embed + await self.handle_case_response(ctx, CaseType.UNJAIL, unjail_case.case_id, flags.reason, target) async def setup(bot: commands.Bot) -> None: diff --git a/tux/cogs/moderation/untimeout.py b/tux/cogs/moderation/untimeout.py index 60587cf..8573e1d 100644 --- a/tux/cogs/moderation/untimeout.py +++ b/tux/cogs/moderation/untimeout.py @@ -3,10 +3,8 @@ from discord.ext import commands from loguru import logger from prisma.enums import CaseType -from prisma.models import Case from tux.utils import checks -from tux.utils.constants import Constants as CONST -from tux.utils.flags import UntimeoutFlags +from tux.utils.flags import UntimeoutFlags, generate_usage from . import ModerationCogBase @@ -14,11 +12,11 @@ from . import ModerationCogBase class Untimeout(ModerationCogBase): def __init__(self, bot: commands.Bot) -> None: super().__init__(bot) + self.untimeout.usage = generate_usage(self.untimeout, UntimeoutFlags) @commands.hybrid_command( name="untimeout", aliases=["ut", "uto", "unmute"], - usage="untimeout [target] [reason] ", ) @commands.guild_only() @checks.has_pl(2) @@ -59,7 +57,6 @@ class Untimeout(ModerationCogBase): await ctx.send(f"{target} is not currently timed out.", delete_after=30, ephemeral=True) try: - await self.send_dm(ctx, flags.silent, target, flags.reason, "untimed out") await target.timeout(None, reason=flags.reason) except discord.DiscordException as e: await ctx.send(f"Failed to untimeout {target}. {e}", delete_after=30, ephemeral=True) @@ -74,49 +71,8 @@ class Untimeout(ModerationCogBase): guild_id=ctx.guild.id, ) - await self.handle_case_response(ctx, flags, case, "created", flags.reason, target) - - async def handle_case_response( - self, - ctx: commands.Context[commands.Bot], - flags: UntimeoutFlags, - case: Case | None, - action: str, - reason: str, - target: discord.Member | discord.User, - previous_reason: str | None = None, - ) -> None: - moderator = ctx.author - - fields = [ - ("Moderator", f"__{moderator}__\n`{moderator.id}`", True), - ("Target", f"__{target}__\n`{target.id}`", True), - ("Reason", f"> {reason}", False), - ] - - if previous_reason: - fields.append(("Previous Reason", f"> {previous_reason}", False)) - - if case is not None: - embed = await self.create_embed( - ctx, - title=f"Case #{case.case_number} {action} ({case.case_type})", - fields=fields, - color=CONST.EMBED_COLORS["CASE"], - icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"], - ) - embed.set_thumbnail(url=target.avatar) - else: - embed = await self.create_embed( - ctx, - title=f"Case #0 {action} ({CaseType.UNTIMEOUT})", - fields=fields, - color=CONST.EMBED_COLORS["CASE"], - icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"], - ) - - await self.send_embed(ctx, embed, log_type="mod") - await ctx.send(embed=embed, delete_after=30, ephemeral=True) + await self.send_dm(ctx, flags.silent, target, flags.reason, "untimed out") + await self.handle_case_response(ctx, CaseType.UNTIMEOUT, case.case_id, flags.reason, target) async def setup(bot: commands.Bot) -> None: diff --git a/tux/cogs/moderation/warn.py b/tux/cogs/moderation/warn.py index 975d86c..ce52840 100644 --- a/tux/cogs/moderation/warn.py +++ b/tux/cogs/moderation/warn.py @@ -3,10 +3,8 @@ from discord.ext import commands from loguru import logger from prisma.enums import CaseType -from prisma.models import Case from tux.utils import checks -from tux.utils.constants import Constants as CONST -from tux.utils.flags import WarnFlags +from tux.utils.flags import WarnFlags, generate_usage from . import ModerationCogBase @@ -14,11 +12,11 @@ from . import ModerationCogBase class Warn(ModerationCogBase): def __init__(self, bot: commands.Bot) -> None: super().__init__(bot) + self.warn.usage = generate_usage(self.warn, WarnFlags) @commands.hybrid_command( name="warn", aliases=["w"], - usage="warn [target] [reason] ", ) @commands.guild_only() @checks.has_pl(2) @@ -61,48 +59,7 @@ class Warn(ModerationCogBase): guild_id=ctx.guild.id, ) - await self.handle_case_response(ctx, case, "created", flags.reason, target) - - async def handle_case_response( - self, - ctx: commands.Context[commands.Bot], - case: Case | None, - action: str, - reason: str, - target: discord.Member | discord.User, - previous_reason: str | None = None, - ) -> None: - moderator = ctx.author - - fields = [ - ("Moderator", f"__{moderator}__\n`{moderator.id}`", True), - ("Target", f"__{target}__\n`{target.id}`", True), - ("Reason", f"> {reason}", False), - ] - - if previous_reason: - fields.append(("Previous Reason", f"> {previous_reason}", False)) - - if case is not None: - embed = await self.create_embed( - ctx, - title=f"Case #{case.case_number} ({case.case_type}) {action}", - fields=fields, - color=CONST.EMBED_COLORS["CASE"], - icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"], - ) - embed.set_thumbnail(url=target.avatar) - else: - embed = await self.create_embed( - ctx, - title=f"Case {action} ({CaseType.WARN})", - fields=fields, - color=CONST.EMBED_COLORS["CASE"], - icon_url=CONST.EMBED_ICONS["ACTIVE_CASE"], - ) - - await self.send_embed(ctx, embed, log_type="mod") - await ctx.send(embed=embed, delete_after=30, ephemeral=True) + await self.handle_case_response(ctx, CaseType.WARN, case.case_id, flags.reason, target) async def setup(bot: commands.Bot) -> None: diff --git a/tux/handlers/error.py b/tux/handlers/error.py index 089c64d..acdb722 100644 --- a/tux/handlers/error.py +++ b/tux/handlers/error.py @@ -9,6 +9,103 @@ from loguru import logger from tux.utils.embeds import create_error_embed from tux.utils.exceptions import AppCommandPermissionLevelError, PermissionLevelError +""" +Exception + DiscordException + GatewayNotFound + ClientException + CommandRegistrationError + InvalidData + LoginFailure + ConnectionClosed + PrivilegedIntentsRequired + InteractionResponded + HTTPException + Forbidden + NotFound + DiscordServerError + app_commands.CommandSyncFailure + RateLimited + CommandError + ConversionError + UserInputError + MissingRequiredArgument + MissingRequiredAttachment + TooManyArguments + BadArgument + MessageNotFound + MemberNotFound + GuildNotFound + UserNotFound + ChannelNotFound + ChannelNotReadable + BadColourArgument + RoleNotFound + BadInviteArgument + EmojiNotFound + GuildStickerNotFound + ScheduledEventNotFound + PartialEmojiConversionFailure + BadBoolArgument + RangeError + ThreadNotFound + FlagError + BadFlagArgument + MissingFlagArgument + TooManyFlags + MissingRequiredFlag + BadUnionArgument + BadLiteralArgument + ArgumentParsingError + UnexpectedQuoteError + InvalidEndOfQuotedStringError + ExpectedClosingQuoteError + CommandNotFound + CheckFailure + CheckAnyFailure + PrivateMessageOnly + NoPrivateMessage + NotOwner + MissingPermissions + BotMissingPermissions + MissingRole + BotMissingRole + MissingAnyRole + BotMissingAnyRole + NSFWChannelRequired + DisabledCommand + CommandInvokeError + CommandOnCooldown + MaxConcurrencyReached + HybridCommandError + ExtensionError + ExtensionAlreadyLoaded + ExtensionNotLoaded + NoEntryPointError + ExtensionFailed + ExtensionNotFound + AppCommandError + CommandInvokeError + TransformerError + TranslationError + CheckFailure + NoPrivateMessage + MissingRole + MissingAnyRole + MissingPermissions + BotMissingPermissions + CommandOnCooldown + CommandLimitReached + CommandAlreadyRegistered + CommandSignatureMismatch + CommandNotFound + MissingApplicationID + CommandSyncFailure + HTTPException + CommandSyncFailure +""" + + error_map: dict[type[Exception], str] = { # app_commands app_commands.AppCommandError: "An error occurred: {error}", @@ -29,12 +126,12 @@ error_map: dict[type[Exception], str] = { commands.MissingRole: "User not in sudoers file. This incident will be reported. (Missing Role)", commands.MissingAnyRole: "User not in sudoers file. This incident will be reported. (Missing Roles)", commands.MissingPermissions: "User not in sudoers file. This incident will be reported. (Missing Permissions)", + commands.FlagError: "An error occurred with the flags:\n`{error}`", + commands.MissingRequiredFlag: "Missing argument: {error}. Correct usage:\n`{ctx.prefix}{ctx.command.usage}`", commands.CheckFailure: "User not in sudoers file. This incident will be reported. (Permission Check Failed)", - commands.CommandNotFound: "This command was not found.", commands.CommandOnCooldown: "This command is on cooldown. Try again in {error.retry_after:.2f} seconds.", - commands.BadArgument: "Invalid argument passed. Correct usage: `{ctx.prefix}{ctx.command.usage}`", - commands.MissingRequiredArgument: "Missing required arg. Correct usage: `{ctx.prefix}{ctx.command.usage}`", - commands.MissingRequiredAttachment: "Missing required attachment.", + commands.MissingRequiredArgument: "Missing required argument. Correct usage:\n`{ctx.prefix}{ctx.command.usage}`", + commands.TooManyArguments: "Too many arguments passed. Correct usage:\n`{ctx.prefix}{ctx.command.usage}`", commands.NotOwner: "User not in sudoers file. This incident will be reported. (Not Owner)", commands.BotMissingPermissions: "User not in sudoers file. This incident will be reported. (Bot Missing Permissions)", # Custom errors @@ -101,12 +198,12 @@ class ErrorHandler(commands.Cog): The error that occurred. """ - # # If the command has its own error handler, return + # If the command has its own error handler, return # if hasattr(ctx.command, "on_error"): # logger.debug(f"Command {ctx.command} has its own error handler.") # return - # # If the cog has its own error handler, return + # If the cog has its own error handler, return # if ctx.cog and ctx.cog._get_overridden_method(ctx.cog.cog_command_error) is not None: # logger.debug(f"Cog {ctx.cog} has its own error handler.") # return diff --git a/tux/help.py b/tux/help.py index 84082c4..4ef90f9 100644 --- a/tux/help.py +++ b/tux/help.py @@ -158,10 +158,12 @@ class TuxHelp(commands.HelpCommand): flag_str = self._format_flag_name(flag) if flag.aliases: - flag_str += f" ({', '.join(flag.aliases)})" + flag_str += f" ({', '.join(flag.aliases)}) : {flag_type}" + else: + flag_str += f" : {flag_type}" flag_str += f"\n\t{flag.description or 'No description provided.'}" - flag_str += f"\n\tType: `{flag_type}`" + if flag.default is not discord.utils.MISSING: flag_str += f"\n\tDefault: {flag.default}" diff --git a/tux/utils/flags.py b/tux/utils/flags.py index eecac80..d0d2a74 100644 --- a/tux/utils/flags.py +++ b/tux/utils/flags.py @@ -1,3 +1,6 @@ +import inspect +from typing import Any + import discord from discord.ext import commands from discord.utils import MISSING @@ -6,16 +9,69 @@ from prisma.enums import CaseType from tux.utils.converters import CaseTypeConverter +def generate_usage( + command: commands.Command[Any, Any, Any], + flag_converter: type[commands.FlagConverter], +) -> str: + """ + Generate a usage string for a command with flags. + + Parameters + ---------- + command : commands.Command + The command for which to generate the usage string. + flag_converter : type[commands.FlagConverter] + The flag converter class for the command. + + Returns + ------- + str + The usage string for the command. Example: "ban [target] -[reason] -" + """ + + # Get the name of the command + command_name = command.qualified_name + + # Start the usage string with the command name + usage = f"{command_name}" + + # Get the parameters of the command (excluding the `ctx` and `flags` parameters) + parameters: dict[str, commands.Parameter] = command.clean_params + + flag_prefix = getattr(flag_converter, "__commands_flag_prefix__", "-") + flags: dict[str, commands.Flag] = flag_converter.get_flags() + + # Add non-flag arguments to the usage string + for param_name, param in parameters.items(): + # Ignore these parameters + if param_name in ["ctx", "flags"]: + continue + # Determine if the parameter is required + is_required = param.default == inspect.Parameter.empty + # Add the parameter to the usage string with required or optional wrapping + usage += f" [{param_name}]" if is_required else f" <{param_name}>" + + # Add flag arguments to the usage string + for flag_name, flag_obj in flags.items(): + # Determine if the flag is required or optional + if flag_obj.required: + usage += f" {flag_prefix}[{flag_name}]" + else: + usage += f" {flag_prefix}<{flag_name}>" + + return usage + + class BanFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"): reason: str = commands.flag( name="reason", - description="The reason for the ban.", + description="Reason for the ban.", aliases=["r"], default=MISSING, ) purge_days: int = commands.flag( name="purge_days", - description="The number of days (< 7) to purge in messages.", + description="Number of days in messages. (< 7)", aliases=["p", "purge"], default=0, ) @@ -30,18 +86,18 @@ class BanFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", pre class TempBanFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"): reason: str = commands.flag( name="reason", - description="The reason for the temp ban.", + description="Reason for the temp ban.", aliases=["r"], default=MISSING, ) expires_at: int = commands.flag( name="expires_at", - description="The time in days the ban will last for.", + description="Number of days the ban will last for.", aliases=["t", "d", "e", "duration", "expires", "time"], ) purge_days: int = commands.flag( name="purge_days", - description="The number of days (< 7) to purge in messages.", + description="Number of days in messages. (< 7)", aliases=["p"], default=0, ) @@ -56,7 +112,7 @@ class TempBanFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", class KickFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"): reason: str = commands.flag( name="reason", - description="The reason for the kick.", + description="Reason for the kick.", aliases=["r"], default=MISSING, ) @@ -71,13 +127,13 @@ class KickFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", pr class TimeoutFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"): duration: str = commands.flag( name="duration", - description="The duration of the timeout. (e.g. 1d, 1h, 1m)", + description="Duration of the timeout. (e.g. 1d, 1h, 1m)", aliases=["d"], default=MISSING, ) reason: str = commands.flag( name="reason", - description="The reason for the timeout.", + description="Reason for the timeout.", aliases=["r"], default=MISSING, ) @@ -92,7 +148,7 @@ class TimeoutFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", class UntimeoutFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"): reason: str = commands.flag( name="reason", - description="The reason for the untimeout.", + description="Reason for the untimeout.", aliases=["r"], default=MISSING, ) @@ -105,16 +161,9 @@ class UntimeoutFlags(commands.FlagConverter, case_insensitive=True, delimiter=" class UnbanFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"): - username_or_id: str = commands.flag( - name="username_or_id", - description="The username or ID of the user.", - aliases=["u"], - default=MISSING, - positional=True, - ) reason: str = commands.flag( name="reason", - description="The reason for the unban.", + description="Reason for the unban.", aliases=["r"], default=MISSING, ) @@ -123,7 +172,7 @@ class UnbanFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", p class JailFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"): reason: str = commands.flag( name="reason", - description="The reason for the jail.", + description="Reason for the jail.", aliases=["r"], default=MISSING, ) @@ -138,7 +187,7 @@ class JailFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", pr class UnjailFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"): reason: str = commands.flag( name="reason", - description="The reason for the unjail.", + description="Reason for the unjail.", aliases=["r"], default=MISSING, ) @@ -153,20 +202,20 @@ class UnjailFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", class CasesViewFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"): type: CaseType = commands.flag( name="case_type", - description="The case type to view.", + description="Type of case to view.", aliases=["t"], default=None, converter=CaseTypeConverter, ) target: discord.User = commands.flag( name="case_target", - description="The user to view cases for.", + description="User to view cases for.", aliases=["user", "u", "member", "memb", "m"], default=None, ) moderator: discord.User = commands.flag( name="case_moderator", - description="The moderator to view cases for.", + description="Moderator to view cases for.", aliases=["mod"], default=None, ) @@ -175,12 +224,12 @@ class CasesViewFlags(commands.FlagConverter, case_insensitive=True, delimiter=" class CaseModifyFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"): status: bool | None = commands.flag( name="case_status", - description="The status of the case.", + description="Status of the case.", aliases=["s"], ) reason: str | None = commands.flag( name="case_reason", - description="The modified reason.", + description="Modified reason.", aliases=["r"], ) @@ -188,7 +237,7 @@ class CaseModifyFlags(commands.FlagConverter, case_insensitive=True, delimiter=" class WarnFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"): reason: str = commands.flag( name="reason", - description="The reason for the warn.", + description="Reason for the warn.", aliases=["r"], default=MISSING, ) @@ -203,7 +252,7 @@ class WarnFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", pr class SnippetBanFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"): reason: str = commands.flag( name="reason", - description="The reason for the snippet ban.", + description="Reason for the snippet ban.", aliases=["r"], default=MISSING, ) @@ -218,7 +267,7 @@ class SnippetBanFlags(commands.FlagConverter, case_insensitive=True, delimiter=" class SnippetUnbanFlags(commands.FlagConverter, case_insensitive=True, delimiter=" ", prefix="-"): reason: str = commands.flag( name="reason", - description="The reason for the snippet unban.", + description="Reason for the snippet unban.", aliases=["r"], default=MISSING, ) diff --git a/tux/utils/functions.py b/tux/utils/functions.py index 2f32761..1245e99 100644 --- a/tux/utils/functions.py +++ b/tux/utils/functions.py @@ -1,5 +1,5 @@ import re -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from typing import Any import discord @@ -26,6 +26,49 @@ def strip_formatting(content: str) -> str: return content.strip() +def parse_time_string(time_str: str) -> timedelta: + """ + Convert a string representation of time into a datetime.timedelta object. + + Parameters + ---------- + time_str : str + The string representation of time to convert. (e.g., '60s', '1m', '2h', '10d') + + Returns + ------- + timedelta + The timedelta object representing the time string. + """ + + # Define regex pattern to parse time strings + time_pattern = re.compile(r"^(?P\d+)(?P[smhdw])$") + + # Match the input string with the pattern + match = time_pattern.match(time_str) + + if not match: + msg = f"Invalid time format: '{time_str}'" + raise ValueError(msg) + + # Extract the value and unit from the pattern match + value = int(match["value"]) + unit = match["unit"] + + # Define the mapping of units to keyword arguments for timedelta + unit_map = {"s": "seconds", "m": "minutes", "h": "hours", "d": "days", "w": "weeks"} + + # Check if the unit is in the map + if unit not in unit_map: + msg = f"Unknown time unit: '{unit}'" + raise ValueError(msg) + + # Create the timedelta with the appropriate keyword argument + kwargs = {unit_map[unit]: value} + + return timedelta(**kwargs) + + def convert_to_seconds(time_str: str) -> int: """ Converts a formatted time string with the formats Mwdhms