diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index 8c09cce..add43e5 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -86,9 +86,13 @@ jobs: - name: Build client run: npm run build --prefix client - bot-install: - # No tests/build to run; a clean install still catches a broken or - # out-of-sync lockfile before it ships in the bot image. + bot-tests: + # The install still runs first and still catches a broken or out-of-sync + # lockfile before it ships in the bot image — that was this job's whole + # purpose until phase 7 (TEAMS.md §7.1) put real logic in the bot: it now + # pulls slash-command definitions from the app, merges them into the + # whole-set PUT, and runs the defer→dispatch→edit path. None of that is + # reachable from the server suite, and phases 8 and 9 add more of it. runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -99,3 +103,7 @@ jobs: cache-dependency-path: bot/package-lock.json - name: Install bot deps run: npm ci --prefix bot + - name: Run bot tests + # Node's built-in runner, no browser and no Discord connection — the + # interaction is a fake that records what was called on it. + run: npm test --prefix bot diff --git a/bot/package.json b/bot/package.json index 7e97402..0c4692a 100644 --- a/bot/package.json +++ b/bot/package.json @@ -6,6 +6,7 @@ "main": "src/server.js", "scripts": { "start": "node src/server.js", + "test": "node --test test/*.test.js", "dev": "nodemon src/server.js" }, "keywords": ["discord", "discord.js"], diff --git a/bot/src/discord/discordManager.js b/bot/src/discord/discordManager.js index 5edc42a..c1b0742 100644 --- a/bot/src/discord/discordManager.js +++ b/bot/src/discord/discordManager.js @@ -5,6 +5,7 @@ const { Client, GatewayIntentBits, REST, Routes } = require('discord.js') const createLogger = require('../utils/logger') const commands = require('./commands') +const dynamicCommands = require('./dynamicCommands') const messageFilter = require('./messageFilter') const scheduler = require('../scheduler/scheduler') const roleMenuHandler = require('./roleMenuHandler') @@ -22,12 +23,46 @@ let status = 'disconnected' // disconnected | connecting | connected | error let statusDetail = null let lastConnectedAt = null +// One whole-set PUT of the bot's own commands plus whatever the app has +// registered (TEAMS.md §7.1). Because it replaces the set rather than adding to +// it, DEREGISTRATION is free: a module that is gone is simply absent from the +// next pull, and nobody has to remember to take its command back. async function registerCommands(applicationId, targetGuildId) { + const dynamic = dynamicCommands.definitions() const rest = new REST({ version: '10' }).setToken(client.token) await rest.put(Routes.applicationGuildCommands(applicationId, targetGuildId), { - body: commands.all.map((c) => c.data), + body: [...commands.all.map((c) => c.data), ...dynamic], }) - log.info('registered guild slash commands', { guildId: targetGuildId, count: commands.all.length }) + log.info('registered guild slash commands', { + guildId: targetGuildId, + builtIn: commands.all.length, + fromApp: dynamic.length, + }) +} + +/** + * Re-pull the app's commands and re-register the set if it moved. + * + * Called on `ready` and again whenever the app nudges + * (`POST /internal/refresh-commands`). A no-op when nothing changed, so a nudge + * per module state change costs one cheap GET rather than a REST.put per + * install — and a disconnected bot does nothing at all, since there is no + * application to register against until it logs in. + */ +async function refreshCommands() { + const result = await dynamicCommands.pull() + if (!result.ok || !result.changed) return result + if (!client || !client.isReady()) return result + try { + await registerCommands(client.application.id, guildId) + } catch (err) { + // The PUT is all-or-nothing: a definition Discord rejects costs every + // command, the built-ins included. Loud, and never fatal to the process. + log.error('re-registering slash commands failed — the previous set is still live', { + message: err.message, + }) + } + return result } async function stop() { @@ -54,6 +89,11 @@ async function stop() { // failure here leaves the client connected but flags an error status. async function onReady() { try { + // Pull BEFORE the single PUT, so the app's commands are in the very first + // registration rather than appearing a beat later. The pull never throws — + // an unreachable app costs the module commands and nothing else, and the + // bot's own set registers exactly as it always did. + await dynamicCommands.pull() await registerCommands(client.application.id, guildId) await scheduler.start(client) tempRoleSweeper.start(client) @@ -70,14 +110,19 @@ async function onReady() { } } -// Route an interaction: role-menu handler first, then chat-input slash commands. +// Route an interaction: role-menu handler first, then chat-input slash commands +// — the bot's own, then the app's. Built-ins are consulted FIRST and the pull +// already drops any module name that collides with one, so the two orderings +// agree; checking here as well means a name that somehow reached Discord twice +// still runs the bot's version rather than whichever registry answered first. async function onInteractionCreate(interaction) { if (await roleMenuHandler.handleInteraction(interaction)) return if (!interaction.isChatInputCommand()) return const command = commands.get(interaction.commandName) - if (!command) return + if (!command && !dynamicCommands.has(interaction.commandName)) return try { - await command.execute(interaction) + if (command) await command.execute(interaction) + else await dynamicCommands.execute(interaction) } catch (err) { log.error('command execution failed', { command: interaction.commandName, message: err.message }) const payload = { content: 'Something went wrong running that command.', ephemeral: true } @@ -146,4 +191,4 @@ function getConnection() { return { client, guildId } } -module.exports = { start, stop, getStatus, getConnection } +module.exports = { start, stop, getStatus, getConnection, refreshCommands } diff --git a/bot/src/discord/dynamicCommands.js b/bot/src/discord/dynamicCommands.js new file mode 100644 index 0000000..3632df1 --- /dev/null +++ b/bot/src/discord/dynamicCommands.js @@ -0,0 +1,242 @@ +// Slash commands whose DEFINITION and HANDLER live in the website process +// (TEAMS.md §7.1). The bot pulls the definitions, registers them alongside its +// own, and executes one by deferring, asking the app, and editing the reply in. +// +// Everything Discord-specific is here and nothing else is: the app's dispatcher +// resolves the actor, enforces access and produces a platform-neutral envelope, +// and this file turns that envelope into an interaction reply. A module never +// touches an interaction, which is what makes the registration API something a +// second platform could implement. +const { PermissionFlagsBits } = require('discord.js') + +const appInternal = require('../site/appInternalClient') +const staticCommands = require('./commands') +const createLogger = require('../utils/logger') + +const log = createLogger('dynamic-commands') + +// §7.1.1's four types, and the only four. The app rejects anything else at +// registration; this map is the second half of that agreement. +const OPTION_TYPE = { string: 3, integer: 4, boolean: 5, user: 6 } + +// The pulled set, and the app's module-state counter it came from. `null` +// version means "never successfully pulled", which is distinct from 0 ("pulled +// while the app had no modules loaded") — the first should retry, the second is +// a true answer. +let pulled = [] +let version = null + +/** + * Ask the app for the current definitions. + * + * **A failed pull KEEPS the previous set.** The app being briefly unreachable is + * not the same as it having no commands, and treating it as such would + * deregister every module command from Discord on a restart blip — then + * re-register them a minute later, with members watching commands appear and + * disappear. Nothing changes until the app actually answers. + * + * @returns {Promise<{ok: boolean, changed: boolean, count: number}>} + */ +async function pull() { + const res = await appInternal.fetchCommands() + if (!res.ok) { + log.warn('command pull failed — keeping the set already registered', { + error: res.error, + holding: pulled.length, + }) + return { ok: false, changed: false, count: pulled.length } + } + + const { version: pulledVersion, commands } = res.data || {} + const next = Array.isArray(commands) ? commands.filter(usable) : [] + const changed = version === null || pulledVersion !== version || next.length !== pulled.length + pulled = next + version = typeof pulledVersion === 'number' ? pulledVersion : 0 + return { ok: true, changed, count: pulled.length } +} + +/** + * Drop a pulled definition the bot cannot honour. + * + * **The name collision the app cannot see.** The app validates a command against + * everything IT has registered; it does not know the bot's own static array + * exists. A module registering `ping` would produce two `ping` entries in one + * `REST.put`, which Discord rejects as a batch — taking down every command + * including the bot's own. The bot's built-ins win, because they are the ones a + * module cannot be asked to change. + */ +function usable(definition) { + if (!definition || typeof definition.name !== 'string') return false + if (staticCommands.get(definition.name)) { + log.warn('module slash command collides with a built-in and is ignored', { + command: definition.name, + owner: definition.owner, + }) + return false + } + return true +} + +/** + * The pulled definitions as Discord command data, for the whole-set PUT. + * + * `access: 'staff'` becomes a Discord-side permission default; `linked` cannot + * be expressed in Discord's permission model at all — there is no "has a website + * account" predicate — so it is simply not advertised and the app's dispatcher + * refuses it. That asymmetry is the reason §7.1 says access is enforced twice + * and that only the server half is the gate. + */ +function definitions() { + return pulled.map((c) => { + const data = { + name: c.name, + description: c.description, + options: (c.options || []).map((o) => ({ + name: o.name, + description: o.description, + type: OPTION_TYPE[o.type], + required: Boolean(o.required), + ...(o.choices ? { choices: o.choices } : {}), + })), + } + if (c.access === 'staff') data.default_member_permissions = PermissionFlagsBits.ModerateMembers.toString() + return data + }) +} + +/** Is this a command the app owns? Asked before the static registry is consulted. */ +const has = (name) => pulled.some((c) => c.name === name) + +// Read the options the member actually supplied, by the names the definition +// declared. A `user` option is passed on as the Discord user id and nothing else +// — a handler receives platform ids, never a platform object. +function collectOptions(interaction, definition) { + const out = {} + for (const option of definition.options || []) { + const supplied = interaction.options.get(option.name) + if (supplied === null || supplied === undefined) continue + out[option.name] = option.type === 'user' ? String(supplied.value) : supplied.value + } + return out +} + +// What the caller sees when the app declined. The COPY lives here rather than in +// the app on purpose: the app answers with a machine reason, and how a refusal is +// phrased to a member is the platform's own voice. +function refusal({ reason, access }) { + if (reason === 'forbidden' && access === 'linked') { + return 'Link your Discord account on the site to use this command.' + } + if (reason === 'forbidden') return 'You do not have access to that command.' + if (reason === 'unknown') return 'That command is no longer available.' + return 'Something went wrong running that command.' +} + +// Envelope → interaction payload. A response with fields or a title is an embed; +// a bare `text` is plain content, which reads better for a one-line answer. +function render(envelope) { + const { text, title, fields, url } = envelope + if (!title && !fields) return { content: text || '​' } + const embed = {} + if (title) embed.title = title + if (text) embed.description = text + if (url) embed.url = url + if (fields) embed.fields = fields + return { embeds: [embed] } +} + +/** + * Deliver the envelope at the privacy the HANDLER asked for, not the privacy the + * deferral guessed. + * + * When the two agree — the ordinary case — this is one `editReply`. When the + * handler wants a private answer to a publicly deferred command, the deferred + * reply is deleted and the answer arrives as an ephemeral follow-up: the + * interaction token stays valid, so this is a supported path rather than a + * trick, and the cost is a "thinking…" that appears and vanishes. + * + * There is no reverse case. A command deferred ephemerally is one whose answers + * are all about the caller's own account, and nothing it returns should become + * public because a handler forgot a flag. + */ +async function reply(interaction, envelope, deferredEphemeral) { + const payload = render(envelope) + if (!envelope.ephemeral || deferredEphemeral) { + await interaction.editReply(payload) + return + } + await interaction.deleteReply() + await interaction.followUp({ ...payload, ephemeral: true }) +} + +/** + * Defer, dispatch, edit. + * + * **The deferral comes first, always.** Discord gives three seconds to acknowledge + * an interaction; the app is given four to answer. Deferring before the dispatch + * is what keeps the website out of that critical path entirely — a wedged handler + * costs its own reply and never an "application did not respond". + * + * A failure at any point after the defer is an edit, not a reply: the interaction + * has already been acknowledged, and `reply()` on a deferred interaction throws. + */ +async function execute(interaction) { + const definition = pulled.find((c) => c.name === interaction.commandName) + if (!definition) return false + + // **Ephemerality is fixed at the DEFERRAL, which happens before the answer + // exists.** That is Discord's rule, not a choice here, and it is the whole + // reason this needs care: the handler decides privacy per answer — a refusal + // is private, a guild summary is not — and by the time it says so the reply is + // already public or already not. + // + // So: defer for the common case (public, or private for a command that only + // ever speaks about the caller's own account), and if the envelope disagrees, + // reconcile below. Getting this wrong is not cosmetic — the live walk caught it + // posting "guild information is not shown to your account" into the channel, + // which announces a member's access level to everyone in it. + const ephemeral = definition.access === 'linked' + await interaction.deferReply({ ephemeral }) + + const res = await appInternal.dispatchCommand({ + command: definition.name, + options: collectOptions(interaction, definition), + platformUserId: interaction.user.id, + guildId: interaction.guildId, + }) + + // A transport failure and a handler failure are the same sentence to the + // member and different lines in the log: one is the app being unreachable, + // the other is a module's code. + // A refusal is ALWAYS private, whatever the command's usual privacy: "you do + // not have access to that" is about one member and belongs to one member. + if (!res.ok) { + log.warn('command dispatch failed', { command: definition.name, error: res.error }) + await reply(interaction, { text: refusal({ reason: 'error' }), ephemeral: true }, ephemeral) + return true + } + if (!res.data || !res.data.ok) { + await reply(interaction, { text: refusal(res.data || {}), ephemeral: true }, ephemeral) + return true + } + + const envelope = res.data.response || {} + await reply(interaction, envelope, ephemeral) + + // The private aside beside a public answer (§9 answer 5). Skipped when the + // reply was already private — the member would just be told the same thing + // twice, in the same place. + if (envelope.notice && !ephemeral && !envelope.ephemeral) { + await interaction.followUp({ content: envelope.notice, ephemeral: true }) + } + return true +} + +// Test-only: the pulled set is process-global, so a test that pulls has to be +// able to hand the process back. +function _reset() { + pulled = [] + version = null +} + +module.exports = { pull, definitions, has, execute, _reset } diff --git a/bot/src/discord/teamNotify.js b/bot/src/discord/teamNotify.js new file mode 100644 index 0000000..09ce99f --- /dev/null +++ b/bot/src/discord/teamNotify.js @@ -0,0 +1,80 @@ +// Team notifications posted into an operator-configured channel (TEAMS.md §7.2). +// +// **The channel comes from the app, not from guild_config.** `newsAnnounce` looks +// its channel up here because there is exactly one #news; a Team's destination is +// per-Team configuration living in `team_integration_config`, and a bot that +// resolved it would need a second copy of that table and a second place for it to +// drift. The app sends the id it already decided on. +// +// **Everything this file knows about a Team it was told.** No lookups, no +// membership checks, no access decisions: whether this content may reach this +// channel was settled on the site, where the acknowledgement that gates it lives. +// The bot is the transport, exactly as it is for slash commands. +const { EmbedBuilder } = require('discord.js') + +const brand = require('../brand') +const createLogger = require('../utils/logger') + +const log = createLogger('team-notify') + +// Discord's own limits. Truncating here rather than trusting the app is not +// distrust — an embed that exceeds them is rejected wholesale, and a message +// silently not appearing is the worst failure mode this path has. +const TITLE_MAX = 256 +const DESCRIPTION_MAX = 4096 + +const clamp = (value, max) => { + const text = String(value || '').trim() + if (!text) return null + return text.length > max ? `${text.slice(0, max - 1)}…` : text +} + +// What each stream is called in a channel. The app composes the BODY; this is +// only the label above it, and it is here because it is Discord presentation — +// the same reason the embed colour is. +const HEADINGS = { + 'team.member.joined': 'New member', + 'team.leadership.changed': 'Leadership change', + 'team.forum.post': 'New forum post', + 'team.announcement': 'Announcement', +} + +async function postTeamNotification(client, { channelId, stream, teamName, teamUrl, title, body, url }) { + if (!channelId) throw new Error('No channel id supplied.') + + const channel = await client.channels.fetch(channelId).catch(() => null) + if (!channel || !channel.isTextBased()) { + throw new Error('Configured channel is missing, not text-based, or not visible to the bot.') + } + + const heading = HEADINGS[stream] || 'Team update' + const name = clamp(teamName, 120) || 'A team' + + const embed = new EmbedBuilder() + .setColor(brand.accentInt) + // The Team is the AUTHOR line and the event is the title, not the other way + // round: a channel carrying one Team's events would otherwise repeat its name + // as every heading, and a channel carrying several needs the name to be the + // thing the eye lands on first. + .setAuthor(teamUrl ? { name, url: teamUrl } : { name }) + .setTitle(clamp(title, TITLE_MAX) || heading) + + if (url) embed.setURL(url) + + // Both a title and a body means a forum post: the heading has to go somewhere + // or "New forum post" and "Announcement" become indistinguishable once the + // thread title takes the title slot. + // + // **Clamped AFTER the heading is prepended, not before.** Clamping the body and + // then adding a prefix produces a description one heading longer than the limit, + // which discord.js rejects outright — so an over-long post would not arrive at + // all rather than arriving truncated. The prefix is part of what has to fit. + const composed = title && body ? `**${heading}**\n${String(body)}` : body + const description = clamp(composed, DESCRIPTION_MAX) + if (description) embed.setDescription(description) + + await channel.send({ embeds: [embed] }) + log.info('team notification posted', { stream, channelId, team: name }) +} + +module.exports = { postTeamNotification, HEADINGS, clamp, TITLE_MAX, DESCRIPTION_MAX } diff --git a/bot/src/discord/teamVoice.js b/bot/src/discord/teamVoice.js new file mode 100644 index 0000000..5f723db --- /dev/null +++ b/bot/src/discord/teamVoice.js @@ -0,0 +1,315 @@ +// Per-Team voice channels (TEAMS.md §7.3, phase 9). +// +// **The site decides; this file compares and applies.** Every judgement — which +// Teams qualify, who may enter, what the channel is called — was made on the site +// and arrives in the request. What cannot be made there is the DIFF: which of +// those people already hold the role, whether the channel still exists, whether +// the category was deleted last week. That is live guild state, only this process +// can see it, and shipping it to the site to be compared and shipped back would +// be a copy of the guild in a database that cannot watch it change. +// +// So the contract is "make it look like this", not "do these calls". +// +// **Access is a per-Team ROLE.** §7.3 designed per-member permission overwrites +// with a role only above ~90 members; the org lead settled on roles always +// (2026-08-18). The channel therefore carries exactly three kinds of overwrite — +// @everyone denied, the Team's role allowed, and each operator-designated staff +// role allowed — and membership is the role's member list rather than a hundred +// entries on the channel. +const { ChannelType, PermissionFlagsBits } = require('discord.js') + +const createLogger = require('../utils/logger') + +const log = createLogger('team-voice') + +// The category every Team channel is created under. Created on the first pass +// that needs one; the site stores the id and sends it back next time. +const CATEGORY_NAME = 'Teams' + +// discord.js REST error codes for "the thing you are addressing is already gone". +// A teardown that finds its target missing has SUCCEEDED — the desired end state +// holds — and the same is true of a sync that finds a channel a human deleted, +// which simply becomes a create. +const UNKNOWN_CHANNEL = 10003 +const UNKNOWN_ROLE = 10011 + +const isMissing = (err) => err && (err.code === UNKNOWN_CHANNEL || err.code === UNKNOWN_ROLE) + +// What a Team member may do in their channel, and what @everyone may not. Both +// halves are needed: denying ViewChannel alone still leaves Connect resolvable +// for anyone who has the id, and allowing ViewChannel alone shows a channel +// nobody can enter. +const ACCESS_BITS = [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect] + +/** + * Can this bot do §7.3's job in this guild? + * + * Asked before an operator may switch voice on, and again at the top of every + * pass. The site has no way to know: the operator invites the bot by hand, there + * is no invite URL with a permission integer anywhere in this project, and an + * unticked box means every call fails with nothing to point at. + * + * `bot_role_position` is reported because it is the second, quieter failure: + * ManageRoles lets the bot create a role, but it can only GRANT roles below its + * own highest one. A bot sitting at the bottom of the role list creates roles it + * then cannot hand to anybody — which looks exactly like a channel nobody can + * enter, with no error anywhere. + */ +async function preflight(client, guildId) { + const guild = await client.guilds.fetch(guildId) + const me = guild.members.me || (await guild.members.fetchMe()) + return { + connected: true, + guild_id: guild.id, + can_manage_channels: me.permissions.has(PermissionFlagsBits.ManageChannels), + can_manage_roles: me.permissions.has(PermissionFlagsBits.ManageRoles), + // The guild's whole role list, not just the ones this feature made. The + // 250-role cap is guild-wide and shared with everything the operator created + // themselves, so counting ours would promise headroom that is not there. + role_count: guild.roles.cache.size, + bot_role_position: me.roles.highest.position, + } +} + +/** The `Teams` category, reusing the one we were given when it is still there. */ +async function ensureCategory(guild, categoryId) { + if (categoryId) { + const existing = await guild.channels.fetch(categoryId).catch(() => null) + if (existing && existing.type === ChannelType.GuildCategory) return existing + log.warn('the configured Teams category is gone; making another', { categoryId }) + } + const created = await guild.channels.create({ + name: CATEGORY_NAME, + type: ChannelType.GuildCategory, + reason: 'Team voice channels', + }) + log.info('created the Teams category', { categoryId: created.id }) + return created +} + +/** + * The Team's own role. + * + * A rename is applied but never allowed to fail the pass: a Team's name is the + * least important thing here and Discord rate-limits name edits hard, so losing + * one is worth strictly less than losing the access change in the same request. + */ +async function ensureRole(guild, roleId, name) { + let role = roleId ? await guild.roles.fetch(roleId).catch(() => null) : null + let created = false + if (!role) { + role = await guild.roles.create({ + name, + // Not mentionable and not hoisted: this role exists to open a door, and a + // Team with two hundred members should not become a way to ping them all or + // a second copy of the member list down the sidebar. + mentionable: false, + hoist: false, + reason: 'Team voice access', + }) + created = true + log.info('created a team role', { roleId: role.id, name }) + } else if (role.name !== name) { + await role.setName(name, 'Team renamed').catch((err) => { + log.warn('could not rename the team role', { roleId: role.id, message: err.message }) + }) + } + return { role, created } +} + +/** The overwrites a Team channel carries, in the order Discord takes them. */ +function overwritesFor(guild, role, staffRoleIds) { + const overwrites = [ + { id: guild.roles.everyone.id, deny: ACCESS_BITS }, + { id: role.id, allow: ACCESS_BITS }, + ] + for (const staffId of staffRoleIds) { + // A staff role the operator has since deleted would make Discord reject the + // WHOLE set, taking the Team's own grant down with it. Filtered here rather + // than validated on the site, which cannot see the guild's role list. + if (!guild.roles.cache.has(staffId)) { + log.warn('a configured staff role is not in this guild; skipping it', { roleId: staffId }) + continue + } + overwrites.push({ id: staffId, allow: ACCESS_BITS }) + } + return overwrites +} + +async function ensureChannel(guild, channelId, { name, category, role, staffRoleIds }) { + const overwrites = overwritesFor(guild, role, staffRoleIds) + let channel = channelId ? await guild.channels.fetch(channelId).catch(() => null) : null + + if (channel && channel.type !== ChannelType.GuildVoice) { + // Somebody pointed us at, or converted this into, something that is not a + // voice channel. Not ours to repurpose — make the right one and leave theirs. + log.warn('the stored channel is not a voice channel; making a new one', { channelId }) + channel = null + } + + if (!channel) { + const created = await guild.channels.create({ + name, + type: ChannelType.GuildVoice, + parent: category.id, + permissionOverwrites: overwrites, + reason: 'Team voice channel', + }) + log.info('created a team voice channel', { channelId: created.id, name }) + return { channel: created, created: true } + } + + // Overwrites are re-set on every pass rather than diffed: the set is three or + // four entries, `set` is one API call, and re-asserting it is what repairs a + // channel somebody edited by hand. + await channel.permissionOverwrites.set(overwrites, 'Team voice access') + if (channel.parentId !== category.id) { + await channel.setParent(category.id, { lockPermissions: false, reason: 'Team voice channel' }) + } + if (channel.name !== name) { + await channel.setName(name, 'Team renamed').catch((err) => { + log.warn('could not rename the team voice channel', { channelId: channel.id, message: err.message }) + }) + } + return { channel, created: false } +} + +/** + * Bring the role's member list to the site's list, up to `maxOps` changes. + * + * **Bounded, and the remainder is reported rather than dropped.** Each grant is + * its own API call under its own rate limit, so an unbounded first pass on a + * large guild is a request that outlives its own timeout — and a timeout is the + * one outcome that leaves the site not knowing what was applied. The site asks + * again until `pending` reaches zero. + * + * **A member the site names who is not in this guild is skipped silently.** They + * linked their Discord account to the site and never joined the guild, which is + * an ordinary state (§2.6 hop 3 without hop 4) and not something an operator + * needs to see a hundred of. + */ +async function syncRoleMembers(guild, role, memberIds, maxOps) { + // One fetch of the whole member list, so `role.members` and the "are they even + // here" check both read from a cache that is actually populated. discord.js + // keeps it current from gateway events afterwards; without the fetch, a bot + // that has been up for five minutes knows only the members who spoke. + await guild.members.fetch() + + const desired = new Set(memberIds.map(String)) + const current = new Set(role.members.map((member) => member.id)) + + const toAdd = [...desired].filter((id) => !current.has(id) && guild.members.cache.has(id)) + const toRemove = [...current].filter((id) => !desired.has(id)) + + let ops = 0 + let added = 0 + let removed = 0 + + for (const id of toAdd) { + if (ops >= maxOps) break + const member = guild.members.cache.get(id) + try { + // eslint-disable-next-line no-await-in-loop + await member.roles.add(role, 'Team member') + added += 1 + } catch (err) { + // One member the bot cannot touch — almost always the role hierarchy, when + // the member outranks the bot — must not cost the other forty-nine. + log.warn('could not grant the team role', { userId: id, roleId: role.id, message: err.message }) + } + ops += 1 + } + + for (const id of toRemove) { + if (ops >= maxOps) break + const member = guild.members.cache.get(id) + if (!member) continue + try { + // eslint-disable-next-line no-await-in-loop + await member.roles.remove(role, 'No longer a team member') + removed += 1 + } catch (err) { + log.warn('could not revoke the team role', { userId: id, roleId: role.id, message: err.message }) + } + ops += 1 + } + + return { added, removed, pending: Math.max(0, toAdd.length + toRemove.length - ops) } +} + +/** One Team, reconciled. */ +async function syncTeamVoice(client, guildId, { + teamId, name, categoryId, channelId, roleId, staffRoleIds = [], memberIds = [], maxMemberOps = 50, +}) { + const guild = await client.guilds.fetch(guildId) + const category = await ensureCategory(guild, categoryId) + const { role, created: roleCreated } = await ensureRole(guild, roleId, name) + const { channel, created: channelCreated } = await ensureChannel(guild, channelId, { + name, category, role, staffRoleIds, + }) + const members = await syncRoleMembers(guild, role, memberIds, maxMemberOps) + + log.info('team voice reconciled', { + teamId, name, channelId: channel.id, roleId: role.id, ...members, + }) + + return { + category_id: category.id, + channel_id: channel.id, + role_id: role.id, + created: { channel: channelCreated, role: roleCreated }, + members, + } +} + +/** + * Remove a Team's channel and role. + * + * Both, in one call, because they are one lifecycle: deleting the channel and + * leaving the role would leave every member wearing a badge for a place that no + * longer exists. Either being already gone is success. + */ +async function removeTeamVoice(client, guildId, { channelId, roleId }) { + const guild = await client.guilds.fetch(guildId) + const result = { channel_deleted: false, role_deleted: false } + + if (channelId) { + const channel = await guild.channels.fetch(channelId).catch(() => null) + if (channel) { + try { + await channel.delete('Team no longer qualifies for a voice channel') + result.channel_deleted = true + } catch (err) { + if (!isMissing(err)) throw err + } + } + } + + if (roleId) { + const role = await guild.roles.fetch(roleId).catch(() => null) + if (role) { + try { + await role.delete('Team no longer qualifies for a voice channel') + result.role_deleted = true + } catch (err) { + if (!isMissing(err)) throw err + } + } + } + + log.info('team voice removed', { channelId, roleId, ...result }) + return result +} + +module.exports = { + CATEGORY_NAME, + ACCESS_BITS, + preflight, + ensureCategory, + ensureRole, + ensureChannel, + overwritesFor, + syncRoleMembers, + syncTeamVoice, + removeTeamVoice, +} diff --git a/bot/src/internal/internal.controller.js b/bot/src/internal/internal.controller.js index 01fe2dd..595bf3c 100644 --- a/bot/src/internal/internal.controller.js +++ b/bot/src/internal/internal.controller.js @@ -1,5 +1,7 @@ const discordManager = require('../discord/discordManager') const newsAnnounce = require('../discord/newsAnnounce') +const teamNotify = require('../discord/teamNotify') +const teamVoice = require('../discord/teamVoice') const modLog = require('../discord/modLog') const createLogger = require('../utils/logger') @@ -99,4 +101,142 @@ async function reverseModAction(req, res) { } } -module.exports = { setConfig, getStatus: getStatusHandler, announce, reverseModAction } +// POST /internal/refresh-commands — the app's nudge that its registered +// slash-command set has moved (TEAMS.md §7.1). No body: the bot re-pulls +// `/internal/commands` and re-registers only if the set actually changed, so the +// nudge stays a cheap thing the app can send on every module state change. +// +// Deliberately its OWN endpoint rather than riding on /internal/config, which +// carries the decrypted bot token: saying "commands changed" should not require +// the app to read a secret out of the database. +// +// Answers 200 even when disconnected — there is no application to register +// against until the bot logs in, and `ready` pulls again anyway. A 5xx here +// would make an ordinary module install look like a failure in the admin panel. +async function refreshCommands(req, res) { + try { + const result = await discordManager.refreshCommands() + return res.json({ ok: true, ...result }) + } catch (err) { + log.error('refresh-commands failed', { message: err.message }) + return res.json({ ok: false, error: err.message }) + } +} + +// POST /internal/team-notify — a Team notification the site has already decided +// belongs in a channel (TEAMS.md §7.2). Body: { channel_id, stream, team_name, +// team_url, title, body, url }. +// +// **The site chose the channel and the site checked the access.** Whether +// members-only forum text may reach this channel is an acknowledgement recorded +// against team_integration_config, and re-deciding it here would mean the bot +// holding a copy of a policy it cannot see the inputs to. +// +// 503 when disconnected and 400 for a channel the bot cannot post to, matching +// /internal/announce — the caller is one-shot and best-effort and only logs the +// difference, but an operator debugging a silent channel needs the two to read +// differently in the bot's log. +async function teamNotifyHandler(req, res) { + const connection = discordManager.getConnection() + if (!connection) return res.status(503).json({ message: 'Bot is not connected' }) + + const { channel_id: channelId, stream, team_name: teamName, team_url: teamUrl, title, body, url } = req.body || {} + if (!channelId || !stream) { + return res.status(400).json({ message: 'channel_id and stream are required' }) + } + + try { + await teamNotify.postTeamNotification(connection.client, { channelId, stream, teamName, teamUrl, title, body, url }) + return res.json({ posted: true }) + } catch (err) { + log.warn('team-notify failed', { message: err.message, stream, channelId }) + return res.status(400).json({ message: err.message }) + } +} + +// ── Voice channels (TEAMS.md §7.3, phase 9) ──────────────────────────────── + +// GET /internal/team-voice/preflight — can this bot do the job at all? +// +// Its own endpoint, and the app asks it BEFORE letting an operator switch voice +// on. §7.3 assumed the bot could manage channels and roles; nothing in this +// project has ever checked, because the operator invites the bot by hand and +// there is no invite URL with a permission integer anywhere in the tree. Without +// this the first symptom of an unticked box is every Team recording its own +// identical error, which reads like forty problems instead of one. +async function voicePreflight(req, res) { + const connection = discordManager.getConnection() + if (!connection) return res.status(503).json({ connected: false, message: 'Bot is not connected' }) + try { + return res.json(await teamVoice.preflight(connection.client, connection.guildId)) + } catch (err) { + log.warn('voice preflight failed', { message: err.message }) + return res.status(400).json({ connected: true, message: err.message }) + } +} + +// POST /internal/team-voice/sync — make one Team's channel, role and role +// membership match what the site sent. +// +// The site sends DESIRED STATE and this works out the calls, which is the +// opposite of the split every other endpoint here uses. The decisions are all +// still the site's; what is here is the comparison against live guild state, +// which only this process can see. +async function voiceSync(req, res) { + const connection = discordManager.getConnection() + if (!connection) return res.status(503).json({ message: 'Bot is not connected' }) + + const { + team_id: teamId, name, category_id: categoryId, channel_id: channelId, role_id: roleId, + staff_role_ids: staffRoleIds, member_ids: memberIds, max_member_ops: maxMemberOps, + } = req.body || {} + + if (!name) return res.status(400).json({ message: 'name is required' }) + + try { + const result = await teamVoice.syncTeamVoice(connection.client, connection.guildId, { + teamId, + name, + categoryId: categoryId || null, + channelId: channelId || null, + roleId: roleId || null, + staffRoleIds: Array.isArray(staffRoleIds) ? staffRoleIds.map(String) : [], + memberIds: Array.isArray(memberIds) ? memberIds.map(String) : [], + maxMemberOps: Number(maxMemberOps) > 0 ? Number(maxMemberOps) : 50, + }) + return res.json(result) + } catch (err) { + // 400 rather than 500, matching /internal/announce: from the app's side this + // is "Discord refused", which is a condition it records against the Team and + // retries next pass — not a bug in this process. + log.warn('voice sync failed', { message: err.message, teamId, name }) + return res.status(400).json({ message: err.message }) + } +} + +// POST /internal/team-voice/remove — the grace window expired, or an admin said so. +async function voiceRemove(req, res) { + const connection = discordManager.getConnection() + if (!connection) return res.status(503).json({ message: 'Bot is not connected' }) + + const { channel_id: channelId, role_id: roleId } = req.body || {} + try { + const result = await teamVoice.removeTeamVoice(connection.client, connection.guildId, { channelId, roleId }) + return res.json(result) + } catch (err) { + log.warn('voice remove failed', { message: err.message, channelId, roleId }) + return res.status(400).json({ message: err.message }) + } +} + +module.exports = { + setConfig, + getStatus: getStatusHandler, + announce, + reverseModAction, + refreshCommands, + teamNotify: teamNotifyHandler, + voicePreflight, + voiceSync, + voiceRemove, +} diff --git a/bot/src/internal/internal.routes.js b/bot/src/internal/internal.routes.js index 49891e4..78c78c5 100644 --- a/bot/src/internal/internal.routes.js +++ b/bot/src/internal/internal.routes.js @@ -11,5 +11,10 @@ router.post('/config', ctrl.setConfig) router.get('/status', ctrl.getStatus) router.post('/announce', ctrl.announce) router.post('/mod-reverse', ctrl.reverseModAction) +router.post('/refresh-commands', ctrl.refreshCommands) +router.post('/team-notify', ctrl.teamNotify) +router.get('/team-voice/preflight', ctrl.voicePreflight) +router.post('/team-voice/sync', ctrl.voiceSync) +router.post('/team-voice/remove', ctrl.voiceRemove) module.exports = router diff --git a/bot/src/site/appInternalClient.js b/bot/src/site/appInternalClient.js new file mode 100644 index 0000000..3f3e07a --- /dev/null +++ b/bot/src/site/appInternalClient.js @@ -0,0 +1,76 @@ +// Shared-secret client for the APP's internal listener (port 3001) — the +// bot→app direction of the channel `botInternalClient.js` runs app→bot. +// +// Two callers, both slash-command plumbing (TEAMS.md §7.1): pull the registered +// command definitions, and dispatch one that a member has just run. Distinct +// from siteApiClient.js, which reads the site's PUBLIC API with no secret at all. +// +// **The base URL is derived from `SITE_INTERNAL_URL`'s origin, not configured +// separately.** That variable already points at the app's internal listener — +// `http://app:3001/internal/bot-config` — and adding a second variable naming the +// same host would be one more thing an operator can get half-right. Deriving it +// means every existing deployment gains these endpoints with no compose change. +const createLogger = require('../utils/logger') + +const log = createLogger('app-internal') + +const KEY = process.env.BOT_INTERNAL_KEY || '' + +// §7.1's budget, and the same 4s `botInternalClient` uses in the other +// direction. The app bounds its own handlers UNDER this (3s), so a timeout here +// normally means the app itself is unreachable rather than a module being slow. +const TIMEOUT_MS = 4000 + +function baseUrl() { + const configured = process.env.SITE_INTERNAL_URL + if (!configured) return null + try { + return new URL(configured).origin + } catch { + log.error('SITE_INTERNAL_URL is not a URL — slash-command registration is off', { configured }) + return null + } +} + +async function call(path, { method = 'GET', body } = {}) { + const base = baseUrl() + if (!base || !KEY) return { ok: false, error: 'SITE_INTERNAL_URL or BOT_INTERNAL_KEY not set' } + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS) + try { + const res = await fetch(`${base}${path}`, { + method, + headers: { 'Content-Type': 'application/json', 'X-Internal-Key': KEY }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }) + if (!res.ok) return { ok: false, status: res.status, error: `app responded ${res.status}` } + return { ok: true, status: res.status, data: await res.json() } + } catch (err) { + log.warn('app internal call failed', { path, message: err.message }) + return { ok: false, status: 0, error: err.message } + } finally { + clearTimeout(timeout) + } +} + +/** The registered slash-command definitions, plus the version they belong to. */ +function fetchCommands() { + return call('/internal/commands') +} + +/** + * Run one command in the app and get the response envelope back. + * + * The bot has already deferred by the time this is called, so the only deadline + * that matters is Discord's 15-minute follow-up window — TIMEOUT_MS is about not + * holding an interaction open on a wedged app, not about the 3-second ack. + */ +function dispatchCommand({ command, options, platformUserId, guildId }) { + return call('/internal/commands/dispatch', { + method: 'POST', + body: { command, options, platform: 'discord', platformUserId, guildId }, + }) +} + +module.exports = { fetchCommands, dispatchCommand } diff --git a/bot/test/appInternalClient.test.js b/bot/test/appInternalClient.test.js new file mode 100644 index 0000000..9d3ae9b --- /dev/null +++ b/bot/test/appInternalClient.test.js @@ -0,0 +1,77 @@ +// The bot→app internal client (TEAMS.md §7.1). +// +// One property carries this file: the base URL is DERIVED from +// `SITE_INTERNAL_URL`, which already names the app's internal listener with a +// path on the end. That derivation is the reason every existing deployment gains +// slash commands with no compose change, and it is exactly the kind of string +// handling that breaks silently — a wrong base means "the app is down" forever, +// with nothing in the logs but a fetch error. + +const { test, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const env = { ...process.env } +const realFetch = global.fetch + +beforeEach(() => { + process.env.SITE_INTERNAL_URL = 'http://app:3001/internal/bot-config' + process.env.BOT_INTERNAL_KEY = 'shh' + delete require.cache[require.resolve('../src/site/appInternalClient')] +}) + +afterEach(() => { + process.env = { ...env } + global.fetch = realFetch +}) + +/** Load the client fresh and record the single fetch it makes. */ +function withFetch(response) { + const seen = {} + global.fetch = async (url, init) => { + seen.url = url + seen.init = init + return response + } + // eslint-disable-next-line global-require + return { client: require('../src/site/appInternalClient'), seen } +} + +const ok = (body) => ({ ok: true, status: 200, json: async () => body }) + +test('the commands URL is the internal listener’s origin, not its bot-config path', async () => { + const { client, seen } = withFetch(ok({ version: 3, commands: [] })) + const res = await client.fetchCommands() + assert.equal(seen.url, 'http://app:3001/internal/commands') + assert.equal(seen.init.headers['X-Internal-Key'], 'shh') + assert.deepEqual(res.data, { version: 3, commands: [] }) +}) + +test('a dispatch names the platform, so the app never has to guess', async () => { + const { client, seen } = withFetch(ok({ ok: true, response: {} })) + await client.dispatchCommand({ command: 'guild', options: { name: 'KOC' }, platformUserId: '5', guildId: '9' }) + assert.equal(seen.url, 'http://app:3001/internal/commands/dispatch') + assert.deepEqual(JSON.parse(seen.init.body), { + command: 'guild', options: { name: 'KOC' }, platform: 'discord', platformUserId: '5', guildId: '9', + }) +}) + +// A bot with no internal URL configured is an ordinary deployment state (the +// warning already exists in bootstrap.js); it must not become an exception on +// every `ready`. +test('an unconfigured or unparseable SITE_INTERNAL_URL is a refusal, not a throw', async () => { + delete process.env.SITE_INTERNAL_URL + const { client } = withFetch(ok({})) + assert.equal((await client.fetchCommands()).ok, false) + + delete require.cache[require.resolve('../src/site/appInternalClient')] + process.env.SITE_INTERNAL_URL = 'not a url' + // eslint-disable-next-line global-require + assert.equal((await require('../src/site/appInternalClient').fetchCommands()).ok, false) +}) + +test('a non-2xx carries its status so the caller can tell "down" from "rejected"', async () => { + const { client } = withFetch({ ok: false, status: 401, json: async () => ({}) }) + const res = await client.fetchCommands() + assert.equal(res.ok, false) + assert.equal(res.status, 401) +}) diff --git a/bot/test/dynamicCommands.test.js b/bot/test/dynamicCommands.test.js new file mode 100644 index 0000000..a7882b4 --- /dev/null +++ b/bot/test/dynamicCommands.test.js @@ -0,0 +1,269 @@ +// ── The bot's half of module slash commands (TEAMS.md §7.1) ──────────────── +// +// The first tests in this package, and they exist for a specific reason: phases +// 8 and 9 put more of the Discord integration in this process, and the failure +// modes here are ones no unit test in `server/` can see — a whole-set PUT that +// one bad entry poisons, a deferral that has to happen before anything slow, and +// a reply that must be EDITED rather than sent once the interaction is deferred. +// +// Nothing here talks to Discord. `interaction` is a fake that records what was +// called on it, which is the whole of what this file is asserting about. + +const { test, beforeEach } = require('node:test') +const assert = require('node:assert/strict') + +const dynamic = require('../src/discord/dynamicCommands') +const appInternal = require('../src/site/appInternalClient') +const staticCommands = require('../src/discord/commands') + +const originals = { + fetchCommands: appInternal.fetchCommands, + dispatchCommand: appInternal.dispatchCommand, + get: staticCommands.get, +} + +beforeEach(() => { + dynamic._reset() + Object.assign(appInternal, originals) + staticCommands.get = originals.get +}) + +const definition = (over = {}) => ({ + name: 'guild', + description: 'Show a guild', + owner: 'uo', + access: 'everyone', + options: [{ name: 'name', type: 'string', description: 'Guild name', required: false }], + ...over, +}) + +const answers = (commands, version = 1) => { + appInternal.fetchCommands = async () => ({ ok: true, data: { version, commands } }) +} + +function fakeInteraction({ commandName = 'guild', options = {}, userId = '555' } = {}) { + const calls = [] + return { + calls, + commandName, + guildId: '999', + user: { id: userId }, + options: { + get: (name) => (name in options ? { value: options[name] } : null), + }, + deferReply: async (payload) => calls.push(['defer', payload]), + editReply: async (payload) => calls.push(['edit', payload]), + deleteReply: async () => calls.push(['delete']), + followUp: async (payload) => calls.push(['followUp', payload]), + } +} + +// ── Pulling ──────────────────────────────────────────────────────────────── + +test('a pull reports whether the set moved, so a nudge is cheap', async () => { + answers([definition()], 7) + assert.deepEqual(await dynamic.pull(), { ok: true, changed: true, count: 1 }) + // Same version, same size: nothing to re-register, and re-registering anyway + // would mean a REST.put per module state change instead of per real change. + assert.deepEqual(await dynamic.pull(), { ok: true, changed: false, count: 1 }) + answers([definition()], 8) + assert.equal((await dynamic.pull()).changed, true) +}) + +// Otherwise a restart blip would deregister every module command from Discord +// and re-register it a minute later, with members watching it happen. +test('a failed pull keeps the set already registered', async () => { + answers([definition()]) + await dynamic.pull() + appInternal.fetchCommands = async () => ({ ok: false, error: 'ECONNREFUSED' }) + assert.deepEqual(await dynamic.pull(), { ok: false, changed: false, count: 1 }) + assert.equal(dynamic.definitions().length, 1) +}) + +// The collision the app cannot see: it validates against what IT registered and +// does not know the bot's own array exists. Two entries of one name in a single +// PUT is rejected as a batch, taking the built-ins down with it. +test('a module command that collides with a built-in is dropped, not registered', async () => { + staticCommands.get = (name) => (name === 'ping' ? { data: { name: 'ping' } } : undefined) + answers([definition({ name: 'ping' }), definition()]) + await dynamic.pull() + assert.deepEqual(dynamic.definitions().map((d) => d.name), ['guild']) + assert.equal(dynamic.has('ping'), false) +}) + +test('definitions carry Discord’s numeric option types, not the contract’s names', async () => { + answers([definition({ + options: [ + { name: 'who', type: 'user', description: 'A member', required: true }, + { name: 'n', type: 'integer', description: 'How many', choices: [{ name: 'one', value: 1 }] }, + ], + })]) + await dynamic.pull() + const [data] = dynamic.definitions() + assert.deepEqual(data.options.map((o) => o.type), [6, 4]) + assert.deepEqual(data.options[1].choices, [{ name: 'one', value: 1 }]) + assert.equal(data.default_member_permissions, undefined) +}) + +// `linked` has no Discord equivalent — there is no "has a website account" +// predicate — so only `staff` maps, and the app re-checks both regardless. +test('only access: staff becomes a Discord permission default', async () => { + answers([definition({ access: 'staff' }), definition({ name: 'other', access: 'linked' })]) + await dynamic.pull() + const [staff, linked] = dynamic.definitions() + assert.equal(typeof staff.default_member_permissions, 'string') + assert.equal(linked.default_member_permissions, undefined) +}) + +// ── Executing ────────────────────────────────────────────────────────────── + +test('the deferral happens before the dispatch, always', async () => { + answers([definition()]) + await dynamic.pull() + let deferredFirst = false + const interaction = fakeInteraction() + appInternal.dispatchCommand = async () => { + deferredFirst = interaction.calls.length === 1 && interaction.calls[0][0] === 'defer' + return { ok: true, data: { ok: true, response: { text: 'hi' } } } + } + await dynamic.execute(interaction) + assert.ok(deferredFirst, 'the website is never in Discord’s 3-second ack path') + assert.deepEqual(interaction.calls.at(-1), ['edit', { content: 'hi' }]) +}) + +test('the options the member supplied are passed by name, as plain values', async () => { + answers([definition({ + options: [ + { name: 'name', type: 'string', description: 'd' }, + { name: 'who', type: 'user', description: 'd' }, + { name: 'missing', type: 'string', description: 'd' }, + ], + })]) + await dynamic.pull() + let sent = null + appInternal.dispatchCommand = async (body) => { + sent = body + return { ok: true, data: { ok: true, response: {} } } + } + await dynamic.execute(fakeInteraction({ options: { name: 'KOC', who: '42' } })) + assert.deepEqual(sent.options, { name: 'KOC', who: '42' }) + assert.equal(sent.platformUserId, '555') + assert.equal(sent.guildId, '999') +}) + +test('a title or fields render as an embed; a bare text does not', async () => { + answers([definition()]) + await dynamic.pull() + appInternal.dispatchCommand = async () => ({ + ok: true, + data: { ok: true, response: { title: 'Knights', text: 'Alliance: Accord', fields: [{ name: 'Members', value: '12' }], url: 'https://site.test/uo/guilds/7' } }, + }) + const interaction = fakeInteraction() + await dynamic.execute(interaction) + const [, payload] = interaction.calls.at(-1) + assert.equal(payload.embeds[0].title, 'Knights') + assert.equal(payload.embeds[0].description, 'Alliance: Accord') + assert.equal(payload.embeds[0].url, 'https://site.test/uo/guilds/7') +}) + +// §9 answer 5: the public projection, plus a private nudge to link. One reply +// cannot be both, so the aside is a follow-up — which is the bot's decision to +// make, not the handler's. +test('a notice becomes an ephemeral follow-up beside a public answer', async () => { + answers([definition()]) + await dynamic.pull() + appInternal.dispatchCommand = async () => ({ + ok: true, + data: { ok: true, response: { text: 'public', notice: 'Link your account' } }, + }) + const interaction = fakeInteraction() + await dynamic.execute(interaction) + assert.deepEqual(interaction.calls.at(-1), ['followUp', { content: 'Link your account', ephemeral: true }]) +}) + +test('a notice is not repeated when the answer was already private', async () => { + answers([definition({ access: 'linked' })]) + await dynamic.pull() + appInternal.dispatchCommand = async () => ({ + ok: true, + data: { ok: true, response: { text: 'private', notice: 'Link your account' } }, + }) + const interaction = fakeInteraction() + await dynamic.execute(interaction) + assert.deepEqual(interaction.calls[0], ['defer', { ephemeral: true }]) + assert.equal(interaction.calls.some(([kind]) => kind === 'followUp'), false) +}) + +// Every failure path EDITS. Replying to a deferred interaction throws, so a +// refusal that used reply() would turn a clean "no" into an unhandled error. +// Ephemerality is fixed at the DEFERRAL, which happens before the handler has +// said anything — so honouring a per-answer flag needs the deferred reply +// withdrawn. The live walk caught the version that ignored it posting "guild +// information is not shown to your account" into the channel, which announces a +// member's access level to everyone in it. +test('a handler asking for privacy gets it, even though the deferral was public', async () => { + answers([definition()]) + await dynamic.pull() + appInternal.dispatchCommand = async () => ({ + ok: true, data: { ok: true, response: { text: 'just for you', ephemeral: true } }, + }) + const interaction = fakeInteraction() + await dynamic.execute(interaction) + assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'delete', 'followUp']) + assert.deepEqual(interaction.calls.at(-1)[1], { content: 'just for you', ephemeral: true }) +}) + +test('an already-private deferral just edits — no second message', async () => { + answers([definition({ access: 'linked' })]) + await dynamic.pull() + appInternal.dispatchCommand = async () => ({ + ok: true, data: { ok: true, response: { text: 'private', ephemeral: true } }, + }) + const interaction = fakeInteraction() + await dynamic.execute(interaction) + assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'edit']) +}) + +// "You do not have access to that" is about one member and belongs to one +// member, whatever the command's usual privacy. +test('a refusal is always private', async () => { + answers([definition()]) + await dynamic.pull() + appInternal.dispatchCommand = async () => ({ ok: true, data: { ok: false, reason: 'forbidden' } }) + const interaction = fakeInteraction() + await dynamic.execute(interaction) + assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'delete', 'followUp']) + assert.equal(interaction.calls.at(-1)[1].ephemeral, true) +}) + +test('a refusal is phrased by the bot and edited into the deferred reply', async () => { + answers([definition({ access: 'linked' })]) + await dynamic.pull() + appInternal.dispatchCommand = async () => ({ + ok: true, data: { ok: false, reason: 'forbidden', access: 'linked', isLinked: false }, + }) + const interaction = fakeInteraction() + await dynamic.execute(interaction) + assert.match(interaction.calls.at(-1)[1].content, /Link your Discord account/) + // Deferred ephemerally (access: 'linked'), so the refusal is one edit and no + // withdrawal — replying twice to a deferred interaction is what throws. + assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'edit']) +}) + +test('an unreachable app is the same sentence to the member and a different line in the log', async () => { + answers([definition()]) + await dynamic.pull() + appInternal.dispatchCommand = async () => ({ ok: false, error: 'timeout' }) + const interaction = fakeInteraction() + await dynamic.execute(interaction) + assert.match(interaction.calls.at(-1)[1].content, /Something went wrong/) + assert.equal(interaction.calls.at(-1)[1].ephemeral, true) +}) + +test('an interaction for a command the app no longer serves is left alone', async () => { + answers([definition()]) + await dynamic.pull() + const interaction = fakeInteraction({ commandName: 'gone' }) + assert.equal(await dynamic.execute(interaction), false) + assert.deepEqual(interaction.calls, [], 'nothing is deferred for a command that is not ours') +}) diff --git a/bot/test/teamNotify.test.js b/bot/test/teamNotify.test.js new file mode 100644 index 0000000..d14a152 --- /dev/null +++ b/bot/test/teamNotify.test.js @@ -0,0 +1,138 @@ +// ── The bot's half of the Team notifications bridge (TEAMS.md §7.2) ──────── +// +// Nothing here talks to Discord. `channel` is a fake that records what was sent, +// and the assertions are about the three things this side genuinely owns: +// +// 1. **the channel comes from the app and is never looked up.** `newsAnnounce` +// reads guild_config because there is one #news; a Team's destination is +// per-Team configuration, and a bot that resolved it would hold a second +// copy of a table it cannot see the inputs to; +// 2. **a channel the bot cannot post to fails loudly rather than silently.** A +// caller that is one-shot and best-effort only logs the difference, but an +// operator debugging a quiet channel needs the bot's log to distinguish +// "not connected" from "that id is not a text channel"; +// 3. **Discord's own limits are enforced here.** An embed that exceeds them is +// rejected WHOLESALE, so a long forum body must be truncated on this side +// even though the app already excerpted it — the app's limit is a product +// decision and this one is a protocol constraint. + +const { test } = require('node:test') +const assert = require('node:assert/strict') + +const teamNotify = require('../src/discord/teamNotify') + +// A fake channel that records what it was sent. `isTextBased` is the one method +// the code branches on, so it is the one worth making configurable. +function fakeChannel({ textBased = true } = {}) { + const sends = [] + return { + sends, + isTextBased: () => textBased, + send: async (payload) => { sends.push(payload); return { id: 'm1' } }, + } +} + +function fakeClient(channel, { throws = false } = {}) { + return { + channels: { + fetch: async (id) => { + if (throws) throw new Error('Unknown Channel') + return id === 'chan-1' ? channel : null + }, + }, + } +} + +const post = (client, over = {}) => teamNotify.postTeamNotification(client, { + channelId: 'chan-1', + stream: 'team.forum.post', + teamName: 'Blackthorn’s Legion', + teamUrl: 'https://site/guilds/blackthorns-legion', + title: 'Siege tonight', + body: 'Meet at the moongate.', + url: 'https://site/guilds/blackthorns-legion?thread=41', + ...over, +}) + +// ── 1. The channel is the app's decision ─────────────────────────────────── + +test('the message goes to the channel the app named', async () => { + const channel = fakeChannel() + await post(fakeClient(channel)) + assert.equal(channel.sends.length, 1) + const [embed] = channel.sends[0].embeds + assert.equal(embed.data.title, 'Siege tonight') + assert.equal(embed.data.author.name, 'Blackthorn’s Legion') + assert.equal(embed.data.url, 'https://site/guilds/blackthorns-legion?thread=41') +}) + +test('no channel id at all is refused before anything is fetched', async () => { + await assert.rejects(() => post(fakeClient(fakeChannel()), { channelId: '' }), /No channel id/) +}) + +// ── 2. A channel the bot cannot use ──────────────────────────────────────── + +test('a channel the bot cannot see is a clear error, not a silent no-op', async () => { + await assert.rejects(() => post(fakeClient(null)), /missing, not text-based, or not visible/) +}) + +test('a fetch that throws is reported the same way — the bot does not distinguish gone from hidden', async () => { + await assert.rejects(() => post(fakeClient(fakeChannel(), { throws: true })), /missing, not text-based/) +}) + +test('a voice channel is refused', async () => { + await assert.rejects(() => post(fakeClient(fakeChannel({ textBased: false }))), /not text-based/) +}) + +// ── 3. Discord's limits, and the heading ─────────────────────────────────── + +test('an over-long title is truncated rather than rejected by Discord as a whole', async () => { + const channel = fakeChannel() + await post(fakeClient(channel), { title: 'y'.repeat(400) }) + const [embed] = channel.sends[0].embeds + assert.equal(embed.data.title.length, teamNotify.TITLE_MAX) + assert.ok(embed.data.title.endsWith('…')) +}) + +test('an over-long body is truncated to the description limit', async () => { + const channel = fakeChannel() + await post(fakeClient(channel), { body: 'z'.repeat(9000) }) + const [embed] = channel.sends[0].embeds + assert.ok(embed.data.description.length <= teamNotify.DESCRIPTION_MAX + 32) +}) + +test('a titled event keeps its heading, so a post and an announcement stay distinguishable', async () => { + const channel = fakeChannel() + await post(fakeClient(channel), { stream: 'team.announcement' }) + const [embed] = channel.sends[0].embeds + assert.match(embed.data.description, /^\*\*Announcement\*\*/) + assert.match(embed.data.description, /Meet at the moongate\./) +}) + +test('a roster event has no title, so the heading becomes the title', async () => { + const channel = fakeChannel() + await post(fakeClient(channel), { stream: 'team.member.joined', title: null, body: '3 new members joined.' }) + const [embed] = channel.sends[0].embeds + assert.equal(embed.data.title, 'New member') + assert.equal(embed.data.description, '3 new members joined.', 'no heading prefix when the title already is one') +}) + +test('an unknown stream still posts, under a neutral heading', async () => { + const channel = fakeChannel() + await post(fakeClient(channel), { stream: 'team.something.new', title: null }) + const [embed] = channel.sends[0].embeds + assert.equal(embed.data.title, 'Team update') +}) + +test('a missing team name does not produce an embed with an empty author line', async () => { + const channel = fakeChannel() + await post(fakeClient(channel), { teamName: '', teamUrl: null }) + const [embed] = channel.sends[0].embeds + assert.equal(embed.data.author.name, 'A team') + assert.equal(embed.data.author.url, undefined) +}) + +test('clamp treats whitespace-only as absent, which is what keeps an empty description off the embed', async () => { + assert.equal(teamNotify.clamp(' ', 100), null) + assert.equal(teamNotify.clamp('ok', 100), 'ok') +}) diff --git a/bot/test/teamVoice.test.js b/bot/test/teamVoice.test.js new file mode 100644 index 0000000..1ce95f5 --- /dev/null +++ b/bot/test/teamVoice.test.js @@ -0,0 +1,364 @@ +// ── The bot's half of Team voice channels (TEAMS.md §7.3, phase 9) ──────── +// +// Nothing here talks to Discord. `fakeGuild` records the calls, and the +// assertions are about the four things this side genuinely owns — the ones the +// site cannot decide because it cannot see the guild: +// +// 1. **The overwrite set.** @everyone denied, the Team's role allowed, each +// configured staff role allowed — and a staff role the operator has since +// deleted is FILTERED, because Discord rejects the whole set for one bad id +// and that would take the Team's own grant down with it. +// 2. **The membership diff is bounded and the remainder is reported.** Each +// grant is its own API call; an unbounded first pass on a large guild +// outlives its own timeout, which is the one failure that leaves the site +// not knowing what was applied. +// 3. **A member who linked Discord but never joined the guild is skipped +// silently.** That is §2.6 hop 3 without hop 4 — an ordinary state, not an +// error, and certainly not a hundred log lines. +// 4. **A missing target is success.** A teardown that finds its channel already +// deleted has reached the desired end state; a sync that finds one deleted +// simply creates it again. + +const { test } = require('node:test') +const assert = require('node:assert/strict') + +const { ChannelType, PermissionFlagsBits } = require('discord.js') +const teamVoice = require('../src/discord/teamVoice') + +const EVERYONE = 'guild-everyone' + +function fakeMember(id, { canGrant = true } = {}) { + const roles = new Set() + return { + id, + roles: { + cache: roles, + add: async (role) => { + if (!canGrant) throw new Error('Missing Permissions') + roles.add(role.id) + }, + remove: async (role) => { roles.delete(role.id) }, + }, + } +} + +function fakeGuild({ + members = [], + roles = [], + channels = [], + botPermissions = [PermissionFlagsBits.ManageChannels, PermissionFlagsBits.ManageRoles], +} = {}) { + const memberMap = new Map(members.map((m) => [m.id, m])) + const roleMap = new Map(roles.map((r) => [r.id, r])) + const channelMap = new Map(channels.map((c) => [c.id, c])) + const created = { roles: [], channels: [] } + let nextId = 1000 + + const guild = { + id: 'guild-1', + created, + roles: { + everyone: { id: EVERYONE }, + cache: roleMap, + fetch: async (id) => roleMap.get(id) || null, + create: async (opts) => { + const role = { + id: String(nextId++), + name: opts.name, + members: [], + setName: async (name) => { role.name = name }, + delete: async () => { roleMap.delete(role.id) }, + } + roleMap.set(role.id, role) + created.roles.push(opts) + return role + }, + }, + channels: { + cache: channelMap, + fetch: async (id) => channelMap.get(id) || null, + create: async (opts) => { + const channel = { + id: String(nextId++), + name: opts.name, + type: opts.type, + parentId: opts.parent || null, + overwrites: opts.permissionOverwrites || [], + permissionOverwrites: { + set: async (list) => { channel.overwrites = list }, + }, + setParent: async (parentId) => { channel.parentId = parentId }, + setName: async (name) => { channel.name = name }, + delete: async () => { channelMap.delete(channel.id) }, + } + channelMap.set(channel.id, channel) + created.channels.push(opts) + return channel + }, + }, + members: { + me: { permissions: { has: (bit) => botPermissions.includes(bit) }, roles: { highest: { position: 7 } } }, + cache: memberMap, + fetch: async () => memberMap, + }, + } + return guild +} + +const fakeClient = (guild) => ({ guilds: { fetch: async () => guild } }) + +const voiceChannel = (id, over = {}) => { + const channel = { + id, + name: 'The Silver Hand', + type: ChannelType.GuildVoice, + parentId: '500', + overwrites: [], + permissionOverwrites: { set: async (list) => { channel.overwrites = list } }, + setParent: async (parentId) => { channel.parentId = parentId }, + setName: async (name) => { channel.name = name }, + delete: async () => {}, + ...over, + } + return channel +} + +const category = (id = '500') => ({ id, type: ChannelType.GuildCategory }) + +const role = (id, name = 'The Silver Hand', members = []) => { + const r = { + id, + name, + members, + setName: async (next) => { r.name = next }, + delete: async () => {}, + } + return r +} + +// ── Preflight ────────────────────────────────────────────────────────────── + +test('preflight reports both permissions and the guild-wide role count', async () => { + const guild = fakeGuild({ roles: [role('1'), role('2')] }) + const result = await teamVoice.preflight(fakeClient(guild), 'guild-1') + assert.equal(result.can_manage_channels, true) + assert.equal(result.can_manage_roles, true) + // The GUILD's roles, not ours. The 250 cap is shared with everything the + // operator made themselves, so counting only ours would promise headroom that + // is not there. + assert.equal(result.role_count, 2) + assert.equal(result.bot_role_position, 7) +}) + +test('preflight reports a missing permission rather than throwing', async () => { + const guild = fakeGuild({ botPermissions: [PermissionFlagsBits.ManageChannels] }) + const result = await teamVoice.preflight(fakeClient(guild), 'guild-1') + assert.equal(result.can_manage_channels, true) + assert.equal(result.can_manage_roles, false) +}) + +// ── Overwrites ───────────────────────────────────────────────────────────── + +test('the overwrite set denies @everyone and allows the Team role', () => { + const guild = fakeGuild() + const list = teamVoice.overwritesFor(guild, role('900'), []) + assert.equal(list.length, 2) + assert.equal(list[0].id, EVERYONE) + assert.deepEqual(list[0].deny, teamVoice.ACCESS_BITS) + assert.equal(list[1].id, '900') + assert.deepEqual(list[1].allow, teamVoice.ACCESS_BITS) +}) + +test('a configured staff role that still exists gets an allow', () => { + const staff = role('777', 'Moderators') + const guild = fakeGuild({ roles: [staff] }) + const list = teamVoice.overwritesFor(guild, role('900'), ['777']) + assert.equal(list.length, 3) + assert.equal(list[2].id, '777') +}) + +test('a staff role deleted in Discord is skipped, not sent — it would void the whole set', () => { + const guild = fakeGuild({ roles: [] }) + const list = teamVoice.overwritesFor(guild, role('900'), ['deleted-1']) + assert.equal(list.length, 2) + assert.ok(!list.some((o) => o.id === 'deleted-1')) +}) + +// ── Ensure ───────────────────────────────────────────────────────────────── + +test('a missing category is created; an existing one is reused', async () => { + const guild = fakeGuild() + const made = await teamVoice.ensureCategory(guild, null) + assert.equal(guild.created.channels.length, 1) + assert.equal(guild.created.channels[0].type, ChannelType.GuildCategory) + + const again = await teamVoice.ensureCategory(guild, made.id) + assert.equal(again.id, made.id) + assert.equal(guild.created.channels.length, 1) +}) + +test('a category id pointing at something that is not a category makes a new one', async () => { + const guild = fakeGuild({ channels: [voiceChannel('700')] }) + await teamVoice.ensureCategory(guild, '700') + assert.equal(guild.created.channels.length, 1) +}) + +test('the Team role is created not mentionable and not hoisted', async () => { + const guild = fakeGuild() + const { role: made, created } = await teamVoice.ensureRole(guild, null, 'The Silver Hand') + assert.equal(created, true) + assert.equal(made.name, 'The Silver Hand') + // A Team with two hundred members must not become a way to ping them all, or a + // second copy of the member list down the sidebar. + assert.equal(guild.created.roles[0].mentionable, false) + assert.equal(guild.created.roles[0].hoist, false) +}) + +test('a renamed Team renames its role rather than making a second', async () => { + const existing = role('900', 'Old Name') + const guild = fakeGuild({ roles: [existing] }) + const { role: made, created } = await teamVoice.ensureRole(guild, '900', 'New Name') + assert.equal(created, false) + assert.equal(made.name, 'New Name') + assert.equal(guild.created.roles.length, 0) +}) + +test('a rename Discord refuses does not fail the pass — access matters more than a label', async () => { + const existing = role('900', 'Old Name') + existing.setName = async () => { throw new Error('rate limited') } + const guild = fakeGuild({ roles: [existing] }) + const { role: made } = await teamVoice.ensureRole(guild, '900', 'New Name') + assert.equal(made.id, '900') +}) + +test('a channel a human deleted is simply created again', async () => { + const guild = fakeGuild() + const { channel, created } = await teamVoice.ensureChannel(guild, 'gone-1', { + name: 'The Silver Hand', category: category(), role: role('900'), staffRoleIds: [], + }) + assert.equal(created, true) + assert.equal(channel.type, ChannelType.GuildVoice) + assert.equal(channel.parentId, '500') +}) + +test('an existing channel has its overwrites re-asserted every pass', async () => { + const existing = voiceChannel('600') + const guild = fakeGuild({ channels: [existing] }) + const { created } = await teamVoice.ensureChannel(guild, '600', { + name: 'The Silver Hand', category: category(), role: role('900'), staffRoleIds: [], + }) + assert.equal(created, false) + // Re-setting rather than diffing is what repairs a channel somebody edited by + // hand. + assert.equal(existing.overwrites.length, 2) +}) + +test('a channel that is no longer a voice channel is left alone and a new one made', async () => { + const text = voiceChannel('600', { type: ChannelType.GuildText }) + const guild = fakeGuild({ channels: [text] }) + const { channel, created } = await teamVoice.ensureChannel(guild, '600', { + name: 'The Silver Hand', category: category(), role: role('900'), staffRoleIds: [], + }) + assert.equal(created, true) + assert.notEqual(channel.id, '600') +}) + +// ── Membership ───────────────────────────────────────────────────────────── + +test('the role is granted to the members the site named', async () => { + const alice = fakeMember('a') + const bob = fakeMember('b') + const guild = fakeGuild({ members: [alice, bob] }) + const teamRole = role('900', 'The Silver Hand', []) + + const result = await teamVoice.syncRoleMembers(guild, teamRole, ['a', 'b'], 50) + assert.equal(result.added, 2) + assert.equal(result.removed, 0) + assert.equal(result.pending, 0) +}) + +test('a member who left the Team has the role taken away', async () => { + const alice = fakeMember('a') + const bob = fakeMember('b') + const guild = fakeGuild({ members: [alice, bob] }) + const teamRole = role('900', 'The Silver Hand', [alice, bob]) + + const result = await teamVoice.syncRoleMembers(guild, teamRole, ['a'], 50) + assert.equal(result.added, 0) + assert.equal(result.removed, 1) +}) + +test('a member who linked Discord but never joined the guild is skipped without an error', async () => { + const guild = fakeGuild({ members: [] }) + const result = await teamVoice.syncRoleMembers(guild, role('900', 'x', []), ['not-in-guild'], 50) + assert.equal(result.added, 0) + assert.equal(result.pending, 0) +}) + +test('the diff is bounded and the remainder is REPORTED, not dropped', async () => { + const members = Array.from({ length: 10 }, (_, i) => fakeMember(`m${i}`)) + const guild = fakeGuild({ members }) + const result = await teamVoice.syncRoleMembers(guild, role('900', 'x', []), members.map((m) => m.id), 4) + assert.equal(result.added, 4) + assert.equal(result.pending, 6) +}) + +test('one member the bot cannot touch does not cost the other forty-nine', async () => { + const ok1 = fakeMember('a') + const nope = fakeMember('b', { canGrant: false }) + const ok2 = fakeMember('c') + const guild = fakeGuild({ members: [ok1, nope, ok2] }) + + const result = await teamVoice.syncRoleMembers(guild, role('900', 'x', []), ['a', 'b', 'c'], 50) + assert.equal(result.added, 2) +}) + +// ── Teardown ─────────────────────────────────────────────────────────────── + +test('a teardown deletes the channel and the role together', async () => { + const channel = voiceChannel('600') + const teamRole = role('900') + let deletedChannel = false + let deletedRole = false + channel.delete = async () => { deletedChannel = true } + teamRole.delete = async () => { deletedRole = true } + const guild = fakeGuild({ channels: [channel], roles: [teamRole] }) + + const result = await teamVoice.removeTeamVoice(fakeClient(guild), 'guild-1', { channelId: '600', roleId: '900' }) + assert.equal(deletedChannel, true) + assert.equal(deletedRole, true) + assert.equal(result.channel_deleted, true) + assert.equal(result.role_deleted, true) +}) + +test('a teardown whose target is already gone is success, not a failure to retry forever', async () => { + const guild = fakeGuild({ channels: [], roles: [] }) + const result = await teamVoice.removeTeamVoice(fakeClient(guild), 'guild-1', { channelId: 'gone', roleId: 'gone' }) + assert.equal(result.channel_deleted, false) + assert.equal(result.role_deleted, false) +}) + +// ── The whole thing ──────────────────────────────────────────────────────── + +test('a first sync creates the category, the role and the channel, and grants the members', async () => { + const alice = fakeMember('a') + const guild = fakeGuild({ members: [alice] }) + + const result = await teamVoice.syncTeamVoice(fakeClient(guild), 'guild-1', { + teamId: 1, + name: 'The Silver Hand', + categoryId: null, + channelId: null, + roleId: null, + staffRoleIds: [], + memberIds: ['a'], + maxMemberOps: 50, + }) + + assert.equal(result.created.channel, true) + assert.equal(result.created.role, true) + assert.ok(result.category_id) + assert.ok(result.channel_id) + assert.ok(result.role_id) + assert.equal(result.members.added, 1) +}) diff --git a/client/src/App.jsx b/client/src/App.jsx index a627509..88ff3f7 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -42,10 +42,12 @@ import UsersAdmin from './routes/admin/views/UsersAdmin.jsx' import UserDetail from './routes/admin/views/UserDetail.jsx' import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx' import ModulesAdmin from './routes/admin/views/ModulesAdmin.jsx' +import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx' import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' import Moderation from './routes/admin/views/Moderation.jsx' import ModerationUser from './routes/admin/views/ModerationUser.jsx' import Appeals from './routes/admin/views/Appeals.jsx' +import ContentReports from './routes/admin/views/ContentReports.jsx' // Player portal import PlayerLogin from './routes/player/PlayerLogin.jsx' @@ -55,6 +57,8 @@ import ResetPassword from './routes/player/ResetPassword.jsx' import AcceptInvite from './routes/player/AcceptInvite.jsx' import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx' import PlayerAccount from './routes/player/PlayerAccount.jsx' +import PlayerNotifications from './routes/player/PlayerNotifications.jsx' +import Unsubscribe from './routes/player/Unsubscribe.jsx' import PlayerAppeals from './routes/player/PlayerAppeals.jsx' export default function App() { @@ -162,6 +166,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> @@ -174,6 +179,10 @@ export default function App() { the volume in the first place. Declared here with the rest of core's routes, above the module-supplied ones below. */} } /> + {/* Staff-wide, like the moderation queues: the gate on the three + actions that publish a game-written name is applied per request + on the server, from the caller's live role (TEAMS.md 2.9). */} + } /> } /> {/* Installed modules' admin pages, at /admin//…, already inside RequireAuth + AdminLayout. A module cannot supply its own auth @@ -197,6 +206,10 @@ export default function App() { } /> } /> } /> + {/* PUBLIC, and grouped with the other tokened landings above rather + than with the portal below: the person following an unsubscribe + link is reading their mail, not signed in (TEAMS.md §6.4). */} + } /> @@ -211,6 +224,7 @@ export default function App() { } /> } /> } /> + } /> {/* Installed modules' player-portal pages, at /player//…. This group's own routes are absolute (its layout route has no path), so the prefix is written here rather than inherited — the one diff --git a/client/src/api/client.js b/client/src/api/client.js index b21db5a..1da6829 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -133,6 +133,80 @@ export const api = { return req(`/public/wiki${withQs(s)}`) }, wikiCategories: () => req('/public/wiki/categories'), + + // ----- Teams (TEAMS.md §2.11, §4.3) ----- + // + // Only the two calls CORE's own client makes. Core renders no Team pages — the + // vocabulary belongs to whichever module owns the surface — so the index, the + // roster and the player list are not here; a module that renders those calls + // the same public API from its own client. + // + // The lookup exists because a module names a Team in its own terms and core + // keys the feed by slug. Resolving that is core's job precisely so a module + // never has to hold core's identifiers. + teamByExternalId: (moduleId, externalId) => + req(`/public/teams/by-external/${encodeURIComponent(moduleId)}/${encodeURIComponent(externalId)}`), + teamActivity: (slug, opts = {}) => { + const qs = new URLSearchParams() + if (opts.limit != null) qs.set('limit', String(opts.limit)) + if (opts.offset != null) qs.set('offset', String(opts.offset)) + return req(`/public/teams/${encodeURIComponent(slug)}/activity${withQs(qs.toString())}`) + }, + // The Team FORUM, under /player because a participant may be a plain player and + // a leader is a player (TEAMS.md §2.11). Core's, for the same reason the feed is + // core's: only core resolves whether this viewer is inside the Team, and the + // member/guest split is a security boundary. The module renders the PLACE. + teamForumThreads: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`), + teamForumThread: (slug, id) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}`), + teamForumPost: (slug, body) => + req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`, { method: 'POST', body }), + teamForumModerate: (slug, id, body) => + req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}/moderate`, { method: 'POST', body }), + // Phase 5 ("5b"). A reply, an edit and post-level moderation are separate + // routes from their thread-level cousins rather than the same route with a + // target kind, because they answer to different rules: a reply is refused by a + // lock, an edit by a clock, and `pin`/`lock` mean nothing to a post at all. + teamForumReply: (slug, threadId, body) => + req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${threadId}/posts`, { method: 'POST', body }), + teamForumEditPost: (slug, postId, body) => + req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}`, { method: 'PATCH', body }), + teamForumModeratePost: (slug, postId, body) => + req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}/moderate`, { method: 'POST', body }), + // The report goes to SITE STAFF, never to the Team's leaders — the whole point + // of it is a path that routes around a Team's own leadership (TEAMS.md §5.6). + // There is no leader-facing counterpart to this call and there should not be. + teamForumReport: (slug, body) => + req(`/player/teams/${encodeURIComponent(slug)}/forum/report`, { method: 'POST', body }), + teamForumUpload: (slug, file) => { + const fd = new FormData() + fd.append('image', file) + return req(`/player/teams/${encodeURIComponent(slug)}/forum/uploads`, { method: 'POST', body: fd, raw: true }) + }, + teamGrantList: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/grants`), + teamGrantAdd: (slug, body) => + req(`/player/teams/${encodeURIComponent(slug)}/grants`, { method: 'POST', body }), + teamGrantRevoke: (slug, userId) => + req(`/player/teams/${encodeURIComponent(slug)}/grants/${userId}`, { method: 'DELETE' }), + + // ----- notifications (TEAMS.md Part 6) ----- + // + // Under /auth/me rather than /player: these are role-agnostic self-service, the + // same rule that put the forum under /player rather than behind a staff gate. + // The streams catalog and the per-stream subscriptions were built for the app + // and had no web consumer at all until phase 6 gave them one. + notificationStreams: () => req('/auth/me/notifications/streams'), + notificationSubscriptions: () => req('/auth/me/notifications/subscriptions'), + // `streams` is always sent, empty array included — the endpoint requires the + // field, so clearing the last subscription must not become an absent key. + setNotificationSubscriptions: (streams) => + req('/auth/me/notifications/subscriptions', { method: 'PUT', body: { streams } }), + teamNotificationPrefs: () => req('/auth/me/notifications/teams'), + setTeamNotificationPrefs: (teams) => + req('/auth/me/notifications/teams', { method: 'PUT', body: { teams } }), + // Unauthenticated, and the one write in the public tier: the caller is reading + // their mail, not signed in. Always resolves 200 whatever the token was. + unsubscribeTeam: (token) => + req(`/public/teams/unsubscribe/${encodeURIComponent(token)}`, { method: 'POST' }), wikiTags: () => req('/public/wiki/tags'), wikiPage: (slug) => req(`/public/wiki/${slug}`), // CMS pages (block-based). Published-only for the public; a draft-preview link @@ -247,8 +321,61 @@ export const api = { setModuleSources: (hosts) => req('/admin/modules/sources', { method: 'PUT', body: { hosts } }), restartServer: () => req('/admin/modules/restart', { method: 'POST' }), + // Teams (docs/website/TEAMS.md §2.11). Three of these mean something + // different depending on who calls them: for a moderator, unhide and + // setTeamDisplayName file a request and the response says `pending: true`. + // The caller does not choose — the server decides from the live role — so + // there is deliberately no "asRequest" argument to get wrong. + listTeams: () => req('/admin/teams'), + getTeam: (id) => req(`/admin/teams/${id}`), + resyncTeams: () => req('/admin/teams/resync', { method: 'POST' }), + archiveTeam: (id, reason) => req(`/admin/teams/${id}/archive`, { method: 'POST', body: { reason } }), + teamGrants: (id) => req(`/admin/teams/${id}/grants`), + hideTeam: (id, reason) => req(`/admin/teams/${id}/hide`, { method: 'POST', body: { reason } }), + unhideTeam: (id, reason) => req(`/admin/teams/${id}/unhide`, { method: 'POST', body: { reason } }), + setTeamDisplayName: (id, displayName, reason) => + req(`/admin/teams/${id}/display-name`, { method: 'POST', body: { displayName, reason } }), + setTeamLeaderOverride: (id, body) => + req(`/admin/teams/${id}/leader-override`, { method: 'POST', body }), + clearTeamLeaderOverride: (id, memberKey) => + req(`/admin/teams/${id}/leader-override/${encodeURIComponent(memberKey)}`, { method: 'DELETE' }), + teamForumSettings: () => req('/admin/teams/forum/settings'), + // The notification bridge (TEAMS.md §7.2). Admin-only server-side, so a + // moderator's admin panel never renders the panel that calls these. + teamIntegrations: () => req('/admin/teams/integrations'), + saveTeamIntegration: (body) => req('/admin/teams/integrations', { method: 'PUT', body }), + deleteTeamIntegration: (teamId) => + req(`/admin/teams/integrations/${teamId === null ? 'default' : teamId}`, { method: 'DELETE' }), + // Voice channels (TEAMS.md §7.3). Admin-only server-side, like the bridge. + teamVoice: () => req('/admin/teams/voice'), + saveTeamVoice: (body) => req('/admin/teams/voice', { method: 'PUT', body }), + teamVoicePass: () => req('/admin/teams/voice/sync', { method: 'POST' }), + removeTeamVoice: (teamId) => req(`/admin/teams/voice/${teamId}`, { method: 'DELETE' }), + teamForumUploads: (opts = {}) => { + const qs = new URLSearchParams() + if (opts.deleted) qs.set('deleted', '1') + return req(`/admin/teams/forum/uploads${withQs(qs.toString())}`) + }, + teamForumModeration: (id) => req(`/admin/teams/${id}/forum/moderation`), + teamReviewQueue: () => req('/admin/teams/review'), + teamRequests: (status) => req(`/admin/teams/requests${status ? `?status=${status}` : ''}`), + decideTeamRequest: (id, status, note) => + req(`/admin/teams/requests/${id}/decide`, { method: 'POST', body: { status, note } }), + // ----- moderation dashboard (admin + moderator) ----- modSummary: () => req('/admin/moderation/stats/summary'), + // The content-report queue (TEAMS.md §5.6). Under moderation rather than + // under Teams because a staffer working a queue should have one place to + // work, and a report about a forum post is the same job as a report about + // anything else — which is also why `targetType` is open-ended. + contentReports: (opts = {}) => { + const qs = new URLSearchParams() + if (opts.status) qs.set('status', opts.status) + if (opts.teamId) qs.set('teamId', String(opts.teamId)) + return req(`/admin/moderation/reports${withQs(qs.toString())}`) + }, + handleContentReport: (id, body) => + req(`/admin/moderation/reports/${id}/handle`, { method: 'POST', body }), modRecent: (params = {}) => { const qs = new URLSearchParams() if (params.type) qs.set('type', params.type) diff --git a/client/src/lib/teamActivity.js b/client/src/lib/teamActivity.js new file mode 100644 index 0000000..2d5c3b6 --- /dev/null +++ b/client/src/lib/teamActivity.js @@ -0,0 +1,100 @@ +// What core's Team activity feed SAYS, separated from how it renders +// (docs/website/TEAMS.md §4.3). +// +// Core renders this feed into a slot a MODULE declares on its own page, because +// Teams is a contract primitive and not a surface: core owns the feed, its +// visibility rules and its wording; the module owns the page and the vocabulary +// around it. So this file is deliberately narrow — the roster and index +// presentation that once lived here went with the core Team pages, to whichever +// module renders them. +// +// Plain JS with tests, following lib/teamAdmin.js. Worth splitting for the same +// reason it was there: a feed that is filtered, or a projection that is stale, +// has to say so in words, and getting that wording right is logic rather than +// markup. + +const MINUTE = 60_000 +const HOUR = 60 * MINUTE +const DAY = 24 * HOUR + +/** "just now" / "14 minutes ago" / "3 hours ago" / "2 days ago". */ +export function relativeTime(when, now = Date.now()) { + if (!when) return null + const ms = now - new Date(when).getTime() + if (!Number.isFinite(ms)) return null + if (ms < MINUTE) return 'just now' + if (ms < HOUR) { + const n = Math.floor(ms / MINUTE) + return `${n} ${n === 1 ? 'minute' : 'minutes'} ago` + } + if (ms < DAY) { + const n = Math.floor(ms / HOUR) + return `${n} ${n === 1 ? 'hour' : 'hours'} ago` + } + const n = Math.floor(ms / DAY) + return `${n} ${n === 1 ? 'day' : 'days'} ago` +} + +/** + * How a public surface describes the projection's freshness (§2.4). + * + * Distinct from `teamAdmin.freshnessOf`, which is worded for an operator + * debugging a sync. A visitor needs one sentence about whether what they are + * looking at is current, and specifically must never be shown an unconfirmed + * empty projection as though it were a confirmed empty shard. + */ +export function freshnessNote(sync = {}, now = Date.now()) { + // Nothing supplies Teams here, so there is nothing to be stale ABOUT. A + // deployment with no game module is not a broken one. + if (!sync.configured) return null + if (!sync.lastSyncAt) return { tone: 'warn', text: 'Not yet confirmed against the game.' } + const ago = relativeTime(sync.lastSyncAt, now) + if (sync.stale) return { tone: 'warn', text: `Last confirmed ${ago} — the game may have moved on.` } + return { tone: 'idle', text: `Last confirmed ${ago}.` } +} + +/** + * Group feed items into days, newest first, preserving order within a day (§4.3). + * + * Keyed by local calendar date rather than by a UTC slice: "yesterday" is a + * property of where the reader is sitting, and a shard's evening raid landing at + * 00:30 UTC belongs on the day the players experienced it. + */ +export function groupByDay(items = [], locale = undefined) { + const days = [] + const byKey = new Map() + for (const item of items) { + const date = new Date(item.occurredAt) + if (Number.isNaN(date.getTime())) continue + const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` + if (!byKey.has(key)) { + const day = { + key, + label: date.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' }), + items: [], + } + byKey.set(key, day) + days.push(day) + } + byKey.get(key).items.push(item) + } + return days +} + +/** + * What to say under a feed that has been filtered. + * + * Only when there is something to say: a caller who saw everything is told + * nothing, and an anonymous caller is invited to sign in rather than simply + * informed that entries exist which they cannot have. + * + * The wording avoids core's own noun. The reader is looking at a page the module + * titled — a guild, a clan — and "this Team" would be core's vocabulary leaking + * onto a surface that deliberately does not use it. + */ +export function activityScopeNote(feed = {}, signedIn = false) { + if (feed.scope !== 'public') return null + return signedIn + ? 'Some entries are visible to members only.' + : 'Sign in as a member to see the members-only entries.' +} diff --git a/client/src/lib/teamAdmin.js b/client/src/lib/teamAdmin.js new file mode 100644 index 0000000..7c3c34f --- /dev/null +++ b/client/src/lib/teamAdmin.js @@ -0,0 +1,140 @@ +// What Admin → Teams SAYS, separated from how it renders (docs/website/TEAMS.md +// §2.4, §2.8, §2.9). +// +// Plain JS with tests, following lib/moduleAdmin.js. The reason it is worth +// splitting here specifically: this screen's job is to tell an operator the +// difference between "the shard has no Teams" and "core has not been able to ask +// for two hours", and those two produce almost the same page. Getting that +// wording right is logic, not markup. + +/** Tones the screen uses. Names, not colours — the view maps them. */ +export const TONE = { ok: 'ok', warn: 'warn', bad: 'bad', idle: 'idle' } + +/** + * How to describe the projection's freshness. + * + * The four states are genuinely different and an operator needs to tell them + * apart: + * + * - no provider registered — nothing to sync, and not a fault; + * - never synced — core has an empty projection it has never confirmed, which + * must NOT read as "there are no Teams"; + * - stale — the projection is real but old, and the reason is usually in + * `lastError`; + * - current. + */ +export function freshnessOf(sync = {}) { + if (!sync.configured) { + return { tone: TONE.idle, label: 'No Team provider', detail: 'No installed module supplies Teams.' } + } + if (!sync.lastSyncAt) { + return { + tone: TONE.bad, + label: 'Never synced', + detail: 'Core has never had an answer it could trust. What is shown below is not a confirmed empty shard.', + } + } + if (sync.stale) { + return { + tone: TONE.warn, + label: 'Stale', + detail: `Last confirmed ${ago(sync.lastSyncAt)}. Rosters below may be out of date.`, + } + } + return { tone: TONE.ok, label: 'Current', detail: `Last confirmed ${ago(sync.lastSyncAt)}.` } +} + +/** + * A short, human age. Deliberately coarse: this exists so a sentence reads + * "confirmed 14 minutes ago", and second-level precision would be false comfort + * about a projection whose interval is fifteen minutes. + */ +export function ago(value) { + if (!value) return 'never' + const seconds = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 1000)) + if (seconds < 90) return 'just now' + const minutes = Math.round(seconds / 60) + if (minutes < 60) return `${minutes} minutes ago` + const hours = Math.round(minutes / 60) + if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago` + return `${Math.round(hours / 24)} days ago` +} + +/** The status pill for one Team row. */ +export function statusOf(team = {}) { + if (team.status === 'archived') { + return { tone: TONE.idle, label: team.archivedReason === 'renamed' ? 'Renamed' : 'Archived' } + } + if (team.hidden && team.hiddenReason === 'reserved_name') { + return { tone: TONE.bad, label: 'Hidden — reserved name' } + } + if (team.hidden) return { tone: TONE.warn, label: 'Hidden by staff' } + return { tone: TONE.ok, label: 'Public' } +} + +/** + * What a staff member is told will happen when they press the button. + * + * The gate is decided server-side from the caller's live role, so this only + * describes it. Saying "Request" to a moderator and "Apply" to an admin is what + * stops the pending result being a surprise. + */ +export function gateLabelFor(role, verb) { + return role === 'admin' ? verb : `Request ${verb.toLowerCase()}` +} + +/** The three gated actions, for the note under the buttons. */ +export const GATED_NOTE = + 'Publishing a game-written name needs an admin: a moderator’s un-hide or display-name change ' + + 'is filed for approval. Hiding is not gated — suppression is always safe.' + +/** A one-line description of a queued request, for the approval queue. */ +export function describeRequest(request = {}) { + const payload = parsePayload(request.payload) + const who = request.requested_username || 'a deleted user' + switch (request.action) { + case 'unhide': + return `${who} asks to publish “${request.team_name}”` + case 'display_name_override': + return `${who} asks to display “${request.team_name}” as “${payload.displayName || ''}”` + case 'clear_display_name_override': + return `${who} asks to clear the display name on “${request.team_name}”` + default: + return `${who} asks for “${request.action}” on “${request.team_name}”` + } +} + +/** + * The payload may arrive parsed or as a JSON string depending on the driver, so + * this normalises rather than assuming either. The server has the same note. + */ +export function parsePayload(payload) { + if (payload == null) return {} + if (typeof payload === 'object') return payload + try { + return JSON.parse(payload) + } catch { + return {} + } +} + +/** + * How a member's leadership should read. + * + * An override is shown AS an override rather than folded into the answer: staff + * looking at a roster need to see that a decision was made, not a fact that looks + * like the game's. + */ +export function leadershipOf(member = {}) { + if (!member.leaderOverride) { + return { isLeader: Boolean(member.isLeader), overridden: false, note: null } + } + const granted = member.leaderOverride.effect === 'grant' + return { + isLeader: granted, + overridden: true, + note: `${granted ? 'Granted' : 'Denied'} by ${member.leaderOverride.by || 'a deleted user'}` + + `${member.leaderOverride.reason ? ` — ${member.leaderOverride.reason}` : ''}` + + ` (the game says ${member.isLeaderSynced ? 'leader' : 'not a leader'})`, + } +} diff --git a/client/src/lib/teamForum.js b/client/src/lib/teamForum.js new file mode 100644 index 0000000..b52ae3a --- /dev/null +++ b/client/src/lib/teamForum.js @@ -0,0 +1,85 @@ +// The Team forum's client-side judgements — the few there are (TEAMS.md Part 5). +// +// This file is small on purpose. **Almost nothing about the forum is the +// client's to decide**: who may post, who may moderate, whether an image +// renders, and whether a post may be edited are all answered by the server and +// read from the payload. What is left here is the handful of pure functions that +// turn those answers into what a reader sees, and they are extracted so they can +// be tested without a browser. +// +// The one that deserves a second look is `editOfferOpen`. It can only ever take +// an offer AWAY — the server grants the edit and re-derives the window from +// `created_at` when the write arrives. A client that granted one would be +// deciding a time-bounded permission against the clock of the party it bounds. + +export const REPORT_REASONS = [ + ['abuse', 'Abusive or harassing'], + ['spam', 'Spam'], + ['sexual', 'Sexual content'], + ['illegal', 'Illegal content'], + ['impersonation', 'Impersonation'], + ['other', 'Something else'], +] + +/** + * Should the Edit control still be offered for this post? + * + * Three states, and the middle one is the reason this exists: + * • the server said no → no offer, and nothing here can create one + * • the server said yes, no deadline (staff) → offer + * • the server said yes with a deadline that has since passed while the page + * sat open → withdraw the offer, rather than leave a button that fails + */ +export function editOfferOpen(post, now = Date.now()) { + if (!post || !post.canEdit) return false + if (!post.editableUntil) return true + const until = new Date(post.editableUntil).getTime() + return Number.isFinite(until) && until > now +} + +/** + * Turn a rendered body back into something an author can edit. + * + * The server stores sanitised HTML and generates images at READ time from the + * URLs an author wrote (§5.5.3), so what comes back is not what was typed. The + * `` has to go — it is core's output, not the author's input, and leaving it + * in would let an author "edit" markup they never wrote and cannot control. + * The URL survives as the link text beside it, which is what re-renders. + */ +export function stripToText(html) { + return String(html || '') + .replace(/]*>/gi, '') + .replace(/<\/p>\s*]*>/gi, '\n\n') + .replace(//gi, '\n') + .replace(/<[^>]*>/g, '') + // Entities last: unescaping before tag-stripping would let an escaped + // "<script>" become a real tag the next pass then removes, which is a + // different string from the one the author wrote. + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' ') + // `&` last of all, or "&lt;" would decode two steps into "<". + .replace(/&/g, '&') + .trim() +} + +/** + * The one-line summary under a thread's title in the list. + * + * `postCount` counts every post including the opening one, so a discussion's + * REPLY count is one less — and an announcement has no replies to count at all, + * which is why the count is omitted rather than shown as zero. + */ +export function threadSummary(thread) { + const parts = [] + if (thread.type === 'announcement') parts.push('Announcement') + parts.push(thread.author) + if (thread.type === 'discussion' && thread.postCount > 1) { + const replies = thread.postCount - 1 + parts.push(`${replies} ${replies === 1 ? 'reply' : 'replies'}`) + } + if (thread.status === 'hidden') parts.push('hidden') + return parts.join(' · ') +} diff --git a/client/src/lib/teamIntegrations.js b/client/src/lib/teamIntegrations.js new file mode 100644 index 0000000..b4198df --- /dev/null +++ b/client/src/lib/teamIntegrations.js @@ -0,0 +1,103 @@ +// What Admin → Teams → Notification bridge decides (TEAMS.md §7.2, phase 8). +// +// The view is a form; these are the rules it applies, extracted for the same +// reason `teamAdmin.js` is: the interesting parts are decisions — when the +// acknowledgement dialog opens, and when a standing acknowledgement stops being +// valid — and a decision embedded in JSX is one nothing can assert on. +// +// **The rules here MIRROR the server's and do not replace them.** The server +// refuses to enable a members-only bridge without the acknowledgement (422) +// whether or not this file ever ran. What is here is so the screen agrees with +// that answer before making the round trip, rather than showing an operator a +// save that fails for a reason the form did not mention. + +// Wording an operator reads, per event id the server offers. Presentation, so it +// lives on this side; the one bit that is policy — which events are members-only — +// comes from the server with each event. +export const EVENT_LABELS = { + 'team.member.joined': 'New members joined', + 'team.leadership.changed': 'Leadership changed', + 'team.forum.post': 'New forum post', + 'team.announcement': 'Announcement posted', +} + +export const eventLabel = (id) => EVENT_LABELS[id] || id + +/** A row's identity in a list. `null` and `undefined` are both the default row. */ +export const rowKey = (row) => + (row.team_id === null || row.team_id === undefined ? 'default' : String(row.team_id)) + +export const isDefaultRow = (row) => row.team_id === null || row.team_id === undefined + +export const blankDraft = (teamId = null) => ({ + teamId, + events: [], + channelRef: '', + enabled: false, + membersAck: false, +}) + +export const draftFrom = (row) => ({ + teamId: row.team_id ?? null, + events: row.events || [], + channelRef: row.channel_ref || '', + enabled: !!row.enabled, + membersAck: !!row.members_ack, +}) + +export function appliesToLabel(row, fallback = 'All Teams') { + if (isDefaultRow(row)) return fallback + return row.display_name_override || row.team_name || `Team #${row.team_id}` +} + +/** Toggle one event in a draft, preserving order of first selection. */ +export const toggleEvent = (draft, id) => ({ + ...draft, + events: draft.events.includes(id) ? draft.events.filter((e) => e !== id) : [...draft.events, id], +}) + +/** + * Repointing the row drops a standing acknowledgement, in the SAME place the + * server does. + * + * Leaving the tick showing while the server has already decided to clear it is + * the one way this screen could actively mislead: an operator repoints a row at a + * public channel, sees "members-only destination confirmed" still ticked, and + * believes the confirmation they gave for a private channel covers the new one. + */ +export function setChannel(draft, channelRef) { + if (channelRef === draft.channelRef) return draft + return { ...draft, channelRef, membersAck: false } +} + +/** Does this draft carry anything that would publish members-only text? */ +export const carriesMembersOnly = (draft, membersOnlyIds) => + draft.events.some((id) => membersOnlyIds.includes(id)) + +/** + * Should saving stop and ask first? + * + * Only when ENABLING. A draft that carries forum events but is switched off is a + * configuration being written, not a channel being published to — asking then + * would make an operator confirm something they have not decided to do yet, which + * is how a confirmation dialog becomes a thing people click through. + */ +export const needsAcknowledgement = (draft, membersOnlyIds) => + !!draft.enabled && carriesMembersOnly(draft, membersOnlyIds) && !draft.membersAck + +/** The ids of every event the server flagged as members-only. */ +export const membersOnlyIdsOf = (events) => (events || []).filter((e) => e.membersOnly).map((e) => e.id) + +/** + * Which Teams may still be given an override, and whether the default is taken. + * + * Offering a Team that already has a row would only produce a save that silently + * overwrote it, since the unique key is (platform, team). + */ +export function availableTargets(rows, teams) { + const taken = new Set(rows.filter((r) => !isDefaultRow(r)).map((r) => r.team_id)) + return { + hasDefault: rows.some(isDefaultRow), + teams: (teams || []).filter((t) => t.status === 'active' && !taken.has(t.id)), + } +} diff --git a/client/src/lib/teamVoice.js b/client/src/lib/teamVoice.js new file mode 100644 index 0000000..77b75e3 --- /dev/null +++ b/client/src/lib/teamVoice.js @@ -0,0 +1,112 @@ +// What Admin → Teams → Voice channels decides (TEAMS.md §7.3, phase 9). +// +// Extracted for the reason `teamIntegrations.js` is: the interesting parts are +// decisions — when the panel refuses to let voice be switched on, how close the +// guild is to running out of roles, what a row's state actually means to the +// person reading it — and a decision written inline in JSX is one nothing can +// assert on. +// +// **These rules MIRROR the server's and do not replace them.** The server refuses +// to enable voice while the bot cannot manage channels and roles (422) whether or +// not this file ever ran, and the reconciler applies the threshold and the grace +// window regardless of what the screen says. What is here is so the screen agrees +// with those answers before making the round trip. + +/** Wording for each state the server can report on a row. */ +export const STATE_LABELS = { + none: 'Not provisioned', + active: 'Active', + pending_removal: 'Scheduled for removal', + error: 'Error', +} + +export const stateLabel = (state) => STATE_LABELS[state] || state || 'Unknown' + +/** + * Is the panel allowed to offer the enable switch? + * + * The preflight answers three separate questions and they fail differently: the + * bot is not connected at all, it is connected but missing a permission, or it + * could not be reached. An operator can act on each of those and they need + * different actions, so the reason is passed through rather than flattened to a + * boolean. + */ +export function enableBlockedReason(preflight) { + if (!preflight) return 'The bot’s status is unknown.' + if (!preflight.connected) return preflight.reason || 'The Discord bot is not connected.' + if (preflight.missingPermissions && preflight.missingPermissions.length > 0) { + return `The bot is missing ${preflight.missingPermissions.join(' and ')} in this guild.` + } + if (!preflight.ready) return preflight.reason || 'The bot cannot manage channels and roles yet.' + return null +} + +// Below this many free roles the panel starts saying so. Not a server rule and +// deliberately not one: it is a warning, and the server's only hard behaviour is +// to refuse the create that would exceed the cap. +const HEADROOM_WARNING = 25 + +/** + * How much room is left, and whether to say something about it. + * + * The 250-role cap is the ceiling this phase's shape brings with it. Access is a + * per-Team role, so it is not "how big can a Team be" — the old overwrite design's + * limit — but "how many Teams can have voice at all", and the difference matters + * to an operator with sixty guilds on their shard. It is guild-wide and shared + * with every role they created themselves, which is why the count comes from the + * bot rather than from core's own rows. + */ +export function roleHeadroom(preflight) { + if (!preflight || !preflight.roleCap) return null + const used = Number(preflight.roleCount) || 0 + const cap = Number(preflight.roleCap) + const free = Math.max(0, cap - used) + return { used, cap, free, tight: free <= HEADROOM_WARNING, exhausted: free === 0 } +} + +/** How a row's grace window reads while it is running. */ +export function removalCountdown(row, now = new Date()) { + if (!row || row.state !== 'pending_removal' || !row.removeAfter) return null + const ms = new Date(row.removeAfter).getTime() - now.getTime() + if (ms <= 0) return 'due for removal on the next pass' + const days = Math.floor(ms / 86400000) + if (days >= 1) return `in ${days} day${days === 1 ? '' : 's'}` + const hours = Math.max(1, Math.round(ms / 3600000)) + return `in ${hours} hour${hours === 1 ? '' : 's'}` +} + +/** + * Parse the staff-role field an operator types. + * + * Comma-separated ids, because that is what a person copying role ids out of + * Discord ends up with. Validated rather than filtered, mirroring the server: a + * quietly dropped id is a settings screen showing a save that did not happen. + */ +export function parseStaffRoles(text) { + const parts = String(text || '') + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + const bad = parts.filter((part) => !/^[0-9]{5,32}$/.test(part)) + return { roles: parts, invalid: bad } +} + +export const formatStaffRoles = (roles) => (roles || []).join(', ') + +/** + * The sentence under the enable switch, which changes meaning with the state. + * + * "Off" is not "nothing is provisioned": switching voice off suspends the + * reconciler in BOTH directions and leaves existing channels in place, which is + * deliberate — a checkbox must not delete structure in somebody's guild — but it + * is also surprising unless the screen says so. + */ +export function statusSummary(settings, rows) { + const provisioned = (rows || []).filter((row) => row.channelRef).length + if (!settings || !settings.enabled) { + return provisioned > 0 + ? `Off. ${provisioned} channel${provisioned === 1 ? '' : 's'} remain in Discord and are no longer being kept in step — remove them below if they are not wanted.` + : 'Off. No channels are provisioned.' + } + return `On. Teams with at least ${settings.minMembers} member${settings.minMembers === 1 ? '' : 's'} get a voice channel and a role; ${provisioned} provisioned.` +} diff --git a/client/src/main.jsx b/client/src/main.jsx index c2c3101..ca3dce6 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -3,7 +3,10 @@ import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App.jsx' import { publishSharedDependencies } from './modules/shared.js' -import { declareSlot } from './modules/registry.js' +import { declareSlot, applyCoreFills, offerCoreFill } from './modules/registry.js' +import TeamActivityFeed from './modules/TeamActivityFeed.jsx' +import TeamForumPanel from './modules/TeamForumPanel.jsx' +import TeamNotifyToggle from './modules/TeamNotifyToggle.jsx' import './styles/theme.css' // Publish window.__rg BEFORE rendering and before any module chunk evaluates. @@ -18,8 +21,6 @@ publishSharedDependencies() // and namespace `uo`, so that the seam was exercised by real content from the // day it was built. That prediction paid out exactly as written: the extraction // deleted the registration and the hook it named, and SiteHeader was not touched. -// There is nothing for core to register now — no core nav row carries a -// `feature` — and the filter is a correct no-op until a module supplies one. // ── Extension slots (MODULE_API.md §3.7) ─────────────────────────────────── // @@ -56,6 +57,49 @@ declareSlot('player.invite.accepted') // all three, and core's own fills had to go for it to be able to — the first // fill wins, and core registered first (§3.7). +// ── The inverted direction: core fills a MODULE's slot ───────────────────── +// +// Teams is a contract PRIMITIVE, not a surface (TEAMS.md Part 3). Core owns the +// tables, the sync, the access rules and the activity feed; it does not own the +// word for one — a UO shard says guild, and the module that comes after it will +// say clan. So core publishes no Team page and no Team nav row, and the module +// that owns the vocabulary owns the page. +// +// The activity feed is the one piece of that page core cannot hand over: only +// core can resolve whether this viewer is inside the Team, and the public/members +// split is a security boundary. So the module declares the place and core fills +// it. Registered here, applied at mount — `applyCoreFills` runs after every +// module chunk has evaluated, which is the only moment a module-declared slot +// exists to be filled. +// +// **Core offers a CONTRIBUTION and never names a slot.** The module that owns the +// page says where each of these goes, in its own vocabulary, by asking for one on +// `declareModuleSlot`. Naming the slots here instead — which is how this was first +// written — meant core's Team content reached exactly one module: any other game +// declaring a place under its own id got an empty page and no error, because a +// fill nobody asked for is deliberately not an error. It also put a module id +// inside core, in string literals `scripts/checkModuleIdentifiers.js` masks by +// construction and so could never have caught. +// +// Offering something nothing asks for is still not an error: a deployment with no +// game module installed asks for none of these, which is the mirror of an +// unfilled slot rendering nothing. +offerCoreFill('team.activity', TeamActivityFeed) + +// The forum is core's for the same reason and goes wherever the module asked for +// it — a SECOND place, in module-uo's case, rather than joining the feed in the +// first: a slot takes one component (first fill wins), and stacking two unrelated +// panels into one contribution would make the module unable to place them +// separately on its own page. It also keeps the two independent — a deployment +// with the forum switched off renders the feed exactly as before. +offerCoreFill('team.forum', TeamForumPanel) + +// And the notification control. A third contribution rather than a corner of the +// feed for the same reason there were two: this is an action on the page and the +// other two are content in it, and only the module can say where each belongs on +// a page it owns. +offerCoreFill('team.notify', TeamNotifyToggle) + // Render on DOMContentLoaded rather than immediately, and that is the one line // of core's boot the module system changes. // @@ -82,6 +126,10 @@ declareSlot('player.invite.accepted') // static deferred script, so this branch is the genuine "the event has already // been and gone" case and not a wrong guess about our own timing. function mount() { + // Every module chunk has evaluated by now, so any slot a module declared is + // present and core's pending fills can land. Must happen before the first + // render: `extensionFor` is read during render and there is no subscription. + applyCoreFills() createRoot(document.getElementById('root')).render( diff --git a/client/src/modules/TeamActivityFeed.jsx b/client/src/modules/TeamActivityFeed.jsx new file mode 100644 index 0000000..873d098 --- /dev/null +++ b/client/src/modules/TeamActivityFeed.jsx @@ -0,0 +1,96 @@ +import { useEffect, useState } from 'react' +import { api } from '../api/client.js' +import { useAuth } from '../contexts/AuthContext.jsx' +import { activityScopeNote, freshnessNote, groupByDay } from '../lib/teamActivity.js' + +// Core's Team activity feed, rendered into a slot a MODULE declares +// (TEAMS.md Part 4, §3.4 as amended). +// +// **This is the inverted slot direction, and this component is why it exists.** +// The feed is core's: core owns `team_activity`, writes the membership and rename +// items into it, enforces the public/members split, and is the only thing that +// can resolve whether this viewer is inside the Team. None of that is a module's +// to reimplement. But the PAGE is the module's, because Teams is a contract +// primitive and core does not own the word for one — a UO shard says guild, the +// next game will say something else. So the module declares the place and core +// puts the feed in it. +// +// The module passes the Team in ITS OWN vocabulary — `externalId` plus its module +// id — and core resolves the slug. A module never learns core's Team id and never +// needs to: it names the thing the way it already names it. +// +// Everything here degrades to rendering nothing. A slot that throws is contained +// by core's own boundary (Slot.jsx), but a slot that renders an error box would +// still be core putting a defect on a page it does not own — so a failed fetch is +// silence, not a message. + +export default function TeamActivityFeed({ externalId, moduleId, limit = 25 }) { + const { user } = useAuth() + const [state, setState] = useState({ loading: true, feed: null, team: null }) + + useEffect(() => { + let active = true + if (!externalId || !moduleId) { + setState({ loading: false, feed: null, team: null }) + return undefined + } + // Two calls because the module names the Team its way and the feed is keyed + // by core's slug. The lookup is core's job precisely so the module does not + // have to hold core's identifiers. + api.teamByExternalId(moduleId, externalId) + .then(async (team) => { + const feed = await api.teamActivity(team.slug, { limit }) + if (active) setState({ loading: false, feed, team }) + }) + .catch(() => { if (active) setState({ loading: false, feed: null, team: null }) }) + return () => { active = false } + }, [externalId, moduleId, limit]) + + const { loading, feed, team } = state + if (loading || !feed) return null + + const days = groupByDay(feed.items || []) + const note = team ? freshnessNote(team) : null + const scopeNote = activityScopeNote(feed, Boolean(user)) + + // Nothing has happened and nothing to explain: render nothing rather than an + // empty heading on someone else's page. + if (days.length === 0 && !scopeNote) return null + + return ( +
+

