1
Fork 0
mirror of https://github.com/wlinator/luminara.git synced 2024-10-02 18:03:12 +00:00

Merge pull request #16 from wlinator/moderation

Moderation: Timeouts
This commit is contained in:
wlinator 2024-08-03 04:08:41 -04:00
commit aa1f446478
10 changed files with 249 additions and 26 deletions

View file

@ -8,6 +8,7 @@
"case_reason_field": "Reason:",
"case_case_field_value": "`{0}`",
"case_type_field_value": "`{0}`",
"case_type_field_value_with_duration": "`{0} ({1})`",
"case_moderator_field_value": "`{0}`",
"case_target_field_value": "`{0}` 🎯",
"case_reason_field_value": "`{0}`",
@ -19,6 +20,8 @@
"case_mod_cases_author": "Cases by Moderator ({0})",
"case_reason_update_author": "Case Reason Updated",
"case_reason_update_description": "case `{0}` reason has been updated.",
"case_duration_field": "Duration:",
"case_duration_field_value": "`{0}`",
"daily_already_claimed_author": "Already Claimed",
"daily_already_claimed_description": "you can claim your daily reward again <t:{0}:R>.",
"daily_already_claimed_footer": "Daily reset is at 7 AM EST",
@ -97,6 +100,15 @@
"mod_warned_author": "User Warned",
"mod_warn_dm": "**{0}** you have been warned in `{1}`.\n\n**Reason:** `{2}`",
"mod_warned_user": "user `{0}` has been warned.",
"mod_timed_out_author": "User Timed Out",
"mod_timeout_dm": "**{0}** you have been timed out in `{1}` for `{2}`.\n\n**Reason:** `{3}`",
"mod_timed_out_user": "user `{0}` has been timed out.",
"mod_untimed_out_author": "User Timeout Removed",
"mod_untimed_out": "timeout has been removed for user `{0}`.",
"mod_not_timed_out_author": "User Not Timed Out",
"mod_not_timed_out": "user `{0}` is not timed out.",
"error_invalid_duration_author": "Invalid Duration",
"error_invalid_duration_description": "Please provide a valid duration between 1 minute and 30 days.",
"ping_author": "I'm online!",
"ping_footer": "Latency: {0}ms",
"ping_pong": "Pong!",
@ -118,5 +130,6 @@
"xp_lb_field_value": "level: **{0}**\nxp: `{1}/{2}`",
"xp_level": "Level {0}",
"xp_progress": "Progress to next level",
"xp_server_rank": "Server Rank: #{0}"
"xp_server_rank": "Server Rank: #{0}",
"error_invalid_duration": "Invalid duration: {0}"
}

View file

@ -12,7 +12,7 @@ class Constants:
TITLE = "Luminara"
AUTHOR = "wlinator"
LICENSE = "GNU General Public License v3.0"
VERSION = "2.8.2" # "Moderation: Warns" update
VERSION = "2.8.3" # "Moderation: Timeouts" update
# bot credentials
TOKEN: Optional[str] = os.environ.get("TOKEN", None)

View file

@ -2,6 +2,9 @@ import textwrap
import discord
from discord.ext import commands
from pytimeparse import parse
from lib.exceptions.LumiExceptions import LumiException
from lib.constants import CONST
from services.config_service import GuildConfig
@ -101,3 +104,35 @@ def get_invoked_name(ctx: commands.Context) -> str | None:
return ctx.invoked_with
except (discord.ApplicationCommandInvokeError, AttributeError):
return ctx.command.name if ctx.command else None
def format_duration_to_seconds(duration: str) -> int:
"""
Formats a duration in seconds to a human-readable string.
"""
parsed_duration = parse(duration)
if isinstance(parsed_duration, int):
return parsed_duration
else:
raise LumiException(CONST.STRINGS["error_invalid_duration"].format(duration))
def format_seconds_to_duration_string(seconds: int) -> str:
"""
Formats a duration in seconds to a human-readable string.
Returns seconds if shorter than a minute.
"""
if seconds < 60:
return f"{seconds}s"
days = seconds // 86400
hours = (seconds % 86400) // 3600
minutes = (seconds % 3600) // 60
if days > 0:
return f"{days}d{hours}h" if hours > 0 else f"{days}d"
elif hours > 0:
return f"{hours}h{minutes}m" if minutes > 0 else f"{hours}h"
else:
return f"{minutes}m"

