From abbe368c6dbedda0c87b7c78eca62af11e1c04e0 Mon Sep 17 00:00:00 2001 From: pushiscool Date: Mon, 25 May 2026 13:38:16 -0400 Subject: [PATCH] Add infraction history update command --- src/cogs/moderation/infraction.py | 108 ++++++++++++++++++++++++------ src/database_handler.py | 53 +++++++++++++++ tests/test_database_handler.py | 71 +++++++++++++++++++- tests/test_moderation_helpers.py | 17 +++++ 4 files changed, 228 insertions(+), 21 deletions(-) diff --git a/src/cogs/moderation/infraction.py b/src/cogs/moderation/infraction.py index d6c4f1c..a91c9f6 100644 --- a/src/cogs/moderation/infraction.py +++ b/src/cogs/moderation/infraction.py @@ -1,6 +1,6 @@ import nextcord import re -from nextcord import slash_command, Permissions, Interaction, User, Embed, Member, TextChannel, Object, Color +from nextcord import slash_command, Permissions, Interaction, User, Embed, Member, TextChannel, Object, Color, SlashOption, SlashOption, SlashOption from nextcord.ext import commands from typing import Optional from bot_base import APBot @@ -23,6 +23,47 @@ def to_snowflake(value): return int(m.group(0)) return None + +def format_update_date(value): + if isinstance(value, datetime): + dt = value + elif isinstance(value, str): + try: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return "unknown time" + else: + return "unknown time" + + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + + return f"" + + +def format_infraction_updates(updates): + if not updates: + return "" + + lines = [] + for update in updates[-5:]: + if isinstance(update, str): + lines.append(f"- {update}") + continue + + if not isinstance(update, dict): + lines.append(f"- {update}") + continue + + moderator = update.get("moderator", "Unknown moderator") + moderator_id = to_snowflake(moderator) + moderator_text = f"<@{moderator_id}>" if moderator_id else str(moderator) + note = update.get("update") or update.get("note") or "No note provided" + date_text = format_update_date(update.get("date")) + lines.append(f"- {note} — {moderator_text}, {date_text}") + + return "Notes:\n" + "\n".join(lines) + "\n" + class Infraction(commands.Cog): def __init__(self, bot: APBot) -> None: self.bot = bot @@ -30,8 +71,12 @@ def __init__(self, bot: APBot) -> None: def has_mod_role(self, member: Member) -> bool: allowed_role_names = {"Trial Chat Moderator", "Chat Moderator", "Admin"} allowed_role_ids = { - conf.get("bot_staff_role_id"), - conf.get("special_perms_role_id"), + role_id + for role_id in ( + conf.get("bot_staff_role_id"), + conf.get("special_perms_role_id"), + ) + if role_id is not None } perms = getattr(member, "guild_permissions", None) @@ -175,12 +220,15 @@ async def warnings(self, inter: Interaction, member: Member): except (ValueError, TypeError, AttributeError): pass + update_line = format_infraction_updates(getattr(inf, "update", [])) embed = Embed( - title=action, + title=f"#{index} - {action}", description=( + f"Index: {index}\n" f"Reason: {reason}\n" f"{duration_line}" f"Responsible Mod: {mod_line}\n" + f"{update_line}" f"{index}/{total} infractions • {timestamp}" ), color=color_map.get(getattr(inf, "actiontype", ""), Color.gold()), @@ -236,27 +284,47 @@ async def editip(self, interaction: Interaction, member: Member, change: int): ) ) -# Work on later -# @slash_command(name="update", description="Update a member's infraction history.", default_member_permissions=Permissions(moderate_members=True)) -# async def update(self, interaction: Interaction, user: User, infraction: int, update: str): -# member_config = await self.bot.db.base_db.read_user_config(user.id) -# infractions = member_config["infractions"] -# infraction_update = infractions[infraction] + @slash_command( + name="update", + description="Update a member's infraction history.", + default_member_permissions=Permissions(moderate_members=True), + guild_ids=COMMAND_GUILD_IDS, + ) + async def update( + self, + interaction: Interaction, + member: Member, + index: int, + action: str = SlashOption(choices=["reason", "delete", "note"], description="Choose what to update."), + text: Optional[str] = SlashOption(description="New reason or note text. Leave empty only for delete.", required=False), + ): + if not self.has_mod_role(interaction.user): + await interaction.response.send_message("You do not have permission to use this command.", ephemeral=True) + return -# if infraction_update.get('update') is None: -# infraction_update['update'] = [] + if index < 1: + await interaction.response.send_message("Use the index shown in /warnings, starting at 1.", ephemeral=True) + return -# update_dict = { -# "moderator": interaction.user.mention, -# "update": update, -# "date": datetime.utcnow() -# } + if action in {"reason", "note"} and not text: + await interaction.response.send_message("Text is required for this update type.", ephemeral=True) + return -# infraction_update['update'].append(update_dict) + if action == "reason": + updated = await self.bot.db.base_db.update_infraction_reason(member.id, index, text) + response = f"Updated reason for infraction #{index} on {member.mention}." + elif action == "delete": + updated = await self.bot.db.base_db.delete_infraction(member.id, index) + response = f"Deleted infraction #{index} from {member.mention}'s history. Infraction points were not changed." + else: + updated = await self.bot.db.base_db.add_infraction_note(member.id, index, interaction.user.id, text) + response = f"Added a note to infraction #{index} on {member.mention}." -# await self.bot.db.base_db.update_user_config(user.id, member_config) # Save changes + if not updated: + await interaction.response.send_message("That infraction index does not exist.", ephemeral=True) + return -# await interaction.response.send_message("Infraction updated successfully.", ephemeral=True) + await interaction.response.send_message(response, ephemeral=True) @slash_command(name="userip", description="View a member's infraction points.", default_member_permissions=Permissions(moderate_members=True), guild_ids=COMMAND_GUILD_IDS) async def userip(self, interaction: Interaction, member: Member): diff --git a/src/database_handler.py b/src/database_handler.py index 0e13591..d85ba1d 100644 --- a/src/database_handler.py +++ b/src/database_handler.py @@ -134,6 +134,59 @@ async def add_infraction(self, user_id: int, infraction: Infraction): + + async def update_infraction_reason(self, user_id: int, index: int, reason: str) -> bool: + user_config = await self.read_user_config(user_id) + infractions = user_config.get("infractions", []) + + if index < 1 or index > len(infractions): + return False + + infraction = infractions[index - 1] + infraction["reason"] = reason + infraction["mute_reason"] = reason + await self.update_user_config(user_id, user_config) + return True + + async def delete_infraction(self, user_id: int, index: int) -> bool: + user_config = await self.read_user_config(user_id) + infractions = user_config.get("infractions", []) + + if index < 1 or index > len(infractions): + return False + + del infractions[index - 1] + user_config["infractions"] = infractions + await self.update_user_config(user_id, user_config) + return True + + async def add_infraction_note(self, user_id: int, index: int, moderator_id: int, note: str, actiontime=None) -> bool: + user_config = await self.read_user_config(user_id) + infractions = user_config.get("infractions", []) + + if index < 1 or index > len(infractions): + return False + + if actiontime is None: + actiontime = datetime.now(timezone.utc) + + if isinstance(actiontime, datetime): + if actiontime.tzinfo is None: + actiontime = actiontime.replace(tzinfo=timezone.utc) + actiontime = actiontime.astimezone(timezone.utc).isoformat() + + infraction = infractions[index - 1] + updates = infraction.get("update") or [] + updates.append({ + "moderator": moderator_id, + "update": note, + "date": actiontime, + }) + infraction["update"] = updates + + await self.update_user_config(user_id, user_config) + return True + async def get_user_infractions(self, user_id: int) -> list: user_config = await self.read_user_config(user_id) infractions = user_config.get("infractions", []) diff --git a/tests/test_database_handler.py b/tests/test_database_handler.py index 9177716..c6d289d 100644 --- a/tests/test_database_handler.py +++ b/tests/test_database_handler.py @@ -163,4 +163,73 @@ def test_get_user_infractions_normalizes_old_mute_records(): assert infractions[0].moderator == 999999999999999999 assert infractions[0].duration == 1800 assert infractions[0].attachment_url == "https://example.com/old.png" - assert infractions[0].actiontime == datetime(2024, 2, 3, 10, 15, 0, tzinfo=timezone.utc) \ No newline at end of file + assert infractions[0].actiontime == datetime(2024, 2, 3, 10, 15, 0, tzinfo=timezone.utc) + +def test_update_infraction_reason_updates_new_and_legacy_reason_fields(): + db = make_base_db([ + { + "_id": 1, + "user_id": 5, + "infraction_points": 0, + "infractions": [{"type": "mute", "mute_reason": "old"}], + } + ]) + + result = asyncio.run(db.update_infraction_reason(5, 1, "new reason")) + stored = asyncio.run(db.read_user_config(5)) + + assert result is True + assert stored["infractions"][0]["reason"] == "new reason" + assert stored["infractions"][0]["mute_reason"] == "new reason" + + +def test_add_infraction_note_appends_update_entry(): + db = make_base_db([ + { + "_id": 1, + "user_id": 5, + "infraction_points": 0, + "infractions": [{"actiontype": "warn", "reason": "old"}], + } + ]) + date = datetime(2024, 1, 2, 3, 4, 5, tzinfo=timezone.utc) + + result = asyncio.run(db.add_infraction_note(5, 1, 999, "extra context", date)) + stored = asyncio.run(db.read_user_config(5)) + + assert result is True + assert stored["infractions"][0]["update"][0]["moderator"] == 999 + assert stored["infractions"][0]["update"][0]["update"] == "extra context" + assert stored["infractions"][0]["update"][0]["date"] == "2024-01-02T03:04:05+00:00" + + +def test_delete_infraction_removes_only_selected_index(): + db = make_base_db([ + { + "_id": 1, + "user_id": 5, + "infraction_points": 10, + "infractions": [ + {"actiontype": "warn", "reason": "first"}, + {"actiontype": "mute", "reason": "second"}, + ], + } + ]) + + result = asyncio.run(db.delete_infraction(5, 1)) + stored = asyncio.run(db.read_user_config(5)) + + assert result is True + assert len(stored["infractions"]) == 1 + assert stored["infractions"][0]["reason"] == "second" + assert stored["infraction_points"] == 10 + + +def test_infraction_update_helpers_reject_bad_index(): + db = make_base_db([ + {"_id": 1, "user_id": 5, "infraction_points": 0, "infractions": []} + ]) + + assert asyncio.run(db.update_infraction_reason(5, 1, "reason")) is False + assert asyncio.run(db.add_infraction_note(5, 1, 999, "note")) is False + assert asyncio.run(db.delete_infraction(5, 1)) is False diff --git a/tests/test_moderation_helpers.py b/tests/test_moderation_helpers.py index 95f3700..a0dcff6 100644 --- a/tests/test_moderation_helpers.py +++ b/tests/test_moderation_helpers.py @@ -26,3 +26,20 @@ def test_to_snowflake_parses_mentions_and_ints(): assert to_snowflake("<@123456789012345678>") == 123456789012345678 assert to_snowflake("not-a-user") is None assert to_snowflake(None) is None + +from datetime import datetime, timezone +from cogs.moderation.infraction import format_infraction_updates + + +def test_format_infraction_updates_shows_note_moderator_and_date(): + text = format_infraction_updates([ + { + "moderator": 123456789012345678, + "update": "extra context", + "date": datetime(2024, 1, 1, tzinfo=timezone.utc), + } + ]) + + assert "Notes:" in text + assert "extra context" in text + assert "<@123456789012345678>" in text