feat(guilds): /guild, the module's own chat command
The first command through `api.registerSlashCommands` (MODULE_API 1.6.0, TEAMS.md §7.1). The definition and the handler both live here; the bot pulls the definition and runs no line of this module. `/guild` and not `/team`, deliberately. Core does not own the word for a Team — that is what deleted its Team pages in phase 3 — so it does not publish the noun in a channel either. Core ships the dispatcher and zero commands. The audience rungs are re-resolved in the handler rather than assumed: a shard that gates guilds to staff does not become public because the question arrived over Discord. The provider's own staleness guard is honoured too, so a stale board answers "not connected" instead of reporting what it still holds, and `resolveUserId` is exported rather than copied so "linked" means here what it means on the roster. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
189
server/commands/guild.command.js
Normal file
189
server/commands/guild.command.js
Normal file
@@ -0,0 +1,189 @@
|
||||
// ── `/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. An unlinked caller
|
||||
// who was told nothing because the shard publishes nothing is not helped by
|
||||
// being invited to link; the prompt appears when linking is what would actually
|
||||
// change the answer.
|
||||
function linkPrompt(actor, audience) {
|
||||
if (actor.isLinked) return null
|
||||
if (audience === 'anonymous') 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 {
|
||||
title: `Guilds on ${core.baseUrl.replace(/^https?:\/\//, '')}`,
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user