import "server-only"; import { randomUUID } from "node:crypto"; import { after } from "next/server"; import { admin, required } from "./supabase"; import { announcementMessage, discordConfiguration, DiscordClient, } from "./discord-client"; import { deliverDiscordAnnouncement } from "./discord-delivery"; import { scheduleDiscordCleanup } from "./discord-cleanup-scheduler"; import type { DiscordAnnouncement, DiscordKind, DiscordStatus, PartyAnnouncement, } from "@/shared/discord"; import type { RoomView } from "@/shared/multiplayer"; const configuration = () => discordConfiguration(process.env); export function discordPartyReady() { const config = configuration(); return !!(config.token && config.origin && config.partyChannel); } async function announcementSource( job: DiscordAnnouncement, ): Promise[3] | null> { const db = admin(); if (job.kind === "map") { const row = await db .from("datasets") .select("id,name,owner_id,panorama_count") .eq("id", job.target_id) .eq("state", "published") .is("parent_dataset_id", null) .is("imported_from_id", null) .maybeSingle(); if (row.error) required(row); if (!row.data) return null; const owner = row.data.owner_id ? await db .from("profiles") .select("display_name") .eq("id", row.data.owner_id) .maybeSingle() : { data: null, error: null }; if (owner.error) required(owner); return { id: row.data.id, name: row.data.name, owner: owner.data?.display_name ?? "Explorer", locations: row.data.panorama_count, }; } const found = await db .from("multiplayer_rooms") .select("*") .eq("id", job.target_id) .maybeSingle(); if (found.error) required(found); const room = found.data; if ( !room || room.status !== "lobby" || Date.parse(room.created_at) < Date.now() - 86400000 || Date.parse(job.created_at) < Date.now() - 3600000 ) return null; const revision = required( await db .from("revisions") .select("dataset_id") .eq("id", room.revision_id) .single(), ); const map = await db .from("datasets") .select("name") .eq("id", revision.dataset_id) .eq("state", "published") .is("parent_dataset_id", null) .is("imported_from_id", null) .maybeSingle(); if (map.error) required(map); if (!map.data) return null; const players = required( await db .from("multiplayer_players") .select("id,display_name,account_id") .eq("room_id", room.id) .eq("has_left", false), ); const host = players.find((p) => p.id === room.host_player_id); if (!host || players.length >= room.player_limit) return null; let hostName = host.display_name; if (host.account_id) { const profile = required( await db .from("profiles") .select("display_name") .eq("id", host.account_id) .single(), ); hostName = profile.display_name; } const difficultySettings = room.difficulty_settings && typeof room.difficulty_settings === "object" && !Array.isArray(room.difficulty_settings) ? room.difficulty_settings : null; return { id: room.id, name: map.data.name, owner: hostName, difficulty: typeof difficultySettings?.name === "string" ? difficultySettings.name : room.difficulty, players: players.length, limit: room.player_limit, }; } export async function dispatchDiscordAnnouncements(target?: string) { const config = configuration(); if (!config.token || !config.origin) return; await scheduleDiscordCleanup(target); const kinds: DiscordKind[] = []; if (config.mapChannel) kinds.push("map"); if (config.partyChannel) kinds.push("party"); if (!kinds.length) return; const db = admin(), worker = randomUUID(); const jobs = required( await db.rpc("claim_discord_announcements", { p_worker: worker, p_kinds: kinds, p_target: target ?? null, }), ); const client = new DiscordClient(config.token); for (const job of jobs) { await deliverDiscordAnnouncement(job, { origin: config.origin, channel: job.kind === "map" ? config.mapChannel! : config.partyChannel!, client, load: () => announcementSource(job), finish: async (patch) => { required( await db .from("discord_announcements") .update(patch) .eq("id", job.id) .eq("lease_owner", worker) .select("id") .single(), ); }, }); } await scheduleDiscordCleanup(target); } export function discordAfterResponse(target?: string) { const c = configuration(); if (!c.token || !c.origin || (!c.mapChannel && !c.partyChannel)) return; after(async () => { try { await dispatchDiscordAnnouncements(target); } catch { console.error( "Discord delivery paused; saved announcements remain available for retry.", ); } }); } export async function partyAnnouncement( room: RoomView, ): Promise { if (!room.you || room.you !== room.hostId) return undefined; const db = admin(); const result = await db .from("discord_announcements") .select("state,next_attempt_at,lease_until") .eq("kind", "party") .eq("target_id", room.id) .maybeSingle(); if (result.error) required(result); const map = await db .from("datasets") .select("state,imported_from_id,parent_dataset_id") .eq("id", room.datasetId) .maybeSingle(); if (map.error) required(map); const publicMap = map.data?.state === "published" && !map.data.imported_from_id && !map.data.parent_dataset_id; if ( result.data && ["pending", "sending"].includes(result.data.state) && Date.parse(result.data.next_attempt_at) <= Date.now() && (!result.data.lease_until || Date.parse(result.data.lease_until) <= Date.now()) ) discordAfterResponse(room.id); return { available: discordPartyReady() && !!publicMap, state: result.data?.state ?? null, }; } export async function discordStatus(): Promise { const c = configuration(); const count = async (states: DiscordAnnouncement["state"][]) => { let query = admin() .from("discord_announcements") .select("id", { count: "exact", head: true }); query = states.includes("failed") ? query.or("state.eq.failed,cleanup_error.not.is.null") : query.in("state", states); const r = await query; if (r.error) required(r); return r.count ?? 0; }; const [pending, failed, sent] = await Promise.all([ count(["pending", "sending"]), count(["failed"]), count(["sent"]), ]); discordAfterResponse(); return { botConfigured: !!c.token, publicUrlConfigured: !!c.origin, mapsConfigured: !!c.mapChannel, partiesConfigured: !!c.partyChannel, pending, failed, sent, }; } export async function checkDiscordSetup() { const c = configuration(); if (!c.token || !c.origin || (!c.mapChannel && !c.partyChannel)) throw new Error( "Configure the Discord bot token, public site URL, and at least one channel ID.", ); return new DiscordClient(c.token).check( [c.mapChannel, c.partyChannel].filter((v): v is string => !!v), ); }