Files
website/bot/src/discord/dynamicCommands.js
Claude aca4d23179
All checks were successful
PR Checks / client-build (pull_request) Successful in 39s
PR Checks / server-tests (pull_request) Successful in 42s
PR Checks / bot-tests (pull_request) Successful in 8m50s
fix(teams): a private answer has to be private, and the deferral decides that
Found on the live rig. Ephemerality is a property of the DEFERRAL, which happens
before the handler has said anything — so the envelope's `ephemeral` was being
read and then ignored, and `/guild`'s "not shown to your account" refusal was
posted into the channel, announcing a member's access level to everyone in it.

When the handler wants privacy the deferral did not give it, the deferred reply
is now withdrawn and the answer arrives as an ephemeral follow-up. The
interaction token stays valid, so this is a supported path and not a trick; the
cost is a "thinking..." that appears and vanishes. There is no reverse case — a
command deferred privately must not become public because a handler omitted a
flag — and a refusal is always private whatever the command's usual privacy.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 19:08:12 -05:00

243 lines
10 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 }