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>
This commit is contained in:
@@ -145,6 +145,30 @@ function render(envelope) {
|
|||||||
return { embeds: [embed] }
|
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.
|
* Defer, dispatch, edit.
|
||||||
*
|
*
|
||||||
@@ -160,11 +184,17 @@ async function execute(interaction) {
|
|||||||
const definition = pulled.find((c) => c.name === interaction.commandName)
|
const definition = pulled.find((c) => c.name === interaction.commandName)
|
||||||
if (!definition) return false
|
if (!definition) return false
|
||||||
|
|
||||||
// Ephemerality has to be decided BEFORE the answer exists, because it is a
|
// **Ephemerality is fixed at the DEFERRAL, which happens before the answer
|
||||||
// property of the deferral. A command declared for linked users only is
|
// exists.** That is Discord's rule, not a choice here, and it is the whole
|
||||||
// answered privately by default — its answer is about the caller's own
|
// reason this needs care: the handler decides privacy per answer — a refusal
|
||||||
// account — and everything else defers publicly; a handler that wants the
|
// is private, a guild summary is not — and by the time it says so the reply is
|
||||||
// opposite says so, and the follow-up carries it.
|
// 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'
|
const ephemeral = definition.access === 'linked'
|
||||||
await interaction.deferReply({ ephemeral })
|
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
|
// 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,
|
// member and different lines in the log: one is the app being unreachable,
|
||||||
// the other is a module's code.
|
// 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) {
|
if (!res.ok) {
|
||||||
log.warn('command dispatch failed', { command: definition.name, error: res.error })
|
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
|
return true
|
||||||
}
|
}
|
||||||
if (!res.data || !res.data.ok) {
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const envelope = res.data.response || {}
|
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
|
// 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
|
// reply was already private — the member would just be told the same thing
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ function fakeInteraction({ commandName = 'guild', options = {}, userId = '555' }
|
|||||||
},
|
},
|
||||||
deferReply: async (payload) => calls.push(['defer', payload]),
|
deferReply: async (payload) => calls.push(['defer', payload]),
|
||||||
editReply: async (payload) => calls.push(['edit', payload]),
|
editReply: async (payload) => calls.push(['edit', payload]),
|
||||||
|
deleteReply: async () => calls.push(['delete']),
|
||||||
followUp: async (payload) => calls.push(['followUp', payload]),
|
followUp: async (payload) => calls.push(['followUp', payload]),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,6 +196,46 @@ test('a notice is not repeated when the answer was already private', async () =>
|
|||||||
|
|
||||||
// Every failure path EDITS. Replying to a deferred interaction throws, so a
|
// Every failure path EDITS. Replying to a deferred interaction throws, so a
|
||||||
// refusal that used reply() would turn a clean "no" into an unhandled error.
|
// refusal that used reply() would turn a clean "no" into an unhandled error.
|
||||||
|
// Ephemerality is fixed at the DEFERRAL, which happens before the handler has
|
||||||
|
// said anything — so honouring a per-answer flag needs the deferred reply
|
||||||
|
// withdrawn. The live walk caught the version that ignored it posting "guild
|
||||||
|
// information is not shown to your account" into the channel, which announces a
|
||||||
|
// member's access level to everyone in it.
|
||||||
|
test('a handler asking for privacy gets it, even though the deferral was public', async () => {
|
||||||
|
answers([definition()])
|
||||||
|
await dynamic.pull()
|
||||||
|
appInternal.dispatchCommand = async () => ({
|
||||||
|
ok: true, data: { ok: true, response: { text: 'just for you', ephemeral: true } },
|
||||||
|
})
|
||||||
|
const interaction = fakeInteraction()
|
||||||
|
await dynamic.execute(interaction)
|
||||||
|
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'delete', 'followUp'])
|
||||||
|
assert.deepEqual(interaction.calls.at(-1)[1], { content: 'just for you', ephemeral: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an already-private deferral just edits — no second message', async () => {
|
||||||
|
answers([definition({ access: 'linked' })])
|
||||||
|
await dynamic.pull()
|
||||||
|
appInternal.dispatchCommand = async () => ({
|
||||||
|
ok: true, data: { ok: true, response: { text: 'private', ephemeral: true } },
|
||||||
|
})
|
||||||
|
const interaction = fakeInteraction()
|
||||||
|
await dynamic.execute(interaction)
|
||||||
|
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'edit'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// "You do not have access to that" is about one member and belongs to one
|
||||||
|
// member, whatever the command's usual privacy.
|
||||||
|
test('a refusal is always private', async () => {
|
||||||
|
answers([definition()])
|
||||||
|
await dynamic.pull()
|
||||||
|
appInternal.dispatchCommand = async () => ({ ok: true, data: { ok: false, reason: 'forbidden' } })
|
||||||
|
const interaction = fakeInteraction()
|
||||||
|
await dynamic.execute(interaction)
|
||||||
|
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'delete', 'followUp'])
|
||||||
|
assert.equal(interaction.calls.at(-1)[1].ephemeral, true)
|
||||||
|
})
|
||||||
|
|
||||||
test('a refusal is phrased by the bot and edited into the deferred reply', async () => {
|
test('a refusal is phrased by the bot and edited into the deferred reply', async () => {
|
||||||
answers([definition({ access: 'linked' })])
|
answers([definition({ access: 'linked' })])
|
||||||
await dynamic.pull()
|
await dynamic.pull()
|
||||||
@@ -204,7 +245,9 @@ test('a refusal is phrased by the bot and edited into the deferred reply', async
|
|||||||
const interaction = fakeInteraction()
|
const interaction = fakeInteraction()
|
||||||
await dynamic.execute(interaction)
|
await dynamic.execute(interaction)
|
||||||
assert.match(interaction.calls.at(-1)[1].content, /Link your Discord account/)
|
assert.match(interaction.calls.at(-1)[1].content, /Link your Discord account/)
|
||||||
assert.equal(interaction.calls.filter(([kind]) => kind === 'edit').length, 1)
|
// Deferred ephemerally (access: 'linked'), so the refusal is one edit and no
|
||||||
|
// withdrawal — replying twice to a deferred interaction is what throws.
|
||||||
|
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'edit'])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('an unreachable app is the same sentence to the member and a different line in the log', async () => {
|
test('an unreachable app is the same sentence to the member and a different line in the log', async () => {
|
||||||
@@ -214,6 +257,7 @@ test('an unreachable app is the same sentence to the member and a different line
|
|||||||
const interaction = fakeInteraction()
|
const interaction = fakeInteraction()
|
||||||
await dynamic.execute(interaction)
|
await dynamic.execute(interaction)
|
||||||
assert.match(interaction.calls.at(-1)[1].content, /Something went wrong/)
|
assert.match(interaction.calls.at(-1)[1].content, /Something went wrong/)
|
||||||
|
assert.equal(interaction.calls.at(-1)[1].ephemeral, true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('an interaction for a command the app no longer serves is left alone', async () => {
|
test('an interaction for a command the app no longer serves is left alone', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user