import config, { type Project } from "@/config"; import { roblox } from "@/roblox/client"; import type { GetUserResponse, RobloxUserId, UserRestrictionLog, } from "@/roblox/types"; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ContainerBuilder, SectionBuilder, SeparatorBuilder, TextDisplayBuilder, } from "discord.js"; import { text, thumbnail } from "../utils/discord/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 ---------------------------------------------------------------------------------------- // helpers --------------------------------------------------------------------- type EnrichedLog = UserRestrictionLog & { startMs: number; endMs: number; nowActive: boolean; universeId?: string; project?: Project; }; type UserRestrictionLogWithUid = UserRestrictionLog & { universeId?: string; }; function enrichLogs(logs: UserRestrictionLogWithUid[]): EnrichedLog[] { const now = Date.now(); return logs.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 as UserRestrictionLog), startMs, endMs, nowActive, ...(log.universeId ? { universeId: log.universeId } : {}), project: Object.values(config.projects).find( (p) => p.universe === log.universeId, ), }; }); } async function formatRestrictionLog( log: EnrichedLog, opts: { showPrivate?: boolean; includeProjectLabel?: string | null; includeStatus?: boolean; } = {}, ) { const { showPrivate = false, includeProjectLabel = null } = opts; const reason = log.displayReason || "no public reason"; const moderator = await moderatorMention(log.moderator); const startRel = tsRelative(log.createTime); const bannedFor = formatDurationShort(log.duration) === "permanent" ? "permanently" : `for ${formatDurationShort(log.duration)}`; const { endAbsolute, endRelative } = getDurationString({ duration: log.duration, startDate: new Date(log.createTime), }); const endString = endAbsolute === "permanent" ? undefined : `> **Ends:** ${endAbsolute} (${endRelative})`; const lines = [ includeProjectLabel ? `**${includeProjectLabel}**` : undefined, `> Banned **${bannedFor}**, ${startRel}, by **${moderator}**`, `> **Reason:** ${reason}`, showPrivate ? `> **Private Reason:** ${log.privateReason || "no private reason"}` : undefined, endString, ].filter(Boolean); return lines.join("\n"); } function buildLogsContainer( headerTitle: string, lines: string[], shown: number, total: number, ) { const header = `### ${headerTitle} - showing ${shown} of ${total}`; const container = new ContainerBuilder() .addTextDisplayComponents( new TextDisplayBuilder().setContent(`${header}\n\n${lines.join("\n\n")}`), ) .addSeparatorComponents( new SeparatorBuilder().setDivider(false).setSpacing(1), ); return container; } 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 }; } const enriched = enrichLogs(bans); const sorted = enriched.sort((a, b) => b.startMs - a.startMs); const shown = sorted.slice(0, limit); const lines = await Promise.all( shown.map((l) => formatRestrictionLog(l, { showPrivate })), ); const container = buildLogsContainer( "Bans", lines, shown.length, bans.length, ); return { container, total: bans.length }; } export async function getBans(userId: RobloxUserId) { const universeIds = Object.values(config.projects) .map((p) => p.universe) .filter((id): id is string => Boolean(id)); const perUniverseLogs = await Promise.all( universeIds.map(async (universeId) => { const [logs] = await roblox.getUserRestrictionLogs(universeId, userId); const arr = logs ?? []; return arr.map( (log) => ({ ...log, universeId, project: Object.values(config.projects).find( (p) => p.universe === universeId, ), }) as EnrichedLog, ); }), ); return perUniverseLogs; } export async function constructBansSummaryContainer( userId: string, opts: { showPrivate?: boolean } = {}, ) { const universeIds = Object.values(config.projects) .map((p) => p.universe) .filter((id): id is string => Boolean(id)); const perUniverseLogs = await Promise.all( universeIds.map(async (universeId) => { const [logs] = await roblox.getUserRestrictionLogs(universeId, userId); const arr = logs ?? []; return arr.map((log) => ({ ...log, universeId }) as EnrichedLog); }), ); const allLogs = perUniverseLogs.flat(); if (!allLogs || allLogs.length === 0) { const c = new ContainerBuilder().addTextDisplayComponents( new TextDisplayBuilder().setContent("## Recent Bans\n-# No bans found"), ); return { container: c, total: 0 }; } const lastBans = allLogs .sort( (a, b) => new Date(b.createTime).getTime() - new Date(a.createTime).getTime(), ) .slice(0, 3); const projectForUniverse = (universeId: string) => Object.values(config.projects).find((p) => p.universe === universeId); const enriched = enrichLogs(lastBans); const lines = await Promise.all( enriched.map((log) => formatRestrictionLog(log, { showPrivate: opts.showPrivate, includeProjectLabel: projectForUniverse(log.universeId || "")?.displayName || null, includeStatus: true, }), ), ); const container = buildLogsContainer( "Recent Bans", lines, lines.length, allLogs.length, ); return { container, total: allLogs.length }; } // ---- user ------------------------------------------------------------------- export async function constructUserContainer( profile: GetUserResponse, extras?: { bans?: EnrichedLog[] | null; thumbnailUri?: string | null; showPrivate?: boolean; originCommand?: string; }, ) { const lastBans = extras?.bans ?.sort( (a, b) => new Date(b.createTime).getTime() - new Date(a.createTime).getTime(), ) .slice(0, 3); const banSummary = lastBans && lastBans.length > 0 ? `Currently banned in ${[...new Set(lastBans.map((b) => b.project?.displayName))].join(", ")}` : "No recent bans"; 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} - ${profile.id} - created ${createdAtString}`, ].join("\n"), ); const aboutText = text( ["### About", profile.about ? profile.about : "-# No bio provided"].join( "\n", ), ); const banText = text([`-# ${banSummary}`].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) .addTextDisplayComponents(banText); const profileButton = new ButtonBuilder() .setLabel("View Profile") .setStyle(ButtonStyle.Link) .setURL(profileUrl); const bansButton = new ButtonBuilder() .setLabel("View Bans") .setStyle(ButtonStyle.Primary) .setCustomId(`ban-history:summary:${profile.id}`); mainContainer.addActionRowComponents( new ActionRowBuilder().addComponents( profileButton, bansButton, ), ); return { mainContainer, buttons: [profileButton] }; }