feat(teams): the slash-command seam, and the first command through it
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>
This commit is contained in:
76
bot/src/site/appInternalClient.js
Normal file
76
bot/src/site/appInternalClient.js
Normal file
@@ -0,0 +1,76 @@
|
||||
// Shared-secret client for the APP's internal listener (port 3001) — the
|
||||
// bot→app direction of the channel `botInternalClient.js` runs app→bot.
|
||||
//
|
||||
// Two callers, both slash-command plumbing (TEAMS.md §7.1): pull the registered
|
||||
// command definitions, and dispatch one that a member has just run. Distinct
|
||||
// from siteApiClient.js, which reads the site's PUBLIC API with no secret at all.
|
||||
//
|
||||
// **The base URL is derived from `SITE_INTERNAL_URL`'s origin, not configured
|
||||
// separately.** That variable already points at the app's internal listener —
|
||||
// `http://app:3001/internal/bot-config` — and adding a second variable naming the
|
||||
// same host would be one more thing an operator can get half-right. Deriving it
|
||||
// means every existing deployment gains these endpoints with no compose change.
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('app-internal')
|
||||
|
||||
const KEY = process.env.BOT_INTERNAL_KEY || ''
|
||||
|
||||
// §7.1's budget, and the same 4s `botInternalClient` uses in the other
|
||||
// direction. The app bounds its own handlers UNDER this (3s), so a timeout here
|
||||
// normally means the app itself is unreachable rather than a module being slow.
|
||||
const TIMEOUT_MS = 4000
|
||||
|
||||
function baseUrl() {
|
||||
const configured = process.env.SITE_INTERNAL_URL
|
||||
if (!configured) return null
|
||||
try {
|
||||
return new URL(configured).origin
|
||||
} catch {
|
||||
log.error('SITE_INTERNAL_URL is not a URL — slash-command registration is off', { configured })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function call(path, { method = 'GET', body } = {}) {
|
||||
const base = baseUrl()
|
||||
if (!base || !KEY) return { ok: false, error: 'SITE_INTERNAL_URL or BOT_INTERNAL_KEY not set' }
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
||||
try {
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', 'X-Internal-Key': KEY },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok) return { ok: false, status: res.status, error: `app responded ${res.status}` }
|
||||
return { ok: true, status: res.status, data: await res.json() }
|
||||
} catch (err) {
|
||||
log.warn('app internal call failed', { path, message: err.message })
|
||||
return { ok: false, status: 0, error: err.message }
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
/** The registered slash-command definitions, plus the version they belong to. */
|
||||
function fetchCommands() {
|
||||
return call('/internal/commands')
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one command in the app and get the response envelope back.
|
||||
*
|
||||
* The bot has already deferred by the time this is called, so the only deadline
|
||||
* that matters is Discord's 15-minute follow-up window — TIMEOUT_MS is about not
|
||||
* holding an interaction open on a wedged app, not about the 3-second ack.
|
||||
*/
|
||||
function dispatchCommand({ command, options, platformUserId, guildId }) {
|
||||
return call('/internal/commands/dispatch', {
|
||||
method: 'POST',
|
||||
body: { command, options, platform: 'discord', platformUserId, guildId },
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { fetchCommands, dispatchCommand }
|
||||
Reference in New Issue
Block a user