Files
MineGeo/lib/discord-announcements.ts

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),
);
}