fix(teams): a private answer has to be private, and the deferral decides that
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

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>
This commit is contained in:
2026-08-18 19:08:12 -05:00
parent cecd72915f
commit aca4d23179
2 changed files with 85 additions and 9 deletions

View File

@@ -145,6 +145,30 @@ function render(envelope) {
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.
*
@@ -160,11 +184,17 @@ 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.
// **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 })
@@ -178,18 +208,20 @@ async function execute(interaction) {
// 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 interaction.editReply({ content: refusal({ reason: 'error' }) })
await reply(interaction, { text: refusal({ reason: 'error' }), ephemeral: true }, ephemeral)
return true
}
if (!res.data || !res.data.ok) {
await interaction.editReply({ content: refusal(res.data || {}) })
await reply(interaction, { text: refusal(res.data || {}), ephemeral: true }, ephemeral)
return true
}
const envelope = res.data.response || {}
await interaction.editReply(render(envelope))
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