// ── `/clan name [server]` — one clan, and its roster where the caller may see it // // D50 deferred it here. A clan's name, colour, score and member count are public // (D58); its ROSTER — who is in it and which of them is on — sits behind the // roster audience (D48), which defaults to the clan's own members plus staff. // // The roster decision is `clans.getForViewer`'s, the same function the web page // and core's `projectRoster` use, so the three cannot disagree about who may // look. What this file adds is D127: **a roster shown to anything short of a // `public` audience goes to the caller alone**, never to the channel. const c = require('./common') const clans = require('../model/clans/clans.model') /** The candidates an ambiguous name lists. */ const CANDIDATES = 10 /** * Every clan on the servers searched, with the server each came from, and the * servers whose clan board cannot be trusted right now. */ async function gather(servers) { const boards = await Promise.all(servers.map(async (s) => ({ server: s, ...(await clans.listForServer(s.id)) }))) return { rows: boards.flatMap((b) => b.clans.map((clan) => ({ clan, server: b.server }))), unreadable: boards.filter((b) => !b.board.supported || !b.board.fresh).map((b) => b.server), } } /** An exact name (case-insensitive), then a unique prefix. */ function find(rows, wanted) { const needle = wanted.trim().toLowerCase() const exact = rows.filter((r) => r.clan.name.toLowerCase() === needle) if (exact.length === 1) return { hit: exact[0] } if (exact.length > 1) return { ambiguous: exact } const prefix = rows.filter((r) => r.clan.name.toLowerCase().startsWith(needle)) if (prefix.length === 1) return { hit: prefix[0] } if (prefix.length > 1) return { ambiguous: prefix } return {} } function memberLine(m) { const tags = [m.leader ? 'leader' : null, m.online ? 'online' : null].filter(Boolean) return `${m.name || 'Unknown player'}${tags.length ? ` (${tags.join(', ')})` : ''}` } async function handler({ options, actor }) { const wanted = options && typeof options.name === 'string' ? options.name.trim() : '' if (!wanted) return c.refuse('Name a clan.') const picked = c.pickServer(await c.listServers(), options.server) const refusal = c.pickRefusal(picked) if (refusal) return refusal const searched = picked.server ? [picked.server] : picked.all const { rows, unreadable } = await gather(searched) const { hit, ambiguous } = find(rows, wanted) if (ambiguous) { const list = ambiguous.slice(0, CANDIDATES).map((r) => (searched.length > 1 ? `${r.clan.name} on ${r.server.name}` : r.clan.name)) return c.refuse(`Several clans match “${wanted}”: ${list.join(', ')}${ambiguous.length > CANDIDATES ? ', …' : ''}`) } if (!hit) { // A board that is missing, stale or unsupported proves nothing about a clan // it does not list. Saying "no such clan" from it would be a guess presented // as a fact. if (unreadable.length) { return c.refuse( `No clan called “${wanted}” is on the boards that can be read right now. ` + `The clan list for ${unreadable.map((s) => s.name).join(', ')} is not available, so it may be there.`, ) } return c.refuse(`No clan called “${wanted}”.`) } const answer = await clans.getForViewer(hit.clan.externalId, c.viewerOf(actor)) if (!answer) return c.refuse(`No clan called “${wanted}”.`) const { clan, roster } = answer const fields = [ { name: 'Score', value: String(clan.score), inline: true }, { name: 'Members', value: clan.maxMembers ? `${clan.memberCount}/${clan.maxMembers}` : String(clan.memberCount), inline: true }, { name: 'Server', value: clan.serverName || hit.server.name, inline: true }, ] if (clan.color) fields.push({ name: 'Colour', value: clan.color, inline: true }) const base = { title: clan.name, url: c.pageUrl(`/rust/clans/${encodeURIComponent(clan.externalId)}`), fields } if (!roster.visible || !roster.members.length) return base // Leaders first, then whoever is on, then everyone else — the order a person // looking for "who can I talk to" wants. const members = [...roster.members].sort( (a, b) => Number(b.leader) - Number(a.leader) || Number(b.online) - Number(a.online) || String(a.name).localeCompare(String(b.name)), ) fields.push({ name: 'Roster', value: c.fitLines(members.map(memberLine)), inline: false }) // D127 and D48 together: a roster the whole site may see may be posted; any // narrower one is for the caller. return { ...base, ephemeral: roster.audience !== 'public' } } module.exports = { name: 'clan', description: 'A clan on a Rust server — score and members, and its roster where you may see it', options: [ { name: 'name', type: 'string', description: 'Clan name, or the start of it', required: true }, { name: 'server', type: 'string', description: 'Server name or id (every server when left out)', required: false }, ], access: 'everyone', handler, }