+ Recent activity +

+ {note && ( +

{note.text}

+ )} + + {days.length === 0 && ( +

Nothing has happened here yet.

+ )} + + {days.map((day) => ( +
+

+ {day.label} +

+
    + {day.items.map((item) => ( +
  • + {item.summary} +
  • + ))} +
+
+ ))} + + {scopeNote && ( +

{scopeNote}

+ )} +
+ ) +} diff --git a/client/src/modules/TeamForumPanel.jsx b/client/src/modules/TeamForumPanel.jsx new file mode 100644 index 0000000..5b2ea9f --- /dev/null +++ b/client/src/modules/TeamForumPanel.jsx @@ -0,0 +1,754 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import DOMPurify from 'dompurify' +import { api } from '../api/client.js' +import { useAuth } from '../contexts/AuthContext.jsx' +import { useSite } from '../contexts/SiteContext.jsx' +import { REPORT_REASONS, editOfferOpen, stripToText, threadSummary } from '../lib/teamForum.js' + +// Core's Team forum, rendered into a second slot a MODULE declares +// (TEAMS.md Part 5, and the phase 3 amendment to §3.4). +// +// **Why the forum is core's content on a module's page.** Everything that decides +// who may read a thread is core's — the §2.5 resolver, the grants ledger, the +// member/guest distinction — and none of it is a module's to reimplement. But +// core does not own the word for a Team, so it publishes no Team page: the module +// that says "guild" owns the page and declares a place on it, and core fills the +// place. Same direction as the activity feed, same reason. +// +// **It is a whole forum inside one slot, and navigates by SEARCH PARAM.** A +// thread needs to be linkable, and core cannot mount a route for it — the route +// belongs to the module's page. `?thread=12` gives a shareable URL that works +// under whatever path the module chose, with no route of core's anywhere in it, +// and the browser's back button behaves. That is the whole reason this component +// holds a list view and a detail view rather than being two components. +// +// **The image mode is published so this can draw the right composer — never to +// decide what renders.** Post bodies arrive already rendered by the server under +// the current policy (§5.5.3); the mode is read here only to show or hide an +// upload control that would otherwise 404. If the two ever disagree, the server +// is right. +// +// **Phase 5 added discussion, and with it three capabilities this file must not +// invent for itself.** `canPost`, `canAnnounce` and each post's `canEdit` are +// computed on the server and read here. In particular the edit window is a +// server decision twice over — the read path stamps `canEdit`/`editableUntil` and +// the write re-derives it — because a time-bounded permission must not take its +// clock from the party it bounds. What this file does with `editableUntil` is +// stop OFFERING an edit whose deadline has passed while the page sat open; it +// never grants one. +// +// Like the feed, everything here degrades to rendering nothing. A 404 from the +// thread list is the ordinary case — the forum is switched off, or this viewer +// has no access — and putting an error box on a page core does not own would be +// core reporting its own absence as a defect on someone else's surface. + +export default function TeamForumPanel({ externalId, moduleId }) { + const { user } = useAuth() + const { settings } = useSite() + const [params, setParams] = useSearchParams() + const [team, setTeam] = useState(null) + const [state, setState] = useState({ loading: true, forum: null }) + const [thread, setThread] = useState(null) + const [composing, setComposing] = useState(null) // 'discussion' | 'announcement' | null + + const openThreadId = params.get('thread') + const imageMode = settings?.teams_forum_images || 'disabled' + const forumsEnabled = String(settings?.teams_forums_enabled ?? '0') === '1' + + const loadThreads = useCallback(async (slug) => { + try { + setState({ loading: false, forum: await api.teamForumThreads(slug) }) + } catch { + setState({ loading: false, forum: null }) + } + }, []) + + const loadThread = useCallback(async (slug, id) => { + try { + setThread(await api.teamForumThread(slug, id)) + } catch { + setThread(null) + } + }, []) + + useEffect(() => { + let active = true + // An anonymous visitor has no forum by definition — every route is behind + // requireAuth — so skip the two calls rather than provoking a 401 per page. + if (!externalId || !moduleId || !user || !forumsEnabled) { + setState({ loading: false, forum: null }) + return undefined + } + // The module names the Team its own way; core resolves that to a slug. Same + // two-call shape as the activity feed, and for the same reason: a module + // never has to hold core's identifiers. + api.teamByExternalId(moduleId, externalId) + .then(async (found) => { + if (!active) return + setTeam(found) + await loadThreads(found.slug) + }) + .catch(() => { if (active) setState({ loading: false, forum: null }) }) + return () => { active = false } + }, [externalId, moduleId, user, forumsEnabled, loadThreads]) + + useEffect(() => { + let active = true + if (!team || !openThreadId) { + setThread(null) + return undefined + } + api.teamForumThread(team.slug, openThreadId) + .then((t) => { if (active) setThread(t) }) + .catch(() => { if (active) setThread(null) }) + return () => { active = false } + }, [team, openThreadId]) + + const openThread = (id) => { + const next = new URLSearchParams(params) + if (id == null) next.delete('thread') + else next.set('thread', String(id)) + setParams(next) + } + + const { loading, forum } = state + if (loading || !forum) return null + + if (openThreadId && thread) { + return ( + openThread(null)} + onChanged={() => loadThread(team.slug, thread.id)} + onModerate={async (action) => { + await api.teamForumModerate(team.slug, thread.id, { action }) + await loadThreads(team.slug) + openThread(null) + }} + /> + ) + } + + return ( +
+
+

