import { roblox } from "@/roblox/client"; import type { GetUserResponse, RobloxUserId, UserRestrictionLog, } from "@/roblox/types"; import { ContainerBuilder, SectionBuilder, SeparatorBuilder, TextDisplayBuilder, } from "discord.js"; import { text, thumbnail } from "./components"; // -- utils ---------------------------------------------------------------------------------------- // ---- timing ----------------------------------------------------------------- export function tsExact(d: string | Date) { const date = new Date(d); return ``; } export function tsRelative(d: string | Date) { const date = new Date(d); return ``; } export function durationToMs(duration?: string | undefined): number | null { if (!duration) return null; const m = duration.trim().match(/^(-?\d+(?:\.\d+)?)(ms|s|m|h|d)$/i); if (!m) return null; const value = Number.parseFloat(m[1]); const unit = m[2].toLowerCase(); switch (unit) { case "ms": return value; case "s": return value * 1000; case "m": return value * 60 * 1000; case "h": return value * 60 * 60 * 1000; case "d": return value * 24 * 60 * 60 * 1000; default: return null; } } export function formatDurationShort(duration?: string | null) { if (!duration) return "permanent"; const ms = durationToMs(duration); if (ms == null) return "permanent"; if (ms < 1000) return `${ms}ms`; const secs = Math.floor(ms / 1000); if (secs < 60) return `${secs}s`; const mins = Math.floor(secs / 60); if (mins < 60) return `${mins}m`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}h`; const days = Math.floor(hours / 24); return `${days}d`; } export function getDurationString(opts: { duration?: string; startDate?: Date; inclDuration?: boolean; }): { endRelative: string; endAbsolute: string; } { const { duration, startDate } = opts; const perm = { endRelative: "permanent", endAbsolute: "permanent", }; if (!duration) return perm; if (!startDate) return perm; const durationMs = durationToMs(duration); if (!durationMs) return perm; const startMs = startDate.getTime(); const endMs = startMs + durationMs; const endDate = new Date(endMs); return { endAbsolute: tsExact(endDate), endRelative: tsRelative(endDate), }; } // ---- moderators ------------------------------------------------------------- const ModeratorCache = new Map(); export async function moderatorMention( moderator: | { robloxUser: `users/${RobloxUserId}` } | { gameServerScript: unknown }, ) { if ("gameServerScript" in moderator) { return "Banned in-game"; } if ("robloxUser" in moderator) { const id = moderator.robloxUser.split("/").pop(); if (!id) return moderator.robloxUser; let moderatorUsername = ModeratorCache.get(id); if (!moderatorUsername) { const [userRes] = await roblox.getUser(id); if (!userRes) return id; moderatorUsername = userRes.displayName; ModeratorCache.set(id, moderatorUsername); } const url = `https://www.roblox.com/users/${id}/profile`; return `[${moderatorUsername || id}](${url})`; } return "Unknown"; } // -- cards ---------------------------------------------------------------------------------------- // ---- bans ------------------------------------------------------------------- const STATUS_MARKERS: Record = { ACTIVE: "🟥 ACTIVE", SUPERSEDED: "⚠️ SUPERSEDED", EXPIRED: "✅ EXPIRED", }; type Status = "ACTIVE" | "SUPERSEDED" | "EXPIRED" | string; export async function constructBansContainer( bans: UserRestrictionLog[] | null, opts: { limit?: number; showPrivate?: boolean; userId?: string } = {}, ) { const limit = opts.limit ?? 5; const showPrivate = Boolean(opts.showPrivate); if (!bans || bans.length === 0) { const c = new ContainerBuilder().addTextDisplayComponents( new TextDisplayBuilder().setContent( "### Bans\n-# No restriction logs found", ), ); return { container: c, total: 0 }; } type EnrichedLog = UserRestrictionLog & { startMs: number; endMs: number; nowActive: boolean; }; const now = Date.now(); const enriched: EnrichedLog[] = bans.map((log) => { const startMs = new Date(log.startTime).getTime(); const durationMs = durationToMs(log.duration ?? undefined); const endMs = durationMs == null ? Number.POSITIVE_INFINITY : startMs + durationMs; const nowActive = now < endMs; return { ...log, startMs, endMs, nowActive }; }); const sorted = enriched.sort((a, b) => b.startMs - a.startMs); const newerIntervals: { startMs: number; endMs: number }[] = []; const statuses = new Map(); for (const log of sorted) { const isSuperseded = newerIntervals.some( (n) => n.startMs <= log.endMs && n.endMs >= log.startMs, ); if (isSuperseded) statuses.set(log.createTime, "SUPERSEDED"); else if (log.nowActive && log.active) statuses.set(log.createTime, "ACTIVE"); else statuses.set(log.createTime, "EXPIRED"); newerIntervals.push({ startMs: log.startMs, endMs: log.endMs }); } const shown = sorted.slice(0, limit); const lines = shown.map(async (log) => { if (!log) return undefined; const status = (statuses.get(log.createTime) ?? "EXPIRED") as Status; const statusMarker = STATUS_MARKERS[status ?? "EXPIRED"] ?? STATUS_MARKERS.EXPIRED; const reason = log.displayReason || "no public reason"; const moderator = await moderatorMention(log.moderator); const startRel = tsRelative(log.createTime); const { endAbsolute, endRelative } = getDurationString({ duration: log.duration, startDate: new Date(log.createTime), }); return [ `- ${statusMarker} • banned ${startRel} by ${moderator === "Banned in-game" ? "ingame script" : moderator}`, ` - **Reason:** ${reason}`, showPrivate ? ` - **Private Reason:** ${log.privateReason || "none"}` : undefined, ` - **End:** ${endAbsolute} (${endRelative})`, ] .filter(Boolean) .join("\n"); }); const awaitedLines = await Promise.all(lines).then((lines) => lines.filter(Boolean), ); const header = `### Bans - showing ${shown.length} of ${bans.length}`; const container = new ContainerBuilder() .addTextDisplayComponents( new TextDisplayBuilder().setContent( `${header}\n\n${awaitedLines.join("\n\n")}`, ), ) .addSeparatorComponents( new SeparatorBuilder().setDivider(false).setSpacing(1), ); return { container, total: bans.length }; } // ---- user ------------------------------------------------------------------- export async function constructUserContainer( profile: GetUserResponse, extras?: { bans: UserRestrictionLog[] | null; thumbnailUri: string | null; showPrivate?: boolean; }, ) { const createdAt = new Date(profile.createTime); const createdAtMs = Math.round(createdAt.getTime() / 1000); const createdAtString = ` ()`; const profileUrl = `https://www.roblox.com/users/${profile.id}/profile`; const bodyText = text( [ `## ${profile.displayName}`, `[@${profile.name}](${profileUrl}) - ${profile.id} - created ${createdAtString}`, ].join("\n"), ); const aboutText = text( ["### About", profile.about ? profile.about : "-# No bio provided"].join( "\n", ), ); const section = new SectionBuilder().addTextDisplayComponents(bodyText); const icon = thumbnail(extras?.thumbnailUri ?? undefined); if (icon) section.setThumbnailAccessory(icon); const mainContainer = new ContainerBuilder() .addSectionComponents(section) .addSeparatorComponents(new SeparatorBuilder()) .addTextDisplayComponents(aboutText); const bans = await constructBansContainer(extras?.bans ?? null, { limit: 5, userId: profile.id, showPrivate: extras?.showPrivate ?? false, }); return { mainContainer, bans }; }