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>
270 lines
12 KiB
JavaScript
270 lines
12 KiB
JavaScript
// ── The bot's half of module slash commands (TEAMS.md §7.1) ────────────────
|
||
//
|
||
// The first tests in this package, and they exist for a specific reason: phases
|
||
// 8 and 9 put more of the Discord integration in this process, and the failure
|
||
// modes here are ones no unit test in `server/` can see — a whole-set PUT that
|
||
// one bad entry poisons, a deferral that has to happen before anything slow, and
|
||
// a reply that must be EDITED rather than sent once the interaction is deferred.
|
||
//
|
||
// Nothing here talks to Discord. `interaction` is a fake that records what was
|
||
// called on it, which is the whole of what this file is asserting about.
|
||
|
||
const { test, beforeEach } = require('node:test')
|
||
const assert = require('node:assert/strict')
|
||
|
||
const dynamic = require('../src/discord/dynamicCommands')
|
||
const appInternal = require('../src/site/appInternalClient')
|
||
const staticCommands = require('../src/discord/commands')
|
||
|
||
const originals = {
|
||
fetchCommands: appInternal.fetchCommands,
|
||
dispatchCommand: appInternal.dispatchCommand,
|
||
get: staticCommands.get,
|
||
}
|
||
|
||
beforeEach(() => {
|
||
dynamic._reset()
|
||
Object.assign(appInternal, originals)
|
||
staticCommands.get = originals.get
|
||
})
|
||
|
||
const definition = (over = {}) => ({
|
||
name: 'guild',
|
||
description: 'Show a guild',
|
||
owner: 'uo',
|
||
access: 'everyone',
|
||
options: [{ name: 'name', type: 'string', description: 'Guild name', required: false }],
|
||
...over,
|
||
})
|
||
|
||
const answers = (commands, version = 1) => {
|
||
appInternal.fetchCommands = async () => ({ ok: true, data: { version, commands } })
|
||
}
|
||
|
||
function fakeInteraction({ commandName = 'guild', options = {}, userId = '555' } = {}) {
|
||
const calls = []
|
||
return {
|
||
calls,
|
||
commandName,
|
||
guildId: '999',
|
||
user: { id: userId },
|
||
options: {
|
||
get: (name) => (name in options ? { value: options[name] } : null),
|
||
},
|
||
deferReply: async (payload) => calls.push(['defer', payload]),
|
||
editReply: async (payload) => calls.push(['edit', payload]),
|
||
deleteReply: async () => calls.push(['delete']),
|
||
followUp: async (payload) => calls.push(['followUp', payload]),
|
||
}
|
||
}
|
||
|
||
// ── Pulling ────────────────────────────────────────────────────────────────
|
||
|
||
test('a pull reports whether the set moved, so a nudge is cheap', async () => {
|
||
answers([definition()], 7)
|
||
assert.deepEqual(await dynamic.pull(), { ok: true, changed: true, count: 1 })
|
||
// Same version, same size: nothing to re-register, and re-registering anyway
|
||
// would mean a REST.put per module state change instead of per real change.
|
||
assert.deepEqual(await dynamic.pull(), { ok: true, changed: false, count: 1 })
|
||
answers([definition()], 8)
|
||
assert.equal((await dynamic.pull()).changed, true)
|
||
})
|
||
|
||
// Otherwise a restart blip would deregister every module command from Discord
|
||
// and re-register it a minute later, with members watching it happen.
|
||
test('a failed pull keeps the set already registered', async () => {
|
||
answers([definition()])
|
||
await dynamic.pull()
|
||
appInternal.fetchCommands = async () => ({ ok: false, error: 'ECONNREFUSED' })
|
||
assert.deepEqual(await dynamic.pull(), { ok: false, changed: false, count: 1 })
|
||
assert.equal(dynamic.definitions().length, 1)
|
||
})
|
||
|
||
// The collision the app cannot see: it validates against what IT registered and
|
||
// does not know the bot's own array exists. Two entries of one name in a single
|
||
// PUT is rejected as a batch, taking the built-ins down with it.
|
||
test('a module command that collides with a built-in is dropped, not registered', async () => {
|
||
staticCommands.get = (name) => (name === 'ping' ? { data: { name: 'ping' } } : undefined)
|
||
answers([definition({ name: 'ping' }), definition()])
|
||
await dynamic.pull()
|
||
assert.deepEqual(dynamic.definitions().map((d) => d.name), ['guild'])
|
||
assert.equal(dynamic.has('ping'), false)
|
||
})
|
||
|
||
test('definitions carry Discord’s numeric option types, not the contract’s names', async () => {
|
||
answers([definition({
|
||
options: [
|
||
{ name: 'who', type: 'user', description: 'A member', required: true },
|
||
{ name: 'n', type: 'integer', description: 'How many', choices: [{ name: 'one', value: 1 }] },
|
||
],
|
||
})])
|
||
await dynamic.pull()
|
||
const [data] = dynamic.definitions()
|
||
assert.deepEqual(data.options.map((o) => o.type), [6, 4])
|
||
assert.deepEqual(data.options[1].choices, [{ name: 'one', value: 1 }])
|
||
assert.equal(data.default_member_permissions, undefined)
|
||
})
|
||
|
||
// `linked` has no Discord equivalent — there is no "has a website account"
|
||
// predicate — so only `staff` maps, and the app re-checks both regardless.
|
||
test('only access: staff becomes a Discord permission default', async () => {
|
||
answers([definition({ access: 'staff' }), definition({ name: 'other', access: 'linked' })])
|
||
await dynamic.pull()
|
||
const [staff, linked] = dynamic.definitions()
|
||
assert.equal(typeof staff.default_member_permissions, 'string')
|
||
assert.equal(linked.default_member_permissions, undefined)
|
||
})
|
||
|
||
// ── Executing ──────────────────────────────────────────────────────────────
|
||
|
||
test('the deferral happens before the dispatch, always', async () => {
|
||
answers([definition()])
|
||
await dynamic.pull()
|
||
let deferredFirst = false
|
||
const interaction = fakeInteraction()
|
||
appInternal.dispatchCommand = async () => {
|
||
deferredFirst = interaction.calls.length === 1 && interaction.calls[0][0] === 'defer'
|
||
return { ok: true, data: { ok: true, response: { text: 'hi' } } }
|
||
}
|
||
await dynamic.execute(interaction)
|
||
assert.ok(deferredFirst, 'the website is never in Discord’s 3-second ack path')
|
||
assert.deepEqual(interaction.calls.at(-1), ['edit', { content: 'hi' }])
|
||
})
|
||
|
||
test('the options the member supplied are passed by name, as plain values', async () => {
|
||
answers([definition({
|
||
options: [
|
||
{ name: 'name', type: 'string', description: 'd' },
|
||
{ name: 'who', type: 'user', description: 'd' },
|
||
{ name: 'missing', type: 'string', description: 'd' },
|
||
],
|
||
})])
|
||
await dynamic.pull()
|
||
let sent = null
|
||
appInternal.dispatchCommand = async (body) => {
|
||
sent = body
|
||
return { ok: true, data: { ok: true, response: {} } }
|
||
}
|
||
await dynamic.execute(fakeInteraction({ options: { name: 'KOC', who: '42' } }))
|
||
assert.deepEqual(sent.options, { name: 'KOC', who: '42' })
|
||
assert.equal(sent.platformUserId, '555')
|
||
assert.equal(sent.guildId, '999')
|
||
})
|
||
|
||
test('a title or fields render as an embed; a bare text does not', async () => {
|
||
answers([definition()])
|
||
await dynamic.pull()
|
||
appInternal.dispatchCommand = async () => ({
|
||
ok: true,
|
||
data: { ok: true, response: { title: 'Knights', text: 'Alliance: Accord', fields: [{ name: 'Members', value: '12' }], url: 'https://site.test/uo/guilds/7' } },
|
||
})
|
||
const interaction = fakeInteraction()
|
||
await dynamic.execute(interaction)
|
||
const [, payload] = interaction.calls.at(-1)
|
||
assert.equal(payload.embeds[0].title, 'Knights')
|
||
assert.equal(payload.embeds[0].description, 'Alliance: Accord')
|
||
assert.equal(payload.embeds[0].url, 'https://site.test/uo/guilds/7')
|
||
})
|
||
|
||
// §9 answer 5: the public projection, plus a private nudge to link. One reply
|
||
// cannot be both, so the aside is a follow-up — which is the bot's decision to
|
||
// make, not the handler's.
|
||
test('a notice becomes an ephemeral follow-up beside a public answer', async () => {
|
||
answers([definition()])
|
||
await dynamic.pull()
|
||
appInternal.dispatchCommand = async () => ({
|
||
ok: true,
|
||
data: { ok: true, response: { text: 'public', notice: 'Link your account' } },
|
||
})
|
||
const interaction = fakeInteraction()
|
||
await dynamic.execute(interaction)
|
||
assert.deepEqual(interaction.calls.at(-1), ['followUp', { content: 'Link your account', ephemeral: true }])
|
||
})
|
||
|
||
test('a notice is not repeated when the answer was already private', async () => {
|
||
answers([definition({ access: 'linked' })])
|
||
await dynamic.pull()
|
||
appInternal.dispatchCommand = async () => ({
|
||
ok: true,
|
||
data: { ok: true, response: { text: 'private', notice: 'Link your account' } },
|
||
})
|
||
const interaction = fakeInteraction()
|
||
await dynamic.execute(interaction)
|
||
assert.deepEqual(interaction.calls[0], ['defer', { ephemeral: true }])
|
||
assert.equal(interaction.calls.some(([kind]) => kind === 'followUp'), false)
|
||
})
|
||
|
||
// 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.
|
||
// 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 () => {
|
||
answers([definition({ access: 'linked' })])
|
||
await dynamic.pull()
|
||
appInternal.dispatchCommand = async () => ({
|
||
ok: true, data: { ok: false, reason: 'forbidden', access: 'linked', isLinked: false },
|
||
})
|
||
const interaction = fakeInteraction()
|
||
await dynamic.execute(interaction)
|
||
assert.match(interaction.calls.at(-1)[1].content, /Link your Discord account/)
|
||
// 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 () => {
|
||
answers([definition()])
|
||
await dynamic.pull()
|
||
appInternal.dispatchCommand = async () => ({ ok: false, error: 'timeout' })
|
||
const interaction = fakeInteraction()
|
||
await dynamic.execute(interaction)
|
||
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 () => {
|
||
answers([definition()])
|
||
await dynamic.pull()
|
||
const interaction = fakeInteraction({ commandName: 'gone' })
|
||
assert.equal(await dynamic.execute(interaction), false)
|
||
assert.deepEqual(interaction.calls, [], 'nothing is deferred for a command that is not ours')
|
||
})
|