feat: add realtime Discord leaderboards

This commit is contained in:
2026-07-25 00:06:52 +07:00 Unverified
parent 7a9e67e283
commit bb6740fcee
39 changed files with 1917 additions and 119 deletions
+112
View File
@@ -0,0 +1,112 @@
import "server-only";
import { and, eq, gte, inArray, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import { integer, numeric, pgTable, text } from "drizzle-orm/pg-core";
import postgres from "postgres";
import {
buildVoiceLeaderboard,
buildXpLeaderboard,
type VoiceSourceRow,
} from "@/lib/leaderboard/ranking";
const userLevels = pgTable("user_levels", {
userId: text("user_id").notNull(),
username: text("username").notNull(),
displayName: text("display_name"),
xp: numeric("xp", { precision: 40, scale: 0 }).notNull(),
level: integer("level").notNull(),
});
const voiceTime = pgTable("voice_time", {
userId: text("user_id").notNull(),
channelId: text("channel_id").notNull(),
totalSeconds: numeric("total_seconds", { precision: 40, scale: 0 }).notNull(),
});
type LeaderboardClient = ReturnType<typeof postgres>;
const globalForLeaderboard = globalThis as typeof globalThis & {
erikaLeaderboardReadOnlyClient?: LeaderboardClient;
};
function getLeaderboardDatabase() {
const connectionString = process.env.LEADERBOARD_DATABASE_URL;
if (!connectionString) {
throw new Error("LEADERBOARD_DATABASE_URL is not configured.");
}
if (!globalForLeaderboard.erikaLeaderboardReadOnlyClient) {
globalForLeaderboard.erikaLeaderboardReadOnlyClient = postgres(connectionString, {
max: 3,
idle_timeout: 20,
connect_timeout: 5,
connection: {
application_name: "erika-leaderboard",
default_transaction_read_only: true,
statement_timeout: 15_000,
},
});
}
return drizzle(globalForLeaderboard.erikaLeaderboardReadOnlyClient);
}
export async function getXpLeaderboardPage(requestedPage: number) {
const rows = await getLeaderboardDatabase()
.select({
userId: userLevels.userId,
username: userLevels.username,
displayName: userLevels.displayName,
level: userLevels.level,
totalXp: userLevels.xp,
})
.from(userLevels)
.where(gte(userLevels.level, 1));
return buildXpLeaderboard(rows, requestedPage);
}
export async function getVoiceLeaderboardPage(
currentChannelIds: string[],
requestedPage: number,
selectedChannelId?: string,
) {
const scopedChannelIds = selectedChannelId
? [selectedChannelId]
: currentChannelIds;
if (scopedChannelIds.length === 0) {
return buildVoiceLeaderboard([], currentChannelIds, requestedPage, selectedChannelId);
}
const rows: VoiceSourceRow[] = await getLeaderboardDatabase()
.select({
userId: voiceTime.userId,
channelId: voiceTime.channelId,
totalSeconds: voiceTime.totalSeconds,
username: userLevels.username,
displayName: userLevels.displayName,
})
.from(voiceTime)
.leftJoin(userLevels, eq(voiceTime.userId, userLevels.userId))
.where(
and(
inArray(voiceTime.channelId, scopedChannelIds),
gte(voiceTime.totalSeconds, "0"),
),
);
return buildVoiceLeaderboard(
rows,
currentChannelIds,
requestedPage,
selectedChannelId,
);
}
export async function getLeaderboardDatabaseReadOnlyStatus() {
const result = await getLeaderboardDatabase().execute<{
transaction_read_only: string;
}>(sql`show transaction_read_only`);
return result[0]?.transaction_read_only ?? null;
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test";
import { parseCurrentVoiceChannels } from "@/lib/leaderboard/discord";
describe("Discord current voice channels", () => {
test("keeps voice and Stage channels in stable position order", () => {
expect(parseCurrentVoiceChannels([
{ id: "text", name: "general", type: 0, position: 0 },
{ id: "stage", name: "Stage", type: 13, position: 3 },
{ id: "voice-b", name: "Beta", type: 2, position: 2 },
{ id: "voice-a", name: "Alpha", type: 2, position: 2 },
])).toEqual([
{ id: "voice-a", name: "Alpha", type: 2, position: 2 },
{ id: "voice-b", name: "Beta", type: 2, position: 2 },
{ id: "stage", name: "Stage", type: 13, position: 3 },
]);
});
test("rejects malformed top-level data", () => {
expect(() => parseCurrentVoiceChannels({ channels: [] })).toThrow();
});
});
+76
View File
@@ -0,0 +1,76 @@
export type DiscordVoiceChannel = {
id: string;
name: string;
type: 2 | 13;
position: number;
};
type DiscordChannelPayload = {
id?: unknown;
name?: unknown;
type?: unknown;
position?: unknown;
};
const CHANNEL_CACHE_TTL_MS = 5 * 60_000;
const channelCache: {
data: DiscordVoiceChannel[] | null;
expiresAt: number;
} = {
data: null,
expiresAt: 0,
};
export function parseCurrentVoiceChannels(payload: unknown): DiscordVoiceChannel[] {
if (!Array.isArray(payload)) {
throw new Error("Discord returned an invalid channel list.");
}
return payload
.filter((channel): channel is DiscordChannelPayload => {
if (!channel || typeof channel !== "object") return false;
const type = (channel as DiscordChannelPayload).type;
return type === 2 || type === 13;
})
.filter((channel) => (
typeof channel.id === "string"
&& typeof channel.name === "string"
))
.map((channel) => ({
id: channel.id as string,
name: channel.name as string,
type: channel.type as 2 | 13,
position: typeof channel.position === "number" ? channel.position : 0,
}))
.sort((a, b) => a.position - b.position || a.name.localeCompare(b.name, "th"));
}
export async function getCurrentDiscordVoiceChannels() {
if (channelCache.data && Date.now() < channelCache.expiresAt) {
return channelCache.data;
}
const token = process.env.DISCORD_BOT_TOKEN;
const guildId = process.env.DISCORD_GUILD_ID;
if (!token || !guildId) {
throw new Error("Discord channel access is not configured.");
}
const response = await fetch(
`https://discord.com/api/v10/guilds/${guildId}/channels`,
{
headers: { Authorization: `Bot ${token}` },
cache: "no-store",
signal: AbortSignal.timeout(5_000),
},
);
if (!response.ok) {
throw new Error(`Discord channel request failed with status ${response.status}.`);
}
const channels = parseCurrentVoiceChannels(await response.json());
channelCache.data = channels;
channelCache.expiresAt = Date.now() + CHANNEL_CACHE_TTL_MS;
return channels;
}
+150
View File
@@ -0,0 +1,150 @@
import { describe, expect, test } from "bun:test";
import {
buildVoiceLeaderboard,
buildXpLeaderboard,
formatInteger,
formatVoiceDuration,
normalizeRequestedPage,
shortenDiscordId,
type VoiceSourceRow,
type XpSourceRow,
} from "@/lib/leaderboard/ranking";
function xpRow(
userId: string,
totalXp: string,
level = 1,
displayName = userId,
): XpSourceRow {
return {
userId,
username: `${userId}-username`,
displayName,
level,
totalXp,
};
}
function voiceRow(
userId: string,
channelId: string,
totalSeconds: string,
displayName: string | null = userId,
): VoiceSourceRow {
return {
userId,
channelId,
totalSeconds,
displayName,
username: displayName ? `${userId}-username` : null,
};
}
describe("XP leaderboard", () => {
test("filters level zero and assigns shared competition ranks", () => {
const result = buildXpLeaderboard([
xpRow("b", "900", 2, "Beta"),
xpRow("a", "1000", 3, "Alpha"),
xpRow("c", "900", 2, "Charlie"),
xpRow("hidden", "9999", 0),
xpRow("d", "800", 1, "Delta"),
], 1);
expect(result.rows.map(({ userId, rank }) => [userId, rank])).toEqual([
["a", 1],
["b", 2],
["c", 2],
["d", 4],
]);
});
test("returns ten rows and normalizes pages beyond the result", () => {
const rows = Array.from({ length: 101 }, (_, index) =>
xpRow(String(index).padStart(3, "0"), String(1_000 - index)));
const middle = buildXpLeaderboard(rows, 2);
expect(middle.rows).toHaveLength(10);
expect(middle.rows[0]?.rank).toBe(11);
expect(middle.totalPages).toBe(11);
const last = buildXpLeaderboard(rows, 99);
expect(last.page).toBe(11);
expect(last.rows).toHaveLength(1);
});
test("uses stable name then user ID ordering for ties", () => {
const result = buildXpLeaderboard([
xpRow("3", "100", 1, "Zulu"),
xpRow("2", "100", 1, "Alpha"),
xpRow("1", "100", 1, "Alpha"),
], 1);
expect(result.rows.map((row) => row.userId)).toEqual(["1", "2", "3"]);
});
});
describe("voice leaderboard", () => {
const rows = [
voiceRow("a", "voice-1", "100"),
voiceRow("a", "voice-2", "200"),
voiceRow("a", "deleted-channel", "9_999"),
voiceRow("b", "voice-1", "300"),
voiceRow("missing", "voice-2", "60", null),
];
test("aggregates current channels and excludes historical channels", () => {
const result = buildVoiceLeaderboard(rows, ["voice-1", "voice-2"], 1);
expect(result.rows.map(({ userId, totalSeconds, rank }) => ({
userId,
totalSeconds,
rank,
}))).toEqual([
{ userId: "a", totalSeconds: "300", rank: 1 },
{ userId: "b", totalSeconds: "300", rank: 1 },
{ userId: "missing", totalSeconds: "60", rank: 3 },
]);
});
test("filters to a selected current channel", () => {
const result = buildVoiceLeaderboard(
rows,
["voice-1", "voice-2"],
1,
"voice-1",
);
expect(result.rows.map(({ userId, totalSeconds }) => [
userId,
totalSeconds,
])).toEqual([
["b", "300"],
["a", "100"],
]);
});
test("keeps unresolved identities and formats their shortened ID", () => {
const result = buildVoiceLeaderboard(rows, ["voice-2"], 1);
const unresolved = result.rows.find((row) => row.userId === "missing");
expect(unresolved?.displayName).toBeNull();
expect(unresolved?.username).toBeNull();
expect(shortenDiscordId("123456789012345678")).toBe("12345…5678");
});
});
describe("leaderboard formatting", () => {
test("normalizes invalid page values", () => {
expect(normalizeRequestedPage(undefined)).toBe(1);
expect(normalizeRequestedPage("0")).toBe(1);
expect(normalizeRequestedPage("2.5")).toBe(1);
expect(normalizeRequestedPage(["2", "3"])).toBe(1);
expect(normalizeRequestedPage("3")).toBe(3);
});
test("formats large integer and voice duration values", () => {
expect(formatInteger("1234567")).toBe("1,234,567");
expect(formatVoiceDuration("90061")).toBe("1 วัน 1 ชม. 1 นาที");
expect(formatVoiceDuration("30")).toBe("น้อยกว่า 1 นาที");
});
});
+187
View File
@@ -0,0 +1,187 @@
export const LEADERBOARD_PAGE_SIZE = 10;
export type LeaderboardIdentity = {
userId: string;
displayName: string | null;
username: string | null;
};
export type RankedLeaderboardRow = LeaderboardIdentity & {
rank: number;
score: string;
};
export type LeaderboardPage<T extends RankedLeaderboardRow> = {
rows: T[];
podium: T[];
page: number;
pageSize: number;
totalRows: number;
totalPages: number;
};
export type XpSourceRow = LeaderboardIdentity & {
level: number;
totalXp: string;
};
export type XpLeaderboardRow = RankedLeaderboardRow & {
level: number;
totalXp: string;
};
export type VoiceSourceRow = LeaderboardIdentity & {
channelId: string;
totalSeconds: string;
};
export type VoiceLeaderboardRow = RankedLeaderboardRow & {
totalSeconds: string;
};
const nameCollator = new Intl.Collator("th", {
numeric: true,
sensitivity: "base",
});
function asBigInt(value: string) {
try {
return BigInt(value);
} catch {
return BigInt(0);
}
}
export function resolveLeaderboardName(identity: LeaderboardIdentity) {
return identity.displayName?.trim()
|| identity.username?.trim()
|| "ไม่พบชื่อผู้ใช้";
}
function compareIdentities(a: LeaderboardIdentity, b: LeaderboardIdentity) {
const byName = nameCollator.compare(
resolveLeaderboardName(a),
resolveLeaderboardName(b),
);
return byName || a.userId.localeCompare(b.userId);
}
function rankAndPaginate<T extends LeaderboardIdentity & { score: string }>(
sourceRows: T[],
requestedPage: number,
): LeaderboardPage<T & { rank: number }> {
const sorted = [...sourceRows].sort((a, b) => {
const scoreA = asBigInt(a.score);
const scoreB = asBigInt(b.score);
if (scoreA !== scoreB) return scoreA > scoreB ? -1 : 1;
return compareIdentities(a, b);
});
let previousScore: string | undefined;
let sharedRank = 0;
const ranked = sorted.map((row, index) => {
if (previousScore === undefined || asBigInt(row.score) !== asBigInt(previousScore)) {
sharedRank = index + 1;
previousScore = row.score;
}
return { ...row, rank: sharedRank };
});
const totalRows = ranked.length;
const totalPages = Math.max(1, Math.ceil(totalRows / LEADERBOARD_PAGE_SIZE));
const page = Math.min(Math.max(1, requestedPage), totalPages);
const start = (page - 1) * LEADERBOARD_PAGE_SIZE;
return {
rows: ranked.slice(start, start + LEADERBOARD_PAGE_SIZE),
podium: ranked.slice(0, 3),
page,
pageSize: LEADERBOARD_PAGE_SIZE,
totalRows,
totalPages,
};
}
export function buildXpLeaderboard(
rows: XpSourceRow[],
requestedPage: number,
): LeaderboardPage<XpLeaderboardRow> {
return rankAndPaginate(
rows
.filter((row) => row.level >= 1)
.map((row) => ({
...row,
totalXp: row.totalXp,
score: row.totalXp,
})),
requestedPage,
);
}
export function buildVoiceLeaderboard(
rows: VoiceSourceRow[],
currentChannelIds: string[],
requestedPage: number,
selectedChannelId?: string,
): LeaderboardPage<VoiceLeaderboardRow> {
const allowedChannels = new Set(
selectedChannelId ? [selectedChannelId] : currentChannelIds,
);
const totals = new Map<
string,
LeaderboardIdentity & { totalSeconds: bigint }
>();
for (const row of rows) {
if (!allowedChannels.has(row.channelId)) continue;
const existing = totals.get(row.userId);
const totalSeconds = (existing?.totalSeconds ?? BigInt(0))
+ asBigInt(row.totalSeconds);
totals.set(row.userId, {
userId: row.userId,
displayName: existing?.displayName || row.displayName,
username: existing?.username || row.username,
totalSeconds,
});
}
return rankAndPaginate(
Array.from(totals.values(), (row) => ({
...row,
totalSeconds: row.totalSeconds.toString(),
score: row.totalSeconds.toString(),
})),
requestedPage,
);
}
export function normalizeRequestedPage(value: string | string[] | undefined) {
if (typeof value !== "string" || !/^[1-9]\d*$/.test(value)) return 1;
const page = Number(value);
return Number.isSafeInteger(page) ? page : 1;
}
export function shortenDiscordId(userId: string) {
return userId.length > 10
? `${userId.slice(0, 5)}…${userId.slice(-4)}`
: userId;
}
export function formatInteger(value: string) {
return asBigInt(value).toLocaleString("th-TH");
}
export function formatVoiceDuration(value: string) {
const totalSeconds = asBigInt(value);
const days = totalSeconds / BigInt(86_400);
const hours = (totalSeconds % BigInt(86_400)) / BigInt(3_600);
const minutes = (totalSeconds % BigInt(3_600)) / BigInt(60);
const parts = [
days > BigInt(0) ? `${formatInteger(days.toString())} วัน` : null,
hours > BigInt(0) ? `${formatInteger(hours.toString())} ชม.` : null,
minutes > BigInt(0) ? `${formatInteger(minutes.toString())} นาที` : null,
].filter((part): part is string => Boolean(part));
return parts.join(" ") || "น้อยกว่า 1 นาที";
}