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:
@@ -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 }
|
||||
|
||||
239
server/src/utils/slashCommands.js
Normal file
239
server/src/utils/slashCommands.js
Normal 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 }
|
||||
Reference in New Issue
Block a user