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

71
src/bots/service.js Normal file
View File

@@ -0,0 +1,71 @@
const clean = value => String(value ?? '—').replace(/[\r\n\u0000-\u001f\u007f]/g, ' ').slice(0, 160);
export class AccessService {
constructor(config, store, providers) {
this.config = config;
this.store = store;
this.providers = providers;
this.pending = new Map();
}
request(identity) {
const key = `${identity.platform}:${identity.user_id}`;
if (this.pending.has(key)) return this.pending.get(key);
const task = this.createRequest(identity).finally(() => this.pending.delete(key));
this.pending.set(key, task);
return task;
}
async createRequest(identity) {
const provider = this.providers[identity.platform];
if (!provider) return 'This access provider is unavailable.';
const { request, fresh } = this.store.request(identity);
if (request.status === 'approved') return `Your existing number is reusable until ${new Date(request.codeExpiresAt).toISOString()}. Check your DMs. If you lost it, contact an administrator.`;
if (request.status === 'delivering') return 'An administrator is sending your number. Check your DMs shortly.';
// Confirm DMs work before asking admins to approve the request.
await provider.sendUser(identity.user_id, fresh
? 'Your Dungeons & Ground access request has been recorded. An administrator will review it. Your number will arrive here after approval.'
: 'Your request is still pending. We are checking that an administrator has received it.');
const lines = ['Dungeons & Ground · Access request', `platform: ${request.platform}`, `user_id: ${request.user_id}`, `request_id: ${request.id}`];
for (const field of this.config.adminFields) {
lines.push(`${field}: ${clean(field === 'created_at' ? new Date(request.createdAt).toISOString() : request[field])}`);
}
const buttons = [{ label: 'Approve', id: `approve:${request.id}` }, { label: 'Deny', id: `deny:${request.id}` }];
for (const adminId of this.config[identity.platform].admins) {
if (this.store.getRequest(request.id).notifiedAdmins.includes(adminId)) continue;
try {
await provider.sendUser(adminId, lines.join('\n'), buttons);
this.store.updateRequest(request.id, r => r.notifiedAdmins.push(adminId));
} catch { /* Keep undelivered admins retryable when the user presses Get access again. */ }
}
if (!this.store.getRequest(request.id).notifiedAdmins.length) return 'Your request is saved, but we could not contact an administrator. Please try Get access again later.';
return 'Request sent. You will receive your access number in a DM after an administrator approves it.';
}
async decide(platform, adminId, requestId, decision) {
if (!this.config[platform]?.admins.includes(adminId)) return 'Only configured administrators can review access requests.';
const request = this.store.getRequest(requestId);
if (!request || request.platform !== platform) return 'Request not found.';
if (request.status !== 'pending') return `This request is already ${request.status}.`;
if (request.expiresAt <= this.store.now()) return 'This request has expired. The user needs to request access again.';
const provider = this.providers[platform];
if (decision === 'deny') {
this.store.updateRequest(requestId, r => { r.status = 'denied'; r.adminId = adminId; });
try { await provider.sendUser(request.user_id, 'Your Dungeons & Ground access request was declined. Contact an administrator if you have questions.'); }
catch { return 'Request denied. The user could not be reached by DM.'; }
return 'Request denied. The user has been notified.';
}
if (decision !== 'approve') return 'Unknown action.';
const code = this.store.reserveCode(requestId, adminId);
if (!code) return 'This request is already being reviewed.';
const expiresAt = this.store.getRequest(requestId).codeExpiresAt;
try {
await provider.sendUser(request.user_id, `Access approved!\n\nYour number: ${code}\n\nEnter it at ${this.config.publicUrl.origin}\nIt is reusable until ${new Date(expiresAt).toISOString()} (UTC). Keep it private. After it expires, use Get access to request a new number.`);
} catch {
this.store.updateRequest(requestId, r => { r.status = 'pending'; delete r.codeHash; delete r.codeExpiresAt; delete r.adminId; });
return 'Could not deliver the number. Ask the user to enable DMs or /start the bot, then press Approve again. No number was activated.';
}
this.store.updateRequest(requestId, r => { r.status = 'approved'; r.approvedAt = this.store.now(); });
return 'Approved. The access number was sent privately to the user.';
}
}