Configure Discord IDs, risk thresholds, and operating modes.
Normal: auto-approve qualifying members. Elevated: stricter account age. Lockdown: all manual review.
Staff are alerted in the audit log channel when a spike is detected. Reconciliation catch-up joins are not counted.
New members who join after this timestamp will automatically receive a verification DM. A scheduled task runs every 5 minutes to detect them.
v2.0.0 — Event-Driven Architecture. This version calls processDiscordMemberJoin on every join, runs reconciliation every 30 min as a safety net, and exposes a health endpoint for the Base44 dashboard.
The canonical source file is at src/droplet/gateway-bot.js in your app repository.
/* global process */
// Collectors' Keep — Onboarding Gateway Bot v2.0.0
// Event-driven Discord Gateway bot for member onboarding.
// See src/droplet/gateway-bot.js in the app repository for the canonical source.
require('dotenv').config();
const express = require('express');
const { Client, GatewayIntentBits, Partials, InteractionType, GatewayDispatchEvents } = require('discord.js');
const GATEWAY_VERSION = process.env.GATEWAY_VERSION || '2.0.0';
const GATEWAY_INSTANCE_ID = process.env.GATEWAY_INSTANCE_ID || 'gateway-' + Date.now();
const BOT_TOKEN = process.env.ONBOARDING_BOT_TOKEN;
const BOT_API_SECRET = process.env.BOT_API_SECRET;
const BASE44_FUNCTIONS_BASE = process.env.BASE44_FUNCTIONS_BASE_URL;
const GUILD_ID = process.env.DISCORD_GUILD_ID;
const RECONCILIATION_INTERVAL_MS = (parseInt(process.env.RECONCILIATION_INTERVAL_MINUTES || '30', 10)) * 60 * 1000;
const PORT = parseInt(process.env.PORT || '3002', 10);
const RECONCILE_BATCH_SIZE = parseInt(process.env.RECONCILE_BATCH_SIZE || '100', 10);
if (!BOT_TOKEN || !BOT_API_SECRET || !BASE44_FUNCTIONS_BASE || !GUILD_ID) {
console.error('[Gateway] Missing required env vars. Check .env file.');
process.exit(1);
}
const state = {
startedAt: null, discordConnected: false, discordWsPing: null,
guildAvailable: false, botUserId: null, lastGatewayEventAt: null,
lastMemberJoinEventAt: null, lastSuccessfulBase44CallAt: null,
lastReconciliationAt: null, lastReconnectAt: null,
reconciliationRunning: false, lastReconciliationSummary: null,
};
const DISCORD_EPOCH = 1420070400000;
function getDiscordAccountCreatedAt(snowflakeId) {
try { const id = BigInt(snowflakeId); return new Date(Number((id >> 22n) + BigInt(DISCORD_EPOCH))).toISOString(); }
catch { return null; }
}
function getDiscordAccountAgeDays(snowflakeId) {
try { const id = BigInt(snowflakeId); return Math.floor((Date.now() - Number((id >> 22n) + BigInt(DISCORD_EPOCH))) / 86400000); }
catch { return -1; }
}
async function callBase44(functionName, body) {
const url = BASE44_FUNCTIONS_BASE.replace(/\/$/, '') + '/' + functionName;
const res = await fetch(url, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + BOT_API_SECRET, 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body, gateway_version: GATEWAY_VERSION, gateway_instance_id: GATEWAY_INSTANCE_ID }),
});
if (!res.ok) { const t = await res.text().catch(() => ''); throw new Error('Base44 ' + functionName + ' returned ' + res.status + ': ' + t.substring(0, 200)); }
state.lastSuccessfulBase44CallAt = new Date().toISOString();
return res.json();
}
function buildMemberSnapshot(member) {
const userId = member.user?.id || member.id;
return {
discord_user_id: userId,
username: member.user?.username || '',
global_display_name: member.user?.globalName || member.user?.global_name || member.user?.username || '',
nickname: member.nickname || '',
avatar_present: Boolean(member.user?.avatar || member.avatar),
joined_at: member.joinedAt ? member.joinedAt.toISOString() : null,
guild_id: member.guild?.id || GUILD_ID,
roles: member.roles?.cache ? Array.from(member.roles.cache.keys()) : [],
account_created_at: getDiscordAccountCreatedAt(userId),
account_age_days: getDiscordAccountAgeDays(userId),
};
}
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMessages, GatewayIntentBits.DirectMessages],
partials: [Partials.GuildMember, Partials.Channel, Partials.Message],
});
// guildMemberAdd -> processDiscordMemberJoin
client.on('guildMemberAdd', async (member) => {
state.lastGatewayEventAt = new Date().toISOString();
state.lastMemberJoinEventAt = new Date().toISOString();
console.log('[Gateway] Member joined: ' + (member.user?.tag || member.id));
try {
const snapshot = buildMemberSnapshot(member);
const result = await callBase44('processDiscordMemberJoin', snapshot);
console.log('[Gateway] processDiscordMemberJoin -> ' + result.status + ': ' + (result.message || ''));
} catch (e) { console.error('[Gateway] processDiscordMemberJoin failed for ' + member.id + ':', e.message); }
});
// Interaction handler (forwards to handleDiscordInteraction)
function buildRawInteraction(interaction) {
const raw = { id: interaction.id, application_id: interaction.applicationId, token: interaction.token, type: interaction.type, guild_id: interaction.guildId || null, channel_id: interaction.channelId || null };
if (interaction.member) {
raw.member = { user: { id: interaction.member.user.id, username: interaction.member.user.username, global_name: interaction.member.user.globalName || interaction.member.user.username, avatar: interaction.member.user.avatar || null }, nick: interaction.member.nickname || null, roles: interaction.member.roles?.cache ? Array.from(interaction.member.roles.cache.keys()) : [], joined_at: interaction.member.joinedAt ? interaction.member.joinedAt.toISOString() : null };
} else if (interaction.user) {
raw.user = { id: interaction.user.id, username: interaction.user.username, global_name: interaction.user.globalName || interaction.user.username, avatar: interaction.user.avatar || null };
}
if (interaction.customId) { raw.data = { custom_id: interaction.customId, component_type: interaction.componentType }; if (interaction.components) { raw.data.components = interaction.components.map(row => ({ type: 1, components: (row.components || []).map(comp => ({ type: 4, custom_id: comp.custom_id || comp.customId, value: comp.value })) })); } }
if (interaction.type === InteractionType.ApplicationCommand && interaction.commandName) { raw.data = { name: interaction.commandName }; }
return raw;
}
const VERIFICATION_MODAL = { title: 'Verify Your Profile', custom_id: 'verification_modal', components: [
{ type: 1, components: [{ type: 4, custom_id: 'first_name', label: 'First Name', style: 1, required: true, min_length: 2, max_length: 40, placeholder: 'Enter your first name' }] },
{ type: 1, components: [{ type: 4, custom_id: 'last_name', label: 'Last Name', style: 1, required: true, min_length: 2, max_length: 50, placeholder: 'Enter your last name' }] },
{ type: 1, components: [{ type: 4, custom_id: 'referral_source', label: 'How did you find Collectors\' Keep?', style: 1, required: true, max_length: 100, placeholder: 'Existing member, Twitter/X, Instagram, Google, Card show, etc.' }] },
{ type: 1, components: [{ type: 4, custom_id: 'referred_by', label: 'Referring Member (optional)', style: 1, required: false, max_length: 100, placeholder: 'Discord username or full name of who referred you' }] },
{ type: 1, components: [{ type: 4, custom_id: 'agreement', label: 'Type "I AGREE" to confirm naming policy', style: 1, required: true, min_length: 7, max_length: 10, placeholder: 'I AGREE' }] },
] };
client.on('interactionCreate', async (interaction) => {
const handledTypes = [InteractionType.MessageComponent, InteractionType.ModalSubmit, InteractionType.ApplicationCommand];
if (!handledTypes.includes(interaction.type)) return;
const customId = interaction.customId || interaction.commandName || 'unknown';
state.lastGatewayEventAt = new Date().toISOString();
if (customId === 'verify_my_profile') { try { await interaction.showModal(VERIFICATION_MODAL); } catch (e) { console.error('[Gateway] Modal failed:', e.message); } return; }
try {
const isModalSubmit = interaction.type === InteractionType.ModalSubmit;
const isButton = interaction.type === InteractionType.MessageComponent;
const staffModalButton = isButton && customId.startsWith('staff_') && !customId.includes('view_history') && !customId.includes('retry_checks');
if (isModalSubmit || (isButton && !staffModalButton)) { await interaction.deferReply({ ephemeral: true }); }
const rawInteraction = buildRawInteraction(interaction);
const interactionsUrl = BASE44_FUNCTIONS_BASE.replace(/\/$/, '') + '/handleDiscordInteraction';
const res = await fetch(interactionsUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-bot-api-secret': BOT_API_SECRET }, body: JSON.stringify(rawInteraction) });
const response = await res.json();
if (response.type === 9) { await interaction.showModal(response.data); }
else if (response.type === 4) { const content = response.data?.content || 'Done.'; const ephemeral = response.data?.flags === 64; if (isModalSubmit || interaction.deferred || (isButton && !staffModalButton)) { await interaction.editReply({ content, ephemeral: true }); } else { await interaction.reply({ content, ephemeral }); } }
else if (response.type === 5) { if (!isModalSubmit && !interaction.deferred && !isButton) { await interaction.deferReply({ ephemeral: response.data?.flags === 64 }); } }
else { if (interaction.deferred) { await interaction.editReply({ content: 'An error occurred. Please try again or contact staff.', ephemeral: true }); } else if (interaction.isRepliable()) { await interaction.reply({ content: 'An error occurred. Please try again or contact staff.', ephemeral: true }); } }
} catch (e) { console.error('[Gateway] Interaction failed for ' + customId + ':', e.message); try { if (interaction.isRepliable()) { if (interaction.deferred) { await interaction.editReply({ content: 'An error occurred.', ephemeral: true }); } else { await interaction.reply({ content: 'An error occurred.', ephemeral: true }); } } } catch {} }
});
// Reconciliation
async function runReconciliation(reason) {
if (state.reconciliationRunning) { return { skipped: true, reason: 'already_running' }; }
state.reconciliationRunning = true;
console.log('[Gateway] Reconciliation started (' + (reason || 'scheduled') + ')');
try {
const guild = client.guilds.cache.get(GUILD_ID);
if (!guild) throw new Error('Guild not found');
const cutoff = state.lastReconciliationAt || state.startedAt || new Date(Date.now() - 86400000).toISOString();
const cutoffMs = new Date(cutoff).getTime();
const members = []; let afterId = '0'; let scanned = 0; const maxScan = 2000;
while (members.length < RECONCILE_BATCH_SIZE && scanned < maxScan) {
const batch = await guild.members.fetch({ limit: 200, after: afterId });
if (batch.size === 0) break;
for (const [, member] of batch) { scanned++; const j = member.joinedAt ? member.joinedAt.getTime() : 0; if (j > cutoffMs) { members.push(buildMemberSnapshot(member)); if (members.length >= RECONCILE_BATCH_SIZE) break; } }
afterId = batch.last().id; if (batch.size < 200) break;
}
let summary;
if (members.length === 0) { summary = { checked: 0, already_processed: 0, new_verification_sent: 0, returning_member_restored: 0, blocked_member_detected: 0, manual_review_required: 0, errors: 0 }; }
else { const result = await callBase44('reconcileDiscordMembers', { members }); summary = { checked: result.checked || 0, already_processed: result.already_processed || 0, new_verification_sent: result.new_verification_sent || 0, returning_member_restored: result.returning_member_restored || 0, blocked_member_detected: result.blocked_member_detected || 0, manual_review_required: result.manual_review_required || 0, errors: result.errors || 0 }; }
state.lastReconciliationAt = new Date().toISOString(); state.lastReconciliationSummary = summary;
console.log('[Gateway] Reconciliation complete:', JSON.stringify(summary));
return { skipped: false, reason, ...summary };
} catch (e) { console.error('[Gateway] Reconciliation failed:', e.message); return { skipped: false, reason, error: e.message }; }
finally { state.reconciliationRunning = false; }
}
// Express server
const app = express();
app.use(express.json());
function requireAuth(req) { const auth = req.headers.authorization || ''; const token = auth.startsWith('Bearer ') ? auth.slice(7) : null; return Boolean(token && token === BOT_API_SECRET); }
app.get('/health', (req, res) => {
const wsPing = client.ws?.ping ?? null;
state.discordWsPing = typeof wsPing === 'number' && !isNaN(wsPing) ? wsPing : null;
state.guildAvailable = client.guilds.cache.has(GUILD_ID);
res.json({ status: state.discordConnected ? 'healthy' : 'degraded', discord_connected: state.discordConnected, discord_ws_ping_ms: state.discordWsPing, guild_available: state.guildAvailable, guild_id: GUILD_ID, bot_user_id: state.botUserId, uptime_seconds: state.startedAt ? Math.floor((Date.now() - new Date(state.startedAt).getTime()) / 1000) : 0, started_at: state.startedAt, last_gateway_event_at: state.lastGatewayEventAt, last_member_join_event_at: state.lastMemberJoinEventAt, last_successful_base44_call_at: state.lastSuccessfulBase44CallAt, last_reconciliation_at: state.lastReconciliationAt, last_reconnect_at: state.lastReconnectAt, last_reconciliation_summary: state.lastReconciliationSummary, reconciliation_running: state.reconciliationRunning, gateway_version: GATEWAY_VERSION, gateway_instance_id: GATEWAY_INSTANCE_ID, timestamp: new Date().toISOString() });
});
app.post('/admin/reconcile', async (req, res) => {
if (!requireAuth(req)) return res.status(401).json({ ok: false, error: 'Unauthorized' });
if (!client.isReady()) return res.status(503).json({ ok: false, error: 'Discord client not ready' });
try { const result = await runReconciliation('manual (' + (req.body?.triggered_by || 'manual') + ')'); res.json({ ok: true, message: 'Reconciliation completed', ...result }); }
catch (e) { res.status(500).json({ ok: false, error: e.message }); }
});
// Discord events
client.once('ready', async (c) => {
state.startedAt = new Date().toISOString(); state.discordConnected = true; state.botUserId = c.user.id; state.lastGatewayEventAt = new Date().toISOString();
console.log('[Gateway] Connected as ' + c.user.tag + ' (v' + GATEWAY_VERSION + ')');
setTimeout(async () => { try { await runReconciliation('startup'); } catch (e) { console.error('[Gateway] Startup reconciliation failed:', e.message); } }, 5000);
});
client.on('shardReconnecting', (shardId) => { state.lastReconnectAt = new Date().toISOString(); state.discordConnected = false; console.log('[Gateway] Shard ' + shardId + ' reconnecting...'); });
client.on('shardResume', (shardId) => { state.lastReconnectAt = new Date().toISOString(); state.discordConnected = true; state.lastGatewayEventAt = new Date().toISOString(); console.log('[Gateway] Shard ' + shardId + ' resumed'); setTimeout(async () => { try { await runReconciliation('reconnect'); } catch (e) {} }, 5000); });
client.on('error', (e) => console.error('[Gateway] Client error:', e.message));
setInterval(() => { if (client.isReady()) { state.discordConnected = true; state.discordWsPing = client.ws?.ping ?? null; } else { state.discordConnected = false; } }, 30000);
setInterval(async () => { if (client.isReady()) { try { await runReconciliation('scheduled'); } catch (e) {} } }, RECONCILIATION_INTERVAL_MS);
app.listen(PORT, () => { console.log('[Gateway] Health + admin API on port ' + PORT); });
client.login(BOT_TOKEN).catch((e) => { console.error('[Gateway] Login failed:', e.message); process.exit(1); });
process.on('SIGTERM', async () => { console.log('[Gateway] SIGTERM received'); client.destroy(); process.exit(0); });{
"name": "collectors-keep-gateway-bot",
"version": "2.0.0",
"description": "Discord Gateway bot for event-driven member onboarding",
"main": "gateway-bot.js",
"scripts": {
"start": "node gateway-bot.js"
},
"dependencies": {
"discord.js": "^14.16.3",
"dotenv": "^16.4.5",
"express": "^4.21.1"
}
}module.exports = {
apps: [{
name: 'ck-gateway-bot',
script: 'gateway-bot.js',
autorestart: true,
max_restarts: 10,
restart_delay: 5000,
env: {
NODE_ENV: 'production',
},
}],
};# ── Discord ──
ONBOARDING_BOT_TOKEN=paste_token_here
# ── Base44 API ──
# The shared secret must match BOT_API_SECRET in your Base44 app secrets
BOT_API_SECRET=paste_secret_here
# Base URL for all Base44 functions (no trailing slash)
# Find it: your published app URL + /functions
BASE44_FUNCTIONS_BASE_URL=https://collect-keep-hub.base44.app/functions
# ── Discord Guild ──
DISCORD_GUILD_ID=your_guild_id
# ── Gateway Configuration ──
GATEWAY_VERSION=2.0.0
# Optional: unique instance identifier for multi-droplet setups
# GATEWAY_INSTANCE_ID=gateway-prod-1
# Reconciliation interval in minutes (default 30)
RECONCILIATION_INTERVAL_MINUTES=30
# Max members per reconciliation batch (default 100)
RECONCILE_BATCH_SIZE=100
# HTTP server port for health + admin endpoints
PORT=3002