import { createPublicKey, verify } from 'node:crypto'; import { botFetch } from './api.js'; export class DiscordBot { constructor(config, { fetchImpl = fetch, now = Date.now, log = console } = {}) { this.config = config; this.fetchImpl = fetchImpl; this.now = now; this.log = log; this.jobs = new Set(); this.seen = new Map(); this.key = createPublicKey({ key: Buffer.from(`302a300506032b6570032100${config.discord.publicKey}`, 'hex'), format: 'der', type: 'spki' }); } api(path, body, method = 'POST', authenticate = true) { return botFetch(`https://discord.com/api/v10${path}`, { method, headers: { 'content-type': 'application/json', ...(authenticate ? { authorization: `Bot ${this.config.discord.token}` } : {}) }, body: JSON.stringify(body), }, { fetchImpl: this.fetchImpl }); } async sendUser(userId, content, buttons = []) { const channel = await this.api('/users/@me/channels', { recipient_id: userId }); return this.api(`/channels/${channel.id}/messages`, { content, allowed_mentions: { parse: [] }, ...(buttons.length ? { components: [{ type: 1, components: buttons.map(b => ({ type: 2, style: b.id.startsWith('deny:') ? 4 : 3, label: b.label, custom_id: b.id })) }] } : {}), }); } validSignature(headers, raw) { const signature = headers['x-signature-ed25519']; const timestamp = headers['x-signature-timestamp']; if (typeof signature !== 'string' || !/^[a-f\d]{128}$/i.test(signature) || typeof timestamp !== 'string' || !/^\d{1,12}$/.test(timestamp)) return false; if (Math.abs(this.now() - Number(timestamp) * 1000) > 300_000) return false; try { return verify(null, Buffer.concat([Buffer.from(timestamp), raw]), this.key, Buffer.from(signature, 'hex')); } catch { return false; } } respond(interaction, service) { if (interaction.type === 1) return { type: 1 }; if (interaction.application_id !== this.config.discord.applicationId) return { type: 4, data: { content: 'Unknown application.', flags: 64 } }; if (interaction.type === 2 && interaction.data?.name === 'access') { return { type: 4, data: { content: 'Dungeons & Ground ยท Private alpha\nAn administrator will review your Discord ID and profile details. Enable DMs to receive your number after approval.', flags: 64, components: [{ type: 1, components: [{ type: 2, style: 3, label: 'Get access', custom_id: 'request' }] }] } }; } const user = interaction.member?.user || interaction.user; const action = interaction.data?.custom_id; if (interaction.type !== 3 || !user?.id || (action !== 'request' && !/^(approve|deny):[a-f\d-]{36}$/.test(action || ''))) { return { type: 4, data: { content: 'Use /access to request an access number.', flags: 64 } }; } for (const [id, expires] of this.seen) if (expires <= this.now()) this.seen.delete(id); if (!this.seen.has(interaction.id)) { this.seen.set(interaction.id, this.now() + 300_000); // Defer the interaction immediately; bot API calls can take longer than 3 seconds. const job = Promise.resolve().then(async () => { let content; try { if (action === 'request') { content = await service.request({ platform: 'discord', user_id: user.id, username: user.username, display_name: user.global_name || user.username, language_code: interaction.locale, guild_id: interaction.guild_id }); } else { const [decision, requestId] = action.split(':'); content = await service.decide('discord', user.id, requestId, decision); } } catch (error) { content = error.message; } await this.api(`/webhooks/${this.config.discord.applicationId}/${interaction.token}/messages/@original`, { content, allowed_mentions: { parse: [] }, components: [] }, 'PATCH', false); }).catch(() => this.log.error('Discord reply failed; the request or decision remains saved.')).finally(() => this.jobs.delete(job)); this.jobs.add(job); } return { type: 5, data: { flags: 64 } }; } async drain() { await Promise.allSettled([...this.jobs]); } }