+ Forum +

+ {!composing && ( +
+ {/* + Two buttons, because phase 5 split one capability in two. `canPost` + means "may open a discussion" and every participant may — including a + granted guest with no game character, which is path 3 doing its job. + `canAnnounce` is the leader-only half. + */} + {forum.canPost && ( + + )} + {forum.canAnnounce && ( + + )} +
+ )} +
+ + {composing && ( + setComposing(null)} + onPosted={async () => { + setComposing(null) + await loadThreads(team.slug) + }} + /> + )} + + {forum.threads.length === 0 && !composing && ( +

+ Nothing has been posted here yet. +

+ )} + + {forum.canModerate && } + +
    + {forum.threads.map((t) => ( +
  • + +
  • + ))} +
+
+ ) +} + +/** + * The leader's grant control — §2.5 path 3, exercised by a leader rather than by + * staff. + * + * Worth being explicit about what this admits someone to and what it does not: a + * grant may name ANY account, including one with no linked game character, and it + * writes nothing but the grants ledger. A guest here never appears on the roster, + * never counts towards the Team's membership, and never becomes eligible for a + * Discord role — an integration cannot verify that an unlinked account is a real + * game member, so it must not hand that account a privilege somewhere + * impersonation has consequences. + * + * A leader is capped; staff are not. The cap is shown rather than only enforced, + * because a leader who hits a limit they were never told about reads it as a bug. + */ +function GuestManager({ slug }) { + const [open, setOpen] = useState(false) + const [data, setData] = useState(null) + const [username, setUsername] = useState('') + const [error, setError] = useState(null) + + const load = useCallback(async () => { + try { + setData(await api.teamGrantList(slug)) + } catch { + setData(null) + } + }, [slug]) + + useEffect(() => { if (open) load() }, [open, load]) + + const add = async (event) => { + event.preventDefault() + setError(null) + try { + await api.teamGrantAdd(slug, { username }) + setUsername('') + await load() + } catch (err) { + setError(err.message || 'Could not grant access') + } + } + + const revoke = async (userId) => { + setError(null) + try { + await api.teamGrantRevoke(slug, userId) + await load() + } catch (err) { + setError(err.message || 'Could not revoke that') + } + } + + if (!open) { + return ( + + ) + } + + return ( +
+
+

Forum guests

+ +
+

+ Guests read and post in this forum without being members of the Team. They do not appear on the + roster and are not counted as members. + {data?.cap ? ` Up to ${data.cap} at a time.` : ''} +

+ +
    + {(data?.guests || []).map((g) => ( +
  • + {g.username} + +
  • + ))} + {data && data.guests.length === 0 && ( +
  • No guests yet.
  • + )} +
+ +
+ setUsername(e.target.value)} + placeholder="Account name" + maxLength={32} + required + /> + +
+ {error &&

{error}

} +
+ ) +} + +function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, onModerate }) { + // A clock that ticks, so an edit control whose deadline passed while the page + // sat open goes away instead of becoming a button that fails. It only ever + // REMOVES an offer — the server decides whether an edit happens, and re-derives + // the window from created_at when it does. + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 30_000) + return () => clearInterval(id) + }, []) + + const [replying, setReplying] = useState(false) + + return ( +
+ +

+ {thread.title} +

+

+ {thread.type === 'announcement' ? 'Announcement · ' : ''} + {thread.author} + {thread.authorDeleted && ' (account removed)'} + {thread.locked && ' · locked'} +

+ + {thread.posts.map((post) => ( + + ))} + + {/* + `canReply` is the server's answer to "does this thread take replies right + now", and it folds together the two reasons it might not: an announcement + takes none by TYPE, and a locked thread takes none by STATE. Both are + reported separately above so the reader can see which. + */} + {thread.canReply && !replying && ( + + )} + {thread.canReply && replying && ( + setReplying(false)} + onPosted={async () => { + setReplying(false) + await onChanged() + }} + /> + )} + {!thread.canReply && thread.locked && ( +

+ This thread is locked. Nobody can reply to it, including staff — a moderator who wants the + last word unlocks it first, which leaves a record. +

+ )} + +
+ + {canModerate && ( + <> + + + + + )} +
+
+ ) +} + +/** + * One post, with whatever this reader may do to it. + * + * Every capability shown here was decided by the server and is read, not + * computed: `canEdit` and `editableUntil` come stamped on the post, and + * `canModerate` on the thread. The one local judgement is whether an + * already-granted edit window has since elapsed, which can only take an offer + * away. + */ +function PostView({ slug, post, canModerate, now, onChanged }) { + const [editing, setEditing] = useState(false) + const [body, setBody] = useState('') + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + const stillEditable = useMemo(() => editOfferOpen(post, now), [post, now]) + + const save = async (event) => { + event.preventDefault() + setBusy(true) + setError(null) + try { + await api.teamForumEditPost(slug, post.id, { body }) + setEditing(false) + await onChanged() + } catch (err) { + setError(err.message || 'Could not save that') + } finally { + setBusy(false) + } + } + + const moderate = async (action) => { + setError(null) + try { + await api.teamForumModeratePost(slug, post.id, { action }) + await onChanged() + } catch (err) { + setError(err.message || 'Could not do that') + } + } + + return ( +
+

+ {post.author} + {post.authorDeleted && ' (account removed)'} + {post.editedAt && ' · edited'} + {post.status === 'hidden' && ' · hidden'} +

+ + {editing ? ( +
+