Add MineGeo gameplay systems and interface
This commit is contained in:
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: [],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user