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:
2026-08-18 18:53:34 -05:00
parent b1d3b87cd6
commit cecd72915f
21 changed files with 1459 additions and 27 deletions

View File

@@ -72,4 +72,16 @@ function reverseModAction({ discordUserId, actionType, appealId }) {
})
}
module.exports = { pushConfig, getStatus, announce, reverseModAction }
// Site -> bot: the registered slash-command set has moved, re-pull it
// (TEAMS.md §7.1). Best-effort like everything else here — a bot that is down
// re-pulls on its next `ready` anyway, so a missed nudge costs nothing but the
// delay until the bot reconnects.
//
// Its own endpoint rather than a field on pushConfig, whose body carries the
// DECRYPTED bot token: telling the bot that a module changed should not require
// reading a secret out of the database.
function refreshCommands() {
return call('/internal/refresh-commands', { method: 'POST', body: {} })
}
module.exports = { pushConfig, getStatus, announce, reverseModAction, refreshCommands }

View File

@@ -0,0 +1,239 @@
// Chat-platform slash commands: the actor resolver, the access gate, and the
// dispatcher that calls a registrant's handler (TEAMS.md §7.1, API 1.6.0).
//
// Where this sits. The bot owns every Discord-specific concern — deferral, the
// 3-second ack, ephemerality, follow-ups, interaction tokens, embeds — and this
// file owns everything that is not Discord-shaped: who is asking, whether they
// may, and what the answer is. Nothing below imports discord.js or knows what an
// interaction is, which is the whole point: the second platform reuses all of it.
//
// The handler runs HERE rather than in the bot because the bot container has no
// `modules` volume and physically cannot load module code (§0.4).
const authProviders = require('../model/authProviders/authProviders.model')
const userIdentities = require('../model/userIdentities/userIdentities.model')
const users = require('../model/users/users.model')
const modules = require('../modules/loader')
const registries = require('../modules/registries')
const log = require('./logger')('slash-commands')
// The same two roles every other Team surface calls staff (teamGrants.STAFF_ROLES).
// Required here rather than duplicated, so a change to what "staff" means reaches
// the Discord surface without anyone having to remember this file exists.
const { STAFF_ROLES } = require('../model/teams/teamGrants.model')
// The handler's own budget, deliberately UNDER the bot's 4s dispatch timeout.
//
// If the bot's abort fires first, the app is left running a handler whose answer
// nobody will read, and the bot reports the same "that failed" either way. Losing
// the race on purpose means the wedged handler is identified HERE, in a log line
// that names the module, and the request ends.
const HANDLER_TIMEOUT_MS = 3000
// Discord's hard limits on what can be rendered. Enforced on the way OUT because
// an oversized reply fails inside the bot's `editReply`, where the module that
// produced it cannot be seen — the caller would get "that command failed" for a
// command whose handler worked perfectly. Truncation is silent to the user and
// logged for the operator.
const MAX_TEXT = 2000
const MAX_FIELDS = 25
const MAX_FIELD_NAME = 256
const MAX_FIELD_VALUE = 1024
const MAX_URL = 512
const clamp = (value, max) => (String(value).length > max ? `${String(value).slice(0, max - 1)}` : String(value))
/**
* Is this registrant's command answerable right now?
*
* **The registries have no removal path.** Registration happens once, during
* `load()`, and nothing takes a claim back — a module the operator disables at
* runtime keeps its streams and its announce legs, and would keep its commands
* too. That is tolerable for a stream (a catalog entry nobody publishes to) and
* NOT tolerable for a command, whose handler is live code an operator believes
* they just switched off.
*
* So liveness is asked at the two moments it matters — the pull and the
* dispatch — and it is asked HERE, once, so the two can never disagree. §7.1's
* "deregistration on module unload is free" holds across the restart an
* uninstall asks for; this is the same promise kept for the runtime toggle,
* which that paragraph did not consider.
*
* `core` is not a module and has no record; it is live whenever the process is.
*/
function ownerIsLive(owner) {
if (owner === 'core') return true
if (!modules.isLoaded()) return false
const record = modules.list().find((m) => m.id === owner)
return Boolean(record && record.state === 'started')
}
/**
* What the bot pulls: the live definitions, and the counter it re-registers on.
*
* Before `load()` has run there is nothing registered and nothing to say, which
* is a legitimate answer rather than an error — a bot that connects during boot
* pulls an empty set and is nudged once the modules are up. Throwing here would
* look to the bot exactly like the app being broken.
*/
function definitions() {
if (!modules.isLoaded()) return { version: 0, commands: [] }
return {
version: modules.version(),
commands: registries.slashCommandDefinitions().filter((c) => ownerIsLive(c.owner)),
}
}
/**
* Who is running this command, in platform-neutral terms.
*
* A handler never parses a platform payload and never learns anything
* platform-shaped beyond `platform` itself. Everything here is resolved before
* the handler is entered, so a module cannot decide for itself who it is talking
* to — the actor is core's answer, not the caller's claim.
*
* **The Discord provider is found by `kind`, not by id.** `auth_providers.id` is
* an operator-chosen slug and only the CONVENTIONAL deployment calls it
* "discord"; the kind column is the enum. Resolving by id would silently return
* "not linked" for every user on a deployment that named its provider anything
* else, which reads as a bug in linking rather than a bug here.
*
* A non-active account resolves to UNLINKED rather than to itself: a banned or
* disabled user keeping `access: 'linked'` commands would make Discord the one
* surface a ban does not reach.
*/
async function resolveActor({ platform, platformUserId, guildId = null }) {
const actor = {
platform,
platformUserId: platformUserId ? String(platformUserId) : null,
guildId: guildId || null,
userId: null,
role: null,
isLinked: false,
isStaff: false,
}
if (platform !== 'discord' || !actor.platformUserId) return actor
const providers = (await authProviders.list()).filter((p) => p.kind === 'discord')
for (const provider of providers) {
// eslint-disable-next-line no-await-in-loop
const identity = await userIdentities.findByProviderSubject(provider.id, actor.platformUserId)
if (!identity) continue
// eslint-disable-next-line no-await-in-loop
const user = await users.getById(identity.user_id)
if (!user || user.status !== 'active') continue
actor.userId = user.id
// `role` alongside `isStaff`, because the two answer different questions and
// collapsing them loses one. `isStaff` is core's gate for `access: 'staff'`;
// `role` is what a module needs to place the caller on its OWN ladder — a
// module with audience rungs distinguishes admin from moderator and cannot
// from a boolean. It is the same pair `projectRoster`'s viewer already
// carries (§3.3), not a new class of disclosure.
actor.role = user.role
actor.isLinked = true
actor.isStaff = STAFF_ROLES.includes(user.role)
break
}
return actor
}
/** Does this actor clear the command's declared access level? */
function permitted(access, actor) {
if (access === 'staff') return actor.isStaff
if (access === 'linked') return actor.isLinked
return true
}
/**
* Shape and clamp whatever a handler returned into the response envelope.
*
* A handler returning nothing at all is a handler that ran and had nothing to
* say, which is not an error — the bot renders it as an empty acknowledgement.
*/
function envelope(result, command) {
const out = {}
const value = result || {}
if (value.text) out.text = clamp(value.text, MAX_TEXT)
if (value.title) out.title = clamp(value.title, MAX_FIELD_NAME)
if (Array.isArray(value.fields) && value.fields.length) {
if (value.fields.length > MAX_FIELDS) {
log.warn('handler returned too many fields; truncated', { command, fields: value.fields.length })
}
out.fields = value.fields.slice(0, MAX_FIELDS).map((f) => ({
name: clamp(f && f.name ? f.name : '—', MAX_FIELD_NAME),
value: clamp(f && f.value ? f.value : '—', MAX_FIELD_VALUE),
inline: Boolean(f && f.inline),
}))
}
// Absolute http(s) only. A handler builds its own link from `ctx.site.baseUrl`
// — core cannot, since Teams have no core page to link to (§3.1 as amended) —
// so the scheme check is the whole of what is enforced here: anything else,
// `javascript:` above all, is dropped rather than posted into a channel the
// operator's members trust.
if (value.url && /^https?:\/\//i.test(value.url)) out.url = clamp(value.url, MAX_URL)
out.ephemeral = Boolean(value.ephemeral)
// A private aside to the caller, delivered ALONGSIDE a public answer — §9
// answer 5's "the public projection plus an ephemeral prompt to link". One
// reply cannot be both public and ephemeral, so this is a second message, and
// that it is a second message is the platform's business rather than the
// handler's: `notice` says "say this to the caller only" and the bot decides it
// is a follow-up.
if (value.notice) out.notice = clamp(value.notice, MAX_TEXT)
return out
}
/**
* Run one command and answer with `{ ok, ... }`.
*
* **Never throws, and never returns a handler's own `ok`.** The result is nested
* under `response` precisely so a module cannot forge the success flag the bot
* branches on — the envelope is data the handler produced, `ok` is core's verdict
* on whether it produced it.
*
* A refusal carries a `reason` and no user-facing copy: the phrasing of "you need
* to link your account" is a platform's own business, and putting the sentence
* here would be core writing Discord's voice.
*
* Failure isolation is per handler, per call. A module whose handler throws or
* wedges costs its own command and nothing else — and cannot cost the bot
* anything at all, because the handler does not run there.
*/
async function dispatch({ command, options = {}, platform = 'discord', platformUserId = null, guildId = null }) {
const entry = registries.slashCommand(command)
// A disabled module's command is UNKNOWN, not forbidden: Discord may still be
// advertising it — the whole-set PUT that removes it has not necessarily
// happened yet — and "there is no such command" is the truthful answer for one
// whose owner is switched off.
if (!entry || !ownerIsLive(entry.owner)) return { ok: false, reason: 'unknown' }
let actor
try {
actor = await resolveActor({ platform, platformUserId, guildId })
} catch (err) {
// Identity resolution is core's, not the module's — a database hiccup here
// is not the command failing, and saying so would send an operator to read
// module code that never ran.
log.error('actor resolution failed', { command, message: err.message })
return { ok: false, reason: 'error' }
}
// The gate. The bot also sets Discord-side default member permissions from
// `access` where it can, but that is advertising; this is the boundary.
if (!permitted(entry.access, actor)) {
return { ok: false, reason: 'forbidden', access: entry.access, isLinked: actor.isLinked }
}
try {
const result = await Promise.race([
entry.handler({ command, options, actor }),
new Promise((_, reject) => {
setTimeout(() => reject(new Error('handler timed out')), HANDLER_TIMEOUT_MS).unref()
}),
])
return { ok: true, response: envelope(result, command) }
} catch (err) {
log.error('slash command handler failed', { command, owner: entry.owner, message: err.message })
return { ok: false, reason: 'error' }
}
}
module.exports = { definitions, resolveActor, dispatch, envelope, ownerIsLive, HANDLER_TIMEOUT_MS }