Add MineGeo gameplay systems and interface

This commit is contained in:
2026-10-10 10:27:47 +05:00
parent bf01d9801d
commit 9af71c85c2
47 changed files with 9954 additions and 93 deletions

View File

@@ -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"]),

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

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

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

View File

@@ -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);