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
94 lines
3.9 KiB
JavaScript
94 lines
3.9 KiB
JavaScript
// ── `/online [server]` — how many, and who, where the caller may see it ─────
|
|
//
|
|
// The command with the gate. The org lead's rule (2026-09-22) is that nothing
|
|
// names who is online by default: the COUNT is public and the NAMES reach the
|
|
// server's presence audience (D42, per-server override D45), staff unless an
|
|
// operator widened it.
|
|
//
|
|
// And D127 on top of it: **a caller inside a narrower-than-public audience gets
|
|
// the names PRIVATELY.** The answer is posted where the command was run, and a
|
|
// moderator's `/online` in a public channel would otherwise hand the roll call to
|
|
// everyone reading the channel. Only a server whose audience is `public` posts
|
|
// its names in the open.
|
|
|
|
const c = require('./common')
|
|
const events = require('../model/events/events.model')
|
|
const visibility = require('../model/visibility/visibility.model')
|
|
|
|
/** Fifty names, then "and N more" (§32.4 reading 2). */
|
|
const LIMIT = 50
|
|
|
|
// The link nudge, and only when it is TRUE — module-uo's rule. Linking a Discord
|
|
// account to a site account earns `signed_in` and nothing above it; `staff` is a
|
|
// role an operator grants. So a server that shows names to staff is not a reason
|
|
// to tell anybody to link.
|
|
function linkNudge(actor, required) {
|
|
if (actor && actor.isLinked) return null
|
|
if (required !== 'signed_in') return null
|
|
return 'Link your Discord account on the site to see who is on — this server shows names to signed-in members.'
|
|
}
|
|
|
|
async function forServer(server, actor, level) {
|
|
const required = await visibility.presenceFor(server.id)
|
|
const visible = visibility.meets(level, required)
|
|
const count = server.online ? server.players : 0
|
|
|
|
// Offline, or nobody on: there are no names to decide about, and saying who
|
|
// was on when the server went down would be the presence board's last word
|
|
// presented as now.
|
|
if (!server.online) return { text: `${server.name} is offline.`, url: c.serverUrl(server) }
|
|
if (!visible || count === 0) {
|
|
return {
|
|
title: `${server.name} — ${count} online`,
|
|
url: c.serverUrl(server),
|
|
...(visible ? {} : { notice: linkNudge(actor, required) }),
|
|
}
|
|
}
|
|
|
|
const players = await events.online(server.id)
|
|
const names = players.map((p) => (p.sleeping ? `${p.name || 'Unknown player'} (sleeping)` : p.name || 'Unknown player'))
|
|
return {
|
|
title: `${server.name} — ${players.length} online`,
|
|
url: c.serverUrl(server),
|
|
text: c.fitLines(names, { max: LIMIT, limit: 1900 }),
|
|
// D127. Anything short of `public` is for the caller alone.
|
|
ephemeral: required !== 'public',
|
|
}
|
|
}
|
|
|
|
async function handler({ options, actor }) {
|
|
const now = Date.now()
|
|
const picked = c.pickServer(await c.listServers(now), options && options.server)
|
|
const refusal = c.pickRefusal(picked)
|
|
if (refusal) return refusal
|
|
|
|
const level = await c.levelFor(actor)
|
|
if (picked.server) return forServer(picked.server, actor, level)
|
|
|
|
// The fleet: counts only, which are public. Names are one server's question,
|
|
// and the closing line offers it only when naming a server would show some.
|
|
const visibleSomewhere = (
|
|
await Promise.all(picked.all.map(async (s) => visibility.meets(level, await visibility.presenceFor(s.id))))
|
|
).some(Boolean)
|
|
return {
|
|
title: 'Online now',
|
|
url: c.pageUrl('/rust'),
|
|
fields: picked.all.slice(0, 25).map((s) => ({
|
|
name: s.name,
|
|
value: s.online ? `${s.players} online` : 'Offline',
|
|
inline: true,
|
|
})),
|
|
...(visibleSomewhere ? { text: 'Name a server to see who is on.' } : {}),
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
name: 'online',
|
|
description: 'How many are on a Rust server, and who, where this site shows names',
|
|
options: [{ name: 'server', type: 'string', description: 'Server name or id (counts for all when left out)', required: false }],
|
|
// Everyone, deliberately: `linked` would hide the command from the unlinked
|
|
// members the nudge exists to invite. The gate is inside the handler.
|
|
access: 'everyone',
|
|
handler,
|
|
}
|