Phase 7 of TEAMS.md. `api.registerSlashCommands` stops throwing: a module registers a command's DEFINITION and its HANDLER together, the bot pulls the definitions over the internal listener and runs none of our code, and the handler executes here — forced by the bot container having no `modules` volume, and the right boundary anyway. Registration validates what Discord would reject as a batch (names, description lengths, the four option types, required-before-optional), because the bot registers the whole set in one PUT and a single bad entry costs every command including the bot's own. Commands are not namespaced under their owner — there is no dot in Discord's name grammar — so collisions are first-come with the holder named. The dispatcher is the access boundary: `linked` has no Discord equivalent, so the platform-side permission default can only ever be advertising. It resolves the actor by `auth_providers.kind` rather than the id slug, treats a banned account as unlinked, bounds a handler under the bot's own timeout, and keeps `ok` outside the envelope so a handler cannot forge it. Liveness is asked at both the pull and the dispatch. The registries have no removal path, so a module an operator disables at runtime would otherwise keep a live handler behind a command Discord still advertises. Co-Authored-By: Claude <noreply@anthropic.com>
195 lines
7.5 KiB
JavaScript
195 lines
7.5 KiB
JavaScript
// 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 }
|