Files
MineGeo/lib/discord-client.ts

292 lines
8.6 KiB
TypeScript

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: [],
};
}