// 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] } } /** * Deliver the envelope at the privacy the HANDLER asked for, not the privacy the * deferral guessed. * * When the two agree — the ordinary case — this is one `editReply`. When the * handler wants a private answer to a publicly deferred command, the deferred * reply is deleted and the answer arrives as an ephemeral follow-up: the * interaction token stays valid, so this is a supported path rather than a * trick, and the cost is a "thinking…" that appears and vanishes. * * There is no reverse case. A command deferred ephemerally is one whose answers * are all about the caller's own account, and nothing it returns should become * public because a handler forgot a flag. */ async function reply(interaction, envelope, deferredEphemeral) { const payload = render(envelope) if (!envelope.ephemeral || deferredEphemeral) { await interaction.editReply(payload) return } await interaction.deleteReply() await interaction.followUp({ ...payload, ephemeral: true }) } /** * 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 is fixed at the DEFERRAL, which happens before the answer // exists.** That is Discord's rule, not a choice here, and it is the whole // reason this needs care: the handler decides privacy per answer — a refusal // is private, a guild summary is not — and by the time it says so the reply is // already public or already not. // // So: defer for the common case (public, or private for a command that only // ever speaks about the caller's own account), and if the envelope disagrees, // reconcile below. Getting this wrong is not cosmetic — the live walk caught it // posting "guild information is not shown to your account" into the channel, // which announces a member's access level to everyone in 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. // A refusal is ALWAYS private, whatever the command's usual privacy: "you do // not have access to that" is about one member and belongs to one member. if (!res.ok) { log.warn('command dispatch failed', { command: definition.name, error: res.error }) await reply(interaction, { text: refusal({ reason: 'error' }), ephemeral: true }, ephemeral) return true } if (!res.data || !res.data.ok) { await reply(interaction, { text: refusal(res.data || {}), ephemeral: true }, ephemeral) return true } const envelope = res.data.response || {} await reply(interaction, envelope, ephemeral) // 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 }