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>
190 lines
8.3 KiB
JavaScript
190 lines
8.3 KiB
JavaScript
// ── `/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,
|
|
}
|