Five read-only commands registered with api.registerSlashCommands: /status, /wipe, /top, /online and /clan (D126). Every refusal is private, and any answer narrower than public (online names, a clan roster) goes to the caller alone (D127). No command asks a sidecar. The next wipe (D128, D130): six nullable columns on rust_servers, a pure nextWipe(row, now) with the zone arithmetic through Intl, computed on every read. The public server shape gains nextWipe; the admin shape gains the stored schedule; PUT /admin/rust/servers/:id takes the six fields and writes them only when wipeRule is present. server/commands joins ci/bundle.json, which checkBundle caught. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
371 lines
16 KiB
JavaScript
371 lines
16 KiB
JavaScript
// ── The slash commands (phase 16) ─────────────────────────────────────────
|
||
//
|
||
// The handlers against stubbed models. What matters most is D127: **an answer
|
||
// is public only when everything in it is public.** Every refusal is private,
|
||
// and a permitted answer narrower than `public` goes to the caller alone — the
|
||
// case core cannot catch, because an answer without the flag is posted to the
|
||
// channel.
|
||
|
||
const test = require('node:test')
|
||
const assert = require('node:assert')
|
||
|
||
const { fakeCtx, fakeApi } = require('./_fakes')
|
||
|
||
// Accounts as `core.users.getById` answers them. The role on the ACTOR is never
|
||
// trusted — the row is re-read — so a test gives the actor one role and the row
|
||
// another to prove it.
|
||
const USERS = {
|
||
1: { id: 1, role: 'user', status: 'active' },
|
||
2: { id: 2, role: 'moderator', status: 'active' },
|
||
3: { id: 3, role: 'moderator', status: 'banned' },
|
||
}
|
||
|
||
function withCore() {
|
||
const ctx = fakeCtx({ users: { getById: async (id) => USERS[id] || null } })
|
||
require('../core')._reset()
|
||
require('../core').init(ctx)
|
||
return ctx
|
||
}
|
||
|
||
const actor = (userId, role = 'user') => ({
|
||
platform: 'discord',
|
||
platformUserId: `d${userId || 0}`,
|
||
guildId: 'g1',
|
||
userId: userId || null,
|
||
role: userId ? role : null,
|
||
isLinked: Boolean(userId),
|
||
isStaff: role === 'admin' || role === 'moderator',
|
||
})
|
||
const STRANGER = actor(null)
|
||
const PLAYER = actor(1)
|
||
const MOD = actor(2, 'moderator')
|
||
|
||
const server = (over = {}) => ({
|
||
id: 'main',
|
||
name: 'Main',
|
||
online: true,
|
||
players: 3,
|
||
maxPlayers: 100,
|
||
worldSize: 4000,
|
||
seed: 1234,
|
||
wipeId: 'w-2026-09',
|
||
wipedAt: '2026-09-03T18:00:00Z',
|
||
lastSeenAt: '2026-09-25T11:59:00Z',
|
||
stale: false,
|
||
nextWipe: { at: '2026-10-01T18:00:00.000Z', source: 'forced' },
|
||
...over,
|
||
})
|
||
|
||
/** Swap model functions for one test, and put them back whatever happens. */
|
||
async function stubbed(stubs, body) {
|
||
const saved = []
|
||
for (const [mod, name, fn] of stubs) {
|
||
saved.push([mod, name, mod[name]])
|
||
mod[name] = fn
|
||
}
|
||
try {
|
||
return await body()
|
||
} finally {
|
||
for (const [mod, name, fn] of saved) mod[name] = fn
|
||
}
|
||
}
|
||
|
||
function models() {
|
||
return {
|
||
servers: require('../model/servers/servers.model'),
|
||
events: require('../model/events/events.model'),
|
||
visibility: require('../model/visibility/visibility.model'),
|
||
clans: require('../model/clans/clans.model'),
|
||
visibilityDb: require('../model/visibility/visibility.db'),
|
||
}
|
||
}
|
||
|
||
const command = (name) => require('../commands').find((cmd) => cmd.name === name)
|
||
|
||
/** Everything an answer would put in front of a reader, as one string. */
|
||
const said = (env) => JSON.stringify([env.title, env.text, env.fields, env.notice])
|
||
|
||
// ── Registration ──────────────────────────────────────────────────────────
|
||
|
||
test('the entry point registers the five commands as one batch', () => {
|
||
withCore()
|
||
const api = fakeApi()
|
||
require('../index')(fakeCtx(), api)
|
||
assert.deepStrictEqual(api.record.slashCommands.map((cmd) => cmd.name), ['status', 'wipe', 'top', 'online', 'clan'])
|
||
})
|
||
|
||
test('every definition is one core will accept, and one Discord will', () => {
|
||
// Core's `checkSlashCommandShape` rules, restated: a definition it refuses
|
||
// fails the module at register(), and one Discord refuses takes every command
|
||
// in the batch down with it, the bot's own included.
|
||
const BUILT_INS = ['announce', 'autorole', 'ban', 'filter', 'filterallow', 'invite', 'kick', 'modlog', 'mute',
|
||
'news', 'ping', 'role', 'rolemenu', 'roles', 'schedule', 'warn', 'warnings', 'wiki']
|
||
for (const cmd of require('../commands')) {
|
||
assert.match(cmd.name, /^[a-z0-9_-]{1,32}$/)
|
||
assert.ok(!BUILT_INS.includes(cmd.name), `${cmd.name} collides with a bot built-in`)
|
||
assert.ok(cmd.description.length >= 1 && cmd.description.length <= 100, `${cmd.name}: description length`)
|
||
assert.strictEqual(cmd.access, 'everyone')
|
||
assert.strictEqual(typeof cmd.handler, 'function')
|
||
let optionalSeen = false
|
||
for (const o of cmd.options) {
|
||
assert.match(o.name, /^[a-z0-9_-]{1,32}$/)
|
||
assert.ok(['string', 'integer', 'boolean', 'user'].includes(o.type))
|
||
assert.ok(o.description.length >= 1 && o.description.length <= 100, `${cmd.name}.${o.name}: description length`)
|
||
if (o.choices) assert.ok(['string', 'integer'].includes(o.type))
|
||
if (!o.required) optionalSeen = true
|
||
else assert.ok(!optionalSeen, `${cmd.name}: a required option after an optional one`)
|
||
}
|
||
}
|
||
})
|
||
|
||
// ── Picking a server ──────────────────────────────────────────────────────
|
||
|
||
test('a server is named by id, then name, then a unique prefix — never guessed', () => {
|
||
withCore()
|
||
const { pickServer } = require('../commands/common')
|
||
const list = [server(), server({ id: 'eu-2x', name: 'EU 2x' }), server({ id: 'eu-5x', name: 'EU 5x' })]
|
||
|
||
assert.strictEqual(pickServer(list, 'MAIN').server.id, 'main')
|
||
assert.strictEqual(pickServer(list, 'eu 2x').server.id, 'eu-2x')
|
||
assert.strictEqual(pickServer(list, 'eu-5').server.id, 'eu-5x')
|
||
assert.strictEqual(pickServer(list, 'eu').ambiguous.length, 2)
|
||
assert.strictEqual(pickServer(list, 'us').missing, 'us')
|
||
assert.strictEqual(pickServer(list, undefined).all.length, 3)
|
||
assert.strictEqual(pickServer([server()], undefined).server.id, 'main', 'a fleet of one is that server')
|
||
assert.ok(pickServer([], 'main').none)
|
||
})
|
||
|
||
// ── Every refusal is private ──────────────────────────────────────────────
|
||
|
||
test('every refusal is ephemeral: unknown, ambiguous, no servers, /top on a fleet', async () => {
|
||
withCore()
|
||
const { servers } = models()
|
||
const two = [server(), server({ id: 'main-2', name: 'Main 2' })]
|
||
|
||
await stubbed([[servers, 'listPublic', async () => two]], async () => {
|
||
for (const [name, options] of [
|
||
['status', { server: 'nope' }],
|
||
['wipe', { server: 'mai' }],
|
||
['online', { server: 'nope' }],
|
||
['top', {}],
|
||
['top', { server: 'm' }],
|
||
['clan', { name: '' }],
|
||
]) {
|
||
// eslint-disable-next-line no-await-in-loop
|
||
const env = await command(name).handler({ command: name, options, actor: STRANGER })
|
||
assert.strictEqual(env.ephemeral, true, `/${name} ${JSON.stringify(options)} refused in public: ${env.text}`)
|
||
}
|
||
})
|
||
|
||
await stubbed([[servers, 'listPublic', async () => []]], async () => {
|
||
const env = await command('status').handler({ options: {}, actor: STRANGER })
|
||
assert.strictEqual(env.ephemeral, true)
|
||
assert.match(env.text, /No Rust servers/)
|
||
})
|
||
})
|
||
|
||
// ── /status and /wipe ─────────────────────────────────────────────────────
|
||
|
||
test('/status answers in public, with the next wipe, and says when an offline server was last seen', async () => {
|
||
withCore()
|
||
const { servers } = models()
|
||
await stubbed([[servers, 'listPublic', async () => [server(), server({ id: 'b', name: 'B', online: false, players: 0 })]]], async () => {
|
||
const one = await command('status').handler({ options: { server: 'main' }, actor: STRANGER })
|
||
assert.ok(!one.ephemeral)
|
||
assert.strictEqual(one.url, 'http://localhost:5173/rust/servers/main')
|
||
assert.match(said(one), /3\/100/)
|
||
assert.match(said(one), /Next wipe.*Thu 1 Oct, 18:00 UTC.*monthly forced wipe/)
|
||
|
||
const down = await command('status').handler({ options: { server: 'b' }, actor: STRANGER })
|
||
assert.match(said(down), /Offline/)
|
||
assert.match(said(down), /Last seen/)
|
||
|
||
const fleet = await command('status').handler({ options: {}, actor: STRANGER })
|
||
assert.ok(!fleet.ephemeral)
|
||
assert.strictEqual(fleet.fields.length, 2)
|
||
})
|
||
})
|
||
|
||
test('/wipe says "no schedule set" rather than inventing a forecast', async () => {
|
||
withCore()
|
||
const { servers } = models()
|
||
await stubbed([[servers, 'listPublic', async () => [server({ nextWipe: null })]]], async () => {
|
||
const env = await command('wipe').handler({ options: {}, actor: STRANGER })
|
||
assert.ok(!env.ephemeral)
|
||
assert.match(env.text, /Next: no schedule set/)
|
||
assert.match(env.text, /Last: Thu 3 Sept?, 18:00 UTC/)
|
||
})
|
||
await stubbed([[servers, 'listPublic', async () => [server({ nextWipe: { at: '2026-10-03T17:00:00.000Z', source: 'once' } })]]], async () => {
|
||
const env = await command('wipe').handler({ options: {}, actor: STRANGER })
|
||
assert.match(env.text, /rescheduled by the operator/)
|
||
})
|
||
})
|
||
|
||
// ── /top ──────────────────────────────────────────────────────────────────
|
||
|
||
test('/top reads the current wipe, never asks for presence, and prints no Steam id', async () => {
|
||
withCore()
|
||
const { servers, events } = models()
|
||
const asked = []
|
||
const rows = [{ steamId: '76561198000000001', name: 'Alice', kills: 9, deaths: 1, npcKills: 0, playtimeSec: 7260, lastSeen: 'x' }]
|
||
await stubbed(
|
||
[
|
||
[servers, 'listPublic', async () => [server()]],
|
||
[events, 'leaderboard', async (q) => { asked.push(q); return rows }],
|
||
],
|
||
async () => {
|
||
const env = await command('top').handler({ options: {}, actor: MOD })
|
||
assert.ok(!env.ephemeral, 'the leaderboard names are public at every setting')
|
||
assert.deepStrictEqual(asked[0], { serverId: 'main', wipeId: 'w-2026-09', sort: 'kills', limit: 10, presence: false })
|
||
assert.match(env.text, /1\. Alice — 9/)
|
||
assert.ok(!said(env).includes('76561198000000001'))
|
||
|
||
const all = await command('top').handler({ options: { stat: 'playtime', alltime: true }, actor: STRANGER })
|
||
assert.strictEqual(asked[1].wipeId, null)
|
||
assert.strictEqual(asked[1].sort, 'playtime')
|
||
assert.match(all.text, /2h 1m/)
|
||
assert.match(all.title, /all time/)
|
||
},
|
||
)
|
||
})
|
||
|
||
// ── /online — D127 ────────────────────────────────────────────────────────
|
||
|
||
async function online(audience, who, over = {}) {
|
||
const { servers, events, visibility } = models()
|
||
return stubbed(
|
||
[
|
||
[servers, 'listPublic', async () => [server(over)]],
|
||
[visibility, 'presenceFor', async () => audience],
|
||
[events, 'online', async () => [{ name: 'Alice', sleeping: false }, { name: 'Bob', sleeping: true }]],
|
||
],
|
||
() => command('online').handler({ options: {}, actor: who }),
|
||
)
|
||
}
|
||
|
||
test('/online below the audience: the count, in public, and no names', async () => {
|
||
withCore()
|
||
const env = await online('staff', STRANGER)
|
||
assert.ok(!env.ephemeral)
|
||
assert.match(env.title, /3 online/)
|
||
assert.ok(!said(env).includes('Alice'))
|
||
// Staff is not something linking earns, so there is no nudge to link.
|
||
assert.ok(!env.notice)
|
||
})
|
||
|
||
test('/online inside a narrower audience: the names, to the caller ALONE', async () => {
|
||
withCore()
|
||
const env = await online('staff', MOD)
|
||
assert.strictEqual(env.ephemeral, true, 'a moderator’s roll call was posted to the channel')
|
||
assert.match(env.text, /Alice/)
|
||
assert.match(env.text, /Bob \(sleeping\)/)
|
||
|
||
const signedIn = await online('signed_in', PLAYER)
|
||
assert.strictEqual(signedIn.ephemeral, true)
|
||
})
|
||
|
||
test('/online with a public audience posts the names in the open', async () => {
|
||
withCore()
|
||
const env = await online('public', STRANGER)
|
||
assert.ok(!env.ephemeral)
|
||
assert.match(env.text, /Alice/)
|
||
})
|
||
|
||
test('/online nudges an unlinked caller to link only when linking would reach the names', async () => {
|
||
withCore()
|
||
const env = await online('signed_in', STRANGER)
|
||
assert.ok(!env.ephemeral)
|
||
assert.match(env.notice, /Link your Discord account/)
|
||
})
|
||
|
||
test('/online judges the ACCOUNT, not the actor: a banned moderator is nobody', async () => {
|
||
withCore()
|
||
const env = await online('staff', actor(3, 'moderator'))
|
||
assert.ok(!said(env).includes('Alice'))
|
||
})
|
||
|
||
test('/online for an offline server names nobody, whatever the audience', async () => {
|
||
withCore()
|
||
const env = await online('public', MOD, { online: false, players: 0 })
|
||
assert.match(env.text, /offline/)
|
||
assert.ok(!said(env).includes('Alice'))
|
||
})
|
||
|
||
// ── /clan — D127 with D48 ─────────────────────────────────────────────────
|
||
|
||
const CLAN = { externalId: 'main:7:1', name: 'Wolves', color: '#aa3300', score: 50, memberCount: 2, maxMembers: 8 }
|
||
|
||
async function clan(rosterAudience, who, options = { name: 'wolves' }, board = { supported: true, fresh: true }) {
|
||
const { servers, clans, visibilityDb, visibility } = models()
|
||
return stubbed(
|
||
[
|
||
[servers, 'listPublic', async () => [server()]],
|
||
[clans, 'listForServer', async () => ({ clans: [CLAN, { ...CLAN, externalId: 'main:8:1', name: 'Wolverines' }], board })],
|
||
[visibilityDb, 'getSetting', async (key) => (key === visibility.CLAN_ROSTER_KEY ? rosterAudience : null)],
|
||
[require('../model/clans/clans.db'), 'findClan', async () => ({ ...CLAN, serverId: 'main', serverName: 'Main', createdMs: 1 })],
|
||
[require('../model/clans/clans.db'), 'userIsMember', async (_ext, userId) => userId === 1],
|
||
[require('../model/clans/clans.db'), 'listMembers', async () => [
|
||
{ name: 'Alice', rank: 1, online: 1 },
|
||
{ name: 'Bob', rank: 2, online: 0 },
|
||
]],
|
||
],
|
||
() => command('clan').handler({ options, actor: who }),
|
||
)
|
||
}
|
||
|
||
test('/clan outside the roster audience: the public facts, in public, no names', async () => {
|
||
withCore()
|
||
const env = await clan('members', STRANGER)
|
||
assert.ok(!env.ephemeral)
|
||
assert.strictEqual(env.title, 'Wolves')
|
||
assert.match(said(env), /50/)
|
||
assert.ok(!said(env).includes('Alice'))
|
||
})
|
||
|
||
test('/clan inside a narrower roster audience: the roster, to the caller ALONE', async () => {
|
||
withCore()
|
||
const member = await clan('members', PLAYER)
|
||
assert.strictEqual(member.ephemeral, true, 'a clan roster was posted to the channel')
|
||
assert.match(said(member), /Alice \(leader, online\)/)
|
||
|
||
const mod = await clan('members', MOD)
|
||
assert.strictEqual(mod.ephemeral, true)
|
||
})
|
||
|
||
test('/clan with a public roster audience posts the roster in the open', async () => {
|
||
withCore()
|
||
const env = await clan('public', STRANGER)
|
||
assert.ok(!env.ephemeral)
|
||
assert.match(said(env), /Alice/)
|
||
})
|
||
|
||
test('/clan: an exact name wins over a prefix, and a shared prefix is ambiguous and private', async () => {
|
||
withCore()
|
||
assert.strictEqual((await clan('members', STRANGER, { name: 'WOLVES' })).title, 'Wolves')
|
||
const env = await clan('members', STRANGER, { name: 'wol' })
|
||
assert.strictEqual(env.ephemeral, true)
|
||
assert.match(env.text, /Wolves, Wolverines/)
|
||
})
|
||
|
||
test('/clan does not say "no such clan" from a board it cannot trust', async () => {
|
||
withCore()
|
||
const env = await clan('members', STRANGER, { name: 'bears' }, { supported: true, fresh: false })
|
||
assert.strictEqual(env.ephemeral, true)
|
||
assert.match(env.text, /not available, so it may be there/)
|
||
|
||
const sure = await clan('members', STRANGER, { name: 'bears' })
|
||
assert.match(sure.text, /^No clan called “bears”\.$/)
|
||
})
|
||
|
||
// ── Words ─────────────────────────────────────────────────────────────────
|
||
|
||
test('times are written in UTC with the distance beside them', () => {
|
||
withCore()
|
||
const { when, relative } = require('../commands/common')
|
||
const now = Date.parse('2026-09-25T12:00:00Z')
|
||
assert.strictEqual(when('2026-10-01T18:00:00Z', now), 'Thu 1 Oct, 18:00 UTC (in 6 days)')
|
||
assert.strictEqual(relative(now - 3 * 3_600_000, now), '3 hours ago')
|
||
assert.strictEqual(relative(now + 90_000, now), 'in 2 minutes')
|
||
assert.strictEqual(when(null, now), null)
|
||
})
|