// ── 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 `` 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, }