249 lines
7.3 KiB
TypeScript
249 lines
7.3 KiB
TypeScript
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<Parameters<typeof announcementMessage>[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<PartyAnnouncement | undefined> {
|
|
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<DiscordStatus> {
|
|
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),
|
|
);
|
|
}
|