import { createHash } from "node:crypto"; import type { DiscordKind } from "../shared/discord"; export function discordConfiguration(env: Record) { 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[] } | { roles: string[]; users: never[]; replied_user: false }; 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.site, 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: [], }; }