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:
@@ -2047,6 +2047,22 @@
|
||||
"gates": [
|
||||
"requireInternalKey"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/internal/commands",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"requireInternalKey"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/internal/commands/dispatch",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"requireInternalKey"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -842,6 +842,14 @@
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/internal/bot-config"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/internal/commands"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/internal/commands/dispatch"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -157,6 +157,10 @@ async function forumGuests(teamId) {
|
||||
module.exports = {
|
||||
CAP_KEY,
|
||||
DEFAULT_CAP,
|
||||
// Exported since phase 7: the Discord dispatcher's `access: 'staff'` has to
|
||||
// mean the same two roles every other Team surface means by it, and a second
|
||||
// copy of the list is a copy that drifts.
|
||||
STAFF_ROLES,
|
||||
grantCap,
|
||||
authorityFor,
|
||||
grant,
|
||||
|
||||
@@ -31,6 +31,33 @@ const log = require('../utils/logger')('modules')
|
||||
// such budget on purpose — it delays the listener binding, which is the feature.
|
||||
const SHUTDOWN_BUDGET_MS = 5000
|
||||
|
||||
/**
|
||||
* Nudge the bot to re-pull the slash-command set, from the two places that
|
||||
* actually change it in a live process: a boot, and an operator disabling a
|
||||
* module (which `remove` and `purge` both run through).
|
||||
*
|
||||
* Enabling and installing are deliberately NOT here — both ask for a restart
|
||||
* before the module runs, and a command whose handler is not registered yet is a
|
||||
* command that would answer "unknown". The nudge follows the state, not the
|
||||
* intention.
|
||||
*
|
||||
* Required lazily, and deliberately NOT awaited by either caller: the bot is
|
||||
* optional infrastructure, and neither a boot nor an operator's disable should
|
||||
* wait out `botInternalClient`'s 4s timeout because a bot container is wedged.
|
||||
* Nothing here throws — a failed nudge is a log line, and the bot re-pulls on its
|
||||
* next `ready` regardless.
|
||||
*/
|
||||
async function nudgeBot(why) {
|
||||
try {
|
||||
// eslint-disable-next-line global-require
|
||||
const bot = require('../utils/botInternalClient')
|
||||
const res = await bot.refreshCommands()
|
||||
if (!res.ok) log.info('bot did not take the slash-command nudge', { why, error: res.error })
|
||||
} catch (err) {
|
||||
log.warn('slash-command nudge failed', { why, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one database call for one module without letting it become everyone's
|
||||
* failure. Returns null on failure, having logged it.
|
||||
@@ -179,6 +206,14 @@ async function boot({ modules, model } = {}) {
|
||||
// is a stale projection, never a site that will not start.
|
||||
// eslint-disable-next-line global-require
|
||||
await safe('starting the team reconciler', () => require('../model/teams/teamSync.model').start())
|
||||
|
||||
// Tell the bot the slash-command set may have moved (TEAMS.md §7.1).
|
||||
//
|
||||
// The bot pulls on its own `ready` too, so this is not the only path — it is
|
||||
// the path for the case `ready` does not cover: the APP restarting while the
|
||||
// bot stays connected, which is every ordinary redeploy. Without it, a module
|
||||
// added in that deploy has no command until someone restarts the bot.
|
||||
nudgeBot('boot')
|
||||
}
|
||||
|
||||
/** Reject if `fn`'s promise has not settled within `ms`. */
|
||||
@@ -280,6 +315,7 @@ async function stop(id, { modules, model, budgetMs = SHUTDOWN_BUDGET_MS } = {})
|
||||
}
|
||||
|
||||
await safe(`disabling module "${id}"`, () => rows.disable(id))
|
||||
nudgeBot(`disable:${id}`)
|
||||
return { stopped, error }
|
||||
}
|
||||
|
||||
|
||||
@@ -282,13 +282,16 @@ function buildApi(record) {
|
||||
once('registerTeamProvider')
|
||||
record.staged.registerTeamProvider(provider)
|
||||
},
|
||||
// Declared in 1.6.0 with the rest of the Team surface; the bot half that
|
||||
// executes a command lands in phase 7 (§7.1). Present and throwing rather
|
||||
// than absent, so a module written against the published version fails at
|
||||
// registration with a sentence naming the phase, instead of at whatever
|
||||
// moment someone first types the command.
|
||||
registerSlashCommands() {
|
||||
throw new Error('api.registerSlashCommands is not available until Discord slash commands land (TEAMS.md §7.1)')
|
||||
// Chat-platform slash commands (API 1.6.0, §7.1), live since phase 7. Like
|
||||
// registerTeamProvider above, the handler this stages is core CALLING THE
|
||||
// MODULE and waiting for an answer — but from further away than any other
|
||||
// member: the caller is a bot in another container, holding a Discord
|
||||
// interaction open on a deadline. `once` for the same reason the two
|
||||
// registries above take it — a second call is a module changing its mind
|
||||
// halfway through register(), not adding to what it already said.
|
||||
registerSlashCommands(commands) {
|
||||
once('registerSlashCommands')
|
||||
record.staged.registerSlashCommands(commands)
|
||||
},
|
||||
// The two lifecycle hooks (§2.5). Registered here, dispatched from
|
||||
// lifecycle.js — this file runs with no database and the hooks run with one.
|
||||
|
||||
@@ -68,6 +68,18 @@ const postHooks = new Map()
|
||||
// second registration is a collision rather than an addition.
|
||||
let teamProvider = null
|
||||
|
||||
// command name → { owner, name, description, options, access, handler }. Slash
|
||||
// commands a registrant has published for the chat platform (API 1.6.0,
|
||||
// TEAMS.md §7.1).
|
||||
//
|
||||
// The DEFINITION and the HANDLER are registered together and the handler runs
|
||||
// HERE, in the website process; the bot pulls the definitions over the internal
|
||||
// API and owns every Discord-specific concern. That split is forced — the bot
|
||||
// container has no `modules` volume, so a module physically cannot put a handler
|
||||
// in it (§0.4) — and it is also the boundary we would pick anyway: a module
|
||||
// calling `interaction.deferReply()` would be a module holding a Discord handle.
|
||||
const slashCommands = new Map()
|
||||
|
||||
let coreRegistered = false
|
||||
|
||||
// Stream ids that predate the module system and may not carry their owner's
|
||||
@@ -214,6 +226,23 @@ const registeredTeamProvider = () => teamProvider
|
||||
/** Is there a Team provider at all? Read by the reconciler and the read API. */
|
||||
const hasTeamProvider = () => teamProvider !== null
|
||||
|
||||
// ── Slash commands (TEAMS.md §7.1) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every registered command WITHOUT its handler — what `/internal/commands`
|
||||
* serves to the bot.
|
||||
*
|
||||
* The handler is stripped rather than merely un-serialisable-and-ignored: this
|
||||
* is the object that crosses a process boundary, and the definition half is the
|
||||
* whole of what the bot is allowed to know. `owner` rides along so the bot can
|
||||
* name the module in a collision warning.
|
||||
*/
|
||||
const slashCommandDefinitions = () =>
|
||||
[...slashCommands.values()].map(({ handler, ...definition }) => definition)
|
||||
|
||||
/** One command, handler included. The dispatcher's lookup. */
|
||||
const slashCommand = (name) => slashCommands.get(name) || null
|
||||
|
||||
// ── Shape checks, run the moment a registrant calls ────────────────────────
|
||||
//
|
||||
// Split from the collision checks below on the same line PR 3 drew through
|
||||
@@ -315,6 +344,94 @@ function checkPageUrlTemplate(value) {
|
||||
return value
|
||||
}
|
||||
|
||||
// A slash command's name and description are validated HERE and not only at the
|
||||
// bot, for a reason worth stating: the bot registers the whole set in a single
|
||||
// `REST.put(applicationGuildCommands)`, so ONE malformed definition is rejected
|
||||
// by Discord as a batch and takes every other command down with it — including
|
||||
// the bot's own. A definition that cannot be registered must therefore fail at
|
||||
// `register()`, where it belongs to a module that can be named and marked
|
||||
// failed, rather than at the next `ready` where it looks like the bot is broken.
|
||||
//
|
||||
// **Commands are NOT namespaced under their owner, unlike every other id in this
|
||||
// file.** Discord's name grammar has no `.` in it, so `uo.guild` is unregistrable
|
||||
// and the prefix rule cannot be expressed. Collisions are caught by first-come
|
||||
// instead, with the holder named — and the bot resolves the one collision core
|
||||
// cannot see (a pulled name against its own built-ins) in the module's disfavour.
|
||||
const SLASH_NAME = /^[a-z0-9_-]{1,32}$/
|
||||
const SLASH_ACCESS = ['everyone', 'linked', 'staff']
|
||||
|
||||
// §7.1.1: `string | integer | boolean | user`, and deliberately nothing else. No
|
||||
// subcommand groups, autocomplete, attachments, modals or component
|
||||
// interactions. Those are exactly the features whose semantics do not survive a
|
||||
// second platform, and admitting one here is how Discord specifics leak into a
|
||||
// platform-agnostic registration API by accident.
|
||||
const SLASH_OPTION_TYPES = ['string', 'integer', 'boolean', 'user']
|
||||
|
||||
function checkSlashOption(command, option) {
|
||||
const { name, type, description, required, choices } = option || {}
|
||||
const where = `registerSlashCommands: ${command}`
|
||||
if (!SLASH_NAME.test(name || '')) throw new Error(`${where}: bad option name "${name}"`)
|
||||
if (!SLASH_OPTION_TYPES.includes(type)) {
|
||||
throw new Error(`${where}: option "${name}" has unsupported type "${type}" (§7.1.1)`)
|
||||
}
|
||||
if (!description || description.length > 100) {
|
||||
throw new Error(`${where}: option "${name}" needs a description of 1-100 characters`)
|
||||
}
|
||||
const out = { name, type, description, required: Boolean(required) }
|
||||
if (choices !== undefined) {
|
||||
if (!Array.isArray(choices) || !choices.length) {
|
||||
throw new Error(`${where}: option "${name}" has an empty choices list`)
|
||||
}
|
||||
// Only the two option types Discord itself allows choices on. `boolean` is
|
||||
// already a two-value choice and `user` is a picker; a choices list on
|
||||
// either is a misunderstanding worth failing rather than dropping.
|
||||
if (type !== 'string' && type !== 'integer') {
|
||||
throw new Error(`${where}: option "${name}" is ${type}; choices need string or integer`)
|
||||
}
|
||||
out.choices = choices.map((c) => {
|
||||
if (!c || !c.name || c.value === undefined) {
|
||||
throw new Error(`${where}: option "${name}" has a choice with no name/value`)
|
||||
}
|
||||
return { name: String(c.name), value: c.value }
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* `registerSlashCommands([{ name, description, options, access, handler }])`.
|
||||
*
|
||||
* `access` is enforced TWICE and this copy is not the gate: the bot sets
|
||||
* Discord-side default member permissions from it where it can, and the
|
||||
* dispatcher re-checks it on every call. Client-side is about not advertising a
|
||||
* dead end; the server is the boundary — the same principle the nav follows.
|
||||
*/
|
||||
function checkSlashCommandShape(entry) {
|
||||
const { name, description, options, access, handler } = entry || {}
|
||||
if (!SLASH_NAME.test(name || '')) {
|
||||
throw new Error(`registerSlashCommands: bad command name "${name}" (lowercase, 1-32, no dots)`)
|
||||
}
|
||||
if (!description || description.length > 100) {
|
||||
throw new Error(`registerSlashCommands: ${name} needs a description of 1-100 characters`)
|
||||
}
|
||||
if (typeof handler !== 'function') throw new Error(`registerSlashCommands: ${name} has no handler()`)
|
||||
if (access !== undefined && !SLASH_ACCESS.includes(access)) {
|
||||
throw new Error(`registerSlashCommands: ${name} has unknown access "${access}"`)
|
||||
}
|
||||
if (options !== undefined && !Array.isArray(options)) {
|
||||
throw new Error(`registerSlashCommands: ${name} options must be an array`)
|
||||
}
|
||||
const checked = (options || []).map((o) => checkSlashOption(name, o))
|
||||
// Discord rejects a definition that puts an optional option before a required
|
||||
// one, and does it for the whole batch. Sorting silently would change what the
|
||||
// module wrote; this is the module's own ordering bug and it gets its name.
|
||||
const firstOptional = checked.findIndex((o) => !o.required)
|
||||
if (firstOptional !== -1 && checked.slice(firstOptional).some((o) => o.required)) {
|
||||
throw new Error(`registerSlashCommands: ${name} lists a required option after an optional one`)
|
||||
}
|
||||
return { name, description, options: checked, access: access || 'everyone', handler }
|
||||
}
|
||||
|
||||
/**
|
||||
* `registerPostHook({ onSaved, onDeleted })` — both optional, at least one
|
||||
* required. A registration with neither is a subscription that can never fire,
|
||||
@@ -355,7 +472,9 @@ function checkExtensionShape(slot, router, specFile) {
|
||||
* `allStreams()` / `announceLeg()` / the slot routers until `apply()`.
|
||||
*/
|
||||
function stage(owner) {
|
||||
const staged = { owner, streams: [], legs: [], extensions: [], postHooks: [], teamProviders: [] }
|
||||
const staged = {
|
||||
owner, streams: [], legs: [], extensions: [], postHooks: [], teamProviders: [], slashCommands: [],
|
||||
}
|
||||
return {
|
||||
staged,
|
||||
registerNotificationStreams(entries) {
|
||||
@@ -374,6 +493,10 @@ function stage(owner) {
|
||||
registerTeamProvider(entry) {
|
||||
staged.teamProviders.push(checkTeamProviderShape(entry))
|
||||
},
|
||||
registerSlashCommands(entries) {
|
||||
if (!Array.isArray(entries)) throw new Error('registerSlashCommands: expected an array')
|
||||
for (const e of entries) staged.slashCommands.push(checkSlashCommandShape(e))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,6 +516,7 @@ function apply({
|
||||
extensions: newExtensions,
|
||||
postHooks: newPostHooks = [],
|
||||
teamProviders: newTeamProviders = [],
|
||||
slashCommands: newSlashCommands = [],
|
||||
}) {
|
||||
// ── validate ──
|
||||
const seenStreams = new Set()
|
||||
@@ -437,6 +561,14 @@ function apply({
|
||||
throw new Error(`a team provider is already registered by "${teamProvider.owner}"`)
|
||||
}
|
||||
|
||||
const seenCommands = new Set()
|
||||
for (const c of newSlashCommands) {
|
||||
const held = slashCommands.get(c.name)
|
||||
if (held) throw new Error(`slash command "/${c.name}" is already registered by "${held.owner}"`)
|
||||
if (seenCommands.has(c.name)) throw new Error(`slash command "/${c.name}" registered twice`)
|
||||
seenCommands.add(c.name)
|
||||
}
|
||||
|
||||
// ── commit — nothing below can fail ──
|
||||
for (const s of newStreams) {
|
||||
streamOwners.set(s.id, owner)
|
||||
@@ -451,6 +583,7 @@ function apply({
|
||||
}
|
||||
for (const h of newPostHooks) postHooks.set(owner, h)
|
||||
for (const p of newTeamProviders) teamProvider = { owner, ...p }
|
||||
for (const c of newSlashCommands) slashCommands.set(c.name, { owner, ...c })
|
||||
}
|
||||
|
||||
// ── Core's own registrations ───────────────────────────────────────────────
|
||||
@@ -517,6 +650,7 @@ function _reset() {
|
||||
legs.clear()
|
||||
postHooks.clear()
|
||||
teamProvider = null
|
||||
slashCommands.clear()
|
||||
coreRegistered = false
|
||||
}
|
||||
|
||||
@@ -536,6 +670,8 @@ module.exports = {
|
||||
dispatchPostHook,
|
||||
registeredTeamProvider,
|
||||
hasTeamProvider,
|
||||
slashCommandDefinitions,
|
||||
slashCommand,
|
||||
stage,
|
||||
apply,
|
||||
registerCore,
|
||||
|
||||
@@ -16,13 +16,12 @@
|
||||
// client slots `team.overview` / `team.member.row`. module-uo's `coreApi:
|
||||
// "^1.3.0"` still resolves.
|
||||
//
|
||||
// **The number covers the whole surface; the members arrive by phase.** The three
|
||||
// this phase implements are live. `activity.push` lands with the Team activity
|
||||
// feed (§4, phase 3) and `registerSlashCommands` with the Discord commands (§7.1,
|
||||
// phase 7) — until then each is present and THROWS rather than being absent or,
|
||||
// worse, silently accepting data into a table that does not exist. MODULE_API.md
|
||||
// names the phase against each member, so a module author reads what is callable
|
||||
// today rather than discovering it at runtime.
|
||||
// **The number covers the whole surface; the members arrived by phase, and all of
|
||||
// them have now arrived.** `activity.push` landed with the Team activity feed
|
||||
// (§4, phase 3) and `registerSlashCommands` with the Discord commands (§7.1,
|
||||
// phase 7); until each did, it was present and THREW rather than being absent or,
|
||||
// worse, silently accepting data into a table that did not exist. Nothing in
|
||||
// 1.6.0 throws any more.
|
||||
//
|
||||
// 1.5.0 — a CLIENT addition: `PublicLayout` takes an optional `shell` prop that
|
||||
// renders the page body wrapper core's own pages write by hand (MODULE_API.md
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const botConfig = require('../../../model/botConfig/botConfig.model')
|
||||
const slashCommands = require('../../../utils/slashCommands')
|
||||
const log = require('../../../utils/logger')('internal')
|
||||
|
||||
// GET /internal/bot-config — called by the bot process on its own boot so a
|
||||
@@ -16,4 +17,40 @@ async function getBotConfig(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getBotConfig }
|
||||
// GET /internal/commands — the registered slash-command definitions, pulled by
|
||||
// the bot on `ready` and again whenever it is nudged (TEAMS.md §7.1).
|
||||
//
|
||||
// `version` is `modules.version()`, the counter every module state change bumps.
|
||||
// The bot holds the value it registered with and re-PUTs only when it differs,
|
||||
// which is what makes DEREGISTRATION free: the bot's single whole-set
|
||||
// `REST.put(applicationGuildCommands)` means a module that is gone is simply
|
||||
// absent from the next pull, with nobody having to remember to unregister it.
|
||||
function listCommands(req, res) {
|
||||
return res.json(slashCommands.definitions())
|
||||
}
|
||||
|
||||
// POST /internal/commands/dispatch — run one command and answer with the
|
||||
// envelope. Never 500s on a handler's behalf: `dispatch` catches per handler and
|
||||
// reports `{ ok: false, reason }`, so the bot always has something to render and
|
||||
// a module's failure is its own.
|
||||
async function dispatchCommand(req, res) {
|
||||
const { command, options, platform, platformUserId, guildId } = req.body || {}
|
||||
if (!command) return res.status(400).json({ ok: false, reason: 'unknown' })
|
||||
try {
|
||||
const result = await slashCommands.dispatch({
|
||||
command,
|
||||
options: options && typeof options === 'object' ? options : {},
|
||||
platform: platform || 'discord',
|
||||
platformUserId,
|
||||
guildId,
|
||||
})
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
// dispatch() is documented never to throw; if it ever does, that is core's
|
||||
// bug and not the module's, and it is logged as one.
|
||||
log.error('internal.dispatchCommand', err)
|
||||
return res.status(500).json({ ok: false, reason: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getBotConfig, listCommands, dispatchCommand }
|
||||
|
||||
@@ -16,4 +16,20 @@ router.get(
|
||||
ctrl.getBotConfig,
|
||||
)
|
||||
|
||||
// The slash-command seam (TEAMS.md §7.1). Both stay off the public API and out
|
||||
// of the OpenAPI document for the same reason /bot-config does: the caller is
|
||||
// the bot process on the private compose network, and `/internal/*` is not a
|
||||
// published contract.
|
||||
router.get(
|
||||
'/commands',
|
||||
// #swagger.ignore = true
|
||||
ctrl.listCommands,
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/commands/dispatch',
|
||||
// #swagger.ignore = true
|
||||
ctrl.dispatchCommand,
|
||||
)
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -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 }
|
||||
261
server/test/slashCommands.test.js
Normal file
261
server/test/slashCommands.test.js
Normal file
@@ -0,0 +1,261 @@
|
||||
// ── Slash commands: registration, the actor, and the dispatcher ────────────
|
||||
//
|
||||
// TEAMS.md §7.1, phase 7. Three things are worth a test here and none of them
|
||||
// can be exercised by hand without a Discord guild:
|
||||
//
|
||||
// 1. **Registration rejects a definition Discord would reject as a batch.** The
|
||||
// bot registers the whole set in one PUT, so one bad option type costs every
|
||||
// command — including the bot's own. That has to fail at `register()`.
|
||||
// 2. **The dispatcher is the access boundary**, not the Discord-side permission
|
||||
// default, which cannot express "has a linked account" at all.
|
||||
// 3. **A handler's failure is its own.** A throw, a hang, and a disabled owner
|
||||
// each produce an answer the bot can render, and never an exception.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, beforeEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const registries = require('../src/modules/registries')
|
||||
const slash = require('../src/utils/slashCommands')
|
||||
const loader = require('../src/modules/loader')
|
||||
const authProviders = require('../src/model/authProviders/authProviders.model')
|
||||
const userIdentities = require('../src/model/userIdentities/userIdentities.model')
|
||||
const users = require('../src/model/users/users.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const originals = {
|
||||
isLoaded: loader.isLoaded,
|
||||
list: loader.list,
|
||||
version: loader.version,
|
||||
providerList: authProviders.list,
|
||||
findIdentity: userIdentities.findByProviderSubject,
|
||||
getUser: users.getById,
|
||||
}
|
||||
|
||||
// The loader is stubbed rather than driven: `load()` needs a modules directory
|
||||
// on disk, and every property below is about what the registry and the
|
||||
// dispatcher do with a state, not about how the state was reached.
|
||||
function loaded(state = 'started', id = 'uo') {
|
||||
loader.isLoaded = () => true
|
||||
loader.list = () => [{ id, state }]
|
||||
loader.version = () => 42
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
registries._reset()
|
||||
Object.assign(loader, { isLoaded: originals.isLoaded, list: originals.list, version: originals.version })
|
||||
Object.assign(authProviders, { list: originals.providerList })
|
||||
Object.assign(userIdentities, { findByProviderSubject: originals.findIdentity })
|
||||
Object.assign(users, { getById: originals.getUser })
|
||||
})
|
||||
|
||||
const cmd = (over = {}) => ({
|
||||
name: 'guild',
|
||||
description: 'Show a guild',
|
||||
handler: async () => ({ text: 'ok' }),
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Register a batch as `owner`; returns the error message, or null on success. */
|
||||
function tryRegister(owner, commands) {
|
||||
const api = registries.stage(owner)
|
||||
try {
|
||||
api.registerSlashCommands(commands)
|
||||
registries.apply(api.staged)
|
||||
return null
|
||||
} catch (err) {
|
||||
return err.message
|
||||
}
|
||||
}
|
||||
|
||||
// ── Registration ───────────────────────────────────────────────────────────
|
||||
|
||||
test('a registered command is served without its handler', () => {
|
||||
assert.equal(tryRegister('uo', [cmd()]), null)
|
||||
const [definition] = registries.slashCommandDefinitions()
|
||||
assert.equal(definition.name, 'guild')
|
||||
assert.equal(definition.owner, 'uo')
|
||||
assert.equal(definition.access, 'everyone', 'access defaults rather than being undefined on the wire')
|
||||
assert.equal(definition.handler, undefined, 'the handler never crosses the process boundary')
|
||||
assert.equal(typeof registries.slashCommand('guild').handler, 'function')
|
||||
})
|
||||
|
||||
// Discord's name grammar has no dot in it, so the owner-prefix rule every other
|
||||
// id in registries.js follows cannot be expressed here. Collisions are the
|
||||
// substitute, and they have to name the holder.
|
||||
test('two registrants cannot hold the same command name', () => {
|
||||
tryRegister('uo', [cmd()])
|
||||
assert.match(tryRegister('rust', [cmd()]), /already registered by "uo"/)
|
||||
// The same name twice inside ONE batch, which the held-by check above cannot
|
||||
// catch — nothing is committed yet when the second entry is validated.
|
||||
registries._reset()
|
||||
assert.match(tryRegister('uo', [cmd(), cmd()]), /registered twice/)
|
||||
})
|
||||
|
||||
test('a name Discord would reject is refused at registration', () => {
|
||||
assert.match(tryRegister('uo', [cmd({ name: 'uo.guild' })]), /bad command name/)
|
||||
assert.match(tryRegister('uo', [cmd({ name: 'Guild' })]), /bad command name/)
|
||||
assert.match(tryRegister('uo', [cmd({ name: 'g'.repeat(33) })]), /bad command name/)
|
||||
assert.match(tryRegister('uo', [cmd({ description: '' })]), /description of 1-100/)
|
||||
assert.match(tryRegister('uo', [cmd({ handler: 'nope' })]), /has no handler/)
|
||||
assert.match(tryRegister('uo', [cmd({ access: 'members' })]), /unknown access "members"/)
|
||||
})
|
||||
|
||||
// §7.1.1 keeps the option schema small on purpose: subcommand groups,
|
||||
// autocomplete, attachments and modals are the features whose semantics do not
|
||||
// survive a second platform.
|
||||
test('only the four option types survive registration', () => {
|
||||
const opt = (over) => cmd({ options: [{ name: 'x', type: 'string', description: 'd', ...over }] })
|
||||
assert.equal(tryRegister('uo', [opt({})]), null)
|
||||
registries._reset()
|
||||
assert.match(tryRegister('uo', [opt({ type: 'attachment' })]), /unsupported type "attachment"/)
|
||||
assert.match(tryRegister('uo', [opt({ type: 'boolean', choices: [{ name: 'a', value: 1 }] })]), /choices need string or integer/)
|
||||
assert.match(tryRegister('uo', [opt({ choices: [] })]), /empty choices list/)
|
||||
})
|
||||
|
||||
// Discord rejects the whole batch for this, so it cannot be left to be
|
||||
// discovered at the next `ready`.
|
||||
test('a required option after an optional one is refused', () => {
|
||||
const options = [
|
||||
{ name: 'a', type: 'string', description: 'd', required: false },
|
||||
{ name: 'b', type: 'string', description: 'd', required: true },
|
||||
]
|
||||
assert.match(tryRegister('uo', [cmd({ options })]), /required option after an optional one/)
|
||||
})
|
||||
|
||||
// The validate-then-commit rule the rest of registries.js follows: a batch that
|
||||
// fails leaves nothing behind, or the bot would pull a half-registered set.
|
||||
test('a batch that fails registers none of it', () => {
|
||||
tryRegister('uo', [cmd(), cmd({ name: 'bad name' })])
|
||||
assert.deepEqual(registries.slashCommandDefinitions(), [])
|
||||
})
|
||||
|
||||
// ── Liveness ───────────────────────────────────────────────────────────────
|
||||
|
||||
// The registries have no removal path — nothing takes a registration back — so a
|
||||
// module an operator disables at runtime would otherwise keep answering.
|
||||
test('a disabled module’s command disappears from the pull and stops dispatching', async () => {
|
||||
tryRegister('uo', [cmd()])
|
||||
loaded('started')
|
||||
assert.equal(slash.definitions().commands.length, 1)
|
||||
assert.equal(slash.definitions().version, 42)
|
||||
|
||||
loaded('disabled')
|
||||
assert.deepEqual(slash.definitions().commands, [])
|
||||
assert.deepEqual(await slash.dispatch({ command: 'guild' }), { ok: false, reason: 'unknown' })
|
||||
})
|
||||
|
||||
test('before load() there is nothing registered and that is an answer, not an error', () => {
|
||||
loader.isLoaded = () => false
|
||||
assert.deepEqual(slash.definitions(), { version: 0, commands: [] })
|
||||
})
|
||||
|
||||
// ── The actor ──────────────────────────────────────────────────────────────
|
||||
|
||||
function identity({ providerId = 'discord', kind = 'discord', role = 'player', status = 'active' } = {}) {
|
||||
authProviders.list = async () => [{ id: providerId, kind }]
|
||||
userIdentities.findByProviderSubject = async (provider, subject) =>
|
||||
(provider === providerId && subject === '555' ? { user_id: 9 } : null)
|
||||
users.getById = async (id) => (id === 9 ? { id: 9, role, status } : null)
|
||||
}
|
||||
|
||||
// `auth_providers.id` is an operator-chosen slug; `kind` is the enum. Resolving
|
||||
// by id would report "not linked" for every user on a deployment that named its
|
||||
// Discord provider anything else.
|
||||
test('the Discord provider is found by kind, whatever the operator named it', async () => {
|
||||
identity({ providerId: 'our-discord' })
|
||||
const actor = await slash.resolveActor({ platform: 'discord', platformUserId: '555' })
|
||||
assert.deepEqual(actor, {
|
||||
platform: 'discord', platformUserId: '555', guildId: null, userId: 9, role: 'player', isLinked: true, isStaff: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('a non-Discord kind on a provider named "discord" does not link anyone', async () => {
|
||||
identity({ kind: 'oidc' })
|
||||
const actor = await slash.resolveActor({ platform: 'discord', platformUserId: '555' })
|
||||
assert.equal(actor.isLinked, false)
|
||||
})
|
||||
|
||||
// Otherwise Discord would be the one surface a ban does not reach.
|
||||
test('a banned or disabled account resolves as unlinked', async () => {
|
||||
identity({ status: 'banned' })
|
||||
const actor = await slash.resolveActor({ platform: 'discord', platformUserId: '555' })
|
||||
assert.equal(actor.isLinked, false)
|
||||
assert.equal(actor.userId, null)
|
||||
})
|
||||
|
||||
test('staff is the same two roles every other Team surface means by it', async () => {
|
||||
identity({ role: 'moderator' })
|
||||
assert.equal((await slash.resolveActor({ platform: 'discord', platformUserId: '555' })).isStaff, true)
|
||||
identity({ role: 'editor' })
|
||||
assert.equal((await slash.resolveActor({ platform: 'discord', platformUserId: '555' })).isStaff, false)
|
||||
})
|
||||
|
||||
// ── Dispatch ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('access is enforced by the dispatcher, not by Discord’s permission model', async () => {
|
||||
identity({ role: 'player' })
|
||||
loaded()
|
||||
tryRegister('uo', [cmd({ name: 'linked-only', access: 'linked' }), cmd({ name: 'staff-only', access: 'staff' })])
|
||||
|
||||
const unlinked = { command: 'linked-only', platformUserId: 'nobody' }
|
||||
assert.deepEqual(await slash.dispatch(unlinked), {
|
||||
ok: false, reason: 'forbidden', access: 'linked', isLinked: false,
|
||||
})
|
||||
// A linked player clears `linked` and not `staff` — the distinction Discord's
|
||||
// default_member_permissions cannot express at all.
|
||||
assert.equal((await slash.dispatch({ command: 'linked-only', platformUserId: '555' })).ok, true)
|
||||
assert.equal((await slash.dispatch({ command: 'staff-only', platformUserId: '555' })).reason, 'forbidden')
|
||||
})
|
||||
|
||||
test('a handler that throws costs its own command and answers renderably', async () => {
|
||||
loaded()
|
||||
tryRegister('uo', [cmd({ handler: async () => { throw new Error('boom') } })])
|
||||
assert.deepEqual(await slash.dispatch({ command: 'guild' }), { ok: false, reason: 'error' })
|
||||
})
|
||||
|
||||
test('a handler that hangs is bounded, not waited on', async () => {
|
||||
loaded()
|
||||
tryRegister('uo', [cmd({ handler: () => new Promise(() => {}) })])
|
||||
const started = Date.now()
|
||||
const res = await slash.dispatch({ command: 'guild' })
|
||||
assert.deepEqual(res, { ok: false, reason: 'error' })
|
||||
assert.ok(Date.now() - started < slash.HANDLER_TIMEOUT_MS + 500)
|
||||
})
|
||||
|
||||
// `ok` is core's verdict on whether the handler produced an answer, so it lives
|
||||
// outside the envelope where a handler cannot write it.
|
||||
test('a handler cannot forge the success flag', async () => {
|
||||
loaded()
|
||||
tryRegister('uo', [cmd({ handler: async () => ({ ok: false, text: 'hi' }) })])
|
||||
const res = await slash.dispatch({ command: 'guild' })
|
||||
assert.equal(res.ok, true)
|
||||
assert.equal(res.response.ok, undefined)
|
||||
assert.equal(res.response.text, 'hi')
|
||||
})
|
||||
|
||||
// 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
|
||||
// handler that worked.
|
||||
test('the envelope is clamped to what Discord will render', () => {
|
||||
const out = slash.envelope({
|
||||
text: 'x'.repeat(3000),
|
||||
fields: Array.from({ length: 30 }, (_, i) => ({ name: `n${i}`, value: 'v'.repeat(2000) })),
|
||||
url: 'javascript:alert(1)',
|
||||
notice: 'link your account',
|
||||
}, 'guild')
|
||||
assert.equal(out.text.length, 2000)
|
||||
assert.equal(out.fields.length, 25)
|
||||
assert.equal(out.fields[0].value.length, 1024)
|
||||
assert.equal(out.url, undefined, 'only absolute http(s) survives')
|
||||
assert.equal(out.notice, 'link your account')
|
||||
})
|
||||
|
||||
test('a handler that returns nothing has simply said nothing', async () => {
|
||||
loaded()
|
||||
tryRegister('uo', [cmd({ handler: async () => undefined })])
|
||||
assert.deepEqual(await slash.dispatch({ command: 'guild' }), { ok: true, response: { ephemeral: false } })
|
||||
})
|
||||
Reference in New Issue
Block a user