Add MineGeo gameplay systems and interface
This commit is contained in:
135
lib/api.ts
135
lib/api.ts
@@ -21,7 +21,17 @@ import { moderatorUsersApi } from "./moderator-users-api";
|
||||
import { contributionCutoff, reportSchema } from "../shared/moderation";
|
||||
import { siteSettingsSchema } from "../shared/site-settings";
|
||||
import { getSiteSettings } from "./site-settings";
|
||||
import {
|
||||
canReviewMaps,
|
||||
isAdministrator,
|
||||
type StaffRole,
|
||||
} from "@/shared/permissions";
|
||||
import { processUploadAfterResponse } from "./process-uploads";
|
||||
import {
|
||||
discordAfterResponse,
|
||||
discordStatus,
|
||||
checkDiscordSetup,
|
||||
} from "./discord-announcements";
|
||||
import { needsProcessing } from "./fabric-jobs";
|
||||
import { archiveMetadataSchema, importManifest } from "../shared/map-archive";
|
||||
import {
|
||||
@@ -56,14 +66,19 @@ async function contributor(request: NextRequest) {
|
||||
if (error) throw new Error(error.message);
|
||||
return owner;
|
||||
}
|
||||
async function moderator(request: NextRequest) {
|
||||
async function moderator(request: NextRequest, adminOnly = false) {
|
||||
const owner = await contributor(request);
|
||||
const profile = required(
|
||||
await admin().from("profiles").select("role").eq("id", owner).single(),
|
||||
);
|
||||
if (profile.role !== "moderator")
|
||||
throw new Error("Moderator permission required");
|
||||
return owner;
|
||||
if (!canReviewMaps(profile.role))
|
||||
throw new ServiceError("Moderator permission required", 403);
|
||||
if (adminOnly) requireAdministrator(profile.role);
|
||||
return { id: owner, role: profile.role };
|
||||
}
|
||||
function requireAdministrator(role: StaffRole) {
|
||||
if (!isAdministrator(role))
|
||||
throw new ServiceError("Admin permission required", 403);
|
||||
}
|
||||
async function limit(key: string, count: number, seconds: number) {
|
||||
const allowed = required(
|
||||
@@ -903,10 +918,42 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
}
|
||||
}
|
||||
if (path[0] === "moderation") {
|
||||
const reviewer = await moderator(request);
|
||||
if (path[1] === "users")
|
||||
const { id: reviewer, role } = await moderator(request);
|
||||
if (path[1] === "discord" && path.length === 2) {
|
||||
requireAdministrator(role);
|
||||
if (method === "GET") return NextResponse.json(await discordStatus());
|
||||
if (method === "POST") {
|
||||
const body = z
|
||||
.object({ action: z.enum(["check", "retry"]) })
|
||||
.strict()
|
||||
.parse(await jsonBody(request));
|
||||
if (body.action === "check")
|
||||
return NextResponse.json(await checkDiscordSetup());
|
||||
required(
|
||||
await client.rpc("retry_discord_announcements", {
|
||||
p_reviewer: reviewer,
|
||||
}),
|
||||
);
|
||||
required(
|
||||
await client
|
||||
.from("discord_announcements")
|
||||
.update({ cleanup_lease_until: null })
|
||||
.not("cleanup_error", "is", null)
|
||||
.is("cleanup_run_id", null)
|
||||
.is("deleted_at", null)
|
||||
.select("id"),
|
||||
);
|
||||
discordAfterResponse();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
if (path[1] === "users") {
|
||||
requireAdministrator(role);
|
||||
return await moderatorUsersApi(request, path, reviewer, jsonBody);
|
||||
}
|
||||
if (path[1] === "settings" && path.length === 2 && method === "PATCH") {
|
||||
requireAdministrator(role);
|
||||
const body = siteSettingsSchema.parse(await jsonBody(request));
|
||||
const enabled = required(
|
||||
await client.rpc("set_beta_badge", {
|
||||
@@ -922,6 +969,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
);
|
||||
}
|
||||
if (path[1] === "appeals" && path.length === 3 && method === "POST") {
|
||||
requireAdministrator(role);
|
||||
const body = z
|
||||
.object({
|
||||
decision: z.enum(["approve", "reject"]),
|
||||
@@ -946,6 +994,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
method === "GET" &&
|
||||
[4, 5].includes(path.length)
|
||||
) {
|
||||
requireAdministrator(role);
|
||||
const report = required(
|
||||
await client
|
||||
.from("reports")
|
||||
@@ -975,7 +1024,11 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
path[4],
|
||||
);
|
||||
}
|
||||
if (method === "GET" && path[2] === "panoramas") {
|
||||
if (
|
||||
method === "GET" &&
|
||||
path[2] === "panoramas" &&
|
||||
[3, 4].includes(path.length)
|
||||
) {
|
||||
const d = required(
|
||||
await client
|
||||
.from("datasets")
|
||||
@@ -987,6 +1040,14 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
throw new Error("Map is not awaiting review or published");
|
||||
if (d.parent_dataset_id)
|
||||
throw new Error("Contributions require owner approval first");
|
||||
if (
|
||||
!isAdministrator(role) &&
|
||||
!(
|
||||
d.state === "review" ||
|
||||
(d.state === "published" && d.update_state === "review")
|
||||
)
|
||||
)
|
||||
throw new ServiceError("Map is not awaiting review", 403);
|
||||
const input = required(
|
||||
(d.state === "published" && d.update_state !== "review") ||
|
||||
d.state === "unpublished"
|
||||
@@ -1007,29 +1068,36 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
path[3],
|
||||
);
|
||||
}
|
||||
if (method === "GET") {
|
||||
const maps = required(
|
||||
await client
|
||||
.from("datasets")
|
||||
.select("*")
|
||||
.is("parent_dataset_id", null)
|
||||
.in("state", ["review", "published", "unpublished"])
|
||||
.order("created_at"),
|
||||
);
|
||||
const reports = required(
|
||||
await client
|
||||
.from("reports")
|
||||
.select("*")
|
||||
.eq("state", "open")
|
||||
.order("created_at"),
|
||||
);
|
||||
const appeals = required(
|
||||
await client
|
||||
.from("map_appeals")
|
||||
.select("*")
|
||||
.eq("state", "open")
|
||||
.order("created_at"),
|
||||
);
|
||||
if (method === "GET" && path.length === 1) {
|
||||
let mapQuery = client
|
||||
.from("datasets")
|
||||
.select("*")
|
||||
.is("parent_dataset_id", null)
|
||||
.in("state", ["review", "published", "unpublished"])
|
||||
.order("created_at");
|
||||
if (!isAdministrator(role))
|
||||
mapQuery = mapQuery.or(
|
||||
"state.eq.review,and(state.eq.published,update_state.eq.review)",
|
||||
);
|
||||
const maps = required(await mapQuery);
|
||||
const reports = isAdministrator(role)
|
||||
? required(
|
||||
await client
|
||||
.from("reports")
|
||||
.select("*")
|
||||
.eq("state", "open")
|
||||
.order("created_at"),
|
||||
)
|
||||
: [];
|
||||
const appeals = isAdministrator(role)
|
||||
? required(
|
||||
await client
|
||||
.from("map_appeals")
|
||||
.select("*")
|
||||
.eq("state", "open")
|
||||
.order("created_at"),
|
||||
)
|
||||
: [];
|
||||
const reportViews = await Promise.all(
|
||||
reports.map(async (report) => {
|
||||
const revision = report.revision_id
|
||||
@@ -1058,6 +1126,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
}),
|
||||
);
|
||||
return NextResponse.json({
|
||||
role,
|
||||
maps: await Promise.all(
|
||||
maps.map(async (m) => {
|
||||
const input =
|
||||
@@ -1087,7 +1156,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
appeals,
|
||||
});
|
||||
}
|
||||
if (method === "POST") {
|
||||
if (method === "POST" && path.length === 1) {
|
||||
const b = z
|
||||
.object({
|
||||
datasetId: idSchema,
|
||||
@@ -1095,6 +1164,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
reason: z.string().max(2000).optional(),
|
||||
})
|
||||
.parse(await jsonBody(request));
|
||||
if (b.decision === "unpublish") requireAdministrator(role);
|
||||
if (b.decision === "reject" && !b.reason?.trim())
|
||||
throw new Error("Provide a rejection reason");
|
||||
const { error } = await client.rpc("publish_dataset", {
|
||||
@@ -1104,6 +1174,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
p_reason: b.reason ?? null,
|
||||
});
|
||||
checkServiceError(error);
|
||||
if (b.decision === "approve") discordAfterResponse();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
}
|
||||
@@ -1164,7 +1235,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
return NextResponse.json({ id });
|
||||
}
|
||||
if (path[0] === "reports" && path.length === 2 && method === "PATCH") {
|
||||
const reviewer = await moderator(request);
|
||||
const { id: reviewer } = await moderator(request, true);
|
||||
const b = z
|
||||
.object({
|
||||
decision: z.enum(["confirm", "dismiss"]),
|
||||
|
||||
226
lib/discord-announcements.ts
Normal file
226
lib/discord-announcements.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
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 = await db
|
||||
.from("profiles")
|
||||
.select("display_name")
|
||||
.eq("id", row.data.owner_id)
|
||||
.maybeSingle();
|
||||
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")
|
||||
.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;
|
||||
return {
|
||||
id: room.id,
|
||||
name: map.data.name,
|
||||
owner: host.display_name,
|
||||
difficulty: 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),
|
||||
);
|
||||
}
|
||||
38
lib/discord-cleanup-scheduler.ts
Normal file
38
lib/discord-cleanup-scheduler.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import "server-only";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { start } from "workflow/api";
|
||||
import { admin, required } from "./supabase";
|
||||
import { discordConfiguration } from "./discord-client";
|
||||
import { cleanupClosedDiscordParty } from "./discord-cleanup-workflow";
|
||||
|
||||
export async function scheduleDiscordCleanup(target?: string) {
|
||||
const config = discordConfiguration(process.env);
|
||||
if (!config.token || !config.origin) return;
|
||||
const db = admin(),
|
||||
claim = `starting:${randomUUID()}`;
|
||||
const jobs = required(
|
||||
await db.rpc("claim_discord_cleanup", {
|
||||
p_worker: claim,
|
||||
p_target: target ?? null,
|
||||
}),
|
||||
);
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
const run = await start(cleanupClosedDiscordParty, [job.id, claim]);
|
||||
required(
|
||||
await db
|
||||
.from("discord_announcements")
|
||||
.update({ cleanup_run_id: run.runId, cleanup_lease_until: null })
|
||||
.eq("id", job.id)
|
||||
.eq("cleanup_run_id", claim)
|
||||
.select("id"),
|
||||
);
|
||||
} catch {
|
||||
// An ambiguous start may already have enqueued a run. Keep the short lease;
|
||||
// a replay keeps the persisted grace period and safely repeats deletion.
|
||||
console.error(
|
||||
"Discord cleanup scheduling paused; the saved announcement will retry.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
154
lib/discord-cleanup-workflow.ts
Normal file
154
lib/discord-cleanup-workflow.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
FatalError,
|
||||
RetryableError,
|
||||
getWorkflowMetadata,
|
||||
sleep,
|
||||
} from "workflow";
|
||||
import { admin, required } from "./supabase";
|
||||
import {
|
||||
DiscordClient,
|
||||
DiscordDeliveryError,
|
||||
discordConfiguration,
|
||||
} from "./discord-client";
|
||||
|
||||
export async function cleanupClosedDiscordParty(event: string, claim: string) {
|
||||
"use workflow";
|
||||
const run = getWorkflowMetadata().workflowRunId;
|
||||
try {
|
||||
const deadline = await markClosed(event);
|
||||
if (deadline) {
|
||||
await sleep(new Date(deadline));
|
||||
await deleteClosed(event);
|
||||
}
|
||||
} catch {
|
||||
await releaseFailedCleanup(event, claim, run);
|
||||
throw new Error(
|
||||
"Discord party cleanup failed; the saved announcement can be retried.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function record(event: string) {
|
||||
const result = await admin()
|
||||
.from("discord_announcements")
|
||||
.select("*")
|
||||
.eq("id", event)
|
||||
.maybeSingle();
|
||||
if (result.error) required(result);
|
||||
const job = result.data;
|
||||
return job?.kind === "party" &&
|
||||
job.state === "sent" &&
|
||||
job.closed_at &&
|
||||
!job.deleted_at &&
|
||||
job.channel_id &&
|
||||
job.message_id
|
||||
? job
|
||||
: null;
|
||||
}
|
||||
|
||||
function client() {
|
||||
const config = discordConfiguration(process.env);
|
||||
if (!config.token || !config.origin)
|
||||
throw new FatalError(
|
||||
"Configure the Discord bot token and public site URL.",
|
||||
);
|
||||
return { client: new DiscordClient(config.token), origin: config.origin! };
|
||||
}
|
||||
|
||||
function retry(error: unknown): never {
|
||||
if (error instanceof DiscordDeliveryError) {
|
||||
if (error.permanent) throw new FatalError(error.message);
|
||||
throw new RetryableError(error.message, {
|
||||
retryAfter: error.retryAfter * 1000,
|
||||
});
|
||||
}
|
||||
throw new RetryableError("Discord cleanup could not be confirmed.", {
|
||||
retryAfter: "30s",
|
||||
});
|
||||
}
|
||||
|
||||
async function markClosed(event: string): Promise<string | null> {
|
||||
"use step";
|
||||
try {
|
||||
const job = await record(event);
|
||||
if (!job) return null;
|
||||
if (job.delete_after) return job.delete_after;
|
||||
const setup = client();
|
||||
const exists = await setup.client.closeParty(
|
||||
job.channel_id!,
|
||||
job.message_id!,
|
||||
setup.origin,
|
||||
job.target_id,
|
||||
);
|
||||
if (!exists) {
|
||||
required(
|
||||
await admin()
|
||||
.from("discord_announcements")
|
||||
.update({ deleted_at: new Date().toISOString(), cleanup_error: null })
|
||||
.eq("id", event)
|
||||
.select("id")
|
||||
.single(),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const result = await admin().rpc("mark_discord_party_closed", {
|
||||
p_event: event,
|
||||
});
|
||||
if (result.error) required(result);
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
if (error instanceof FatalError || error instanceof RetryableError)
|
||||
throw error;
|
||||
retry(error);
|
||||
}
|
||||
}
|
||||
markClosed.maxRetries = 10;
|
||||
|
||||
async function deleteClosed(event: string) {
|
||||
"use step";
|
||||
try {
|
||||
const job = await record(event);
|
||||
if (!job) return;
|
||||
if (!job.delete_after)
|
||||
throw new FatalError("The closed notice has not been confirmed.");
|
||||
const deadline = new Date(job.delete_after);
|
||||
if (deadline.getTime() > Date.now())
|
||||
throw new RetryableError("Keep the five-minute grace period.", {
|
||||
retryAfter: deadline,
|
||||
});
|
||||
await client().client.remove(job.channel_id!, job.message_id!);
|
||||
required(
|
||||
await admin()
|
||||
.from("discord_announcements")
|
||||
.update({ deleted_at: new Date().toISOString(), cleanup_error: null })
|
||||
.eq("id", event)
|
||||
.select("id")
|
||||
.single(),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof FatalError || error instanceof RetryableError)
|
||||
throw error;
|
||||
retry(error);
|
||||
}
|
||||
}
|
||||
deleteClosed.maxRetries = 10;
|
||||
|
||||
async function releaseFailedCleanup(event: string, claim: string, run: string) {
|
||||
"use step";
|
||||
required(
|
||||
await admin()
|
||||
.from("discord_announcements")
|
||||
.update({
|
||||
cleanup_run_id: null,
|
||||
cleanup_lease_until: new Date(Date.now() + 300000).toISOString(),
|
||||
cleanup_error:
|
||||
"Party cleanup failed. Check Discord setup and retry saved announcements.",
|
||||
})
|
||||
.eq("id", event)
|
||||
.in("cleanup_run_id", [claim, run])
|
||||
.is("deleted_at", null)
|
||||
.select("id"),
|
||||
);
|
||||
console.error("Discord party cleanup failed", { event, run });
|
||||
}
|
||||
releaseFailedCleanup.maxRetries = 10;
|
||||
291
lib/discord-client.ts
Normal file
291
lib/discord-client.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { DiscordKind } from "../shared/discord";
|
||||
|
||||
export function discordConfiguration(env: Record<string, string | undefined>) {
|
||||
let origin: string | null = null;
|
||||
try {
|
||||
const url = new URL(env.MINEGEO_URL ?? "");
|
||||
if (
|
||||
url.protocol === "https:" &&
|
||||
!url.username &&
|
||||
!url.password &&
|
||||
!url.search &&
|
||||
!url.hash &&
|
||||
url.pathname === "/"
|
||||
)
|
||||
origin = url.origin;
|
||||
} catch {
|
||||
/* An integration needs a public site URL. */
|
||||
}
|
||||
const token = env.DISCORD_BOT_TOKEN?.trim() || null;
|
||||
const channel = (value: string | undefined) =>
|
||||
/^\d{17,20}$/.test(value?.trim() ?? "") ? value!.trim() : null;
|
||||
return {
|
||||
token: token && !/\s/.test(token) ? token : null,
|
||||
origin,
|
||||
mapChannel: channel(env.DISCORD_MAPS_CHANNEL_ID),
|
||||
partyChannel: channel(env.DISCORD_PARTIES_CHANNEL_ID),
|
||||
};
|
||||
}
|
||||
export function discordText(value: string, max = 256) {
|
||||
return (
|
||||
value
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, " ")
|
||||
.replace(/([\\*_~`|[\]<>])/g, "\\$1")
|
||||
.slice(0, max) || "Unknown"
|
||||
);
|
||||
}
|
||||
export type DiscordMessage = {
|
||||
content: string;
|
||||
embeds: {
|
||||
title: string;
|
||||
url: string;
|
||||
description: string;
|
||||
color: number;
|
||||
thumbnail: { url: string };
|
||||
fields: { name: string; value: string; inline: boolean }[];
|
||||
footer: { text: string };
|
||||
}[];
|
||||
allowed_mentions: { parse: never[] };
|
||||
nonce: string;
|
||||
enforce_nonce: true;
|
||||
};
|
||||
export function announcementMessage(
|
||||
event: string,
|
||||
kind: DiscordKind,
|
||||
origin: string,
|
||||
data: {
|
||||
id: string;
|
||||
name: string;
|
||||
owner: string;
|
||||
locations?: number;
|
||||
difficulty?: string;
|
||||
players?: number;
|
||||
limit?: number;
|
||||
},
|
||||
): DiscordMessage {
|
||||
const link = `${origin}/${kind === "map" ? "servers" : "multiplayer"}/${data.id}`;
|
||||
return {
|
||||
content:
|
||||
kind === "map"
|
||||
? "A new MineGeo map is ready to explore!"
|
||||
: "A MineGeo multiplayer party is looking for players!",
|
||||
allowed_mentions: { parse: [] },
|
||||
nonce: createHash("sha256").update(event).digest("hex").slice(0, 24),
|
||||
enforce_nonce: true,
|
||||
embeds: [
|
||||
{
|
||||
title: discordText(data.name),
|
||||
url: link,
|
||||
color: 0x55d6ad,
|
||||
description:
|
||||
kind === "map"
|
||||
? `[Explore this map](${link})`
|
||||
: `[Join this party](${link}) — join while the lobby is open.`,
|
||||
thumbnail: { url: `${origin}/images/minegeo-mark.webp` },
|
||||
fields:
|
||||
kind === "map"
|
||||
? [
|
||||
{
|
||||
name: "Created by",
|
||||
value: discordText(data.owner),
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: "Locations",
|
||||
value: String(data.locations),
|
||||
inline: true,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{ name: "Host", value: discordText(data.owner), inline: true },
|
||||
{
|
||||
name: "Difficulty",
|
||||
value: discordText(data.difficulty ?? "Normal"),
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: "Players",
|
||||
value: `${data.players} / ${data.limit}`,
|
||||
inline: true,
|
||||
},
|
||||
],
|
||||
footer: {
|
||||
text:
|
||||
kind === "map"
|
||||
? "MineGeo · Approved community map"
|
||||
: "MineGeo · Lobby details at the time of sharing",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
export class DiscordDeliveryError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public retryAfter = 60,
|
||||
public permanent = false,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
export class DiscordClient {
|
||||
constructor(
|
||||
private token: string,
|
||||
private request: typeof fetch = fetch,
|
||||
) {}
|
||||
private async api(
|
||||
path: string,
|
||||
body?: object,
|
||||
method = body ? "POST" : "GET",
|
||||
) {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.request(`https://discord.com/api/v10${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bot ${this.token}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "MineGeo (https://minegeo.vercel.app, 1.0)",
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
redirect: "error",
|
||||
});
|
||||
} catch {
|
||||
throw new DiscordDeliveryError(
|
||||
"Discord could not be reached. Delivery will retry.",
|
||||
);
|
||||
}
|
||||
const data = await response.json().catch(() => null);
|
||||
// A manually removed message already satisfies cleanup. Other 404s still fail.
|
||||
if (
|
||||
["PATCH", "DELETE"].includes(method) &&
|
||||
response.status === 404 &&
|
||||
data?.code === 10008
|
||||
)
|
||||
return null;
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
const delay = Number(
|
||||
data?.retry_after ?? response.headers.get("retry-after"),
|
||||
);
|
||||
throw new DiscordDeliveryError(
|
||||
"Discord rate limit. Delivery will retry.",
|
||||
Number.isFinite(delay) && delay > 0
|
||||
? Math.min(Math.ceil(delay), 86400)
|
||||
: 60,
|
||||
);
|
||||
}
|
||||
if (response.status === 401)
|
||||
throw new DiscordDeliveryError(
|
||||
"Discord bot token is invalid. Update it in Vercel.",
|
||||
60,
|
||||
true,
|
||||
);
|
||||
if (response.status === 403)
|
||||
throw new DiscordDeliveryError(
|
||||
"Discord bot needs View Channel, Send Messages, and Embed Links in the configured channel.",
|
||||
60,
|
||||
true,
|
||||
);
|
||||
if (response.status === 404)
|
||||
throw new DiscordDeliveryError(
|
||||
"Discord channel was not found. Check the channel ID and bot access.",
|
||||
60,
|
||||
true,
|
||||
);
|
||||
throw new DiscordDeliveryError(
|
||||
"Discord rejected this notification.",
|
||||
60,
|
||||
response.status < 500,
|
||||
);
|
||||
}
|
||||
if (method === "DELETE" && response.status === 204) return null;
|
||||
if (!data)
|
||||
throw new DiscordDeliveryError("Discord did not confirm delivery.");
|
||||
return data;
|
||||
}
|
||||
async send(channel: string, message: DiscordMessage) {
|
||||
if (!/^\d{17,20}$/.test(channel))
|
||||
throw new DiscordDeliveryError(
|
||||
"Discord channel ID is invalid.",
|
||||
60,
|
||||
true,
|
||||
);
|
||||
const result = await this.api(`/channels/${channel}/messages`, message);
|
||||
if (!/^\d{17,20}$/.test(result.id ?? "") || result.channel_id !== channel)
|
||||
throw new DiscordDeliveryError("Discord did not confirm delivery.");
|
||||
return result.id as string;
|
||||
}
|
||||
private messagePath(channel: string, message: string) {
|
||||
if (![channel, message].every((id) => /^\d{17,20}$/.test(id)))
|
||||
throw new DiscordDeliveryError(
|
||||
"Discord message identity is invalid.",
|
||||
60,
|
||||
true,
|
||||
);
|
||||
return `/channels/${channel}/messages/${message}`;
|
||||
}
|
||||
async closeParty(
|
||||
channel: string,
|
||||
message: string,
|
||||
origin: string,
|
||||
room: string,
|
||||
) {
|
||||
const result = await this.api(
|
||||
this.messagePath(channel, message),
|
||||
closedPartyMessage(origin, room),
|
||||
"PATCH",
|
||||
);
|
||||
if (result === null) return false;
|
||||
if (result.id !== message || result.channel_id !== channel)
|
||||
throw new DiscordDeliveryError(
|
||||
"Discord did not confirm the party update.",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
async remove(channel: string, message: string) {
|
||||
await this.api(this.messagePath(channel, message), undefined, "DELETE");
|
||||
}
|
||||
async check(channels: string[]) {
|
||||
const bot = await this.api("/users/@me");
|
||||
if (!bot.bot)
|
||||
throw new DiscordDeliveryError("Use a Discord bot token.", 60, true);
|
||||
for (const channel of new Set(channels)) {
|
||||
if (!/^\d{17,20}$/.test(channel))
|
||||
throw new DiscordDeliveryError(
|
||||
"Discord channel ID is invalid.",
|
||||
60,
|
||||
true,
|
||||
);
|
||||
const found = await this.api(`/channels/${channel}`);
|
||||
if (!found.guild_id || ![0, 5].includes(found.type))
|
||||
throw new DiscordDeliveryError(
|
||||
"Choose a Discord server text or announcement channel.",
|
||||
60,
|
||||
true,
|
||||
);
|
||||
}
|
||||
return { botName: discordText(bot.username) };
|
||||
}
|
||||
}
|
||||
|
||||
export function closedPartyMessage(origin: string, room: string) {
|
||||
return {
|
||||
content: "🔴 This MineGeo multiplayer party is closed.",
|
||||
allowed_mentions: { parse: [] },
|
||||
embeds: [
|
||||
{
|
||||
title: "Party closed",
|
||||
url: `${origin}/multiplayer/${room}`,
|
||||
color: 0xed4245,
|
||||
description:
|
||||
"This party is no longer accepting players. This message will be removed in five minutes.",
|
||||
fields: [{ name: "Status", value: "Closed", inline: true }],
|
||||
footer: { text: "MineGeo · Party closed" },
|
||||
},
|
||||
],
|
||||
components: [],
|
||||
};
|
||||
}
|
||||
67
lib/discord-delivery.ts
Normal file
67
lib/discord-delivery.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
announcementMessage,
|
||||
DiscordClient,
|
||||
DiscordDeliveryError,
|
||||
} from "./discord-client";
|
||||
import type { DiscordAnnouncement } from "../shared/discord";
|
||||
type Source = Parameters<typeof announcementMessage>[3];
|
||||
export async function deliverDiscordAnnouncement(
|
||||
job: DiscordAnnouncement,
|
||||
dependencies: {
|
||||
origin: string;
|
||||
channel: string;
|
||||
client: Pick<DiscordClient, "send">;
|
||||
load: () => Promise<Source | null>;
|
||||
finish: (patch: Partial<DiscordAnnouncement>) => Promise<void>;
|
||||
now?: () => number;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
origin,
|
||||
channel,
|
||||
client,
|
||||
load,
|
||||
finish,
|
||||
now = Date.now,
|
||||
} = dependencies;
|
||||
const released = { lease_owner: null, lease_until: null };
|
||||
try {
|
||||
const source = await load();
|
||||
if (!source) {
|
||||
await finish({ ...released, state: "skipped", last_error: null });
|
||||
return;
|
||||
}
|
||||
const messageId = await client.send(
|
||||
channel,
|
||||
announcementMessage(job.id, job.kind, origin, source),
|
||||
);
|
||||
await finish({
|
||||
...released,
|
||||
state: "sent",
|
||||
channel_id: channel,
|
||||
message_id: messageId,
|
||||
sent_at: new Date(now()).toISOString(),
|
||||
last_error: null,
|
||||
});
|
||||
} catch (failure) {
|
||||
const error =
|
||||
failure instanceof DiscordDeliveryError
|
||||
? failure
|
||||
: new DiscordDeliveryError(
|
||||
"Discord delivery could not be confirmed. Delivery will retry.",
|
||||
);
|
||||
await finish({
|
||||
...released,
|
||||
state: error.permanent || job.attempts >= 8 ? "failed" : "pending",
|
||||
last_error: error.message,
|
||||
next_attempt_at: new Date(
|
||||
now() +
|
||||
Math.max(
|
||||
error.retryAfter,
|
||||
Math.min(3600, 30 * 2 ** (job.attempts - 1)),
|
||||
) *
|
||||
1000,
|
||||
).toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,12 @@ import { multiplayerAppearance } from "./multiplayer-appearance";
|
||||
import { mediaUrls } from "./catalog";
|
||||
import { requestOrigin } from "./request-origin";
|
||||
import { ServiceError } from "./service-error";
|
||||
import { scheduleDiscordCleanup } from "./discord-cleanup-scheduler";
|
||||
import {
|
||||
discordPartyReady,
|
||||
discordAfterResponse,
|
||||
partyAnnouncement,
|
||||
} from "./discord-announcements";
|
||||
import {
|
||||
createRoomSchema,
|
||||
joinRoomSchema,
|
||||
@@ -83,6 +89,35 @@ export async function multiplayerApi(
|
||||
}
|
||||
const id = z.string().uuid().parse(path[1]);
|
||||
const action = path[2] ?? "view";
|
||||
if (action === "discord" && path.length === 3 && request.method === "POST") {
|
||||
const view = required(
|
||||
await client.rpc("multiplayer_action", {
|
||||
p_room: id,
|
||||
p_access_hash: accessHash,
|
||||
p_action: "view",
|
||||
p_payload: {},
|
||||
}),
|
||||
) as unknown as RoomView;
|
||||
if (!savedSecret || !view.you || view.you !== view.hostId)
|
||||
throw new ServiceError("Only the room owner can publish this party", 403);
|
||||
z.object({})
|
||||
.strict()
|
||||
.parse(await jsonBody(request));
|
||||
if (!discordPartyReady())
|
||||
throw new ServiceError(
|
||||
"Discord party sharing is not configured yet",
|
||||
503,
|
||||
);
|
||||
await limit("multiplayer:discord:" + accessHash, 5, 3600);
|
||||
required(
|
||||
await client.rpc("publish_discord_party", {
|
||||
p_room: id,
|
||||
p_access_hash: accessHash,
|
||||
}),
|
||||
);
|
||||
discordAfterResponse(id);
|
||||
return respond({ ok: true });
|
||||
}
|
||||
if (
|
||||
action === "chat" &&
|
||||
path.length === 3 &&
|
||||
@@ -144,6 +179,13 @@ export async function multiplayerApi(
|
||||
p_payload: payload,
|
||||
}),
|
||||
) as unknown as RoomView;
|
||||
if (room.status === "closed") {
|
||||
try {
|
||||
await scheduleDiscordCleanup(room.id);
|
||||
} catch {
|
||||
console.error("Closed Discord party remains queued for cleanup.");
|
||||
}
|
||||
}
|
||||
if (action === "join" && room.you) {
|
||||
const account = await user();
|
||||
if (account) {
|
||||
@@ -170,7 +212,10 @@ export async function multiplayerApi(
|
||||
...appearances.get(player.id),
|
||||
}));
|
||||
}
|
||||
if (action !== "media") return respond(room);
|
||||
if (action !== "media") {
|
||||
room.discord = await partyAnnouncement(room);
|
||||
return respond(room);
|
||||
}
|
||||
if (!room.you) throw new ServiceError("Join this room to play", 403);
|
||||
if (["lobby", "closed"].includes(room.status))
|
||||
throw new ServiceError("This round is not available yet", 409);
|
||||
|
||||
Reference in New Issue
Block a user