DUNGEONS-30: add access gateway with Telegram and Discord approvals
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
73
src/bots/discord.js
Normal file
73
src/bots/discord.js
Normal file
@@ -0,0 +1,73 @@
|
||||
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]); }
|
||||
}
|
||||
Reference in New Issue
Block a user