DashboardSettings

Onboarding Configuration

Configure Discord IDs, risk thresholds, and operating modes.

Operating Mode

Normal: auto-approve qualifying members. Elevated: stricter account age. Lockdown: all manual review.

Discord Configuration

11371110921291695286898973123148514451122550589482610778

Risk & Verification Rules

Join Spike Detection

Staff are alerted in the audit log channel when a spike is detected. Reconciliation catch-up joins are not counted.

Reminder & Expiration

Automation Toggles

Verification DM

Active since 9/26/2026, 3:40:46 AM

New members who join after this timestamp will automatically receive a verification DM. A scheduled task runs every 5 minutes to detect them.

Gateway Bot Setup

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.

  1. $ mkdir -p /opt/ck-gateway-bot && cd /opt/ck-gateway-bot
  2. Create each file using nano — copy the code, paste, then Ctrl+O, Enter, Ctrl+X:
    nano gateway-bot.js · nano package.json · nano ecosystem.config.js · nano .env
  3. $ npm install
  4. $ pm2 start ecosystem.config.js
  5. $ pm2 save
  6. Enable Server Members Intent in Discord Developer Portal → your onboarding bot app → Bot → Privileged Gateway Intents
  7. Set gateway_base_url in Onboarding Settings to the droplet's HTTPS URL (e.g. https://gateway.collectorskeep.net) so the dashboard can check its health.
/* 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