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:
@@ -31,6 +31,33 @@ const log = require('../utils/logger')('modules')
|
||||
// such budget on purpose — it delays the listener binding, which is the feature.
|
||||
const SHUTDOWN_BUDGET_MS = 5000
|
||||
|
||||
/**
|
||||
* Nudge the bot to re-pull the slash-command set, from the two places that
|
||||
* actually change it in a live process: a boot, and an operator disabling a
|
||||
* module (which `remove` and `purge` both run through).
|
||||
*
|
||||
* Enabling and installing are deliberately NOT here — both ask for a restart
|
||||
* before the module runs, and a command whose handler is not registered yet is a
|
||||
* command that would answer "unknown". The nudge follows the state, not the
|
||||
* intention.
|
||||
*
|
||||
* Required lazily, and deliberately NOT awaited by either caller: the bot is
|
||||
* optional infrastructure, and neither a boot nor an operator's disable should
|
||||
* wait out `botInternalClient`'s 4s timeout because a bot container is wedged.
|
||||
* Nothing here throws — a failed nudge is a log line, and the bot re-pulls on its
|
||||
* next `ready` regardless.
|
||||
*/
|
||||
async function nudgeBot(why) {
|
||||
try {
|
||||
// eslint-disable-next-line global-require
|
||||
const bot = require('../utils/botInternalClient')
|
||||
const res = await bot.refreshCommands()
|
||||
if (!res.ok) log.info('bot did not take the slash-command nudge', { why, error: res.error })
|
||||
} catch (err) {
|
||||
log.warn('slash-command nudge failed', { why, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one database call for one module without letting it become everyone's
|
||||
* failure. Returns null on failure, having logged it.
|
||||
@@ -179,6 +206,14 @@ async function boot({ modules, model } = {}) {
|
||||
// is a stale projection, never a site that will not start.
|
||||
// eslint-disable-next-line global-require
|
||||
await safe('starting the team reconciler', () => require('../model/teams/teamSync.model').start())
|
||||
|
||||
// Tell the bot the slash-command set may have moved (TEAMS.md §7.1).
|
||||
//
|
||||
// The bot pulls on its own `ready` too, so this is not the only path — it is
|
||||
// the path for the case `ready` does not cover: the APP restarting while the
|
||||
// bot stays connected, which is every ordinary redeploy. Without it, a module
|
||||
// added in that deploy has no command until someone restarts the bot.
|
||||
nudgeBot('boot')
|
||||
}
|
||||
|
||||
/** Reject if `fn`'s promise has not settled within `ms`. */
|
||||
@@ -280,6 +315,7 @@ async function stop(id, { modules, model, budgetMs = SHUTDOWN_BUDGET_MS } = {})
|
||||
}
|
||||
|
||||
await safe(`disabling module "${id}"`, () => rows.disable(id))
|
||||
nudgeBot(`disable:${id}`)
|
||||
return { stopped, error }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user