Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 122 additions & 39 deletions src/cogs/moderation/infraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"(?<!\d)(\d{17,20})(?!\d)", text):
add_snowflake_candidate(candidates, seen, match.group(1))

for match in re.finditer(r"\d{18,}", text):
digits = match.group(0)

for size in range(min(20, len(digits) - 1), 16, -1):
add_snowflake_candidate(candidates, seen, digits[:size])
add_snowflake_candidate(candidates, seen, digits[-size:])

for size in range(min(20, len(digits) - 1), 16, -1):
for start in range(1, len(digits) - size):
add_snowflake_candidate(candidates, seen, digits[start:start + size])

return candidates


def to_snowflake(value):
candidates = snowflake_candidates(value)
return candidates[0] if candidates else None


def format_update_date(value):
Expand Down Expand Up @@ -68,6 +120,65 @@ class Infraction(commands.Cog):
def __init__(self, bot: APBot) -> 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 = {
Expand Down Expand Up @@ -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)

Expand Down
54 changes: 45 additions & 9 deletions src/database_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"(?<!\d)(\d{17,20})(?!\d)"):
match = re.search(pattern, text)

if match:
candidate = int(match.group(1))

if is_possible_snowflake(candidate):
return candidate

for match in re.finditer(r"\d{18,}", text):
digits = match.group(0)

for size in range(min(20, len(digits) - 1), 16, -1):
for candidate_text in (digits[:size], digits[-size:]):
candidate = int(candidate_text)

if is_possible_snowflake(candidate):
return candidate

return None



Expand Down Expand Up @@ -219,16 +261,10 @@ def normalize_moderator(value):
if value is None:
return 0

if hasattr(value, "id"):
return value.id

if isinstance(value, int):
return value
moderator_id = first_snowflake(value)

if isinstance(value, str):
digits = "".join(char for char in value if char.isdigit())
if digits:
return int(digits)
if moderator_id is not None:
return moderator_id

return value

Expand Down
24 changes: 24 additions & 0 deletions tests/test_database_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,30 @@ def test_get_user_infractions_normalizes_old_mute_records():
assert infractions[0].attachment_url == "https://example.com/old.png"
assert infractions[0].actiontime == datetime(2024, 2, 3, 10, 15, 0, tzinfo=timezone.utc)


def test_get_user_infractions_recovers_legacy_moderator_id_with_extra_digits():
db = make_base_db(
[
{
"_id": 1,
"user_id": 5,
"infraction_points": 0,
"infractions": [
{
"type": "warn",
"mute_moderator": "7079852600207606280",
"date": datetime(2024, 2, 3, 10, 15, 0),
}
],
}
]
)

infractions = asyncio.run(db.get_user_infractions(5))

assert infractions[0].moderator == 707985260020760628


def test_update_infraction_reason_updates_new_and_legacy_reason_fields():
db = make_base_db([
{
Expand Down
33 changes: 31 additions & 2 deletions tests/test_moderation_helpers.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import asyncio
from datetime import datetime, 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 format_infraction_updates
from cogs.moderation.infraction import to_snowflake


Expand All @@ -28,8 +31,34 @@ def test_to_snowflake_parses_mentions_and_ints():
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_to_snowflake_recovers_legacy_ids_with_extra_digits():
assert to_snowflake("7079852600207606280") == 707985260020760628
assert to_snowflake("1002335003411222638140") == 1002335003411222638


def test_resolve_moderator_fetches_member_when_not_cached():
moderator = SimpleNamespace(
id=707985260020760628,
mention="<@707985260020760628>",
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():
Expand Down
Loading