356 lines
10 KiB
TypeScript
Executable File
356 lines
10 KiB
TypeScript
Executable File
import { env } from "node:process";
|
|
import { cron, redis } from "bun";
|
|
import { formatDistanceToNow } from "date-fns";
|
|
import { and, asc, eq, gt, isNull, lt, not, sql } from "drizzle-orm";
|
|
import { removeExpiredSubmissions } from "@/app/(ui)/rubgram/api";
|
|
import { auth, issueInternalToken } from "@/lib/auth";
|
|
import { db } from "@/lib/db";
|
|
import {
|
|
artifactSettings,
|
|
cards,
|
|
endgameSettings,
|
|
endgameSubmissions,
|
|
endgameTypes,
|
|
settings,
|
|
slipSync,
|
|
submissions,
|
|
tierlistColumns,
|
|
tierlistTiers,
|
|
user,
|
|
} from "@/lib/db/schema";
|
|
import { rubgramWebhookTemplate } from "./webhook";
|
|
|
|
function logger(group: string) {
|
|
const prefix = `[${group}]`;
|
|
const t = {
|
|
// biome-ignore lint/suspicious/noExplicitAny: logger
|
|
log(...args: any[]) {
|
|
console.log(prefix, ...args);
|
|
},
|
|
// biome-ignore lint/suspicious/noExplicitAny: logger
|
|
error(...args: any[]) {
|
|
console.error(prefix, ...args);
|
|
},
|
|
schedule(target: Date[] | number, callback: () => Promise<void> | void) {
|
|
let timeout: number;
|
|
const now = Date.now();
|
|
// conv sec to milli
|
|
if (typeof target === "number") timeout = target * 1000;
|
|
// find nearest date, poll 60s if no date
|
|
else
|
|
timeout = target.length
|
|
? target
|
|
.map((d) => d.getTime() - now)
|
|
.reduce((a, b) => Math.min(a, b), 3600000)
|
|
: 60000;
|
|
if (typeof target === "number" || target.length)
|
|
console.log(
|
|
prefix,
|
|
`Running ${formatDistanceToNow(new Date(now + timeout), { addSuffix: true })}`,
|
|
);
|
|
return setTimeout(
|
|
() =>
|
|
callback()?.catch((e) => {
|
|
t.error(e);
|
|
t.schedule(300, callback);
|
|
}),
|
|
timeout,
|
|
);
|
|
},
|
|
};
|
|
return t;
|
|
}
|
|
|
|
async function checkMobileSyncExpiration() {
|
|
const { log, schedule } = logger("mobileSync");
|
|
|
|
const count = await db
|
|
.delete(slipSync)
|
|
.where(lt(slipSync.created, sql`NOW() - INTERVAL '1 hour'`))
|
|
.returning({ id: slipSync.trackingKey });
|
|
const removed = count.length;
|
|
|
|
if (removed) log("Purged", removed, "sessions");
|
|
|
|
schedule([], checkMobileSyncExpiration);
|
|
}
|
|
|
|
async function checkRubgramExpiration() {
|
|
const { log, schedule } = logger("rubgramExpiration");
|
|
const { removed } = await removeExpiredSubmissions();
|
|
if (removed) log("Purged", removed, "submissions");
|
|
|
|
const expirable = await db
|
|
.select({ expires: endgameSubmissions.expires })
|
|
.from(endgameSubmissions)
|
|
.where(not(endgameSubmissions.paid));
|
|
schedule(
|
|
expirable.map((e) => e.expires).filter((x): x is Date => !!x),
|
|
checkRubgramExpiration,
|
|
);
|
|
}
|
|
|
|
async function seedDatabase() {
|
|
if (process.env.ENVIRONMENT !== "development") return;
|
|
const { log, error } = logger("seeding");
|
|
try {
|
|
const res = await db.select().from(artifactSettings);
|
|
if (res.length) return;
|
|
} catch {
|
|
// schema isn't there yet
|
|
error("Schema is not applied or not updated, skipping database seeding");
|
|
return;
|
|
}
|
|
log("Setting up database schema");
|
|
const existing = await db
|
|
.select({ id: user.id })
|
|
.from(user)
|
|
.where(eq(user.email, "[email protected]"));
|
|
if (existing) log("Admin account already exist, skipping creation");
|
|
else if (env.INITIAL_ADMIN_PWD) {
|
|
const u = await auth.api.signUpEmail({
|
|
body: {
|
|
name: "Admin",
|
|
email: "[email protected]",
|
|
password: env.INITIAL_ADMIN_PWD,
|
|
},
|
|
});
|
|
await db
|
|
.update(user)
|
|
.set({
|
|
role: "admin",
|
|
})
|
|
.where(eq(user.id, u.user.id));
|
|
log(
|
|
"User [email protected] created successfully with INITIAL_ADMIN_PWD password",
|
|
);
|
|
} else
|
|
return error("INITIAL_ADMIN_PWD not specified, skipping database seeding");
|
|
log("Transaction BEGIN");
|
|
await db.transaction(async (tx) => {
|
|
log("Creating default Global Settings");
|
|
await tx.insert(settings).values({
|
|
id: true,
|
|
enka: true,
|
|
});
|
|
log("Creating default Artifact Settings");
|
|
await tx.insert(artifactSettings).values({
|
|
id: true,
|
|
limit: -1,
|
|
locked: true,
|
|
});
|
|
log("Creating default Endgame Settings");
|
|
await tx.insert(endgameSettings).values({
|
|
id: true,
|
|
limit: -1,
|
|
locked: true,
|
|
allDiscount: 10,
|
|
free: 2,
|
|
});
|
|
await tx.insert(endgameTypes).values([
|
|
{ id: "abyss", display: "Spiral Abyss (อบิส)", price: 60, order: 1 },
|
|
{ id: "theater", display: "โรงละครในจินตนาการ", price: 100, order: 2 },
|
|
{
|
|
id: "stygian",
|
|
display: "Stygian Onslaught (ศึกเดือดแดนเร้นลับ)",
|
|
price: 100,
|
|
order: 3,
|
|
},
|
|
{
|
|
id: "stygian1rm",
|
|
display: "ลง 1 ห้อง (Stygian Onslaught)",
|
|
price: 35,
|
|
order: 4,
|
|
},
|
|
]);
|
|
log("Creating default Tierlist layout");
|
|
await tx.insert(tierlistTiers).values([
|
|
{
|
|
id: "p",
|
|
name: "SS",
|
|
order: 10,
|
|
badges: ["sss", "ssp", "ssm"],
|
|
},
|
|
{
|
|
id: "s",
|
|
name: "S",
|
|
order: 20,
|
|
badges: ["sp", "sm"],
|
|
},
|
|
{
|
|
id: "a",
|
|
name: "A",
|
|
order: 30,
|
|
badges: ["ap", "am"],
|
|
},
|
|
{
|
|
id: "b",
|
|
name: "B",
|
|
order: 40,
|
|
badges: ["bp", "bm"],
|
|
},
|
|
{
|
|
id: "c",
|
|
name: "C",
|
|
order: 50,
|
|
badges: ["cp", "cm"],
|
|
},
|
|
]);
|
|
await tx.insert(tierlistColumns).values([
|
|
{
|
|
id: "nd",
|
|
name: "On-Field DPS",
|
|
order: 10,
|
|
},
|
|
{
|
|
id: "fd",
|
|
name: "Off-Field DPS",
|
|
order: 20,
|
|
},
|
|
{
|
|
id: "s",
|
|
name: "Support",
|
|
order: 30,
|
|
},
|
|
]);
|
|
});
|
|
log("Database seeding complete");
|
|
}
|
|
|
|
async function cacheCards() {
|
|
const { log, error, schedule } = logger("cacheCards");
|
|
const [notCached] = await db
|
|
.select({
|
|
id: submissions.id,
|
|
})
|
|
.from(submissions)
|
|
.leftJoin(cards, eq(cards.submission, submissions.id))
|
|
.where(
|
|
and(
|
|
eq(submissions.checked, false),
|
|
isNull(cards.image),
|
|
lt(sql<number>`coalesce(${cards.tries}, 0)`, 20),
|
|
),
|
|
)
|
|
.orderBy(
|
|
asc(sql`coalesce(${cards.tries}, 0)`),
|
|
asc(submissions.queue),
|
|
asc(submissions.id),
|
|
)
|
|
.limit(1);
|
|
if (!notCached) {
|
|
schedule([], cacheCards);
|
|
return;
|
|
}
|
|
log(`Generating card for ${notCached.id}`);
|
|
const res = await fetch(`http://app:3000/api/card/${notCached.id}`).catch(
|
|
() => ({
|
|
ok: false,
|
|
status: 502,
|
|
text: () => "เกิดข้อผิดพลาดภายในระบบ กำลังพยายามลองใหม่",
|
|
}),
|
|
);
|
|
if (!res.ok) {
|
|
log(`Wasn't okay, queueing for retry.`);
|
|
let text = await res.text();
|
|
error(text.includes("502") ? "502" : text);
|
|
if (text === "The showcase for this UID is private")
|
|
text = "ผู้เล่นนี้ไม่มีโชว์เคส มองไม่เห็นตัวละครใดๆ";
|
|
else if (text === "Character not found in showcase")
|
|
text = "ตัวละครที่ผู้เล่นเลือก ไม่ได้อยู่ในโชว์เคส";
|
|
else if (text === "Invalid UID Provided")
|
|
text = "ผู้เล่นนี้ไม่มีอยู่จริง โดนแบนไปแล้วรีเปล่า";
|
|
else if (text.length > 2000 || res.status === 502)
|
|
text = "ไม่สามารถสร้างการ์ดได้ กำลังพยายามลองใหม่";
|
|
text = text.split("\n")[0];
|
|
await db
|
|
.insert(cards)
|
|
.values({
|
|
submission: notCached.id,
|
|
tries: 1,
|
|
error: text,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: cards.submission,
|
|
set: {
|
|
tries:
|
|
res.status === 400 || text === "ผู้เล่นนี้ไม่มีโชว์เคส มองไม่เห็นตัวละครใดๆ"
|
|
? 21
|
|
: sql<number>`${cards.tries} + 1`,
|
|
error: sql<string>`case when ${gt(cards.tries, 15)}
|
|
then ${text.replace(" กำลังพยายามลองใหม่", "")}
|
|
else ${text}
|
|
end`,
|
|
},
|
|
});
|
|
schedule(60, cacheCards);
|
|
} else schedule(120, cacheCards);
|
|
}
|
|
|
|
cron("0 1 * * *", async function syncAmber() {
|
|
const { log, error } = logger("syncAmber");
|
|
log("Syncing...");
|
|
const token = await issueInternalToken();
|
|
const res = await fetch(`http://app:3000/api/amber/sync`, {
|
|
headers: {
|
|
"X-Internal-Auth": token,
|
|
},
|
|
}).catch(() => ({
|
|
ok: false,
|
|
}));
|
|
if (res.ok) log("Success!");
|
|
else error("Sync failed.");
|
|
});
|
|
|
|
const redisSubscribers: Record<
|
|
string,
|
|
(payload: { data: unknown; event?: string }) => void
|
|
> = {
|
|
"sse:rubgram": async ({ data, event }) => {
|
|
type RubgramEvent = { type: "submit" | "paid" | "cancel"; sub: string };
|
|
if (event !== "update") return;
|
|
const { type, sub } = data as RubgramEvent;
|
|
if (!(type === "submit" || type === "paid")) return;
|
|
if (!process.env.WEBHOOK_RUBGRAM_SUBMIT) return;
|
|
const body = rubgramWebhookTemplate(
|
|
type,
|
|
await db
|
|
.select()
|
|
.from(endgameSubmissions)
|
|
.where(eq(endgameSubmissions.id, sub))
|
|
.limit(1)
|
|
.then((s) => s[0]),
|
|
await db.select().from(endgameTypes),
|
|
);
|
|
const res = await fetch(
|
|
`${process.env.WEBHOOK_RUBGRAM_SUBMIT}?with_components=true`,
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
},
|
|
);
|
|
if (!res.ok)
|
|
console.error(
|
|
"Webhook request wasn't ok.",
|
|
await res.clone().json().catch(res.text.bind(res)),
|
|
);
|
|
},
|
|
};
|
|
|
|
checkRubgramExpiration();
|
|
checkMobileSyncExpiration();
|
|
seedDatabase();
|
|
cacheCards();
|
|
console.log("Tasks assigned");
|
|
for (const [name, handler] of Object.entries(redisSubscribers))
|
|
redis.subscribe(name, (p) => handler(JSON.parse(p)));
|
|
|
|
process
|
|
.addListener("uncaughtException", console.error)
|
|
.addListener("unhandledRejection", console.error);
|
|
|
|
process.on(15 as unknown as "SIGTERM", () => process.exit());
|