// ── `/guild` — the first chat command through the module contract ────────── // // Registered with `api.registerSlashCommands` (MODULE_API 1.6.0, TEAMS.md §7.1). // The definition and this handler live here; the bot pulls the definition over // the app's internal API and runs nothing of ours. Nothing in this file knows // what Discord is — it is handed an `actor` and returns an envelope, and the // same handler would serve a second platform unchanged. // // **Why `/guild` and not `/team`.** Teams are core's primitive and "guild" is // this module's word for one; core does not own the word, so it does not publish // the noun in a channel either. That is the same correction that deleted core's // Team pages in phase 3, applied to the chat surface. // // **The audience rungs are enforced here, exactly as they are on the website.** // A shard whose `guilds` feature is gated to staff does not become public // because the question arrived over Discord — this handler resolves the caller's // rung through the same `shardVisibility` config the routes use. It is the one // piece of this file that is a security boundary rather than presentation. const core = require('../core') const db = require('../model/teamProvider/teamProvider.db') const provider = require('../model/teamProvider/teamProvider.model') const visibility = require('../utils/shardVisibility') const log = core.logger('guild-command') // How many guilds the no-argument form lists. A Discord embed takes 25 fields; // ten is a summary a person reads rather than a table they scroll past. const LIST_LIMIT = 10 /** * Where the caller sits on this module's ladder. * * The same resolution `projectRoster` does, and it is duplicated in shape rather * than shared because the inputs differ: that one is handed a viewer core * described, this one an actor. Both end at `viewerLevel`, and both answer * `anonymous` DIRECTLY for a caller with no site account — handing `viewerLevel` * a synthetic empty request makes it fall through to `auth.getUserFromRequest`, * which expects real cookies and throws (the phase 3 bug). */ async function levelFor(actor) { if (!actor || !actor.userId) return 'anonymous' return visibility.viewerLevel({ user: { id: actor.userId, role: actor.role } }) } // The nudge §9 answer 5 asks for, and only when it is TRUE. // // **Linking reaches exactly two rungs and no further.** Signing in gets a caller // to `logged_in` and linking a game account to `player`; `staff` and `admin` are // roles an operator grants and no amount of linking will earn. So a shard that // gates guilds to staff refuses an unlinked caller WITHOUT the invitation — // telling them to link would be telling them to do something that changes // nothing, which is worse than saying no. // // The live walk found this: gated to `staff`, the refusal still read "this shard // shows guild information to linked players". const LINKING_REACHES = new Set(['logged_in', 'player']) function linkPrompt(actor, audience) { if (actor.isLinked) return null if (!LINKING_REACHES.has(audience)) return null return 'Link your account on the site to see more — this shard shows guild information to linked players.' } const pageUrl = (externalId) => `${core.baseUrl}${provider.pageUrlTemplate.replace('{externalId}', externalId)}` // Match on abbreviation first, then an exact name, then a unique prefix. Players // type the abbreviation — it is what appears over a character's head — and a // wrong-guild answer is worse than "say which one". function findByName(rows, wanted) { const needle = wanted.trim().toLowerCase() const byAbbr = rows.filter((r) => (r.abbr || '').toLowerCase() === needle) if (byAbbr.length === 1) return { guild: byAbbr[0] } const exact = rows.filter((r) => r.name.toLowerCase() === needle) if (exact.length === 1) return { guild: exact[0] } const partial = rows.filter((r) => r.name.toLowerCase().includes(needle)) if (partial.length === 1) return { guild: partial[0] } if (partial.length > 1) return { ambiguous: partial.slice(0, LIST_LIMIT) } return {} } /** The counts for one guild, from the roster rather than the board's assertions. */ async function summarise(guild) { const members = await db.listGuildMembers(guild.id) const leaders = members .filter((m) => Number(m.rank) >= db.LEADER_RANK) .map((m) => m.name) // The board's founder-leader is folded in as a floor, the same way // getTeamLeaders does it: it arrives on a different frame, and a shard whose // roster predates the rank amendment has no other leadership signal. if (guild.leader_name && !leaders.includes(guild.leader_name)) leaders.push(guild.leader_name) return { // `members`/`online` are the BOARD's counts, which is what the shard asserts; // the roster is what it enumerated, and the two legitimately disagree for the // moment between a membership change and the sweep that reports it. The // assertion is the more current of the two, so it is what is shown. members: guild.members, online: guild.online, linked: members.filter((m) => provider.resolveUserId(m) !== null).length, leaders, } } async function detail(guild, actor, audience) { const counts = await summarise(guild) const fields = [ { name: 'Members', value: String(counts.members ?? '—'), inline: true }, { name: 'Online', value: String(counts.online ?? 0), inline: true }, { name: 'Linked accounts', value: String(counts.linked), inline: true }, ] if (counts.leaders.length) { fields.push({ name: 'Leaders', value: counts.leaders.join(', ') }) } return { title: guild.abbr ? `${guild.name} [${guild.abbr}]` : guild.name, text: guild.alliance ? `Alliance: ${guild.alliance}` : undefined, fields, url: pageUrl(guild.id), notice: linkPrompt(actor, audience), } } /** * `/guild [name]` — one guild's summary, or the shard's largest guilds. * * Never throws for an ordinary miss: "no such guild" and "the shard is offline" * are answers, and letting either become an exception would turn a routine * question into "that command failed" with nothing an operator could act on. */ async function handler({ options, actor }) { const config = await visibility.getConfig() const feature = config.guilds // An admin turned guilds off. The switch means "this shard does not publish // guild data" — over any surface, to anyone, staff included. if (!feature || !feature.enabled) { return { text: 'This shard does not publish guild information.', ephemeral: true } } const level = await levelFor(actor) if (!visibility.meets(level, feature.audience)) { return { text: 'Guild information on this shard is not shown to your account.', ephemeral: true, notice: linkPrompt(actor, feature.audience), } } // The provider's own staleness guard, asked before any board read: an // unreachable sidecar means the board is a snapshot of unknown age, and // reporting it as current here would contradict what every other surface says. const ready = await provider.boardIsCurrent() if (!ready.ok) { log.info('guild command answered offline', { reason: ready.reason }) return { text: 'The shard is not connected right now, so guild information may be out of date.', ephemeral: true } } const rows = await db.listGuilds() if (!rows.length) return { text: 'No guilds are on the board yet.', ephemeral: true } const wanted = options && typeof options.name === 'string' ? options.name : null if (!wanted) { const top = [...rows].sort((a, b) => (b.members || 0) - (a.members || 0)).slice(0, LIST_LIMIT) return { // Not "Guilds on ": `ctx.site` carries a base URL and no brand name, // so naming the deployment here can only mean printing its hostname into // an embed title, which is noise on a shard's own Discord server. title: 'Guilds on this shard', fields: top.map((g) => ({ name: g.abbr ? `${g.name} [${g.abbr}]` : g.name, value: `${g.members || 0} members · ${g.online || 0} online`, inline: true, })), notice: linkPrompt(actor, feature.audience), } } const { guild, ambiguous } = findByName(rows, wanted) if (ambiguous) { return { text: `Several guilds match “${wanted}”: ${ambiguous.map((g) => g.name).join(', ')}`, ephemeral: true, } } if (!guild) return { text: `No guild matches “${wanted}”.`, ephemeral: true } return detail(guild, actor, feature.audience) } module.exports = { name: 'guild', description: 'Show a guild on this shard — members, who is online, and its leaders', options: [ { name: 'name', type: 'string', description: 'Guild name or abbreviation', required: false }, ], // Everyone, deliberately. The gate that matters is the shard's own audience // rung, resolved inside the handler — `access: 'linked'` would hide the command // from exactly the unlinked members §9 answer 5 wants to invite to link. access: 'everyone', handler, }