Files
Module-Rust/server/commands/common.js
wtclaude 0670341198 feat(rust): slash commands and the next wipe, server half (phase 16)
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
2026-09-25 13:29:59 -05:00

188 lines
7.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── What the five commands share (phase 16, R11) ───────────────────────────
//
// Registered with `api.registerSlashCommands` (MODULE_API 1.6.0). The handlers
// run in the WEBSITE process: core pulls the definitions over its internal API to
// the bot and dispatches each call back here. Nothing in these files knows what
// Discord is — a handler is handed an `actor` and returns an envelope.
//
// ── The two privacy rules (D127) ──────────────────────────────────────────
//
// 1. **A refusal is private.** Core already delivers an `ephemeral` answer as a
// private follow-up; `refuse()` below is the one way these files say no, and
// it always sets the flag.
// 2. **An answer narrower than `public` is private too.** Core has no reverse
// case: an answer WITHOUT the flag is posted to the channel the command was
// run in, where everybody reads it. So a moderator running `/online` in a
// public channel must get the names privately, or the names they may see are
// published to everyone who may not. That is the leak module-uo's `/guild`
// has (D132, Module-uo#46), and every answer here decides it explicitly.
//
// ── Three seconds (`HANDLER_TIMEOUT_MS`) ──────────────────────────────────
//
// Every answer reads this module's own tables. **No command asks a sidecar**: a
// game that is slow to answer would cost the reply, and the tables already hold
// the last thing each server said.
const core = require('../core')
const servers = require('../model/servers/servers.model')
const visibility = require('../model/visibility/visibility.model')
/**
* Where the caller sits on the presence ladder.
*
* An UNLINKED caller is `public`, answered directly. Handing `visibility.viewer`
* a synthetic request with no user would make it fall through to
* `getUserFromRequest`, which expects real cookies (module-uo's phase 3 bug). A
* linked caller is handed over as `{ user }`, which `viewer` reads first and then
* re-reads from the account row — so a banned or demoted account is judged by
* what it is now, not by what core resolved.
*/
async function levelFor(actor) {
if (!actor || actor.userId == null) return 'public'
return visibility.viewerLevel({ user: { id: actor.userId, role: actor.role } })
}
/** The viewer shape `model/clans` takes: `{ userId, role }` or null. */
const viewerOf = (actor) => (actor && actor.userId != null ? { userId: actor.userId, role: actor.role || null } : null)
/** Every answer that says no. Private, always (D127 rule 1). */
const refuse = (text) => ({ text, ephemeral: true })
/** An absolute link to a page on the site. */
const pageUrl = (path) => `${core.baseUrl}${path}`
const serverUrl = (server) => pageUrl(`/rust/servers/${encodeURIComponent(server.id)}`)
/**
* Which server a `server` option names, among `list`.
*
* An exact id, then an exact name (case-insensitive), then a unique prefix of
* either. Servers are added at runtime and `choices` are fixed at load, so this
* is free text matched here. A wrong-server answer is worse than "say which one",
* so two prefix matches are ambiguous rather than a guess.
*
* Answers `{ server }`, `{ all }` (no option and more than one server),
* `{ none }` (no servers at all), `{ ambiguous }` or `{ missing }`.
*/
function pickServer(list, option) {
if (!list.length) return { none: true }
const wanted = typeof option === 'string' ? option.trim().toLowerCase() : ''
if (!wanted) return list.length === 1 ? { server: list[0] } : { all: list }
const byId = list.find((s) => s.id.toLowerCase() === wanted)
if (byId) return { server: byId }
const byName = list.filter((s) => s.name.toLowerCase() === wanted)
if (byName.length === 1) return { server: byName[0] }
const prefix = list.filter((s) => s.id.toLowerCase().startsWith(wanted) || s.name.toLowerCase().startsWith(wanted))
if (prefix.length === 1) return { server: prefix[0] }
if (prefix.length > 1) return { ambiguous: prefix }
return { missing: option.trim() }
}
/** The refusal for each way `pickServer` can fail to name exactly one server. */
function pickRefusal(picked, { needOne = false } = {}) {
const names = (list) => list.map((s) => `${s.name} (\`${s.id}\`)`).join(', ')
if (picked.none) return refuse('No Rust servers are set up on this site yet.')
if (picked.missing) return refuse(`No server matches “${picked.missing}”.`)
if (picked.ambiguous) return refuse(`Several servers match: ${names(picked.ambiguous)}. Name one.`)
if (needOne && picked.all) return refuse(`Which server? This site follows ${names(picked.all)}.`)
return null
}
/** Every enabled server, as the public shape. */
const listServers = (now = Date.now()) => servers.listPublic(now)
// ── Time, in words ────────────────────────────────────────────────────────
//
// Plain text, not a platform's timestamp markup: the envelope is platform-
// agnostic (§7.1), and a second platform would print `<t:…>` literally. So an
// instant is written in UTC, with how far away it is beside it — the relative
// part is the one every reader can use without converting.
const UTC_FORMAT = new Intl.DateTimeFormat('en-GB', {
timeZone: 'UTC',
weekday: 'short',
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
})
function relative(ms, now = Date.now()) {
const diff = ms - now
const abs = Math.abs(diff)
const unit = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
let span
if (abs < 60_000) span = 'less than a minute'
else if (abs < 3_600_000) span = unit(Math.round(abs / 60_000), 'minute')
else if (abs < 172_800_000) span = unit(Math.round(abs / 3_600_000), 'hour')
else span = unit(Math.round(abs / 86_400_000), 'day')
return diff >= 0 ? `in ${span}` : `${span} ago`
}
/** `Thu 1 Oct, 18:00 UTC (in 6 days)`, or null for no instant. */
function when(value, now = Date.now()) {
if (!value) return null
const ms = value instanceof Date ? value.getTime() : Date.parse(value)
if (Number.isNaN(ms)) return null
return `${UTC_FORMAT.format(ms).replace(/,? (\d\d:\d\d)$/, ', $1')} UTC (${relative(ms, now)})`
}
/** Just the distance: `3 days ago`. */
function ago(value, now = Date.now()) {
if (!value) return null
const ms = value instanceof Date ? value.getTime() : Date.parse(value)
return Number.isNaN(ms) ? null : relative(ms, now)
}
const SOURCES = {
forced: 'the monthly forced wipe',
rule: 'the server’s own schedule',
once: 'rescheduled by the operator',
}
/** The next wipe in words, with what decided it — or `null` for no schedule. */
function nextWipeText(server, now = Date.now()) {
const next = server.nextWipe
if (!next) return null
return `${when(next.at, now)} — ${SOURCES[next.source] || next.source}`
}
/** Players, as a number out of the maximum, for a server that is up. */
const playerCount = (server) => (server.maxPlayers ? `${server.players}/${server.maxPlayers}` : String(server.players))
/**
* Cut a list of lines to fit one embed field (1 024 characters) and a count,
* ending in "and N more" when it had to cut.
*/
function fitLines(lines, { max = lines.length, limit = 1000 } = {}) {
const kept = []
let length = 0
for (const line of lines.slice(0, max)) {
if (length + line.length + 1 > limit - 20) break
kept.push(line)
length += line.length + 1
}
const rest = lines.length - kept.length
return rest > 0 ? `${kept.join('\n')}\nand ${rest} more` : kept.join('\n')
}
module.exports = {
levelFor,
viewerOf,
refuse,
pageUrl,
serverUrl,
pickServer,
pickRefusal,
listServers,
relative,
when,
ago,
nextWipeText,
playerCount,
fitLines,
}