1
Fork 0
mirror of https://github.com/allthingslinux/tux.git synced 2024-10-03 00:53:12 +00:00

Merge branch 'main' into snippeteditban

This commit is contained in:
TargzBalls 2024-08-25 14:53:15 -05:00 committed by GitHub
commit 84b01c3b5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 662 additions and 788 deletions

View file

@ -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=""

42
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -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.

View file

@ -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.

40
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -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.

View file

@ -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'

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

6
poetry.lock generated
View file

@ -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]

View file

@ -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)

85
tux/cogs/guild/setup.py Normal file
View file

@ -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))

View file

@ -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)

View file

@ -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] <purge_days> <silent>",
)
@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:

View file

@ -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,

View file

@ -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] <silent>",
)
@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))

View file

@ -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] <silent>",
)
@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:

View file

@ -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:
"""

View file

@ -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:
"""

View file

@ -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<value>\d+)(?P<unit>[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:

View file

@ -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:

View file

@ -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] <silent>",
)
@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:

View file

@ -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] <silent>",
)
@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:

View file

@ -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] <silent>",
)
@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:

View file

@ -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

View file

@ -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}"

View file

@ -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] -<silent>"
"""
# 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,
)

View file

@ -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<value>\d+)(?P<unit>[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