292 lines
8.6 KiB
TypeScript
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: [],
|
|
};
|
|
}
|