// 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 dynamicCommands = require('./dynamicCommands') 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 // One whole-set PUT of the bot's own commands plus whatever the app has // registered (TEAMS.md §7.1). Because it replaces the set rather than adding to // it, DEREGISTRATION is free: a module that is gone is simply absent from the // next pull, and nobody has to remember to take its command back. async function registerCommands(applicationId, targetGuildId) { const dynamic = dynamicCommands.definitions() const rest = new REST({ version: '10' }).setToken(client.token) await rest.put(Routes.applicationGuildCommands(applicationId, targetGuildId), { body: [...commands.all.map((c) => c.data), ...dynamic], }) log.info('registered guild slash commands', { guildId: targetGuildId, builtIn: commands.all.length, fromApp: dynamic.length, }) } /** * Re-pull the app's commands and re-register the set if it moved. * * Called on `ready` and again whenever the app nudges * (`POST /internal/refresh-commands`). A no-op when nothing changed, so a nudge * per module state change costs one cheap GET rather than a REST.put per * install — and a disconnected bot does nothing at all, since there is no * application to register against until it logs in. */ async function refreshCommands() { const result = await dynamicCommands.pull() if (!result.ok || !result.changed) return result if (!client || !client.isReady()) return result try { await registerCommands(client.application.id, guildId) } catch (err) { // The PUT is all-or-nothing: a definition Discord rejects costs every // command, the built-ins included. Loud, and never fatal to the process. log.error('re-registering slash commands failed — the previous set is still live', { message: err.message, }) } return result } 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 { // Pull BEFORE the single PUT, so the app's commands are in the very first // registration rather than appearing a beat later. The pull never throws — // an unreachable app costs the module commands and nothing else, and the // bot's own set registers exactly as it always did. await dynamicCommands.pull() 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 // — the bot's own, then the app's. Built-ins are consulted FIRST and the pull // already drops any module name that collides with one, so the two orderings // agree; checking here as well means a name that somehow reached Discord twice // still runs the bot's version rather than whichever registry answered first. async function onInteractionCreate(interaction) { if (await roleMenuHandler.handleInteraction(interaction)) return if (!interaction.isChatInputCommand()) return const command = commands.get(interaction.commandName) if (!command && !dynamicCommands.has(interaction.commandName)) return try { if (command) await command.execute(interaction) else await dynamicCommands.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, refreshCommands }