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
188 lines
7.8 KiB
JavaScript
188 lines
7.8 KiB
JavaScript
// ── 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,
|
||
}
|