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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -282,13 +282,16 @@ function buildApi(record) {
|
||||
once('registerTeamProvider')
|
||||
record.staged.registerTeamProvider(provider)
|
||||
},
|
||||
// Declared in 1.6.0 with the rest of the Team surface; the bot half that
|
||||
// executes a command lands in phase 7 (§7.1). Present and throwing rather
|
||||
// than absent, so a module written against the published version fails at
|
||||
// registration with a sentence naming the phase, instead of at whatever
|
||||
// moment someone first types the command.
|
||||
registerSlashCommands() {
|
||||
throw new Error('api.registerSlashCommands is not available until Discord slash commands land (TEAMS.md §7.1)')
|
||||
// Chat-platform slash commands (API 1.6.0, §7.1), live since phase 7. Like
|
||||
// registerTeamProvider above, the handler this stages is core CALLING THE
|
||||
// MODULE and waiting for an answer — but from further away than any other
|
||||
// member: the caller is a bot in another container, holding a Discord
|
||||
// interaction open on a deadline. `once` for the same reason the two
|
||||
// registries above take it — a second call is a module changing its mind
|
||||
// halfway through register(), not adding to what it already said.
|
||||
registerSlashCommands(commands) {
|
||||
once('registerSlashCommands')
|
||||
record.staged.registerSlashCommands(commands)
|
||||
},
|
||||
// The two lifecycle hooks (§2.5). Registered here, dispatched from
|
||||
// lifecycle.js — this file runs with no database and the hooks run with one.
|
||||
|
||||
@@ -68,6 +68,18 @@ const postHooks = new Map()
|
||||
// second registration is a collision rather than an addition.
|
||||
let teamProvider = null
|
||||
|
||||
// command name → { owner, name, description, options, access, handler }. Slash
|
||||
// commands a registrant has published for the chat platform (API 1.6.0,
|
||||
// TEAMS.md §7.1).
|
||||
//
|
||||
// The DEFINITION and the HANDLER are registered together and the handler runs
|
||||
// HERE, in the website process; the bot pulls the definitions over the internal
|
||||
// API and owns every Discord-specific concern. That split is forced — the bot
|
||||
// container has no `modules` volume, so a module physically cannot put a handler
|
||||
// in it (§0.4) — and it is also the boundary we would pick anyway: a module
|
||||
// calling `interaction.deferReply()` would be a module holding a Discord handle.
|
||||
const slashCommands = new Map()
|
||||
|
||||
let coreRegistered = false
|
||||
|
||||
// Stream ids that predate the module system and may not carry their owner's
|
||||
@@ -214,6 +226,23 @@ const registeredTeamProvider = () => teamProvider
|
||||
/** Is there a Team provider at all? Read by the reconciler and the read API. */
|
||||
const hasTeamProvider = () => teamProvider !== null
|
||||
|
||||
// ── Slash commands (TEAMS.md §7.1) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every registered command WITHOUT its handler — what `/internal/commands`
|
||||
* serves to the bot.
|
||||
*
|
||||
* The handler is stripped rather than merely un-serialisable-and-ignored: this
|
||||
* is the object that crosses a process boundary, and the definition half is the
|
||||
* whole of what the bot is allowed to know. `owner` rides along so the bot can
|
||||
* name the module in a collision warning.
|
||||
*/
|
||||
const slashCommandDefinitions = () =>
|
||||
[...slashCommands.values()].map(({ handler, ...definition }) => definition)
|
||||
|
||||
/** One command, handler included. The dispatcher's lookup. */
|
||||
const slashCommand = (name) => slashCommands.get(name) || null
|
||||
|
||||
// ── Shape checks, run the moment a registrant calls ────────────────────────
|
||||
//
|
||||
// Split from the collision checks below on the same line PR 3 drew through
|
||||
@@ -315,6 +344,94 @@ function checkPageUrlTemplate(value) {
|
||||
return value
|
||||
}
|
||||
|
||||
// A slash command's name and description are validated HERE and not only at the
|
||||
// bot, for a reason worth stating: the bot registers the whole set in a single
|
||||
// `REST.put(applicationGuildCommands)`, so ONE malformed definition is rejected
|
||||
// by Discord as a batch and takes every other command down with it — including
|
||||
// the bot's own. A definition that cannot be registered must therefore fail at
|
||||
// `register()`, where it belongs to a module that can be named and marked
|
||||
// failed, rather than at the next `ready` where it looks like the bot is broken.
|
||||
//
|
||||
// **Commands are NOT namespaced under their owner, unlike every other id in this
|
||||
// file.** Discord's name grammar has no `.` in it, so `uo.guild` is unregistrable
|
||||
// and the prefix rule cannot be expressed. Collisions are caught by first-come
|
||||
// instead, with the holder named — and the bot resolves the one collision core
|
||||
// cannot see (a pulled name against its own built-ins) in the module's disfavour.
|
||||
const SLASH_NAME = /^[a-z0-9_-]{1,32}$/
|
||||
const SLASH_ACCESS = ['everyone', 'linked', 'staff']
|
||||
|
||||
// §7.1.1: `string | integer | boolean | user`, and deliberately nothing else. No
|
||||
// subcommand groups, autocomplete, attachments, modals or component
|
||||
// interactions. Those are exactly the features whose semantics do not survive a
|
||||
// second platform, and admitting one here is how Discord specifics leak into a
|
||||
// platform-agnostic registration API by accident.
|
||||
const SLASH_OPTION_TYPES = ['string', 'integer', 'boolean', 'user']
|
||||
|
||||
function checkSlashOption(command, option) {
|
||||
const { name, type, description, required, choices } = option || {}
|
||||
const where = `registerSlashCommands: ${command}`
|
||||
if (!SLASH_NAME.test(name || '')) throw new Error(`${where}: bad option name "${name}"`)
|
||||
if (!SLASH_OPTION_TYPES.includes(type)) {
|
||||
throw new Error(`${where}: option "${name}" has unsupported type "${type}" (§7.1.1)`)
|
||||
}
|
||||
if (!description || description.length > 100) {
|
||||
throw new Error(`${where}: option "${name}" needs a description of 1-100 characters`)
|
||||
}
|
||||
const out = { name, type, description, required: Boolean(required) }
|
||||
if (choices !== undefined) {
|
||||
if (!Array.isArray(choices) || !choices.length) {
|
||||
throw new Error(`${where}: option "${name}" has an empty choices list`)
|
||||
}
|
||||
// Only the two option types Discord itself allows choices on. `boolean` is
|
||||
// already a two-value choice and `user` is a picker; a choices list on
|
||||
// either is a misunderstanding worth failing rather than dropping.
|
||||
if (type !== 'string' && type !== 'integer') {
|
||||
throw new Error(`${where}: option "${name}" is ${type}; choices need string or integer`)
|
||||
}
|
||||
out.choices = choices.map((c) => {
|
||||
if (!c || !c.name || c.value === undefined) {
|
||||
throw new Error(`${where}: option "${name}" has a choice with no name/value`)
|
||||
}
|
||||
return { name: String(c.name), value: c.value }
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* `registerSlashCommands([{ name, description, options, access, handler }])`.
|
||||
*
|
||||
* `access` is enforced TWICE and this copy is not the gate: the bot sets
|
||||
* Discord-side default member permissions from it where it can, and the
|
||||
* dispatcher re-checks it on every call. Client-side is about not advertising a
|
||||
* dead end; the server is the boundary — the same principle the nav follows.
|
||||
*/
|
||||
function checkSlashCommandShape(entry) {
|
||||
const { name, description, options, access, handler } = entry || {}
|
||||
if (!SLASH_NAME.test(name || '')) {
|
||||
throw new Error(`registerSlashCommands: bad command name "${name}" (lowercase, 1-32, no dots)`)
|
||||
}
|
||||
if (!description || description.length > 100) {
|
||||
throw new Error(`registerSlashCommands: ${name} needs a description of 1-100 characters`)
|
||||
}
|
||||
if (typeof handler !== 'function') throw new Error(`registerSlashCommands: ${name} has no handler()`)
|
||||
if (access !== undefined && !SLASH_ACCESS.includes(access)) {
|
||||
throw new Error(`registerSlashCommands: ${name} has unknown access "${access}"`)
|
||||
}
|
||||
if (options !== undefined && !Array.isArray(options)) {
|
||||
throw new Error(`registerSlashCommands: ${name} options must be an array`)
|
||||
}
|
||||
const checked = (options || []).map((o) => checkSlashOption(name, o))
|
||||
// Discord rejects a definition that puts an optional option before a required
|
||||
// one, and does it for the whole batch. Sorting silently would change what the
|
||||
// module wrote; this is the module's own ordering bug and it gets its name.
|
||||
const firstOptional = checked.findIndex((o) => !o.required)
|
||||
if (firstOptional !== -1 && checked.slice(firstOptional).some((o) => o.required)) {
|
||||
throw new Error(`registerSlashCommands: ${name} lists a required option after an optional one`)
|
||||
}
|
||||
return { name, description, options: checked, access: access || 'everyone', handler }
|
||||
}
|
||||
|
||||
/**
|
||||
* `registerPostHook({ onSaved, onDeleted })` — both optional, at least one
|
||||
* required. A registration with neither is a subscription that can never fire,
|
||||
@@ -355,7 +472,9 @@ function checkExtensionShape(slot, router, specFile) {
|
||||
* `allStreams()` / `announceLeg()` / the slot routers until `apply()`.
|
||||
*/
|
||||
function stage(owner) {
|
||||
const staged = { owner, streams: [], legs: [], extensions: [], postHooks: [], teamProviders: [] }
|
||||
const staged = {
|
||||
owner, streams: [], legs: [], extensions: [], postHooks: [], teamProviders: [], slashCommands: [],
|
||||
}
|
||||
return {
|
||||
staged,
|
||||
registerNotificationStreams(entries) {
|
||||
@@ -374,6 +493,10 @@ function stage(owner) {
|
||||
registerTeamProvider(entry) {
|
||||
staged.teamProviders.push(checkTeamProviderShape(entry))
|
||||
},
|
||||
registerSlashCommands(entries) {
|
||||
if (!Array.isArray(entries)) throw new Error('registerSlashCommands: expected an array')
|
||||
for (const e of entries) staged.slashCommands.push(checkSlashCommandShape(e))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,6 +516,7 @@ function apply({
|
||||
extensions: newExtensions,
|
||||
postHooks: newPostHooks = [],
|
||||
teamProviders: newTeamProviders = [],
|
||||
slashCommands: newSlashCommands = [],
|
||||
}) {
|
||||
// ── validate ──
|
||||
const seenStreams = new Set()
|
||||
@@ -437,6 +561,14 @@ function apply({
|
||||
throw new Error(`a team provider is already registered by "${teamProvider.owner}"`)
|
||||
}
|
||||
|
||||
const seenCommands = new Set()
|
||||
for (const c of newSlashCommands) {
|
||||
const held = slashCommands.get(c.name)
|
||||
if (held) throw new Error(`slash command "/${c.name}" is already registered by "${held.owner}"`)
|
||||
if (seenCommands.has(c.name)) throw new Error(`slash command "/${c.name}" registered twice`)
|
||||
seenCommands.add(c.name)
|
||||
}
|
||||
|
||||
// ── commit — nothing below can fail ──
|
||||
for (const s of newStreams) {
|
||||
streamOwners.set(s.id, owner)
|
||||
@@ -451,6 +583,7 @@ function apply({
|
||||
}
|
||||
for (const h of newPostHooks) postHooks.set(owner, h)
|
||||
for (const p of newTeamProviders) teamProvider = { owner, ...p }
|
||||
for (const c of newSlashCommands) slashCommands.set(c.name, { owner, ...c })
|
||||
}
|
||||
|
||||
// ── Core's own registrations ───────────────────────────────────────────────
|
||||
@@ -517,6 +650,7 @@ function _reset() {
|
||||
legs.clear()
|
||||
postHooks.clear()
|
||||
teamProvider = null
|
||||
slashCommands.clear()
|
||||
coreRegistered = false
|
||||
}
|
||||
|
||||
@@ -536,6 +670,8 @@ module.exports = {
|
||||
dispatchPostHook,
|
||||
registeredTeamProvider,
|
||||
hasTeamProvider,
|
||||
slashCommandDefinitions,
|
||||
slashCommand,
|
||||
stage,
|
||||
apply,
|
||||
registerCore,
|
||||
|
||||
@@ -16,13 +16,12 @@
|
||||
// client slots `team.overview` / `team.member.row`. module-uo's `coreApi:
|
||||
// "^1.3.0"` still resolves.
|
||||
//
|
||||
// **The number covers the whole surface; the members arrive by phase.** The three
|
||||
// this phase implements are live. `activity.push` lands with the Team activity
|
||||
// feed (§4, phase 3) and `registerSlashCommands` with the Discord commands (§7.1,
|
||||
// phase 7) — until then each is present and THROWS rather than being absent or,
|
||||
// worse, silently accepting data into a table that does not exist. MODULE_API.md
|
||||
// names the phase against each member, so a module author reads what is callable
|
||||
// today rather than discovering it at runtime.
|
||||
// **The number covers the whole surface; the members arrived by phase, and all of
|
||||
// them have now arrived.** `activity.push` landed with the Team activity feed
|
||||
// (§4, phase 3) and `registerSlashCommands` with the Discord commands (§7.1,
|
||||
// phase 7); until each did, it was present and THREW rather than being absent or,
|
||||
// worse, silently accepting data into a table that did not exist. Nothing in
|
||||
// 1.6.0 throws any more.
|
||||
//
|
||||
// 1.5.0 — a CLIENT addition: `PublicLayout` takes an optional `shell` prop that
|
||||
// renders the page body wrapper core's own pages write by hand (MODULE_API.md
|
||||
|
||||
Reference in New Issue
Block a user