Phase 7 of TEAMS.md. `api.registerSlashCommands` stops throwing: a module registers a command's DEFINITION and its HANDLER together, the bot pulls the definitions over the internal listener and runs none of our code, and the handler executes here — forced by the bot container having no `modules` volume, and the right boundary anyway. Registration validates what Discord would reject as a batch (names, description lengths, the four option types, required-before-optional), because the bot registers the whole set in one PUT and a single bad entry costs every command including the bot's own. Commands are not namespaced under their owner — there is no dot in Discord's name grammar — so collisions are first-come with the holder named. The dispatcher is the access boundary: `linked` has no Discord equivalent, so the platform-side permission default can only ever be advertising. It resolves the actor by `auth_providers.kind` rather than the id slug, treats a banned account as unlinked, bounds a handler under the bot's own timeout, and keeps `ok` outside the envelope so a handler cannot forge it. Liveness is asked at both the pull and the dispatch. The registries have no removal path, so a module an operator disables at runtime would otherwise keep a live handler behind a command Discord still advertises. Co-Authored-By: Claude <noreply@anthropic.com>
78 lines
3.1 KiB
JavaScript
78 lines
3.1 KiB
JavaScript
// The bot→app internal client (TEAMS.md §7.1).
|
||
//
|
||
// One property carries this file: the base URL is DERIVED from
|
||
// `SITE_INTERNAL_URL`, which already names the app's internal listener with a
|
||
// path on the end. That derivation is the reason every existing deployment gains
|
||
// slash commands with no compose change, and it is exactly the kind of string
|
||
// handling that breaks silently — a wrong base means "the app is down" forever,
|
||
// with nothing in the logs but a fetch error.
|
||
|
||
const { test, beforeEach, afterEach } = require('node:test')
|
||
const assert = require('node:assert/strict')
|
||
|
||
const env = { ...process.env }
|
||
const realFetch = global.fetch
|
||
|
||
beforeEach(() => {
|
||
process.env.SITE_INTERNAL_URL = 'http://app:3001/internal/bot-config'
|
||
process.env.BOT_INTERNAL_KEY = 'shh'
|
||
delete require.cache[require.resolve('../src/site/appInternalClient')]
|
||
})
|
||
|
||
afterEach(() => {
|
||
process.env = { ...env }
|
||
global.fetch = realFetch
|
||
})
|
||
|
||
/** Load the client fresh and record the single fetch it makes. */
|
||
function withFetch(response) {
|
||
const seen = {}
|
||
global.fetch = async (url, init) => {
|
||
seen.url = url
|
||
seen.init = init
|
||
return response
|
||
}
|
||
// eslint-disable-next-line global-require
|
||
return { client: require('../src/site/appInternalClient'), seen }
|
||
}
|
||
|
||
const ok = (body) => ({ ok: true, status: 200, json: async () => body })
|
||
|
||
test('the commands URL is the internal listener’s origin, not its bot-config path', async () => {
|
||
const { client, seen } = withFetch(ok({ version: 3, commands: [] }))
|
||
const res = await client.fetchCommands()
|
||
assert.equal(seen.url, 'http://app:3001/internal/commands')
|
||
assert.equal(seen.init.headers['X-Internal-Key'], 'shh')
|
||
assert.deepEqual(res.data, { version: 3, commands: [] })
|
||
})
|
||
|
||
test('a dispatch names the platform, so the app never has to guess', async () => {
|
||
const { client, seen } = withFetch(ok({ ok: true, response: {} }))
|
||
await client.dispatchCommand({ command: 'guild', options: { name: 'KOC' }, platformUserId: '5', guildId: '9' })
|
||
assert.equal(seen.url, 'http://app:3001/internal/commands/dispatch')
|
||
assert.deepEqual(JSON.parse(seen.init.body), {
|
||
command: 'guild', options: { name: 'KOC' }, platform: 'discord', platformUserId: '5', guildId: '9',
|
||
})
|
||
})
|
||
|
||
// A bot with no internal URL configured is an ordinary deployment state (the
|
||
// warning already exists in bootstrap.js); it must not become an exception on
|
||
// every `ready`.
|
||
test('an unconfigured or unparseable SITE_INTERNAL_URL is a refusal, not a throw', async () => {
|
||
delete process.env.SITE_INTERNAL_URL
|
||
const { client } = withFetch(ok({}))
|
||
assert.equal((await client.fetchCommands()).ok, false)
|
||
|
||
delete require.cache[require.resolve('../src/site/appInternalClient')]
|
||
process.env.SITE_INTERNAL_URL = 'not a url'
|
||
// eslint-disable-next-line global-require
|
||
assert.equal((await require('../src/site/appInternalClient').fetchCommands()).ok, false)
|
||
})
|
||
|
||
test('a non-2xx carries its status so the caller can tell "down" from "rejected"', async () => {
|
||
const { client } = withFetch({ ok: false, status: 401, json: async () => ({}) })
|
||
const res = await client.fetchCommands()
|
||
assert.equal(res.ok, false)
|
||
assert.equal(res.status, 401)
|
||
})
|