// Owns the single discord.js Client instance for this process: lifecycle // (start/stop/status) and slash-command registration/dispatch. Command // definitions themselves live in ./commands — this file only wires them up. const { Client, GatewayIntentBits, REST, Routes } = require('discord.js') const createLogger = require('../utils/logger') const commands = require('./commands') const messageFilter = require('./messageFilter') const scheduler = require('../scheduler/scheduler') const roleMenuHandler = require('./roleMenuHandler') const { handleGuildMemberAdd } = require('./guildMemberAdd') const { handleGuildMemberRemove } = require('./guildMemberRemove') const inviteTracker = require('./inviteTracker') const tempRoleSweeper = require('../roles/tempRoleSweeper') const inviteScheduler = require('../invites/inviteScheduler') const log = createLogger('discord') let client = null let guildId = null let status = 'disconnected' // disconnected | connecting | connected | error let statusDetail = null let lastConnectedAt = null async function registerCommands(applicationId, targetGuildId) { const rest = new REST({ version: '10' }).setToken(client.token) await rest.put(Routes.applicationGuildCommands(applicationId, targetGuildId), { body: commands.all.map((c) => c.data), }) log.info('registered guild slash commands', { guildId: targetGuildId, count: commands.all.length }) } async function stop() { if (!client) { status = 'disconnected' statusDetail = null return } scheduler.stop() tempRoleSweeper.stop() inviteScheduler.stop() try { await client.destroy() } catch (err) { log.warn('error while destroying client', { message: err.message }) } client = null status = 'disconnected' statusDetail = null log.info('discord client disconnected') } // Post-login startup: register commands and start the background workers. A // failure here leaves the client connected but flags an error status. async function onReady() { try { await registerCommands(client.application.id, guildId) await scheduler.start(client) tempRoleSweeper.start(client) inviteScheduler.start(client, guildId) await inviteTracker.prime(client, guildId) status = 'connected' statusDetail = null lastConnectedAt = new Date() log.info('discord client ready', { user: client.user?.tag, guildId }) } catch (err) { status = 'error' statusDetail = `startup failed: ${err.message}` log.error('post-login startup failed (commands/scheduler/temp-roles/invites)', { message: err.message }) } } // Route an interaction: role-menu handler first, then chat-input slash commands. async function onInteractionCreate(interaction) { if (await roleMenuHandler.handleInteraction(interaction)) return if (!interaction.isChatInputCommand()) return const command = commands.get(interaction.commandName) if (!command) return try { await command.execute(interaction) } catch (err) { log.error('command execution failed', { command: interaction.commandName, message: err.message }) const payload = { content: 'Something went wrong running that command.', ephemeral: true } if (interaction.replied || interaction.deferred) await interaction.followUp(payload) else await interaction.reply(payload) } } // start({ token, guildId }) — (re)connects. Always stops any existing client // first so re-saving config or toggling Enabled off/on is idempotent. async function start({ token, guildId: gid }) { await stop() guildId = gid status = 'connecting' statusDetail = null // GuildMessages + MessageContent (Phase 3, filter) and GuildMembers // (Phase 5, auto-role + bulk role ops) are all privileged — must be enabled // in the Discord Developer Portal, see the Phase 1 setup notes. GuildInvites // (Phase 6b, invite-usage attribution) is NOT privileged — no portal toggle. client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildInvites, ], }) client.once('ready', onReady) client.on('interactionCreate', onInteractionCreate) client.on('messageCreate', messageFilter.handleMessageCreate) client.on('guildMemberAdd', handleGuildMemberAdd) client.on('guildMemberRemove', handleGuildMemberRemove) // Keep the invite-use cache fresh so guildMemberAdd can attribute joins. client.on('inviteCreate', inviteTracker.onInviteCreate) client.on('inviteDelete', inviteTracker.onInviteDelete) client.on('error', (err) => { status = 'error' statusDetail = err.message log.error('discord client error', { message: err.message }) }) try { await client.login(token) } catch (err) { status = 'error' statusDetail = err.message client = null log.error('discord login failed', { message: err.message }) throw err } } function getStatus() { return { status, statusDetail, guildId, lastConnectedAt } } // For code that needs the live client + which guild it's connected to (the // /internal/announce handler, slash commands already get both from the // interaction itself so they don't need this). Returns null if disconnected. function getConnection() { if (!client || status !== 'connected') return null return { client, guildId } } module.exports = { start, stop, getStatus, getConnection }