feat(teams): the slash-command seam, and the first command through it
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>
This commit is contained in:
@@ -5,6 +5,7 @@ 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')
|
||||
@@ -22,12 +23,46 @@ 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),
|
||||
body: [...commands.all.map((c) => c.data), ...dynamic],
|
||||
})
|
||||
log.info('registered guild slash commands', { guildId: targetGuildId, count: commands.all.length })
|
||||
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() {
|
||||
@@ -54,6 +89,11 @@ async function stop() {
|
||||
// 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)
|
||||
@@ -70,14 +110,19 @@ async function onReady() {
|
||||
}
|
||||
}
|
||||
|
||||
// Route an interaction: role-menu handler first, then chat-input slash commands.
|
||||
// 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) return
|
||||
if (!command && !dynamicCommands.has(interaction.commandName)) return
|
||||
try {
|
||||
await command.execute(interaction)
|
||||
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 }
|
||||
@@ -146,4 +191,4 @@ function getConnection() {
|
||||
return { client, guildId }
|
||||
}
|
||||
|
||||
module.exports = { start, stop, getStatus, getConnection }
|
||||
module.exports = { start, stop, getStatus, getConnection, refreshCommands }
|
||||
|
||||
Reference in New Issue
Block a user