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:
210
bot/src/discord/dynamicCommands.js
Normal file
210
bot/src/discord/dynamicCommands.js
Normal file
@@ -0,0 +1,210 @@
|
||||
// Slash commands whose DEFINITION and HANDLER live in the website process
|
||||
// (TEAMS.md §7.1). The bot pulls the definitions, registers them alongside its
|
||||
// own, and executes one by deferring, asking the app, and editing the reply in.
|
||||
//
|
||||
// Everything Discord-specific is here and nothing else is: the app's dispatcher
|
||||
// resolves the actor, enforces access and produces a platform-neutral envelope,
|
||||
// and this file turns that envelope into an interaction reply. A module never
|
||||
// touches an interaction, which is what makes the registration API something a
|
||||
// second platform could implement.
|
||||
const { PermissionFlagsBits } = require('discord.js')
|
||||
|
||||
const appInternal = require('../site/appInternalClient')
|
||||
const staticCommands = require('./commands')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('dynamic-commands')
|
||||
|
||||
// §7.1.1's four types, and the only four. The app rejects anything else at
|
||||
// registration; this map is the second half of that agreement.
|
||||
const OPTION_TYPE = { string: 3, integer: 4, boolean: 5, user: 6 }
|
||||
|
||||
// The pulled set, and the app's module-state counter it came from. `null`
|
||||
// version means "never successfully pulled", which is distinct from 0 ("pulled
|
||||
// while the app had no modules loaded") — the first should retry, the second is
|
||||
// a true answer.
|
||||
let pulled = []
|
||||
let version = null
|
||||
|
||||
/**
|
||||
* Ask the app for the current definitions.
|
||||
*
|
||||
* **A failed pull KEEPS the previous set.** The app being briefly unreachable is
|
||||
* not the same as it having no commands, and treating it as such would
|
||||
* deregister every module command from Discord on a restart blip — then
|
||||
* re-register them a minute later, with members watching commands appear and
|
||||
* disappear. Nothing changes until the app actually answers.
|
||||
*
|
||||
* @returns {Promise<{ok: boolean, changed: boolean, count: number}>}
|
||||
*/
|
||||
async function pull() {
|
||||
const res = await appInternal.fetchCommands()
|
||||
if (!res.ok) {
|
||||
log.warn('command pull failed — keeping the set already registered', {
|
||||
error: res.error,
|
||||
holding: pulled.length,
|
||||
})
|
||||
return { ok: false, changed: false, count: pulled.length }
|
||||
}
|
||||
|
||||
const { version: pulledVersion, commands } = res.data || {}
|
||||
const next = Array.isArray(commands) ? commands.filter(usable) : []
|
||||
const changed = version === null || pulledVersion !== version || next.length !== pulled.length
|
||||
pulled = next
|
||||
version = typeof pulledVersion === 'number' ? pulledVersion : 0
|
||||
return { ok: true, changed, count: pulled.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a pulled definition the bot cannot honour.
|
||||
*
|
||||
* **The name collision the app cannot see.** The app validates a command against
|
||||
* everything IT has registered; it does not know the bot's own static array
|
||||
* exists. A module registering `ping` would produce two `ping` entries in one
|
||||
* `REST.put`, which Discord rejects as a batch — taking down every command
|
||||
* including the bot's own. The bot's built-ins win, because they are the ones a
|
||||
* module cannot be asked to change.
|
||||
*/
|
||||
function usable(definition) {
|
||||
if (!definition || typeof definition.name !== 'string') return false
|
||||
if (staticCommands.get(definition.name)) {
|
||||
log.warn('module slash command collides with a built-in and is ignored', {
|
||||
command: definition.name,
|
||||
owner: definition.owner,
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The pulled definitions as Discord command data, for the whole-set PUT.
|
||||
*
|
||||
* `access: 'staff'` becomes a Discord-side permission default; `linked` cannot
|
||||
* be expressed in Discord's permission model at all — there is no "has a website
|
||||
* account" predicate — so it is simply not advertised and the app's dispatcher
|
||||
* refuses it. That asymmetry is the reason §7.1 says access is enforced twice
|
||||
* and that only the server half is the gate.
|
||||
*/
|
||||
function definitions() {
|
||||
return pulled.map((c) => {
|
||||
const data = {
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
options: (c.options || []).map((o) => ({
|
||||
name: o.name,
|
||||
description: o.description,
|
||||
type: OPTION_TYPE[o.type],
|
||||
required: Boolean(o.required),
|
||||
...(o.choices ? { choices: o.choices } : {}),
|
||||
})),
|
||||
}
|
||||
if (c.access === 'staff') data.default_member_permissions = PermissionFlagsBits.ModerateMembers.toString()
|
||||
return data
|
||||
})
|
||||
}
|
||||
|
||||
/** Is this a command the app owns? Asked before the static registry is consulted. */
|
||||
const has = (name) => pulled.some((c) => c.name === name)
|
||||
|
||||
// Read the options the member actually supplied, by the names the definition
|
||||
// declared. A `user` option is passed on as the Discord user id and nothing else
|
||||
// — a handler receives platform ids, never a platform object.
|
||||
function collectOptions(interaction, definition) {
|
||||
const out = {}
|
||||
for (const option of definition.options || []) {
|
||||
const supplied = interaction.options.get(option.name)
|
||||
if (supplied === null || supplied === undefined) continue
|
||||
out[option.name] = option.type === 'user' ? String(supplied.value) : supplied.value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// What the caller sees when the app declined. The COPY lives here rather than in
|
||||
// the app on purpose: the app answers with a machine reason, and how a refusal is
|
||||
// phrased to a member is the platform's own voice.
|
||||
function refusal({ reason, access }) {
|
||||
if (reason === 'forbidden' && access === 'linked') {
|
||||
return 'Link your Discord account on the site to use this command.'
|
||||
}
|
||||
if (reason === 'forbidden') return 'You do not have access to that command.'
|
||||
if (reason === 'unknown') return 'That command is no longer available.'
|
||||
return 'Something went wrong running that command.'
|
||||
}
|
||||
|
||||
// Envelope → interaction payload. A response with fields or a title is an embed;
|
||||
// a bare `text` is plain content, which reads better for a one-line answer.
|
||||
function render(envelope) {
|
||||
const { text, title, fields, url } = envelope
|
||||
if (!title && !fields) return { content: text || '' }
|
||||
const embed = {}
|
||||
if (title) embed.title = title
|
||||
if (text) embed.description = text
|
||||
if (url) embed.url = url
|
||||
if (fields) embed.fields = fields
|
||||
return { embeds: [embed] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Defer, dispatch, edit.
|
||||
*
|
||||
* **The deferral comes first, always.** Discord gives three seconds to acknowledge
|
||||
* an interaction; the app is given four to answer. Deferring before the dispatch
|
||||
* is what keeps the website out of that critical path entirely — a wedged handler
|
||||
* costs its own reply and never an "application did not respond".
|
||||
*
|
||||
* A failure at any point after the defer is an edit, not a reply: the interaction
|
||||
* has already been acknowledged, and `reply()` on a deferred interaction throws.
|
||||
*/
|
||||
async function execute(interaction) {
|
||||
const definition = pulled.find((c) => c.name === interaction.commandName)
|
||||
if (!definition) return false
|
||||
|
||||
// Ephemerality has to be decided BEFORE the answer exists, because it is a
|
||||
// property of the deferral. A command declared for linked users only is
|
||||
// answered privately by default — its answer is about the caller's own
|
||||
// account — and everything else defers publicly; a handler that wants the
|
||||
// opposite says so, and the follow-up carries it.
|
||||
const ephemeral = definition.access === 'linked'
|
||||
await interaction.deferReply({ ephemeral })
|
||||
|
||||
const res = await appInternal.dispatchCommand({
|
||||
command: definition.name,
|
||||
options: collectOptions(interaction, definition),
|
||||
platformUserId: interaction.user.id,
|
||||
guildId: interaction.guildId,
|
||||
})
|
||||
|
||||
// A transport failure and a handler failure are the same sentence to the
|
||||
// member and different lines in the log: one is the app being unreachable,
|
||||
// the other is a module's code.
|
||||
if (!res.ok) {
|
||||
log.warn('command dispatch failed', { command: definition.name, error: res.error })
|
||||
await interaction.editReply({ content: refusal({ reason: 'error' }) })
|
||||
return true
|
||||
}
|
||||
if (!res.data || !res.data.ok) {
|
||||
await interaction.editReply({ content: refusal(res.data || {}) })
|
||||
return true
|
||||
}
|
||||
|
||||
const envelope = res.data.response || {}
|
||||
await interaction.editReply(render(envelope))
|
||||
|
||||
// The private aside beside a public answer (§9 answer 5). Skipped when the
|
||||
// reply was already private — the member would just be told the same thing
|
||||
// twice, in the same place.
|
||||
if (envelope.notice && !ephemeral && !envelope.ephemeral) {
|
||||
await interaction.followUp({ content: envelope.notice, ephemeral: true })
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Test-only: the pulled set is process-global, so a test that pulls has to be
|
||||
// able to hand the process back.
|
||||
function _reset() {
|
||||
pulled = []
|
||||
version = null
|
||||
}
|
||||
|
||||
module.exports = { pull, definitions, has, execute, _reset }
|
||||
Reference in New Issue
Block a user