View file

@ -1,7 +1,7 @@
import discord
from discord.ext import bridge, commands
from modules.moderation import ban, cases, warn
from modules.moderation import ban, cases, warn, timeout
class Moderation(commands.Cog):
@ -126,6 +126,43 @@ class Moderation(commands.Cog):
):
await warn.warn_user(ctx, target, reason)
@bridge.bridge_command(
name="timeout",
aliases=["t", "to"],
description="Timeout a user.",
help="Timeouts a user in the server for a specified duration.",
guild_only=True,
)
@bridge.has_permissions(moderate_members=True)
@commands.guild_only()
async def timeout_command(
self,
ctx,
target: discord.Member,
duration: str,
*,
reason: str | None = None,
):
await timeout.timeout_user(self, ctx, target, duration, reason)
@bridge.bridge_command(
name="untimeout",
aliases=["removetimeout", "rto", "uto"],
description="Remove timeout from a user.",
help="Removes the timeout from a user in the server.",
guild_only=True,
)
@bridge.has_permissions(moderate_members=True)
@commands.guild_only()
async def untimeout_command(
self,
ctx,
target: discord.Member,
*,
reason: str | None = None,
):
await timeout.untimeout_user(ctx, target, reason)
def setup(client):
client.add_cog(Moderation(client))

View file

