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:
2026-08-18 18:53:45 -05:00
parent 990a50b491
commit 2d1d91e372
5 changed files with 350 additions and 1 deletions

View File

@@ -96,6 +96,7 @@ function fakeApi() {
streams: null,
legs: [],
teamProvider: null,
slashCommands: [],
hooks: {},
}
const called = new Set()
@@ -112,6 +113,10 @@ function fakeApi() {
// 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.
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 },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
}

View 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)
})