feat(guilds): a UO guild is a Team (Teams cutover 5/6) #16
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,
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ module.exports = function register(ctx, api) {
|
|||||||
const shardStreams = require('./config/shardStreams')
|
const shardStreams = require('./config/shardStreams')
|
||||||
const townCrierLeg = require('./utils/shardAnnounce')
|
const townCrierLeg = require('./utils/shardAnnounce')
|
||||||
const teamProvider = require('./model/teamProvider/teamProvider.model')
|
const teamProvider = require('./model/teamProvider/teamProvider.model')
|
||||||
|
const guildCommand = require('./commands/guild.command')
|
||||||
const boot = require('./boot')
|
const boot = require('./boot')
|
||||||
/* eslint-enable global-require */
|
/* eslint-enable global-require */
|
||||||
|
|
||||||
@@ -96,6 +97,16 @@ module.exports = function register(ctx, api) {
|
|||||||
// the database, and registration must not.
|
// the database, and registration must not.
|
||||||
api.registerTeamProvider(teamProvider)
|
api.registerTeamProvider(teamProvider)
|
||||||
|
|
||||||
|
// `/guild` — the chat surface for the same guilds (MODULE_API 1.6.0, TEAMS.md
|
||||||
|
// §7.1). The definition travels to the bot; the handler stays here and runs in
|
||||||
|
// the website process, because the bot container has no `modules` volume and
|
||||||
|
// cannot load a line of this module's code.
|
||||||
|
//
|
||||||
|
// Core registers NO commands of its own. "Guild" is this module's word — core
|
||||||
|
// does not own it on a page (phase 3) and does not publish it in a channel
|
||||||
|
// either.
|
||||||
|
api.registerSlashCommands([guildCommand])
|
||||||
|
|
||||||
api.onBoot(boot.onBoot)
|
api.onBoot(boot.onBoot)
|
||||||
api.onShutdown(boot.onShutdown)
|
api.onShutdown(boot.onShutdown)
|
||||||
|
|
||||||
|
|||||||
@@ -331,4 +331,9 @@ async function projectRoster(externalId, members, viewer) {
|
|||||||
// data and not a callback.
|
// data and not a callback.
|
||||||
const pageUrlTemplate = '/uo/guilds/{externalId}'
|
const pageUrlTemplate = '/uo/guilds/{externalId}'
|
||||||
|
|
||||||
module.exports = { getTeams, getTeamMembers, getTeamLeaders, projectRoster, boardIsCurrent, pageUrlTemplate }
|
// `resolveUserId` is exported for the `/guild` chat command, which counts linked
|
||||||
|
// members and must decide "linked" by the same rule the roster does — a second
|
||||||
|
// copy of that two-source check is a copy that drifts.
|
||||||
|
module.exports = {
|
||||||
|
getTeams, getTeamMembers, getTeamLeaders, projectRoster, boardIsCurrent, pageUrlTemplate, resolveUserId,
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ function fakeApi() {
|
|||||||
streams: null,
|
streams: null,
|
||||||
legs: [],
|
legs: [],
|
||||||
teamProvider: null,
|
teamProvider: null,
|
||||||
|
slashCommands: [],
|
||||||
hooks: {},
|
hooks: {},
|
||||||
}
|
}
|
||||||
const called = new Set()
|
const called = new Set()
|
||||||
@@ -112,6 +113,10 @@ function fakeApi() {
|
|||||||
// deployment — a second registration is a collision there, so it has to be
|
// deployment — a second registration is a collision there, so it has to be
|
||||||
// one here too, or this suite would pass a shape core rejects at load.
|
// one here too, or this suite would pass a shape core rejects at load.
|
||||||
registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider },
|
registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider },
|
||||||
|
// MODULE_API 1.6.0, live since phase 7. `once` for the same reason core
|
||||||
|
// takes it: a second call is a module changing its mind halfway through
|
||||||
|
// register(), which core rejects.
|
||||||
|
registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands },
|
||||||
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||||
}
|
}
|
||||||
|
|||||||
139
server/test/guildCommand.test.js
Normal file
139
server/test/guildCommand.test.js
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
// `/guild` — the chat command registered through `api.registerSlashCommands`
|
||||||
|
// (TEAMS.md §7.1, MODULE_API 1.6.0).
|
||||||
|
//
|
||||||
|
// The properties worth pinning are all about the ANSWER being the same answer
|
||||||
|
// the website gives, because that is the whole risk of a second surface: the
|
||||||
|
// audience rungs are re-resolved here rather than assumed, the shard's own
|
||||||
|
// offline guard is honoured, and the link prompt appears only when linking would
|
||||||
|
// actually change what the caller is told.
|
||||||
|
|
||||||
|
const { test, afterEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const command = require('../commands/guild.command')
|
||||||
|
const db = require('../model/teamProvider/teamProvider.db')
|
||||||
|
const provider = require('../model/teamProvider/teamProvider.model')
|
||||||
|
const visibility = require('../utils/shardVisibility')
|
||||||
|
|
||||||
|
const originals = {
|
||||||
|
getConfig: visibility.getConfig,
|
||||||
|
viewerLevel: visibility.viewerLevel,
|
||||||
|
boardIsCurrent: provider.boardIsCurrent,
|
||||||
|
listGuilds: db.listGuilds,
|
||||||
|
listGuildMembers: db.listGuildMembers,
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
visibility.getConfig = originals.getConfig
|
||||||
|
visibility.viewerLevel = originals.viewerLevel
|
||||||
|
provider.boardIsCurrent = originals.boardIsCurrent
|
||||||
|
db.listGuilds = originals.listGuilds
|
||||||
|
db.listGuildMembers = originals.listGuildMembers
|
||||||
|
})
|
||||||
|
|
||||||
|
const GUILDS = [
|
||||||
|
{ id: 7, name: 'Knights of the Codex', abbr: 'KOC', alliance: 'The Accord', members: 12, online: 3, leader_name: 'Dain' },
|
||||||
|
{ id: 9, name: 'Knights Hospitaller', abbr: 'KH', alliance: null, members: 4, online: 0, leader_name: null },
|
||||||
|
]
|
||||||
|
|
||||||
|
const MEMBERS = [
|
||||||
|
{ serial: 1, name: 'Dain', rank: 4, web_id: '31', linked_user_id: null },
|
||||||
|
{ serial: 2, name: 'Elowen', rank: 4, web_id: null, linked_user_id: 44 },
|
||||||
|
{ serial: 3, name: 'Wat', rank: 2, web_id: null, linked_user_id: null },
|
||||||
|
]
|
||||||
|
|
||||||
|
function stub({ audience = 'anonymous', enabled = true, level = 'anonymous', current = true } = {}) {
|
||||||
|
visibility.getConfig = async () => ({ guilds: { enabled, audience } })
|
||||||
|
visibility.viewerLevel = async () => level
|
||||||
|
provider.boardIsCurrent = async () => (current ? { ok: true } : { ok: false, reason: 'socket down' })
|
||||||
|
db.listGuilds = async () => GUILDS
|
||||||
|
db.listGuildMembers = async () => MEMBERS
|
||||||
|
}
|
||||||
|
|
||||||
|
const anonymous = { platform: 'discord', platformUserId: '1', userId: null, role: null, isLinked: false, isStaff: false }
|
||||||
|
const linked = { platform: 'discord', platformUserId: '2', userId: 31, role: 'player', isLinked: true, isStaff: false }
|
||||||
|
|
||||||
|
test('the definition stays inside the option schema §7.1.1 allows', () => {
|
||||||
|
assert.equal(command.name, 'guild')
|
||||||
|
assert.equal(command.access, 'everyone')
|
||||||
|
for (const option of command.options) {
|
||||||
|
assert.ok(['string', 'integer', 'boolean', 'user'].includes(option.type))
|
||||||
|
assert.ok(option.description.length <= 100)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the guilds feature being off withholds everything, staff included', async () => {
|
||||||
|
stub({ enabled: false, level: 'admin' })
|
||||||
|
const res = await command.handler({ options: {}, actor: { ...linked, role: 'admin', isStaff: true } })
|
||||||
|
assert.match(res.text, /does not publish guild information/)
|
||||||
|
assert.equal(res.ephemeral, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The reason this command is not a thin wrapper over a public route: a rung
|
||||||
|
// below the feature's audience must be refused HERE, or a shard that gates
|
||||||
|
// guilds to staff would publish them to a Discord channel.
|
||||||
|
test('a caller below the feature audience is refused', async () => {
|
||||||
|
stub({ audience: 'staff', level: 'anonymous' })
|
||||||
|
const res = await command.handler({ options: {}, actor: anonymous })
|
||||||
|
assert.match(res.text, /not shown to your account/)
|
||||||
|
assert.equal(res.ephemeral, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unlinked caller is invited to link — but only when linking would change the answer', async () => {
|
||||||
|
stub({ audience: 'player', level: 'anonymous' })
|
||||||
|
const gated = await command.handler({ options: {}, actor: anonymous })
|
||||||
|
assert.match(gated.notice, /Link your account/)
|
||||||
|
|
||||||
|
// Public guilds: there is nothing more to see, so there is nothing to prompt.
|
||||||
|
stub({ audience: 'anonymous', level: 'anonymous' })
|
||||||
|
const open = await command.handler({ options: {}, actor: anonymous })
|
||||||
|
assert.equal(open.notice, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a stale board answers offline rather than reporting what it still holds', async () => {
|
||||||
|
stub({ current: false })
|
||||||
|
const res = await command.handler({ options: {}, actor: anonymous })
|
||||||
|
assert.match(res.text, /not connected right now/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('no argument lists the largest guilds', async () => {
|
||||||
|
stub()
|
||||||
|
const res = await command.handler({ options: {}, actor: anonymous })
|
||||||
|
assert.equal(res.fields.length, 2)
|
||||||
|
assert.match(res.fields[0].name, /Knights of the Codex/)
|
||||||
|
assert.match(res.fields[0].value, /12 members · 3 online/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a name resolves by abbreviation, then exactly, then by unique prefix', async () => {
|
||||||
|
stub()
|
||||||
|
const byAbbr = await command.handler({ options: { name: 'koc' }, actor: anonymous })
|
||||||
|
assert.match(byAbbr.title, /Knights of the Codex/)
|
||||||
|
|
||||||
|
const exact = await command.handler({ options: { name: 'Knights Hospitaller' }, actor: anonymous })
|
||||||
|
assert.match(exact.title, /Hospitaller/)
|
||||||
|
|
||||||
|
// "knights" hits both, and answering with either would be worse than asking.
|
||||||
|
const ambiguous = await command.handler({ options: { name: 'knights' }, actor: anonymous })
|
||||||
|
assert.match(ambiguous.text, /Several guilds match/)
|
||||||
|
assert.equal(ambiguous.ephemeral, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a miss is an answer, not a failure', async () => {
|
||||||
|
stub()
|
||||||
|
const res = await command.handler({ options: { name: 'nobody' }, actor: anonymous })
|
||||||
|
assert.match(res.text, /No guild matches/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// `linked` counts BOTH sources the roster uses — the shard's asserted web id and
|
||||||
|
// the link table — because that is what "linked" means everywhere else here.
|
||||||
|
test('the detail carries the counts, the leaders and a link to the module page', async () => {
|
||||||
|
stub({ level: 'player' })
|
||||||
|
const res = await command.handler({ options: { name: 'KOC' }, actor: linked })
|
||||||
|
const field = (name) => res.fields.find((f) => f.name === name).value
|
||||||
|
assert.equal(field('Members'), '12')
|
||||||
|
assert.equal(field('Online'), '3')
|
||||||
|
assert.equal(field('Linked accounts'), '2')
|
||||||
|
assert.equal(field('Leaders'), 'Dain, Elowen')
|
||||||
|
assert.match(res.url, /\/uo\/guilds\/7$/)
|
||||||
|
assert.equal(res.notice, null)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user