@ -27,13 +27,14 @@ async def view_case_by_number(ctx, guild_id: int, case_number: int):
return await ctx.respond(embed=embed)
target = await UserConverter().convert(ctx, str(case["target_id"]))
embed = create_case_embed(
ctx,
target,
case["case_number"],
case["action_type"],
case["reason"],
case["created_at"],
embed: discord.Embed = create_case_embed(
ctx=ctx,
target=target,
case_number=case["case_number"],
action_type=case["action_type"],
reason=case["reason"],
timestamp=case["created_at"],
duration=case["duration"] or None,
)
await ctx.respond(embed=embed)

View file

@ -0,0 +1,102 @@
import asyncio
import discord
import datetime
from lib import formatter
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from modules.moderation.utils.actionable import async_actionable
from modules.moderation.utils.case_handler import create_case
from typing import Optional
from discord.ext.commands import UserConverter
from lib.formatter import format_duration_to_seconds, format_seconds_to_duration_string
async def timeout_user(
cog,
ctx,
target: discord.Member,
duration: str,
reason: Optional[str] = None,
):
bot_member = await cog.client.get_or_fetch_member(ctx.guild, ctx.bot.user.id)
await async_actionable(target, ctx.author, bot_member)
output_reason = reason or CONST.STRINGS["mod_no_reason"]
# Parse duration to minutes and validate
duration_int = format_duration_to_seconds(duration)
duration_str = format_seconds_to_duration_string(duration_int)
await target.timeout_for(
duration=datetime.timedelta(seconds=duration_int),
reason=CONST.STRINGS["mod_reason"].format(
ctx.author.name,
formatter.shorten(output_reason, 200),
),
)
dm_task = target.send(
embed=EmbedBuilder.create_warning_embed(
ctx,
author_text=CONST.STRINGS["mod_timed_out_author"],
description=CONST.STRINGS["mod_timeout_dm"].format(
target.name,
ctx.guild.name,
duration_str,
output_reason,
),
show_name=False,
),
)
respond_task = ctx.respond(
embed=EmbedBuilder.create_success_embed(
ctx,
author_text=CONST.STRINGS["mod_timed_out_author"],
description=CONST.STRINGS["mod_timed_out_user"].format(target.name),
),
)
target_user = await UserConverter().convert(ctx, str(target.id))
create_case_task = create_case(ctx, target_user, "TIMEOUT", reason, duration_int)
await asyncio.gather(
dm_task,
respond_task,
create_case_task,
return_exceptions=True,
)
async def untimeout_user(ctx, target: discord.Member, reason: Optional[str] = None):
output_reason = reason or CONST.STRINGS["mod_no_reason"]
try:
await target.remove_timeout(
reason=CONST.STRINGS["mod_reason"].format(
ctx.author.name,
formatter.shorten(output_reason, 200),
),
)
respond_task = ctx.respond(
embed=EmbedBuilder.create_success_embed(
ctx,
author_text=CONST.STRINGS["mod_untimed_out_author"],
description=CONST.STRINGS["mod_untimed_out"].format(target.name),
),
)
target_user = await UserConverter().convert(ctx, str(target.id))
create_case_task = create_case(ctx, target_user, "UNTIMEOUT", reason)
await asyncio.gather(respond_task, create_case_task, return_exceptions=True)
except discord.HTTPException:
return await ctx.respond(
embed=EmbedBuilder.create_warning_embed(
ctx,
author_text=CONST.STRINGS["mod_not_timed_out_author"],
description=CONST.STRINGS["mod_not_timed_out"].format(target.name),
),
)

View file

@ -4,6 +4,7 @@ from lib.constants import CONST
from lib.formatter import format_case_number
from typing import Optional
import datetime
from lib.formatter import format_seconds_to_duration_string
def create_case_embed(
@ -13,6 +14,7 @@ def create_case_embed(
action_type: str,
reason: Optional[str],
timestamp: Optional[datetime.datetime] = None,
duration: Optional[int] = None,
) -> discord.Embed:
embed = EmbedBuilder.create_warning_embed(
ctx,
@ -28,13 +30,25 @@ def create_case_embed(
),
inline=True,
)
embed.add_field(
name=CONST.STRINGS["case_type_field"],
value=CONST.STRINGS["case_type_field_value"].format(
action_type.lower().capitalize(),
),
inline=True,
)
if not duration:
embed.add_field(
name=CONST.STRINGS["case_type_field"],
value=CONST.STRINGS["case_type_field_value"].format(
action_type.lower().capitalize(),
),
inline=True,
)
else:
embed.add_field(
name=CONST.STRINGS["case_type_field"],
value=CONST.STRINGS["case_type_field_value_with_duration"].format(
action_type.lower().capitalize(),
format_seconds_to_duration_string(duration),
),
inline=True,
)
embed.add_field(
name=CONST.STRINGS["case_moderator_field"],
value=CONST.STRINGS["case_moderator_field_value"].format(

View file

@ -65,7 +65,15 @@ async def create_case(
ctx,
str(mod_log_channel_id),
)
embed = create_case_embed(ctx, target, case_number, action_type, reason)
embed: discord.Embed = create_case_embed(
ctx=ctx,
target=target,
case_number=case_number,
action_type=action_type,
reason=reason,
timestamp=None,
duration=duration,
)
message = await mod_log_channel.send(embed=embed)
# Update the case with the modlog_message_id
@ -118,13 +126,14 @@ async def edit_case_modlog(
message = await mod_log_channel.fetch_message(modlog_message_id)
target = await UserConverter().convert(ctx, str(case["target_id"]))
updated_embed = create_case_embed(
ctx,
target,
case_number,
case["action_type"],
new_reason,
case["created_at"],
updated_embed: discord.Embed = create_case_embed(
ctx=ctx,
target=target,
case_number=case_number,
action_type=case["action_type"],
reason=new_reason,
timestamp=case["created_at"],
duration=case["duration"] or None,
)
await message.edit(embed=updated_embed)

13
poetry.lock generated
View file

@ -797,6 +797,17 @@ files = [
[package.extras]
cli = ["click (>=5.0)"]
[[package]]
name = "pytimeparse"
version = "1.1.8"
description = "Time expression parser"
optional = false
python-versions = "*"
files = [
{file = "pytimeparse-1.1.8-py2.py3-none-any.whl", hash = "sha256:04b7be6cc8bd9f5647a6325444926c3ac34ee6bc7e69da4367ba282f076036bd"},
{file = "pytimeparse-1.1.8.tar.gz", hash = "sha256:e86136477be924d7e670646a98561957e8ca7308d44841e21f5ddea757556a0a"},
]
[[package]]
name = "pytz"
version = "2024.1"
@ -1111,4 +1122,4 @@ multidict = ">=4.0"
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "f99a624c8fbf0661b2dfe3a937f1182c527ed3d565715bb868b1cbac95221405"
content-hash = "9288a5bc8e1fdb08faf1bc44752ed3adb05e225879e142cc0bf2bbce2436fa5c"

View file

@ -20,6 +20,7 @@ python = "^3.12"
python-dotenv = "^1.0.1"
pytz = "^2024.1"
ruff = "^0.5.2"
pytimeparse = "^1.1.8"
[build-system]
build-backend = "poetry.core.masonry.api"