DUNGEONS-30: add access gateway with Telegram and Discord approvals

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-09-05 17:09:53 +05:00
commit b87a8ef70e
30 changed files with 1838 additions and 0 deletions

73
src/bots/discord.js Normal file
View 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]); }
}