From 5fe9fc68c3088eecb071759bd3691a3fba9df4b2 Mon Sep 17 00:00:00 2001 From: pushiscool Date: Mon, 25 May 2026 21:11:37 -0400 Subject: [PATCH 1/3] Resolve legacy warning moderator IDs --- src/cogs/moderation/infraction.py | 161 ++++++++++++++++++++++-------- src/database_handler.py | 54 ++++++++-- tests/test_database_handler.py | 24 +++++ tests/test_moderation_helpers.py | 33 +++++- 4 files changed, 222 insertions(+), 50 deletions(-) diff --git a/src/cogs/moderation/infraction.py b/src/cogs/moderation/infraction.py index a91c9f6..99115eb 100644 --- a/src/cogs/moderation/infraction.py +++ b/src/cogs/moderation/infraction.py @@ -10,18 +10,70 @@ conf = load_optional_config() COMMAND_GUILD_IDS = get_command_guild_ids(conf) +DISCORD_EPOCH_MS = 1420070400000 -def to_snowflake(value): +def is_possible_snowflake(value: int) -> bool: + text = str(value) + if not 17 <= len(text) <= 20: + return False + + created_ms = (value >> 22) + DISCORD_EPOCH_MS + now_ms = int(datetime.now(timezone.utc).timestamp() * 1000) + return DISCORD_EPOCH_MS <= created_ms <= now_ms + 86400000 + + +def add_snowflake_candidate(candidates: list[int], seen: set[int], value: str) -> None: + try: + candidate = int(value) + except (TypeError, ValueError): + return + + if candidate in seen: + return + + if not is_possible_snowflake(candidate): + return + + seen.add(candidate) + candidates.append(candidate) + + +def snowflake_candidates(value) -> list[int]: + candidates: list[int] = [] + seen: set[int] = set() + if value is None: - return None - if isinstance(value, int): - return value - if isinstance(value, str): - m = re.search(r"\d{17,20}", value) - if m: - return int(m.group(0)) - return None + return candidates + + if hasattr(value, "id"): + value = value.id + + text = str(value) + + for match in re.finditer(r"<@!?(\d{17,20})>", text): + add_snowflake_candidate(candidates, seen, match.group(1)) + + for match in re.finditer(r"(? None: self.bot = bot + async def fetch_moderator_from_id(self, guild, moderator_id: int): + if guild is not None: + member = guild.get_member(moderator_id) + + if member is not None: + return member + + try: + member = await guild.fetch_member(moderator_id) + + if member is not None: + return member + except (AttributeError, nextcord.NotFound, nextcord.Forbidden, nextcord.HTTPException): + pass + + try: + cached_user = self.bot.get_user(moderator_id) + + if cached_user is not None: + return cached_user + except AttributeError: + pass + + try: + return await self.bot.fetch_user(moderator_id) + except (AttributeError, nextcord.NotFound, nextcord.Forbidden, nextcord.HTTPException): + return None + + async def resolve_moderator(self, guild, raw_moderator): + candidates = snowflake_candidates(raw_moderator) + + for moderator_id in candidates: + moderator = await self.fetch_moderator_from_id(guild, moderator_id) + + if moderator is not None: + return moderator, moderator_id + + fallback_id = candidates[0] if candidates else None + return None, fallback_id + + def format_moderator(self, raw_moderator, moderator, moderator_id: Optional[int]) -> str: + if moderator is not None: + display = ( + getattr(moderator, "global_name", None) + or getattr(moderator, "display_name", None) + or getattr(moderator, "name", None) + or str(moderator) + ) + mention = getattr(moderator, "mention", f"<@{moderator_id}>") + return f"{mention} ({display})" + + if moderator_id is not None: + return f"<@{moderator_id}> (not found)" + + if raw_moderator: + return f"`{raw_moderator}` (invalid legacy moderator ID)" + + return "Unknown moderator" + def has_mod_role(self, member: Member) -> bool: allowed_role_names = {"Trial Chat Moderator", "Chat Moderator", "Admin"} allowed_role_ids = { @@ -143,37 +254,9 @@ async def warnings(self, inter: Interaction, member: Member): ) reason = inf.reason or "No reason provided" - # ---- Moderator lookup (fixed) ---- raw_mod = getattr(inf, "moderator", None) - moderator_id = to_snowflake(raw_mod) - - mod = None - if moderator_id: - # Try guild cache first (only if in a guild) - if inter.guild is not None: - mod = inter.guild.get_member(moderator_id) - - # Fallback to API fetch - if mod is None: - try: - mod = await self.bot.fetch_user(moderator_id) - except nextcord.HTTPException: - mod = None - - # Build moderator display: - # - If we found the user => mention + display name - # - If not => still mention the ID if we have one, else show whatever was stored - if mod: - display = getattr(mod, "global_name", None) or mod.name - mod_line = f"{mod.mention} ({display})" - else: - if moderator_id: - mod_line = f"<@{moderator_id}> (unknown)" - else: - mod_line = f"{raw_mod} (unknown)" if raw_mod else "Unknown moderator" - # ------------------------------- - - + mod, moderator_id = await self.resolve_moderator(inter.guild, raw_mod) + mod_line = self.format_moderator(raw_mod, mod, moderator_id) time_val = getattr(inf, "actiontime", None) diff --git a/src/database_handler.py b/src/database_handler.py index e1e8bab..9bde678 100644 --- a/src/database_handler.py +++ b/src/database_handler.py @@ -16,6 +16,48 @@ from typing import Optional import re logger = logging.getLogger(__name__) +DISCORD_EPOCH_MS = 1420070400000 + + +def is_possible_snowflake(value: int) -> bool: + text = str(value) + if not 17 <= len(text) <= 20: + return False + + created_ms = (value >> 22) + DISCORD_EPOCH_MS + now_ms = int(datetime.now(timezone.utc).timestamp() * 1000) + return DISCORD_EPOCH_MS <= created_ms <= now_ms + 86400000 + + +def first_snowflake(value): + if value is None: + return None + + if hasattr(value, "id"): + value = value.id + + text = str(value) + + for pattern in (r"<@!?(\d{17,20})>", r"(?", + display_name="Senior Mod", + name="senior-mod", + ) + guild = SimpleNamespace( + get_member=lambda user_id: None, + fetch_member=AsyncMock(return_value=moderator), + ) + bot = SimpleNamespace( + get_user=lambda user_id: None, + fetch_user=AsyncMock(return_value=None), + ) + cog = InfractionCog(bot=bot) + + resolved, moderator_id = asyncio.run(cog.resolve_moderator(guild, "7079852600207606280")) + + assert resolved is moderator + assert moderator_id == 707985260020760628 + guild.fetch_member.assert_awaited_once_with(707985260020760628) def test_format_infraction_updates_shows_note_moderator_and_date(): From 0513943c8ca591e7f503e51b74aed6884f672499 Mon Sep 17 00:00:00 2001 From: pushiscool Date: Mon, 25 May 2026 21:35:19 -0400 Subject: [PATCH 2/3] Recover legacy mute durations --- src/database_handler.py | 110 ++++++++++++++++++++++++++++++++- tests/test_database_handler.py | 34 +++++++++- 2 files changed, 142 insertions(+), 2 deletions(-) diff --git a/src/database_handler.py b/src/database_handler.py index 9bde678..130867f 100644 --- a/src/database_handler.py +++ b/src/database_handler.py @@ -17,6 +17,29 @@ import re logger = logging.getLogger(__name__) DISCORD_EPOCH_MS = 1420070400000 +DURATION_UNITS = { + "s": 1, + "sec": 1, + "secs": 1, + "second": 1, + "seconds": 1, + "m": 60, + "min": 60, + "mins": 60, + "minute": 60, + "minutes": 60, + "h": 3600, + "hr": 3600, + "hrs": 3600, + "hour": 3600, + "hours": 3600, + "d": 86400, + "day": 86400, + "days": 86400, + "w": 604800, + "week": 604800, + "weeks": 604800, +} def is_possible_snowflake(value: int) -> bool: @@ -60,6 +83,80 @@ def first_snowflake(value): return None +def parse_duration_string(value: str) -> Optional[int]: + text = value.strip().lower() + + if not text: + return None + + if text.isdigit(): + return int(text) + + time_parts = text.split(":") + if 2 <= len(time_parts) <= 3 and all(part.isdigit() for part in time_parts): + numbers = [int(part) for part in time_parts] + + if len(numbers) == 2: + minutes, seconds = numbers + return minutes * 60 + seconds + + hours, minutes, seconds = numbers + return hours * 3600 + minutes * 60 + seconds + + day_prefix = 0 + day_match = re.match(r"^(\d+)\s+days?,\s*(.+)$", text) + + if day_match: + day_prefix = int(day_match.group(1)) * 86400 + text = day_match.group(2) + time_duration = parse_duration_string(text) + return day_prefix + time_duration if time_duration is not None else None + + matches = list( + re.finditer( + r"(\d+(?:\.\d+)?)\s*(weeks?|w|days?|d|hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)", + text, + ) + ) + + if not matches: + return None + + leftovers = re.sub( + r"(\d+(?:\.\d+)?)\s*(weeks?|w|days?|d|hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)", + "", + text, + ) + leftovers = re.sub(r"[\s,;:+-]", "", leftovers) + + if leftovers: + return None + + total = 0 + + for match in matches: + amount, unit = match.groups() + total += float(amount) * DURATION_UNITS[unit] + + return int(total) + + +def normalize_duration(value) -> Optional[int]: + if value is None: + return None + + if isinstance(value, timedelta): + return int(value.total_seconds()) + + if isinstance(value, (int, float)): + return int(value) + + if isinstance(value, str): + return parse_duration_string(value) + + return None + + class SingletonMeta(type): _instances = {} @@ -276,7 +373,18 @@ def normalize_moderator(value): "reason": first_value(inf.get("reason"), inf.get("mute_reason"), "No reason provided"), "moderator": normalize_moderator(first_value(inf.get("moderator"), inf.get("mute_moderator"), 0)), "actiontime": normalize_time(first_value(inf.get("actiontime"), inf.get("date"))), - "duration": inf.get("duration"), + "duration": normalize_duration( + first_value( + inf.get("duration"), + inf.get("mute_duration"), + inf.get("mute_length"), + inf.get("mute_time"), + inf.get("duration_seconds"), + inf.get("mute_duration_seconds"), + inf.get("length"), + inf.get("time"), + ) + ), "attachment_url": first_value(inf.get("attachment_url"), inf.get("attachment")), "update": inf.get("update") or [], } diff --git a/tests/test_database_handler.py b/tests/test_database_handler.py index e4332f6..7894e4e 100644 --- a/tests/test_database_handler.py +++ b/tests/test_database_handler.py @@ -3,7 +3,7 @@ from datetime import datetime, timezone from types import SimpleNamespace -from database_handler import BaseDatabase, WordleDatabase +from database_handler import BaseDatabase, WordleDatabase, normalize_duration from models import Infraction @@ -236,6 +236,38 @@ def test_get_user_infractions_recovers_legacy_moderator_id_with_extra_digits(): assert infractions[0].moderator == 707985260020760628 +def test_normalize_duration_accepts_legacy_duration_strings(): + assert normalize_duration("30m") == 1800 + assert normalize_duration("1h 15m") == 4500 + assert normalize_duration("0:30:00") == 1800 + assert normalize_duration("2 days, 3:04:05") == 183845 + + +def test_get_user_infractions_normalizes_legacy_mute_duration_fields(): + db = make_base_db( + [ + { + "_id": 1, + "user_id": 5, + "infraction_points": 0, + "infractions": [ + { + "type": "mute", + "mute_reason": "Old duration field", + "mute_moderator": "<@999999999999999999>", + "date": datetime(2024, 2, 3, 10, 15, 0), + "mute_duration": "45m", + } + ], + } + ] + ) + + infractions = asyncio.run(db.get_user_infractions(5)) + + assert infractions[0].duration == 2700 + + def test_update_infraction_reason_updates_new_and_legacy_reason_fields(): db = make_base_db([ { From 82cec27605ad04d5a0bf62b2c34ad013d566111a Mon Sep 17 00:00:00 2001 From: pushiscool Date: Mon, 25 May 2026 21:45:56 -0400 Subject: [PATCH 3/3] Harden legacy warning display --- src/cogs/moderation/infraction.py | 118 +++++++++++++------ src/database_handler.py | 190 +++++++++++++++++++++++++++--- tests/test_database_handler.py | 121 ++++++++++++++++++- tests/test_moderation_helpers.py | 18 ++- 4 files changed, 391 insertions(+), 56 deletions(-) diff --git a/src/cogs/moderation/infraction.py b/src/cogs/moderation/infraction.py index 99115eb..01d3a25 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, SlashOption, SlashOption, SlashOption +import nextcord +from nextcord import slash_command, Permissions, Interaction, Embed, Member, TextChannel, Color, SlashOption from nextcord.ext import commands from typing import Optional from bot_base import APBot @@ -11,6 +11,12 @@ conf = load_optional_config() COMMAND_GUILD_IDS = get_command_guild_ids(conf) DISCORD_EPOCH_MS = 1420070400000 +IMAGE_URL_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp") + + +def clip_text(value, limit: int = 1000) -> str: + text = str(value or "") + return text if len(text) <= limit else f"{text[:limit - 3]}..." def is_possible_snowflake(value: int) -> bool: @@ -93,6 +99,71 @@ def format_update_date(value): return f"" +def format_action_name(actiontype: str) -> str: + action = actiontype or "unknown" + + if action == "note": + return "Internal Note" + + return action.replace("-", " ").title() + + +def format_duration(value) -> Optional[str]: + if not value: + return None + + try: + duration_seconds = int(value.total_seconds()) if isinstance(value, timedelta) else int(value) + except (ValueError, TypeError, AttributeError): + return None + + if duration_seconds <= 0: + return None + + days, rem = divmod(duration_seconds, 86400) + hours, rem = divmod(rem, 3600) + minutes, seconds = divmod(rem, 60) + + parts = [] + if days: + parts.append(f"{days}d") + if hours: + parts.append(f"{hours}h") + if minutes: + parts.append(f"{minutes}m") + if seconds: + parts.append(f"{seconds}s") + + return " ".join(parts) if parts else "0s" + + +def is_http_url(value) -> bool: + return str(value or "").lower().startswith(("http://", "https://")) + + +def can_embed_image(value) -> bool: + if not is_http_url(value): + return False + + url = str(value).split("?", 1)[0].split("#", 1)[0].lower() + return url.endswith(IMAGE_URL_EXTENSIONS) + + +def format_attachment_line(value) -> str: + if not value: + return "" + + attachment = str(value).strip() + + if not attachment: + return "" + + if is_http_url(attachment): + return f"Evidence: [View attachment]({attachment})\n" + + return f"Evidence: `{clip_text(attachment, 200)}`\n" + + def format_infraction_updates(updates): if not updates: return "" @@ -110,7 +181,7 @@ def format_infraction_updates(updates): 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" + note = clip_text(update.get("update") or update.get("note") or "No note provided", 500) date_text = format_update_date(update.get("date")) lines.append(f"- {note} — {moderator_text}, {date_text}") @@ -247,12 +318,8 @@ async def warnings(self, inter: Interaction, member: Member): total = len(infractions) for index, inf in enumerate(infractions, start=1): - action = ( - "Internal Note" - if inf.actiontype == "note" - else (inf.actiontype or "unknown").capitalize().replace("-", " ") - ) - reason = inf.reason or "No reason provided" + action = format_action_name(inf.actiontype) + reason = clip_text(inf.reason or "No reason provided", 1400) raw_mod = getattr(inf, "moderator", None) mod, moderator_id = await self.resolve_moderator(inter.guild, raw_mod) @@ -277,32 +344,10 @@ async def warnings(self, inter: Interaction, member: Member): unix = int(time_val.timestamp()) timestamp = f" ()" - - # ---- Duration ---- - duration_line = "" - if getattr(inf, "duration", None): - try: - dur = inf.duration - duration_seconds = int(dur.total_seconds()) if isinstance(dur, timedelta) else int(dur) - - days, rem = divmod(duration_seconds, 86400) - hours, rem = divmod(rem, 3600) - minutes, seconds = divmod(rem, 60) - - parts = [] - if days: - parts.append(f"{days}d") - if hours: - parts.append(f"{hours}h") - if minutes: - parts.append(f"{minutes}m") - if seconds: - parts.append(f"{seconds}s") - - duration_line = f"Duration: {' '.join(parts) if parts else '0s'}\n" - except (ValueError, TypeError, AttributeError): - pass - + duration = format_duration(getattr(inf, "duration", None)) + duration_line = f"Duration: {duration}\n" if duration else "" + attachment_url = getattr(inf, "attachment_url", None) + attachment_line = format_attachment_line(attachment_url) update_line = format_infraction_updates(getattr(inf, "update", [])) embed = Embed( title=f"#{index} - {action}", @@ -310,6 +355,7 @@ async def warnings(self, inter: Interaction, member: Member): f"Index: {index}\n" f"Reason: {reason}\n" f"{duration_line}" + f"{attachment_line}" f"Responsible Mod: {mod_line}\n" f"{update_line}" f"{index}/{total} infractions • {timestamp}" @@ -317,6 +363,8 @@ async def warnings(self, inter: Interaction, member: Member): color=color_map.get(getattr(inf, "actiontype", ""), Color.gold()), ) + if can_embed_image(attachment_url): + embed.set_image(url=attachment_url) await inter.channel.send(embed=embed) diff --git a/src/database_handler.py b/src/database_handler.py index 130867f..72a3779 100644 --- a/src/database_handler.py +++ b/src/database_handler.py @@ -40,6 +40,7 @@ "week": 604800, "weeks": 604800, } +MAX_TIMEOUT_DURATION_SECONDS = 28 * 86400 def is_possible_snowflake(value: int) -> bool: @@ -80,6 +81,13 @@ def first_snowflake(value): if is_possible_snowflake(candidate): return candidate + for size in range(min(20, len(digits) - 1), 16, -1): + for start in range(1, len(digits) - size): + candidate = int(digits[start:start + size]) + + if is_possible_snowflake(candidate): + return candidate + return None @@ -90,7 +98,8 @@ def parse_duration_string(value: str) -> Optional[int]: return None if text.isdigit(): - return int(text) + duration = int(text) + return duration if duration > 0 else None time_parts = text.split(":") if 2 <= len(time_parts) <= 3 and all(part.isdigit() for part in time_parts): @@ -98,10 +107,12 @@ def parse_duration_string(value: str) -> Optional[int]: if len(numbers) == 2: minutes, seconds = numbers - return minutes * 60 + seconds + duration = minutes * 60 + seconds + return duration if duration > 0 else None hours, minutes, seconds = numbers - return hours * 3600 + minutes * 60 + seconds + duration = hours * 3600 + minutes * 60 + seconds + return duration if duration > 0 else None day_prefix = 0 day_match = re.match(r"^(\d+)\s+days?,\s*(.+)$", text) @@ -138,7 +149,8 @@ def parse_duration_string(value: str) -> Optional[int]: amount, unit = match.groups() total += float(amount) * DURATION_UNITS[unit] - return int(total) + duration = int(total) + return duration if duration > 0 else None def normalize_duration(value) -> Optional[int]: @@ -146,10 +158,22 @@ def normalize_duration(value) -> Optional[int]: return None if isinstance(value, timedelta): - return int(value.total_seconds()) + duration = int(value.total_seconds()) + return duration if duration > 0 else None if isinstance(value, (int, float)): - return int(value) + duration = int(value) + + if duration <= 0: + return None + + if duration > MAX_TIMEOUT_DURATION_SECONDS and duration % 1000 == 0: + duration_ms = duration // 1000 + + if 0 < duration_ms <= MAX_TIMEOUT_DURATION_SECONDS: + return duration_ms + + return duration if isinstance(value, str): return parse_duration_string(value) @@ -157,6 +181,110 @@ def normalize_duration(value) -> Optional[int]: return None +def normalize_duration_milliseconds(value) -> Optional[int]: + if value is None: + return None + + if isinstance(value, str) and not value.strip().isdigit(): + return normalize_duration(value) + + try: + duration = int(value) + except (TypeError, ValueError): + return None + + if duration <= 0: + return None + + duration_seconds = duration // 1000 + return duration_seconds if duration_seconds > 0 else None + + +def normalize_actiontype(value) -> str: + action = str(value or "unknown").strip().lower().replace("_", "-") + action = re.sub(r"\s+", "-", action) + + aliases = { + "wm": "mute", + "warn-mute": "mute", + "warnmute": "mute", + "warning-mute": "mute", + "pseudomute": "pseudo-mute", + "pseudo-mute": "pseudo-mute", + "forceban": "force-ban", + "force-ban": "force-ban", + "internal-note": "note", + } + + return aliases.get(action, action or "unknown") + + +def normalize_attachment(value): + if value is None: + return None + + if isinstance(value, (list, tuple)): + for item in value: + normalized = normalize_attachment(item) + + if normalized: + return normalized + + return None + + if isinstance(value, dict): + return normalize_attachment( + value.get("url") + or value.get("proxy_url") + or value.get("attachment_url") + or value.get("filename") + ) + + text = str(value).strip() + return text or None + + +def legacy_unmute_update(infraction: dict): + moderator = ( + infraction.get("unmute_moderator") + or infraction.get("unmuted_by") + or infraction.get("unmute_mod") + ) + reason = ( + infraction.get("unmute_reason") + or infraction.get("unmuted_reason") + or infraction.get("unmute_note") + ) + + if not moderator and not reason: + return None + + return { + "moderator": moderator or "Unknown moderator", + "update": f"Unmuted: {reason or 'No reason provided'}", + "date": ( + infraction.get("unmute_date") + or infraction.get("unmuted_at") + or infraction.get("unmute_time") + or infraction.get("date") + or infraction.get("actiontime") + ), + } + + +def normalize_updates(value) -> list: + if not value: + return [] + + if isinstance(value, list): + return list(value) + + if isinstance(value, tuple): + return list(value) + + return [value] + + class SingletonMeta(type): _instances = {} @@ -315,7 +443,7 @@ async def add_infraction_note(self, user_id: int, index: int, moderator_id: int, actiontime = actiontime.astimezone(timezone.utc).isoformat() infraction = infractions[index - 1] - updates = infraction.get("update") or [] + updates = normalize_updates(infraction.get("update")) updates.append({ "moderator": moderator_id, "update": note, @@ -368,25 +496,49 @@ def normalize_moderator(value): cleaned_infractions = [] for inf in infractions: + updates = normalize_updates( + inf.get("update") if inf.get("update") is not None else inf.get("updates") + ) + unmute_update = legacy_unmute_update(inf) + + if unmute_update: + updates.append(unmute_update) + + duration = normalize_duration( + first_value( + inf.get("duration"), + inf.get("mute_duration"), + inf.get("mute_length"), + inf.get("mute_time"), + inf.get("duration_seconds"), + inf.get("mute_duration_seconds"), + inf.get("length"), + ) + ) + duration_ms = normalize_duration_milliseconds( + first_value( + inf.get("duration_ms"), + inf.get("mute_duration_ms"), + inf.get("length_ms"), + ) + ) + cleaned = { - "actiontype": first_value(inf.get("actiontype"), inf.get("type"), "unknown"), + "actiontype": normalize_actiontype(first_value(inf.get("actiontype"), inf.get("type"), "unknown")), "reason": first_value(inf.get("reason"), inf.get("mute_reason"), "No reason provided"), "moderator": normalize_moderator(first_value(inf.get("moderator"), inf.get("mute_moderator"), 0)), "actiontime": normalize_time(first_value(inf.get("actiontime"), inf.get("date"))), - "duration": normalize_duration( + "duration": duration if duration is not None else duration_ms, + "attachment_url": normalize_attachment( first_value( - inf.get("duration"), - inf.get("mute_duration"), - inf.get("mute_length"), - inf.get("mute_time"), - inf.get("duration_seconds"), - inf.get("mute_duration_seconds"), - inf.get("length"), - inf.get("time"), + inf.get("attachment_url"), + inf.get("attachment"), + inf.get("attachments"), + inf.get("evidence"), + inf.get("image_url"), ) ), - "attachment_url": first_value(inf.get("attachment_url"), inf.get("attachment")), - "update": inf.get("update") or [], + "update": updates, } cleaned_infractions.append(Infraction(**cleaned)) diff --git a/tests/test_database_handler.py b/tests/test_database_handler.py index 7894e4e..69fb38b 100644 --- a/tests/test_database_handler.py +++ b/tests/test_database_handler.py @@ -1,6 +1,6 @@ import asyncio import copy -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from types import SimpleNamespace from database_handler import BaseDatabase, WordleDatabase, normalize_duration @@ -241,6 +241,13 @@ def test_normalize_duration_accepts_legacy_duration_strings(): assert normalize_duration("1h 15m") == 4500 assert normalize_duration("0:30:00") == 1800 assert normalize_duration("2 days, 3:04:05") == 183845 + assert normalize_duration("0") is None + + +def test_normalize_duration_accepts_large_legacy_millisecond_values(): + assert normalize_duration(3600000) == 3600 + assert normalize_duration(timedelta(minutes=10)) == 600 + assert normalize_duration(timedelta(seconds=0)) is None def test_get_user_infractions_normalizes_legacy_mute_duration_fields(): @@ -268,6 +275,89 @@ def test_get_user_infractions_normalizes_legacy_mute_duration_fields(): assert infractions[0].duration == 2700 +def test_get_user_infractions_does_not_treat_time_as_duration(): + db = make_base_db( + [ + { + "_id": 1, + "user_id": 5, + "infraction_points": 0, + "infractions": [ + { + "type": "mute", + "mute_reason": "timestamp field", + "date": datetime(2024, 2, 3, 10, 15, 0), + "time": 1800, + } + ], + } + ] + ) + + infractions = asyncio.run(db.get_user_infractions(5)) + + assert infractions[0].duration is None + + +def test_get_user_infractions_normalizes_legacy_aliases_attachments_and_unmute_notes(): + db = make_base_db( + [ + { + "_id": 1, + "user_id": 5, + "infraction_points": 0, + "infractions": [ + { + "type": "wm", + "mute_reason": "legacy mute", + "mute_moderator": "1002335003411222638140", + "date": datetime(2024, 4, 12, 12, 8, 0), + "mute_duration_ms": 1800000, + "attachments": [{"url": "https://example.com/evidence.png"}], + "update": { + "moderator": "<@123456789012345678>", + "update": "older context", + "date": "2024-04-12T12:30:00+00:00", + }, + "unmute_moderator": "<@123456789012345678>", + "unmute_reason": "appeal accepted", + "unmute_date": "2024-04-12T13:08:00+00:00", + }, + { + "type": "forceban", + "reason": "legacy ban", + "moderator": "687432678571458912718280", + "date": datetime(2024, 4, 13, 12, 8, 0), + "image_url": "https://example.com/ban.jpg", + }, + ], + } + ] + ) + + infractions = asyncio.run(db.get_user_infractions(5)) + + assert infractions[0].actiontype == "mute" + assert infractions[0].moderator == 1002335003411222638 + assert infractions[0].duration == 1800 + assert infractions[0].attachment_url == "https://example.com/evidence.png" + assert infractions[0].update == [ + { + "moderator": "<@123456789012345678>", + "update": "older context", + "date": "2024-04-12T12:30:00+00:00", + }, + { + "moderator": "<@123456789012345678>", + "update": "Unmuted: appeal accepted", + "date": "2024-04-12T13:08:00+00:00", + } + ] + assert infractions[1].actiontype == "force-ban" + assert infractions[1].moderator == 687432678571458912 + assert infractions[1].attachment_url == "https://example.com/ban.jpg" + + def test_update_infraction_reason_updates_new_and_legacy_reason_fields(): db = make_base_db([ { @@ -306,6 +396,35 @@ def test_add_infraction_note_appends_update_entry(): assert stored["infractions"][0]["update"][0]["date"] == "2024-01-02T03:04:05+00:00" +def test_add_infraction_note_preserves_single_legacy_update_entry(): + db = make_base_db([ + { + "_id": 1, + "user_id": 5, + "infraction_points": 0, + "infractions": [ + { + "actiontype": "warn", + "reason": "old", + "update": { + "moderator": 111, + "update": "existing context", + "date": "2024-01-01T00:00:00+00:00", + }, + } + ], + } + ]) + 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]["update"] == "existing context" + assert stored["infractions"][0]["update"][1]["update"] == "extra context" + + def test_delete_infraction_removes_only_selected_index(): db = make_base_db([ { diff --git a/tests/test_moderation_helpers.py b/tests/test_moderation_helpers.py index 709a661..02c0602 100644 --- a/tests/test_moderation_helpers.py +++ b/tests/test_moderation_helpers.py @@ -1,10 +1,14 @@ import asyncio -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from types import SimpleNamespace from unittest.mock import AsyncMock from cogs.moderation.commands import ModerationCommands from cogs.moderation.infraction import Infraction as InfractionCog +from cogs.moderation.infraction import can_embed_image +from cogs.moderation.infraction import format_action_name +from cogs.moderation.infraction import format_attachment_line +from cogs.moderation.infraction import format_duration from cogs.moderation.infraction import format_infraction_updates from cogs.moderation.infraction import to_snowflake @@ -73,3 +77,15 @@ def test_format_infraction_updates_shows_note_moderator_and_date(): assert "Notes:" in text assert "extra context" in text assert "<@123456789012345678>" in text + + +def test_warning_display_helpers_format_legacy_details(): + assert format_action_name("force-ban") == "Force Ban" + assert format_action_name("note") == "Internal Note" + assert format_duration(timedelta(hours=1, minutes=30)) == "1h 30m" + assert format_duration(0) is None + assert format_attachment_line("https://example.com/evidence.png") == ( + "Evidence: [View attachment](https://example.com/evidence.png)\n" + ) + assert can_embed_image("https://example.com/evidence.png?size=1024") is True + assert can_embed_image("https://example.com/evidence.txt") is False