From 61abb3ec8942bcf73d1aa3a2e5afcce18be522b4 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 18 Aug 2026 23:49:28 -0500 Subject: [PATCH] =?UTF-8?q?feat(teams):=20phase=209=20=E2=80=94=20one=20vo?= =?UTF-8?q?ice=20channel=20per=20Team,=20granted=20by=20a=20role?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEAMS.md §7.3. Each qualifying Team gets a Discord voice channel of its own and a role that opens it, kept in step by a reconciler that rides the Team reconcile it already depends on. Access is a per-Team ROLE, always. §7.3 designed per-member overwrites with escalation to a role above ~90 members; the org lead settled on roles always (2026-08-18), which deletes `voice_overwrite_max`, the escalation and the `mode` column — and moves the ceiling. Overwrites are capped per channel, so the old shape's limit was "how big can one Team be"; roles are capped per guild at 250, so the new one is "how many Teams can have voice at all". That is a limit an operator must be told about before they hit it, so the panel reports it and the pass refuses the create rather than letting Discord do it. Three things §7.3 named that this codebase does not have, all settled by asking the operator because nothing in the data model can answer: - "the staff role" — there is no staff-role concept anywhere. Now a list of role ids the admin designates; empty is a normal answer, since guild administrators bypass overwrites and what is really missing is a way to let NON-admin staff in. - the parent category — §7.3 said the bot creates it and gave the id nowhere to live (`team_integrations.team_id` is NOT NULL). The bot creates it and the server stores the id in settings. - whether the bot can act at all — nothing has ever checked. The operator invites the bot by hand and no invite URL with a permission integer exists in the tree, so a deployment can be one unticked box from every call failing. A preflight is now a PRECONDITION to enabling (422), not a per-Team error discovered afterwards. Two more, decided rather than asked: - the threshold counts every active member, not linked ones. §7.3 wrote `voice_min_linked_members`; the operator is judging whether a Team is real, and link state answers a different question. - hidden Teams are never provisioned. A channel name is a game-sourced string published outside the site, which is exactly §2.8's concern — reservedNames.js already names "and eventually a Discord channel name" as a surface it protects — so the screen that suppresses a Team's page suppresses its channel, and a Team that becomes hidden takes the grace window. Turning voice OFF tears nothing down: the pass suspends in both directions and the panel offers per-row removal. A checkbox must not delete structure in somebody's guild. Fixes a phase 8 defect that blocks this phase's own artifact: `npm run swagger` has been unable to run on `edge` at all. `param('teamId').custom((v) => ... || /^[0-9]+$/.test(v))` makes swagger-autogen's parser run away — a regex literal followed directly by `.test(`. Hoisted to a const, as modules.router.js already does. Underneath it, `teams.router.js` sits exactly at that parser's per-file limit: at twenty `teamsRouter.*` statements it dies, at nineteen it generates, and one more statement of ANY shape tips it — an unannotated route does, and so does a bare `use`. So the voice routes are their own router file mounted from `admin/index.js`, and teams.router.js keeps its nineteen. Also breaks a require cycle this phase would have introduced: teamSync -> teamVoiceSync -> teams.model -> teamSync left `teams.model` holding the reconciler's exports object as it stood mid-load — the empty one, since `module.exports = {…}` replaces rather than fills. The symptom is not in the new code: it is `teamSync.intervalSeconds is not a function` thrown out of `syncStatus()`, the freshness banner on every public Team page. Tests: 1160 server (+40), 53 bot (+21), 284 client (+21). Swagger, routes manifest and guards regenerated; the guard shape of the four new routes is byte-identical to the existing admin-only ones. Co-Authored-By: Claude --- bot/src/discord/teamVoice.js | 315 ++++++++ bot/src/internal/internal.controller.js | 79 ++ bot/src/internal/internal.routes.js | 3 + bot/test/teamVoice.test.js | 364 +++++++++ client/src/api/client.js | 5 + client/src/lib/teamVoice.js | 112 +++ client/src/routes/admin/views/TeamVoice.jsx | 256 +++++++ client/src/routes/admin/views/TeamsAdmin.jsx | 2 + client/test/teamVoice.test.js | 152 ++++ server/db/schema.sql | 47 ++ server/routes.guards.json | 40 + server/routes.manifest.json | 16 + server/src/model/teams/teamSync.model.js | 9 + server/src/model/teams/teamVoice.db.js | 201 +++++ server/src/model/teams/teamVoice.model.js | 235 ++++++ .../model/teams/teamVoiceSettings.model.js | 256 +++++++ server/src/router/v1/admin/index.js | 11 + .../src/router/v1/admin/teams.controller.js | 120 +++ server/src/router/v1/admin/teams.router.js | 10 +- .../src/router/v1/admin/teamsVoice.router.js | 93 +++ server/src/utils/botInternalClient.js | 87 ++- server/src/utils/teamVoiceSync.js | 406 ++++++++++ server/swagger/swagger-output.json | 704 ++++++++++++++++++ server/swagger/swagger.js | 101 +++ server/test/teamVoice.test.js | 270 +++++++ server/test/teamVoiceSync.test.js | 324 ++++++++ 26 files changed, 4214 insertions(+), 4 deletions(-) create mode 100644 bot/src/discord/teamVoice.js create mode 100644 bot/test/teamVoice.test.js create mode 100644 client/src/lib/teamVoice.js create mode 100644 client/src/routes/admin/views/TeamVoice.jsx create mode 100644 client/test/teamVoice.test.js create mode 100644 server/src/model/teams/teamVoice.db.js create mode 100644 server/src/model/teams/teamVoice.model.js create mode 100644 server/src/model/teams/teamVoiceSettings.model.js create mode 100644 server/src/router/v1/admin/teamsVoice.router.js create mode 100644 server/src/utils/teamVoiceSync.js create mode 100644 server/test/teamVoice.test.js create mode 100644 server/test/teamVoiceSync.test.js 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 5c236ee..595bf3c 100644 --- a/bot/src/internal/internal.controller.js +++ b/bot/src/internal/internal.controller.js @@ -1,6 +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') @@ -153,6 +154,81 @@ async function teamNotifyHandler(req, res) { } } +// ── 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, @@ -160,4 +236,7 @@ module.exports = { reverseModAction, refreshCommands, teamNotify: teamNotifyHandler, + voicePreflight, + voiceSync, + voiceRemove, } diff --git a/bot/src/internal/internal.routes.js b/bot/src/internal/internal.routes.js index cae1ff7..78c78c5 100644 --- a/bot/src/internal/internal.routes.js +++ b/bot/src/internal/internal.routes.js @@ -13,5 +13,8 @@ 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/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/api/client.js b/client/src/api/client.js index c2a3490..1da6829 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -346,6 +346,11 @@ export const api = { 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') 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/routes/admin/views/TeamVoice.jsx b/client/src/routes/admin/views/TeamVoice.jsx new file mode 100644 index 0000000..a88e765 --- /dev/null +++ b/client/src/routes/admin/views/TeamVoice.jsx @@ -0,0 +1,256 @@ +import { useCallback, useEffect, useState } from 'react' +import { api } from '../../../api/client.js' +import { + stateLabel, enableBlockedReason, roleHeadroom, removalCountdown, + parseStaffRoles, formatStaffRoles, statusSummary, +} from '../../../lib/teamVoice.js' + +// Team voice channels (TEAMS.md §7.3, phase 9). +// +// Named for the Team concern and placed under Teams beside the notification +// bridge, for the reason that panel gives: phase 10 replaces "Discord" with +// whatever the capability registry declares, and what should change then is what +// fills this panel rather than where an operator goes to find it. +// +// **The preflight is the first thing on the page, not a diagnostic.** §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 no invite URL +// with a permission integer exists anywhere in the tree. An operator whose bot +// lacks Manage Roles otherwise has a screen full of controls that cannot work, +// and finds out one Team at a time from a column of identical errors. + +export default function TeamVoice() { + const [config, setConfig] = useState(null) + const [draft, setDraft] = useState(null) + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + const [busy, setBusy] = useState(false) + + const load = useCallback(async () => { + setError('') + try { + const cfg = await api.admin.teamVoice() + setConfig(cfg) + setDraft({ + enabled: cfg.settings.enabled, + minMembers: cfg.settings.minMembers, + graceDays: cfg.settings.graceDays, + staffRoles: formatStaffRoles(cfg.settings.staffRoles), + }) + } catch (err) { + // A moderator never reaches this panel — the admin nav does not render it — + // so a 403 means the role changed underneath an open tab. + setError(err.status === 403 + ? 'Only an admin can configure Team voice channels.' + : (err.message || 'Could not load the voice configuration.')) + } + }, []) + + useEffect(() => { load() }, [load]) + + if (!config || !draft) { + return ( +
+

Voice channels

+ {error &&

{error}

} +
+ ) + } + + const blocked = enableBlockedReason(config.preflight) + const headroom = roleHeadroom(config.preflight) + + async function save() { + const { roles, invalid } = parseStaffRoles(draft.staffRoles) + if (invalid.length > 0) { + setError(`Not a role id: ${invalid.join(', ')}. Copy role ids from Discord with Developer Mode on.`) + return + } + setBusy(true) + setError('') + setNotice('') + try { + await api.admin.saveTeamVoice({ + enabled: draft.enabled, + minMembers: Number(draft.minMembers), + graceDays: Number(draft.graceDays), + staffRoles: roles, + }) + setNotice('Saved.') + await load() + } catch (err) { + setError(err.message || 'Could not save.') + } finally { + setBusy(false) + } + } + + async function runPass() { + setBusy(true) + setError('') + setNotice('') + try { + const result = await api.admin.teamVoicePass() + // A pass that refused says why, and that is the useful answer far more often + // than a count is — "stale projection" and "synced 0" look identical in a + // summary and mean completely different things. + setNotice(result.ran + ? `Synced ${result.synced}, created ${result.created}, scheduled ${result.scheduled}, removed ${result.removed}, failed ${result.failed}.` + : `Nothing was done: ${result.reason}`) + await load() + } catch (err) { + setError(err.message || 'Could not run a pass.') + } finally { + setBusy(false) + } + } + + async function remove(row) { + setBusy(true) + setError('') + try { + await api.admin.removeTeamVoice(row.teamId) + setNotice('Removed.') + await load() + } catch (err) { + setError(err.message || 'Could not remove.') + } finally { + setBusy(false) + } + } + + return ( +
+

Voice channels

+

+ Give each Team a {config.platform} voice channel of its own. Access is granted with a role per + Team, so members of a Team can see and join their channel and nobody else can. Members need a + linked {config.platform} account and must be in the guild. +

+ + {blocked && ( +

+ {blocked} Voice channels cannot be switched on until that is fixed. +

+ )} + + {headroom && ( +

+ {headroom.used} of {headroom.cap} {config.platform} roles used in this guild + {headroom.exhausted + ? ' — no room for another Team.' + : headroom.tight + ? ` — room for about ${headroom.free} more Teams.` + : '.'} +

+ )} + + {error &&

{error}

} + {notice &&

{notice}

} + +

{statusSummary(config.settings, config.rows)}

+ +
+ + + + + + + + + + +
+ + {config.rows.length > 0 && ( + + + + + + {config.rows.map((row) => ( + + + + + + + + ))} + +
TeamMembersChannelState
{row.teamName}{row.memberCount} + {row.channelRef || none} + + {stateLabel(row.state)} + {removalCountdown(row) && ( + {removalCountdown(row)} + )} + {row.lastError && ( + {row.lastError} + )} + + +
+ )} + + {config.lastPass && config.lastPass.at && ( +

+ Last pass {new Date(config.lastPass.at).toLocaleString()} + {config.lastPass.ran ? '' : ` — nothing was done: ${config.lastPass.reason}`} +

+ )} +
+ ) +} diff --git a/client/src/routes/admin/views/TeamsAdmin.jsx b/client/src/routes/admin/views/TeamsAdmin.jsx index 4c412c5..85f8f9a 100644 --- a/client/src/routes/admin/views/TeamsAdmin.jsx +++ b/client/src/routes/admin/views/TeamsAdmin.jsx @@ -7,6 +7,7 @@ import { import { useAuth } from '../../../contexts/AuthContext.jsx' import { api } from '../../../api/client.js' import TeamIntegrations from './TeamIntegrations.jsx' +import TeamVoice from './TeamVoice.jsx' // Admin → Teams (docs/website/TEAMS.md §2.4, §2.8, §2.9). // @@ -346,6 +347,7 @@ export default function TeamsAdmin() { be a panel every action in fails 403 — the role gate is the server's, and this is only how the screen agrees with it. */} {role === 'admin' && } + {role === 'admin' && } diff --git a/client/test/teamVoice.test.js b/client/test/teamVoice.test.js new file mode 100644 index 0000000..a93d20c --- /dev/null +++ b/client/test/teamVoice.test.js @@ -0,0 +1,152 @@ +// Admin → Teams → Voice channels, the decisions (TEAMS.md §7.3, phase 9). +// +// These mirror server rules and do not replace them: the server refuses to enable +// voice while the bot cannot act, and the reconciler applies the threshold and the +// grace window, whether or not this file ever ran. What is asserted here is that +// the SCREEN agrees with those answers instead of offering a control that will +// fail, or describing a state the deployment is not in. +// +// The one that matters most is `statusSummary`'s "off" branch. Switching voice off +// suspends the reconciler in both directions and deliberately leaves existing +// channels standing — a checkbox must not delete structure in somebody's guild — +// and an operator who reads "off" as "nothing is provisioned" would never go +// looking for the channels that are still there. + +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { + stateLabel, enableBlockedReason, roleHeadroom, removalCountdown, + parseStaffRoles, formatStaffRoles, statusSummary, +} from '../src/lib/teamVoice.js' + +test('every state the server can report has wording', () => { + for (const state of ['none', 'active', 'pending_removal', 'error']) { + assert.notEqual(stateLabel(state), state) + } +}) + +test('an unknown state falls back to itself rather than rendering blank', () => { + assert.equal(stateLabel('something-new'), 'something-new') +}) + +// ── The enable gate ──────────────────────────────────────────────────────── + +test('a ready bot blocks nothing', () => { + assert.equal(enableBlockedReason({ ready: true, connected: true, missingPermissions: [] }), null) +}) + +test('a disconnected bot and a bot missing a permission read differently', () => { + const disconnected = enableBlockedReason({ ready: false, connected: false, reason: 'the bot is not connected to Discord' }) + const missing = enableBlockedReason({ ready: false, connected: true, missingPermissions: ['Manage Roles'] }) + assert.match(disconnected, /not connected/) + assert.match(missing, /Manage Roles/) + // An operator fixes these in two completely different places, so collapsing + // them into one message would send half of them to the wrong one. + assert.notEqual(disconnected, missing) +}) + +test('an absent preflight blocks rather than silently allowing', () => { + assert.ok(enableBlockedReason(null)) + assert.ok(enableBlockedReason(undefined)) +}) + +// ── The role ceiling ─────────────────────────────────────────────────────── + +test('headroom is counted against the guild-wide cap', () => { + const h = roleHeadroom({ roleCount: 200, roleCap: 250 }) + assert.equal(h.free, 50) + assert.equal(h.tight, false) + assert.equal(h.exhausted, false) +}) + +test('a nearly full guild is flagged before the create fails, not after', () => { + // The whole reason this is in the panel: access is a per-Team role, so the cap + // limits how many TEAMS can have voice, and an operator with sixty guilds needs + // to know that before the sixtieth silently errors. + const h = roleHeadroom({ roleCount: 240, roleCap: 250 }) + assert.equal(h.tight, true) + assert.equal(h.exhausted, false) +}) + +test('a full guild is exhausted, and never reports negative headroom', () => { + const h = roleHeadroom({ roleCount: 260, roleCap: 250 }) + assert.equal(h.free, 0) + assert.equal(h.exhausted, true) +}) + +test('no preflight means no claim about headroom', () => { + assert.equal(roleHeadroom(null), null) + assert.equal(roleHeadroom({}), null) +}) + +// ── The grace window ─────────────────────────────────────────────────────── + +test('a row that is not scheduled has no countdown', () => { + assert.equal(removalCountdown({ state: 'active', removeAfter: null }), null) +}) + +test('a running window reads in days', () => { + const now = new Date('2026-08-19T00:00:00Z') + const text = removalCountdown({ state: 'pending_removal', removeAfter: '2026-08-24T00:00:00Z' }, now) + assert.equal(text, 'in 5 days') +}) + +test('under a day reads in hours rather than rounding to zero days', () => { + const now = new Date('2026-08-19T00:00:00Z') + const text = removalCountdown({ state: 'pending_removal', removeAfter: '2026-08-19T06:00:00Z' }, now) + assert.equal(text, 'in 6 hours') +}) + +test('an expired window says the next pass will act, not "in 0 days"', () => { + const now = new Date('2026-08-19T00:00:00Z') + const text = removalCountdown({ state: 'pending_removal', removeAfter: '2026-08-18T00:00:00Z' }, now) + assert.match(text, /next pass/) +}) + +// ── Staff roles ──────────────────────────────────────────────────────────── + +test('staff roles parse from the comma-separated ids a person actually pastes', () => { + const { roles, invalid } = parseStaffRoles(' 123456789012345678 , 987654321098765432 ') + assert.deepEqual(roles, ['123456789012345678', '987654321098765432']) + assert.deepEqual(invalid, []) +}) + +test('a typo is REPORTED, never quietly dropped', () => { + const { invalid } = parseStaffRoles('123456789012345678, @Moderators') + assert.deepEqual(invalid, ['@Moderators']) +}) + +test('an empty field is a legitimate answer and not an error', () => { + const { roles, invalid } = parseStaffRoles('') + assert.deepEqual(roles, []) + assert.deepEqual(invalid, []) +}) + +test('roles round-trip through the field', () => { + const { roles } = parseStaffRoles(formatStaffRoles(['111111111111111111', '222222222222222222'])) + assert.deepEqual(roles, ['111111111111111111', '222222222222222222']) +}) + +// ── The status line ──────────────────────────────────────────────────────── + +test('off with channels still standing says so — the surprising case', () => { + const text = statusSummary({ enabled: false }, [{ channelRef: '900' }, { channelRef: '901' }]) + assert.match(text, /^Off\./) + assert.match(text, /2 channels remain/) +}) + +test('off with nothing provisioned does not invent a warning', () => { + const text = statusSummary({ enabled: false }, []) + assert.match(text, /No channels are provisioned/) +}) + +test('on states the threshold in the words the setting uses', () => { + const text = statusSummary({ enabled: true, minMembers: 5 }, [{ channelRef: '900' }]) + assert.match(text, /at least 5 members/) + assert.match(text, /1 provisioned/) +}) + +test('a threshold of one is not pluralised', () => { + assert.match(statusSummary({ enabled: true, minMembers: 1 }, []), /at least 1 member get/) +}) diff --git a/server/db/schema.sql b/server/db/schema.sql index 8f3e826..c20fb32 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -1353,6 +1353,53 @@ CREATE TABLE IF NOT EXISTS team_integration_config ( CONSTRAINT fk_tic_ack_by FOREIGN KEY (members_ack_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- ── Per-Team external resources: the voice channel (TEAMS.md §7.3, phase 9) ─ +-- +-- One row per (Team, platform, resource). Today the only resource is 'voice', +-- and the column exists because the NEXT one — a text channel, a Matrix room — +-- is the same lifecycle with a different noun, and phase 10's capability +-- registry needs somewhere to say which resources a platform declares. +-- +-- **Access is a per-Team ROLE, not per-member overwrites.** §7.3 designed +-- overwrites-by-default with escalation to a role above ~90 members; the org lead +-- settled on roles always (2026-08-18). That deletes `voice_overwrite_max` and the +-- mode transition, and it moves the ceiling: the binding limit is no longer ~100 +-- overwrites on one channel but Discord's guild-wide cap of 250 roles, which the +-- admin panel surfaces rather than letting a create fail into `state='error'`. +-- `role_ref` is therefore NOT the escalation artefact it was in §7.3 — it is the +-- grant itself, and a row with a channel and no role is a broken row. +-- +-- **Two external refs, two lifetimes, and the pair is why this is a table rather +-- than two columns on `teams`.** A channel can be deleted in Discord while the +-- role survives, and vice versa; the reconciler has to be able to say "the role is +-- there, the channel is not" and repair one without touching the other. +-- +-- `state` is core's belief about Discord, never Discord's own answer: the +-- reconciler writes what it just did, and the next pass re-derives the truth. A +-- Team dropping below the threshold goes to 'pending_removal' with `remove_after` +-- set rather than being deleted at once (§7.3's grace window) — a Team hovering +-- around the threshold would otherwise delete-and-recreate, changing the channel +-- id and breaking every pinned link to it, and a voice channel holds no message +-- history, so the window costs nothing to keep. +CREATE TABLE IF NOT EXISTS team_integrations ( + id INT AUTO_INCREMENT PRIMARY KEY, + team_id INT NOT NULL, + platform VARCHAR(32) NOT NULL, -- 'discord'; opaque here, a registry key in phase 10 + resource VARCHAR(32) NOT NULL, -- 'voice' + external_ref VARCHAR(64) NULL, -- the channel id + role_ref VARCHAR(64) NULL, -- the Team's own role; the grant itself, not an escalation + state ENUM('none','active','pending_removal','error') NOT NULL DEFAULT 'none', + remove_after DATETIME NULL, -- set with 'pending_removal'; the grace window's expiry + last_error VARCHAR(500) NULL, + synced_at DATETIME NULL, -- last pass that reached Discord and was believed + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_team_integration (team_id, platform, resource), + -- Expiry is swept across every Team, so the index is on the pair the sweep + -- filters by rather than on the Team the unique key already covers. + INDEX idx_ti_pending (state, remove_after), + CONSTRAINT fk_ti_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- Migrations for databases created before the wiki upgrade. Each statement uses -- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get -- these columns from the CREATE TABLE above; existing installs get them here. diff --git a/server/routes.guards.json b/server/routes.guards.json index baffc1d..05b5ce9 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -951,6 +951,46 @@ "requireAuth" ] }, + { + "method": "GET", + "path": "/api/v1/admin/teams/voice", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "PUT", + "path": "/api/v1/admin/teams/voice", + "handlers": 7, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/admin/teams/voice/:teamId", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/voice/sync", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, { "method": "POST", "path": "/api/v1/admin/uploads", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index cdfbc8d..178300d 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -377,6 +377,22 @@ "method": "GET", "path": "/api/v1/admin/teams/review" }, + { + "method": "GET", + "path": "/api/v1/admin/teams/voice" + }, + { + "method": "PUT", + "path": "/api/v1/admin/teams/voice" + }, + { + "method": "DELETE", + "path": "/api/v1/admin/teams/voice/:teamId" + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/voice/sync" + }, { "method": "POST", "path": "/api/v1/admin/uploads" diff --git a/server/src/model/teams/teamSync.model.js b/server/src/model/teams/teamSync.model.js index f875b61..56e9cd2 100644 --- a/server/src/model/teams/teamSync.model.js +++ b/server/src/model/teams/teamSync.model.js @@ -34,6 +34,7 @@ const teamProvider = require('./teamProvider') const moderation = require('./teamModeration.model') const activity = require('./teamActivity.model') const teamNotify = require('../../utils/teamNotify') +const teamVoiceSync = require('../../utils/teamVoiceSync') const { slugify, uniqueSlug } = require('./teamSlug') const settings = require('../settings/settings.model') const log = require('../../utils/logger')('teams') @@ -423,6 +424,14 @@ async function runOnce(reason) { const rehidden = await moderation.rescreen(moduleId) await teamsDb.recordSuccess(moduleId) + + // §7.3's "after a successful Team reconcile": the voice reconciler runs off the + // projection this run just refreshed. Requested rather than awaited — it makes + // Discord calls, and a roster sync must never be slowed down, failed or held + // open by an integration hanging off it. It has its own debounce and its own + // suspensions (including the stale check, which is why it re-reads the state + // this run just wrote rather than trusting that it was called from a good one). + teamVoiceSync.request({ reason: 'reconcile' }) log.info('reconcile complete', { trigger: reason, created, renamed, archived, rosters, rehidden, total: answer.teams.length, }) diff --git a/server/src/model/teams/teamVoice.db.js b/server/src/model/teams/teamVoice.db.js new file mode 100644 index 0000000..6129142 --- /dev/null +++ b/server/src/model/teams/teamVoice.db.js @@ -0,0 +1,201 @@ +// SQL for per-Team external resources — today, the Discord voice channel +// (TEAMS.md §7.3, phase 9). +// +// Two queries carry the phase. `desiredTeams` is what SHOULD have a channel and +// `holdersWithoutClaim` is what HAS one and should not; the reconciler is the +// difference between them, and keeping both as single queries is what stops a +// pass from being one round trip per Team before it has made a single Discord +// call. +// +// **`discordSubjectsFor` is the whole identity chain in one statement** (§2.6): +// team_members → users → user_identities. A member with no site account has no +// row to join, and a member with a site account but no Discord identity drops out +// at the second join — which is exactly right, because a role can only be granted +// to somebody Discord knows about. Nothing else in the phase is allowed to +// shortcut this with `teams.linked_count`, which counts hop 1 and is always the +// larger number. + +const { query } = require('../../utils/db') + +// The provider id a Discord identity is stored under. Matches `auth_providers.id` +// and the built-in provider in `auth/providers/discord.provider.js`; a constant +// rather than a literal because it appears in two queries and a typo in either +// would silently return an empty grant set — a Team whose channel nobody can +// enter, with no error anywhere. +const DISCORD_PROVIDER = 'discord' + +const COLUMNS = ` + i.id, i.team_id, i.platform, i.resource, i.external_ref, i.role_ref, + i.state, i.remove_after, i.last_error, i.synced_at, i.updated_at` + +/** + * Every Team that qualifies for a resource, with its integration row if it has + * one. + * + * The three conditions are §7.3's provisioning gate and §2.8's publication rule + * together: + * + * - `status = 'active'` — an archived Team is a record, not a place to talk. + * - `hidden = 0` — the channel is NAMED after the Team, and a Discord channel + * name is a game-sourced string published outside the site. A hidden Team's + * name is suppressed on every public surface; a voice channel would be the + * one place it still appeared. + * - `member_count >= ?` — the operator's threshold, counting ALL active members + * regardless of what they have linked (org lead, 2026-08-18). §7.3 wrote + * `voice_min_linked_members`; the number an operator is actually judging is + * "is this Team real", and link state answers a different question. + * + * LEFT JOIN rather than two queries: the reconciler needs "should have, and does + * it" as one answer, and a Team with no row yet is the create case. + */ +async function desiredTeams({ platform, resource, minMembers }) { + return query( + `SELECT t.id AS team_id, t.name, t.display_name_override, t.slug, t.abbr, + t.member_count, t.linked_count, ${COLUMNS} + FROM teams t + LEFT JOIN team_integrations i + ON i.team_id = t.id AND i.platform = ? AND i.resource = ? + WHERE t.status = 'active' AND t.hidden = 0 AND t.member_count >= ? + ORDER BY t.id`, + [platform, resource, Number(minMembers)], + ) +} + +/** + * Rows that hold a resource for a Team that no longer qualifies. + * + * The mirror of `desiredTeams`, and deliberately not its negation in JavaScript: + * a Team can stop qualifying by being archived, by being hidden, by losing + * members, or by having its row deleted out from under core, and enumerating + * those in a filter would mean re-deriving the gate in a second place that could + * disagree with the first. + * + * Rows already in 'pending_removal' are included — the grace window is decided by + * the caller, which needs to see them to know whether one has expired. + */ +async function holdersWithoutClaim({ platform, resource, minMembers }) { + return query( + `SELECT t.id AS team_id, t.name, t.display_name_override, t.status, t.hidden, + t.member_count, ${COLUMNS} + FROM team_integrations i + JOIN teams t ON t.id = i.team_id + WHERE i.platform = ? AND i.resource = ? + AND (i.external_ref IS NOT NULL OR i.role_ref IS NOT NULL) + AND (t.status <> 'active' OR t.hidden = 1 OR t.member_count < ?) + ORDER BY t.id`, + [platform, resource, Number(minMembers)], + ) +} + +/** + * The Discord user ids of a Team's members — hop 3 of §2.6, and the only set a + * role can be granted to. + * + * DISTINCT because a user could in principle hold two rows for the same provider + * across a provider rename; the unique key prevents it for one (provider, + * subject) pair, not for one user with two subjects. Two role-adds for the same + * person is harmless and one duplicate in a diff is a phantom removal next pass, + * which is not. + */ +async function discordSubjectsFor(teamId) { + const rows = await query( + `SELECT DISTINCT ui.subject + FROM team_members m + JOIN user_identities ui ON ui.user_id = m.user_id AND ui.provider = ? + WHERE m.team_id = ? AND m.status = 'active' AND m.user_id IS NOT NULL + ORDER BY ui.subject`, + [DISCORD_PROVIDER, Number(teamId)], + ) + return rows.map((row) => String(row.subject)) +} + +/** Every row for a platform, with the Team's name — the admin panel's listing. */ +async function listForPlatform(platform, resource) { + return query( + `SELECT ${COLUMNS}, t.name AS team_name, t.slug AS team_slug, + t.display_name_override, t.status AS team_status, t.hidden AS team_hidden, + t.member_count, t.linked_count + FROM team_integrations i + JOIN teams t ON t.id = i.team_id + WHERE i.platform = ? AND i.resource = ? + ORDER BY t.name`, + [platform, resource], + ) +} + +async function getForTeam(teamId, platform, resource) { + const rows = await query( + `SELECT ${COLUMNS} FROM team_integrations i + WHERE i.team_id = ? AND i.platform = ? AND i.resource = ? LIMIT 1`, + [Number(teamId), platform, resource], + ) + return rows[0] || null +} + +/** + * Write what the reconciler believes after a pass. + * + * A full upsert of the mutable columns rather than a patch, because every caller + * has just decided all of them together: a pass that created a channel knows the + * state, the refs, the error (none) and the stamp, and letting it write three of + * the four would leave the fourth describing a previous pass. + * + * `remove_after` is written explicitly on every call, `NULL` included — a Team + * that climbs back above the threshold inside its window has to have the window + * cleared, and an upsert that skipped NULLs would leave it armed. + */ +async function upsert({ teamId, platform, resource, externalRef, roleRef, state, removeAfter, lastError, syncedAt }) { + await query( + `INSERT INTO team_integrations + (team_id, platform, resource, external_ref, role_ref, state, remove_after, last_error, synced_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + external_ref = VALUES(external_ref), + role_ref = VALUES(role_ref), + state = VALUES(state), + remove_after = VALUES(remove_after), + last_error = VALUES(last_error), + synced_at = VALUES(synced_at)`, + [ + Number(teamId), + platform, + resource, + externalRef || null, + roleRef || null, + state, + removeAfter || null, + lastError ? String(lastError).slice(0, 500) : null, + syncedAt || null, + ], + ) + return getForTeam(teamId, platform, resource) +} + +async function remove(teamId, platform, resource) { + const res = await query( + 'DELETE FROM team_integrations WHERE team_id = ? AND platform = ? AND resource = ?', + [Number(teamId), platform, resource], + ) + return Number(res && res.affectedRows) || 0 +} + +/** How many rows currently hold a role — the input to the 250-role ceiling. */ +async function roleCount(platform) { + const rows = await query( + 'SELECT COUNT(*) AS n FROM team_integrations WHERE platform = ? AND role_ref IS NOT NULL', + [platform], + ) + return Number(rows[0] && rows[0].n) || 0 +} + +module.exports = { + DISCORD_PROVIDER, + desiredTeams, + holdersWithoutClaim, + discordSubjectsFor, + listForPlatform, + getForTeam, + upsert, + remove, + roleCount, +} diff --git a/server/src/model/teams/teamVoice.model.js b/server/src/model/teams/teamVoice.model.js new file mode 100644 index 0000000..dc3ba1e --- /dev/null +++ b/server/src/model/teams/teamVoice.model.js @@ -0,0 +1,235 @@ +// ── One voice channel per Team: what core believes, and what it wants ────── +// +// TEAMS.md §7.3, phase 9. This file answers three questions and makes no calls: +// which Teams should have a voice channel, who should be able to enter it, and +// what should happen to the ones that should not have it any more. The pass that +// actually reaches Discord is `utils/teamVoiceSync.js`. +// +// **Access is a per-Team ROLE, always.** §7.3 specified per-member permission +// overwrites with escalation to a role above ~90 members; the org lead settled on +// roles always (2026-08-18). What that changes is not just a code path: +// +// - `voice_overwrite_max`, the escalation and the `overwrites`/`role` mode +// transition all leave the design. There is no mode. +// - The binding limit moves. Overwrites are capped per channel (~100), so the +// old shape's ceiling was "one very large Team"; roles are capped per GUILD +// (250), so the new shape's ceiling is "how many Teams have voice at all". A +// limit on the number of Teams is a limit an operator has to be told about +// before they hit it, which is why `roleCap` is in the admin payload and not +// just in a `last_error` after a create failed. +// - A role is visible on a member's Discord profile and an overwrite is not, so +// membership of a Team becomes guild-visible. That is the trade the decision +// bought and it is not reversible per-deployment. +// +// **Three things §7.3 named that this codebase does not have**, all settled the +// same way — by asking the operator, because nothing in the data model can answer: +// "the staff role" (see `teamVoiceSettings`), the parent category's identity, and +// whether the bot can manage channels and roles at all. +// +// **Hidden Teams are never provisioned.** A Discord channel name is a +// game-sourced string published outside the site, which is precisely §2.8's +// concern — `utils/reservedNames.js` already names "and eventually a Discord +// channel name" as one of the surfaces it protects. So the screen that suppresses +// a Team's public page suppresses its channel too, and the interlock is free: the +// gate is `hidden = 0` in one query rather than a second policy that could drift +// from the first. + +const voiceDb = require('./teamVoice.db') +const settings = require('./teamVoiceSettings.model') + +const PLATFORM = 'discord' +const RESOURCE = 'voice' + +// Discord's own limits on the two names this phase writes. Both are 100; kept as +// two constants because they are two independent promises and a future platform +// will not share them. +const CHANNEL_NAME_MAX = 100 +const ROLE_NAME_MAX = 100 + +// How many role add/remove operations one pass hands the bot for one Team. +// +// A bound rather than "all of them", because each is its own Discord API call and +// an unbounded first pass on a 300-member guild is a request that outlives its own +// timeout — and a timeout is the one failure that leaves core not knowing what was +// applied. Bounded passes converge instead: the remainder is reported and the next +// pass takes the next slice. +const MEMBER_OPS_PER_PASS = 50 + +// Control characters, as a named constant: a literal control byte in a source +// file is invisible to every reader and to most diffs. +const CONTROL_CHARS = /[\u0000-\u001f\u007f]/g + +/** + * The name a Team's channel and role carry. + * + * `display_name_override` first, because §2.8.3 gives staff a way to change what + * is DISPLAYED without touching identity, and a channel is a display surface. A + * Team whose name staff rewrote must not keep publishing the original one to + * Discord. + * + * The fallback is the Team's id, not its slug: a name that sanitises down to + * nothing is a name made entirely of characters Discord will not take, and the + * slug is derived from that same name, so it can be empty for the same reason. + */ +function displayName(team) { + return sanitiseName(team.display_name_override || team.name) || `team-${team.team_id || team.id}` +} + +/** + * Strip what Discord will not carry, and nothing else. + * + * Deliberately not a slugifier. A voice channel keeps its spaces and its case — + * unlike a text channel, which Discord lowercases and hyphenates itself — so + * "The Silver Hand" should reach the guild as "The Silver Hand" and not as + * "the-silver-hand". Control characters go because they can hide the rest of a + * name; everything else a player can type is left alone, since core is a mirror of + * the game and not an editor of it. + */ +function sanitiseName(value) { + const text = String(value || '').replace(CONTROL_CHARS, ' ').replace(/\s+/g, ' ').trim() + return text.slice(0, Math.min(CHANNEL_NAME_MAX, ROLE_NAME_MAX)) +} + +/** When a Team that stopped qualifying loses its channel. */ +function removeAfterFrom(graceDays, now = new Date()) { + return new Date(now.getTime() + graceDays * 86400_000) +} + +const isExpired = (row, now = new Date()) => !!row + && !!row.remove_after + && new Date(row.remove_after).getTime() <= now.getTime() + +/** + * Everything one pass needs, resolved before it makes a single call. + * + * Returns `null` when voice is off, which is the answer on most deployments and + * is not an error. + * + * **Turning the feature off does not tear anything down.** A toggle that deleted + * guild structure would make "let me see what this does" destructive, and a voice + * channel that outlives its setting is inert — nobody's access changes, the + * channel simply stops being reconciled. The admin panel says how many are still + * provisioned and offers to remove them one at a time, which is a decision an + * operator makes rather than a side effect of a checkbox. + */ +async function plan({ now = new Date() } = {}) { + const config = await settings.all() + if (!config.enabled) return null + + const [desired, holders] = await Promise.all([ + voiceDb.desiredTeams({ platform: PLATFORM, resource: RESOURCE, minMembers: config.minMembers }), + voiceDb.holdersWithoutClaim({ platform: PLATFORM, resource: RESOURCE, minMembers: config.minMembers }), + ]) + + // A Team that qualifies again while inside its grace window appears in BOTH + // queries only if the queries disagree, which they cannot — `desiredTeams` + // requires it to qualify and `holdersWithoutClaim` requires it not to. So the + // recovery case lands in `provision` with a row that still has `remove_after` + // set, and clearing that stamp is what "cancel the removal" means. + // + // §7.3 promises no Discord call is made when a Team recovers. As built the + // promise is narrower and truer: no DESTRUCTIVE call is made. A Team that + // regained members has members to grant, and the ordinary membership diff is + // what grants them — refusing to make any call at all would leave the people + // who brought it back above the threshold outside the channel. + const provision = desired.map((row) => ({ + team: row, + name: displayName(row), + hasRow: !!row.id, + recovering: row.state === 'pending_removal', + })) + + const removals = [] + const scheduled = [] + for (const row of holders) { + if (row.state !== 'pending_removal' || !row.remove_after) { + scheduled.push({ team: row, removeAfter: removeAfterFrom(config.graceDays, now), reason: removalReason(row) }) + } else if (isExpired(row, now)) { + removals.push({ team: row, reason: removalReason(row) }) + } + } + + return { config, provision, scheduled, removals } +} + +/** + * Why a Team is losing its channel, in the words an operator reads in the panel. + * + * Three distinguishable causes, and they are worth distinguishing: "archived" is + * expected, "below the threshold" is a Team shrinking, and "hidden" is a + * moderation decision somebody made — which is the one where a surprised operator + * would otherwise go looking for a bug. + */ +function removalReason(row) { + if (row.team_status && row.team_status !== 'active') return 'archived' + if (row.status && row.status !== 'active') return 'archived' + if (row.hidden || row.team_hidden) return 'hidden' + return 'below_threshold' +} + +/** The Discord ids a Team's role should be granted to — hop 3 of §2.6. */ +async function memberRefs(teamId) { + return voiceDb.discordSubjectsFor(teamId) +} + +/** The admin panel's listing: every row, with the Team it belongs to. */ +async function list() { + const rows = await voiceDb.listForPlatform(PLATFORM, RESOURCE) + return rows.map((row) => ({ + teamId: row.team_id, + teamName: row.display_name_override || row.team_name, + teamSlug: row.team_slug, + teamStatus: row.team_status, + teamHidden: !!row.team_hidden, + memberCount: row.member_count, + linkedCount: row.linked_count, + channelRef: row.external_ref, + roleRef: row.role_ref, + state: row.state, + removeAfter: row.remove_after, + lastError: row.last_error, + syncedAt: row.synced_at, + updatedAt: row.updated_at, + })) +} + +async function getForTeam(teamId) { + return voiceDb.getForTeam(teamId, PLATFORM, RESOURCE) +} + +/** Record the outcome of one Team's pass. */ +async function record({ teamId, channelRef, roleRef, state, removeAfter = null, lastError = null, syncedAt = null }) { + return voiceDb.upsert({ + teamId, + platform: PLATFORM, + resource: RESOURCE, + externalRef: channelRef, + roleRef, + state, + removeAfter, + lastError, + syncedAt, + }) +} + +async function forget(teamId) { + return voiceDb.remove(teamId, PLATFORM, RESOURCE) +} + +module.exports = { + PLATFORM, + RESOURCE, + CHANNEL_NAME_MAX, + MEMBER_OPS_PER_PASS, + displayName, + sanitiseName, + removeAfterFrom, + isExpired, + removalReason, + plan, + memberRefs, + list, + getForTeam, + record, + forget, +} diff --git a/server/src/model/teams/teamVoiceSettings.model.js b/server/src/model/teams/teamVoiceSettings.model.js new file mode 100644 index 0000000..76c08cd --- /dev/null +++ b/server/src/model/teams/teamVoiceSettings.model.js @@ -0,0 +1,256 @@ +// ── The operator's voice controls ────────────────────────────────────────── +// +// TEAMS.md §7.3, phase 9. Five `settings` keys, in their own file for the reason +// `teamForumSettings` is: two of them are not ordinary keys. `teams_voice_enabled` +// has a server-side precondition (the bot must actually be able to manage channels +// and roles — §7.3 assumed it could and the tree has never checked), and +// `teams_voice_category_ref` is written by the SERVER after the bot reports what +// it created, not by the admin who is looking at the form. +// +// teams_voice_enabled '0' | '1' default '0' — off +// teams_voice_min_members 1 … 10000 default 5 +// teams_voice_grace_days 0 … 90 default 7 +// teams_voice_category_ref a channel id absent until the bot makes one +// teams_voice_staff_roles CSV of role ids empty by default +// +// **Every read fails closed**, the same bargain the forum settings take: a DB +// fault reports voice off, which costs a pass that does nothing and is repeated +// fifteen minutes later. Failing open would mean creating guild structure on the +// strength of a query that did not answer. +// +// **`teams_voice_staff_roles` exists because "the staff role" does not.** §7.3 +// grants the staff role an overwrite on every Team channel; this codebase has no +// staff-role concept at all — `guild_config` knows a news channel, a modlog +// channel, an autorole and a filter allowlist, and none of them means "staff". +// Guild administrators bypass channel overwrites anyway, so what is actually +// missing is a way to let NON-admin staff in, and only the operator can say which +// of their Discord roles those are. Empty is a legitimate and common answer. + +const settingsDb = require('../settings/settings.db') + +const ENABLED_KEY = 'teams_voice_enabled' +const MIN_MEMBERS_KEY = 'teams_voice_min_members' +const GRACE_DAYS_KEY = 'teams_voice_grace_days' +const CATEGORY_KEY = 'teams_voice_category_ref' +const STAFF_ROLES_KEY = 'teams_voice_staff_roles' + +const MIN_MEMBERS_DEFAULT = 5 +const MIN_MEMBERS_MAX = 10000 + +const GRACE_DAYS_DEFAULT = 7 +const GRACE_DAYS_MAX = 90 + +// Discord's guild-wide role cap. It is the ceiling on how many Teams can have +// voice at all, and it is here rather than in the bot because the admin panel has +// to be able to say "you are at 231 of 250" BEFORE a create fails — §7.3's error +// state per Team is a diagnosis, not a warning. +// +// The number is Discord's and core cannot read it; a guild that gets a different +// one is a guild where this warns early, which is the harmless direction. +const ROLE_CAP = 250 + +// The same shape `teamIntegration.model` validates a channel with. Core treats +// every Discord id as opaque and only checks it could be one. +const SNOWFLAKE_RE = /^[0-9]{5,32}$/ + +/** Is voice provisioning switched on? Fail closed. */ +async function enabled() { + try { + return String(await settingsDb.get(ENABLED_KEY)) === '1' + } catch { + return false + } +} + +/** + * The membership threshold, counting every active member (org lead, 2026-08-18). + * + * Fails closed to the DEFAULT rather than to zero, unlike the forum's edit window: + * zero here would mean "provision every Team including the one-person ones", which + * is the expensive direction against a 250-role cap. The default is the + * conservative answer, not the permissive one. + */ +async function minMembers() { + try { + const raw = await settingsDb.get(MIN_MEMBERS_KEY) + if (raw == null || raw === '') return MIN_MEMBERS_DEFAULT + const n = Number(raw) + if (!Number.isFinite(n) || n < 1 || n > MIN_MEMBERS_MAX) return MIN_MEMBERS_DEFAULT + return Math.floor(n) + } catch { + return MIN_MEMBERS_DEFAULT + } +} + +/** + * How long a Team keeps its channel after it stops qualifying (§7.3). + * + * `0` is legitimate and means "remove on the next pass" — an operator who would + * rather not have stale channels lying about. A DB fault reports the default, so a + * transient error can never turn the window off and delete something early; the + * grace window's whole job is to not act in a hurry. + */ +async function graceDays() { + try { + const raw = await settingsDb.get(GRACE_DAYS_KEY) + if (raw == null || raw === '') return GRACE_DAYS_DEFAULT + const n = Number(raw) + if (!Number.isFinite(n) || n < 0 || n > GRACE_DAYS_MAX) return GRACE_DAYS_DEFAULT + return Math.floor(n) + } catch { + return GRACE_DAYS_DEFAULT + } +} + +/** + * The parent category every Team channel is created under, or null. + * + * Not an admin field. The bot creates the category on the first pass that needs + * one and reports the id back; the server stores it here so the next pass reuses + * it instead of making a second. An operator who wants a different category + * deletes this value (or the category) and the next pass makes a fresh one — which + * is why it is exposed read-only in the panel with a clear button rather than as a + * text input somebody could point at a channel that is not a category. + */ +async function categoryRef() { + try { + const value = await settingsDb.get(CATEGORY_KEY) + const text = String(value || '').trim() + return SNOWFLAKE_RE.test(text) ? text : null + } catch { + return null + } +} + +async function setCategoryRef(value, actorId = null) { + const text = String(value || '').trim() + if (text && !SNOWFLAKE_RE.test(text)) throw new Error('category ref must be a numeric channel id') + return settingsDb.set(CATEGORY_KEY, text || null, actorId) +} + +/** + * The roles that see every Team voice channel, in addition to that Team's own. + * + * Stored as CSV for the same reason `filter_allow_roles` is — `settings.value` is + * a VARCHAR and a JSON array in it buys nothing when the elements are numeric ids. + * Unreadable entries are DROPPED rather than rejected on read: a hand-edited row + * with one bad id should cost that id, not every staff grant on the deployment. + */ +async function staffRoles() { + try { + const raw = await settingsDb.get(STAFF_ROLES_KEY) + return parseRoles(raw) + } catch { + return [] + } +} + +function parseRoles(raw) { + return String(raw || '') + .split(',') + .map((part) => part.trim()) + .filter((part) => SNOWFLAKE_RE.test(part)) +} + +/** + * Validate an operator-supplied staff-role list on the way IN, where a typo can + * still be reported to the person who made it. + * + * Rejected rather than filtered, the same call the bridge's event list makes: a + * silently-dropped id is a settings screen that shows you saved something you did + * not, and a role that was supposed to see every Team channel and does not is a + * failure nobody would think to look for. + */ +function normaliseStaffRoles(input) { + const parts = Array.isArray(input) + ? input + : String(input == null ? '' : input).split(',') + const seen = [] + for (const part of parts) { + const id = String(part || '').trim() + if (!id) continue + if (!SNOWFLAKE_RE.test(id)) { + const err = new Error(`not a role id: ${id}`) + err.status = 400 + throw err + } + if (!seen.includes(id)) seen.push(id) + } + return seen +} + +/** Everything the reconciler and the admin panel both need, in one read. */ +async function all() { + const [on, min, grace, category, staff] = await Promise.all([ + enabled(), minMembers(), graceDays(), categoryRef(), staffRoles(), + ]) + return { + enabled: on, + minMembers: min, + graceDays: grace, + categoryRef: category, + staffRoles: staff, + roleCap: ROLE_CAP, + } +} + +/** + * Persist an admin's save. Returns the settings as they now read, so the panel + * renders what was stored rather than what was typed. + * + * The enable PRECONDITION is not here: it needs the bot, and a settings module + * that reached across to another process to validate a write would be impossible + * to test and surprising to read. The controller asks the bot and refuses, in the + * same shape §7.2's acknowledgement refuses — 422 before the write, never a quiet + * failure after it. + */ +async function save({ enabled: on, minMembers: min, graceDays: grace, staffRoles: staff }, actorId = null) { + const next = {} + if (on !== undefined) next[ENABLED_KEY] = on ? '1' : '0' + if (min !== undefined) { + const n = Number(min) + if (!Number.isInteger(n) || n < 1 || n > MIN_MEMBERS_MAX) { + const err = new Error(`minimum members must be between 1 and ${MIN_MEMBERS_MAX}`) + err.status = 400 + throw err + } + next[MIN_MEMBERS_KEY] = String(n) + } + if (grace !== undefined) { + const n = Number(grace) + if (!Number.isInteger(n) || n < 0 || n > GRACE_DAYS_MAX) { + const err = new Error(`the grace window must be between 0 and ${GRACE_DAYS_MAX} days`) + err.status = 400 + throw err + } + next[GRACE_DAYS_KEY] = String(n) + } + if (staff !== undefined) next[STAFF_ROLES_KEY] = normaliseStaffRoles(staff).join(',') + + for (const [key, value] of Object.entries(next)) { + // eslint-disable-next-line no-await-in-loop + await settingsDb.set(key, value, actorId) + } + return all() +} + +module.exports = { + ENABLED_KEY, + MIN_MEMBERS_KEY, + GRACE_DAYS_KEY, + CATEGORY_KEY, + STAFF_ROLES_KEY, + MIN_MEMBERS_DEFAULT, + GRACE_DAYS_DEFAULT, + ROLE_CAP, + enabled, + minMembers, + graceDays, + categoryRef, + setCategoryRef, + staffRoles, + parseRoles, + normaliseStaffRoles, + all, + save, +} diff --git a/server/src/router/v1/admin/index.js b/server/src/router/v1/admin/index.js index cc3e9aa..1b59c7a 100644 --- a/server/src/router/v1/admin/index.js +++ b/server/src/router/v1/admin/index.js @@ -32,6 +32,7 @@ const discordBotRouter = require('./discordBot.router') const settingsRouter = require('./settings.router') const modulesRouter = require('./modules.router') const teamsRouter = require('./teams.router') +const teamsVoiceRouter = require('./teamsVoice.router') const dashboardRouter = require('./dashboard.router') const adminRouter = express.Router() @@ -84,6 +85,16 @@ adminRouter.use('/modules', modulesRouter) // queue. The three actions that PUBLISH untrusted game-sourced strings are gated // per request inside the controller, not per route — a moderator may call them, // and calling them files a request rather than applying one (TEAMS.md §2.9). +// Voice channels (TEAMS.md §7.3, phase 9) are mounted at the more specific prefix +// FIRST, so /teams/voice/* never reaches the teams router's `/:id`. +// +// They live out here rather than inside `teams.router.js` beside the bridge they +// belong with, for a mechanical reason worth recording: that file sits exactly at +// swagger-autogen's per-file limit. At twenty `teamsRouter.*` statements +// `npm run swagger` dies with "invalid array length — heap out of memory"; at +// nineteen it generates. One more statement of any shape tips it, a mount +// included, so the mount is here and the file keeps its nineteen. +adminRouter.use('/teams/voice', teamsVoiceRouter) adminRouter.use('/teams', teamsRouter) // The two singletons that own no path segment of their own: GET /dashboard and diff --git a/server/src/router/v1/admin/teams.controller.js b/server/src/router/v1/admin/teams.controller.js index 865d310..2735caa 100644 --- a/server/src/router/v1/admin/teams.controller.js +++ b/server/src/router/v1/admin/teams.controller.js @@ -17,6 +17,9 @@ const forumDb = require('../../../model/teams/teamForum.db') const forumUploadsModel = require('../../../model/teams/teamForumUploads.model') const forumSettings = require('../../../model/teams/teamForumSettings.model') const integration = require('../../../model/teams/teamIntegration.model') +const voice = require('../../../model/teams/teamVoice.model') +const voiceSettings = require('../../../model/teams/teamVoiceSettings.model') +const voiceSync = require('../../../utils/teamVoiceSync') const log = require('../../../utils/logger')('teams') @@ -347,7 +350,124 @@ async function deleteIntegrationConfig(req, res) { } } +// ── Voice channels (§7.3, phase 9) — admin only ──────────────────────────── +// +// Admin-only for the same reason the bridge is: this creates and destroys +// structure in somebody's Discord guild, which is deployment configuration and +// not the kind of decision §2.9 files a request for. + +/** + * Everything the panel renders, in one call: the settings, the live rows, and + * the bot's own answer about whether it can do the job. + * + * The preflight is here rather than behind a separate endpoint the panel polls, + * because it is not a detail — an operator whose bot lacks Manage Roles has a + * screen full of controls that cannot work, and finding that out needs to be the + * first thing on the page rather than the result of pressing something. + */ +async function voiceConfig(req, res) { + try { + const [config, rows, flight] = await Promise.all([ + voiceSettings.all(), + voice.list(), + // Never fatal: a bot container that is down must not take the settings + // screen with it, since fixing the settings may be exactly why the operator + // came. `preflight` already turns every failure into a `ready: false`. + voiceSync.preflight().catch((err) => ({ ready: false, connected: false, reason: err.message })), + ]) + return res.json({ + platform: voice.PLATFORM, + settings: config, + preflight: flight, + rows, + lastPass: voiceSync.lastPass(), + }) + } catch (err) { + return fail(res, err, 'voice config') + } +} + +/** + * Save the settings, with one precondition. + * + * **Switching voice ON is refused 422 while the bot cannot act.** The same shape + * §7.2's acknowledgement takes, and for the same reason: a setting that saves and + * then quietly does nothing is worse than one that will not save. Turning it OFF + * is never gated — an operator disabling a feature because it is misbehaving must + * not be blocked by the misbehaviour. + */ +async function saveVoiceConfig(req, res) { + try { + const turningOn = req.body.enabled === true && !(await voiceSettings.enabled()) + if (turningOn) { + const flight = await voiceSync.preflight() + if (!flight.ready) { + return res.status(422).json({ + message: flight.reason || 'the bot cannot manage channels and roles in this guild yet', + code: 'voice_preflight_failed', + preflight: flight, + }) + } + } + + const config = await voiceSettings.save(req.body, req.user.id) + await activity.log({ + req, + action: 'team.voice.settings', + detail: + `${req.user.username} (#${req.user.id}) saved the Team voice settings: ` + + `${config.enabled ? 'enabled' : 'disabled'}, minimum ${config.minMembers} members, ` + + `${config.graceDays}-day grace window, ${config.staffRoles.length} staff role(s)`, + }) + + // A save that just switched it on should not wait fifteen minutes for the + // first channel to appear. + if (config.enabled) voiceSync.request({ reason: 'settings saved' }) + return res.json(config) + } catch (err) { + if (err.status) return res.status(err.status).json({ message: err.message, code: err.code }) + return fail(res, err, 'save voice config') + } +} + +/** Run a pass now, awaited, so the operator gets the outcome and not a promise. */ +async function voicePass(req, res) { + try { + return res.json(await voiceSync.passNow('admin')) + } catch (err) { + return fail(res, err, 'voice pass') + } +} + +/** + * Remove one Team's channel and role now, ignoring the grace window. + * + * The window exists to stop churn on a Team crossing the threshold twice in a + * week; an operator pressing remove is not churn. It is also the only way to + * clean up while voice is switched off, which is the one state where no pass will + * ever reach the row. + */ +async function removeVoice(req, res) { + try { + const teamId = Number(req.params.teamId) + const result = await voiceSync.removeNow(teamId) + if (!result.ok) return res.status(result.status || 400).json({ message: result.message }) + await activity.log({ + req, + action: 'team.voice.remove', + detail: `${req.user.username} (#${req.user.id}) removed the voice channel and role for Team #${teamId}`, + }) + return res.json({ ok: true }) + } catch (err) { + return fail(res, err, 'remove voice') + } +} + module.exports = { + voiceConfig, + saveVoiceConfig, + voicePass, + removeVoice, integrationConfig, saveIntegrationConfig, deleteIntegrationConfig, diff --git a/server/src/router/v1/admin/teams.router.js b/server/src/router/v1/admin/teams.router.js index dade2a8..6f8740e 100644 --- a/server/src/router/v1/admin/teams.router.js +++ b/server/src/router/v1/admin/teams.router.js @@ -27,6 +27,14 @@ const teamsRouter = express.Router() // where a Team's events leave the site for is not the §2.9 kind of decision a // moderator files a request for; it is deployment configuration, and it sits with // the role that already holds the bot token. +// Hoisted rather than written inline, and it has to stay that way: a regex +// LITERAL followed directly by `.test(` makes swagger-autogen's static parser run +// away, and `npm run swagger` dies with "invalid array length — heap out of +// memory" instead of generating a spec. Phase 8 shipped it inline and left the +// generator unable to run at all; the same regex reached through a const (the +// idiom `modules.router.js` already uses) parses fine. +const TEAM_ID = /^[0-9]+$/ + const adminOnly = requireRole('admin') // ── Literal paths, first ─────────────────────────────────────────────────── @@ -173,7 +181,7 @@ teamsRouter.delete( /* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */ /* #swagger.responses[404] = { description: 'Nothing configured for that Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ adminOnly, - param('teamId').custom((v) => v === 'default' || /^[0-9]+$/.test(v)), + param('teamId').custom((v) => v === 'default' || TEAM_ID.test(v)), validate, ctrl.deleteIntegrationConfig, ) diff --git a/server/src/router/v1/admin/teamsVoice.router.js b/server/src/router/v1/admin/teamsVoice.router.js new file mode 100644 index 0000000..cd944fb --- /dev/null +++ b/server/src/router/v1/admin/teamsVoice.router.js @@ -0,0 +1,93 @@ +// Admin · Teams · Voice channels (TEAMS.md §7.3, phase 9). +// +// Mounted at /api/v1/admin/teams/voice by `admin/index.js`, which has already +// applied `noindex, isLoggedIn, staffOnly` above it — and which mounts this +// prefix BEFORE `/teams`, so these paths never reach the teams router's `/:id`. +// Every route here adds `adminOnly` on top: this creates and destroys structure +// in somebody's Discord guild, which is deployment configuration and not the §2.9 +// kind of decision a moderator files a request for. +// +// **Its own file for a mechanical reason, and the reason is worth recording.** +// These four routes belong beside the notification bridge's three in +// `teams.router.js`, and they started there. That file sits exactly at +// swagger-autogen's per-file limit: at twenty `teamsRouter.*` statements +// `npm run swagger` dies with "invalid array length — heap out of memory", and at +// nineteen it generates. ONE more statement of any shape tips it — a route with no +// annotations at all does, and so does a bare `use`, which is why the mount is in +// `admin/index.js` rather than here in the file it logically belongs to. The same +// probe route added to `discordBot.router.js` generates fine, so the limit is +// per-file and not tree-wide. +// +// So: if this file grows, split it again rather than moving it back. + +const express = require('express') +const { body, param } = require('express-validator') + +const ctrl = require('./teams.controller') +const validate = require('../../../middleware/validate') +const { requireRole } = require('../../../utils/auth') + +const voiceRouter = express.Router() + +const adminOnly = requireRole('admin') + +// `/sync` before `/:teamId`, the same first-match-wins rule the parent file +// follows: a `:teamId` declared first would turn the pass into a lookup for a Team +// whose id is "sync". + +voiceRouter.get( + '/', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Team voice channel configuration and state (admin only)' + // #swagger.description = 'The settings, every provisioned channel with its state and last error, and the bot’s own preflight — whether it is connected, whether it holds Manage Channels and Manage Roles, and how close the guild is to Discord’s cap of 250 roles. Access is granted with a role per Team, so that cap is the ceiling on how many Teams can have voice at all.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Voice configuration and state', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamVoiceConfig" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + ctrl.voiceConfig, +) + +voiceRouter.put( + '/', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Save the Team voice settings (admin only)' + // #swagger.description = 'Switching voice on is refused 422 while the bot cannot manage channels and roles in the guild — a setting that saves and then quietly does nothing is worse than one that will not save. Switching it off is never gated, and never tears anything down: existing channels stop being reconciled and are removed one at a time by an operator who means it.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The saved settings', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamVoiceSettings" } } } } */ + /* #swagger.responses[422] = { description: 'The bot cannot manage channels or roles yet', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + body('enabled').optional().isBoolean().toBoolean(), + body('minMembers').optional().isInt({ min: 1, max: 10000 }).toInt(), + body('graceDays').optional().isInt({ min: 0, max: 90 }).toInt(), + body('staffRoles').optional({ nullable: true }), + validate, + ctrl.saveVoiceConfig, +) + +voiceRouter.post( + '/sync', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Run a voice reconciliation now (admin only)' + // #swagger.description = 'Awaited, so the response carries the outcome. The three suspensions still apply — a manual pass will not run while voice is off, while the Team projection is stale, or while the bot cannot act — and the response says which one stopped it.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The pass result', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamVoicePassResult" } } } } */ + adminOnly, + ctrl.voicePass, +) + +voiceRouter.delete( + '/:teamId', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Remove one Team’s voice channel and role (admin only)' + // #swagger.description = 'Immediate, ignoring the grace window: the window exists to stop churn on a Team that crosses the threshold twice in a week, and an operator pressing remove is not churn. The channel and the role go together — a role for a channel that no longer exists is a badge for nowhere.' + // #swagger.parameters['teamId'] = { in: 'path', required: true, schema: { type: 'integer' } } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */ + /* #swagger.responses[404] = { description: 'That Team has no voice channel', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + param('teamId').isInt({ min: 1 }).toInt(), + validate, + ctrl.removeVoice, +) + +module.exports = voiceRouter diff --git a/server/src/utils/botInternalClient.js b/server/src/utils/botInternalClient.js index 92d3a3f..62d1053 100644 --- a/server/src/utils/botInternalClient.js +++ b/server/src/utils/botInternalClient.js @@ -10,9 +10,9 @@ const BASE_URL = process.env.BOT_INTERNAL_URL || 'http://localhost:4100' const KEY = process.env.BOT_INTERNAL_KEY || '' const TIMEOUT_MS = 4000 -async function call(path, { method = 'GET', body } = {}) { +async function call(path, { method = 'GET', body, timeoutMs = TIMEOUT_MS } = {}) { const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS) + const timeout = setTimeout(() => controller.abort(), timeoutMs) try { const res = await fetch(`${BASE_URL}${path}`, { method, @@ -101,4 +101,85 @@ function teamNotify({ channelId, streamId, teamName, teamUrl, title, body, url } }) } -module.exports = { pushConfig, getStatus, announce, reverseModAction, refreshCommands, teamNotify } +// ── Voice channels (TEAMS.md §7.3, phase 9) ──────────────────────────────── +// +// **These three take a longer budget than everything above.** The default 4s is +// sized for "post a message" and "read a status"; one voice pass for one Team can +// create a role, create a channel, write its overwrites and then apply up to +// MEMBER_OPS_PER_PASS role grants, each of which is its own Discord call under its +// own rate limit. Timing out mid-pass is the one failure that leaves core not +// knowing what was applied, so the budget is generous and the WORK is bounded +// instead — the caller caps the operations per pass and the bot reports what it +// could not finish. +const VOICE_TIMEOUT_MS = 30000 + +/** + * Does the bot have what §7.3 needs? Asked BEFORE an operator can switch voice + * on, and again at the start of every pass. + * + * §7.3 assumed the bot could manage channels and roles. Nothing in this codebase + * has ever checked: the operator invites the bot by hand and no invite URL with a + * permission integer exists anywhere in the tree, so a deployment can be one + * unticked box away from every call failing. Answering that question early turns + * a per-Team `state='error'` discovered later into a refusal the operator reads + * while they are still looking at the setting. + */ +function voicePreflight() { + return call('/internal/team-voice/preflight') +} + +/** + * Bring one Team's channel, role and role membership to the state core wants. + * + * Core sends the desired state and the bot works out the calls, which is the + * opposite of the split everywhere else in this file — and it is deliberate. The + * DECISIONS are all core's (who qualifies, who may enter, what it is called); the + * diff is not a decision, it is a comparison against live guild state that only + * the bot can see, and doing it here would mean shipping the whole guild's role + * membership over the wire to compare it and shipping the answer back. + */ +function voiceSync({ teamId, name, categoryRef, channelRef, roleRef, staffRoleRefs, memberRefs, maxMemberOps }) { + return call('/internal/team-voice/sync', { + method: 'POST', + timeoutMs: VOICE_TIMEOUT_MS, + body: { + team_id: teamId, + name, + category_id: categoryRef || null, + channel_id: channelRef || null, + role_id: roleRef || null, + staff_role_ids: staffRoleRefs || [], + member_ids: memberRefs || [], + max_member_ops: maxMemberOps, + }, + }) +} + +/** + * Delete a Team's channel and role after the grace window. + * + * Both refs in one call because they are one lifecycle: a teardown that removed + * the channel and left the role would leave every member wearing a badge for a + * place that no longer exists. Either may already be gone — the bot treats a + * missing target as success, since the desired end state holds. + */ +function voiceRemove({ channelRef, roleRef }) { + return call('/internal/team-voice/remove', { + method: 'POST', + timeoutMs: VOICE_TIMEOUT_MS, + body: { channel_id: channelRef || null, role_id: roleRef || null }, + }) +} + +module.exports = { + pushConfig, + getStatus, + announce, + reverseModAction, + refreshCommands, + teamNotify, + voicePreflight, + voiceSync, + voiceRemove, + VOICE_TIMEOUT_MS, +} diff --git a/server/src/utils/teamVoiceSync.js b/server/src/utils/teamVoiceSync.js new file mode 100644 index 0000000..b8b1ecd --- /dev/null +++ b/server/src/utils/teamVoiceSync.js @@ -0,0 +1,406 @@ +// ── The integration reconciler ───────────────────────────────────────────── +// +// TEAMS.md §7.3, phase 9. One pass: read what core wants, ask the bot to make +// Discord match, write down what happened. It rides the Team reconciler — §7.3's +// "after a successful Team reconcile" — because the input to every decision here +// is the projection that reconcile just refreshed. +// +// **It never destroys anything on data core does not trust.** Three suspensions, +// and they are the whole reason this file is careful: +// +// 1. Voice switched off → the pass does not run AT ALL, in either +// direction. A toggle must not delete guild +// structure; see `teamVoice.model.plan`. +// 2. The projection is stale → skip entirely (§7.3, verbatim). A sidecar that +// has been down for an hour reports rosters core +// cannot vouch for, and "every Team lost its +// members" is exactly what that looks like from +// here. A voice channel is never destroyed +// because a sidecar was down. +// 3. The bot cannot act → skip, and say why once. Missing ManageChannels +// is not forty Teams each failing individually; +// it is one deployment misconfiguration, and +// writing it into forty `last_error` columns +// would bury the one fact that matters. +// +// **Failures are per-Team and never abort the pass.** One Team whose channel a +// human deleted, or whose name Discord rejected, records `state='error'` with the +// message and is retried next pass; the other Teams are reconciled normally. This +// is the same shape as the Team reconciler's gate 3 and for the same reason — one +// Team's problem is not the other Teams' problem. +// +// **Nothing here throws.** It is a background job hanging off another background +// job; a rejection would surface as an unhandled rejection in a timer rather than +// as anything an operator could act on. What an operator can act on is in +// `team_integrations.last_error` and in this module's `lastPass()`. + +const voice = require('../model/teams/teamVoice.model') +const settings = require('../model/teams/teamVoiceSettings.model') +const botClient = require('./botInternalClient') +const log = require('./logger')('team-voice') + +// At most one pass per 30s, matching the Team reconciler's debounce. Every Team +// reconcile asks for a pass and a flapping sidecar can produce a run a second; +// without this, so could this. +const DEBOUNCE_MS = 30_000 + +let running = false +let rerun = false +let lastRunAt = 0 +let debounceTimer = null + +// What the last pass concluded, for the admin panel. In process rather than in a +// table on purpose: it describes a run, not a fact about the deployment, and a +// restart genuinely does invalidate it. `team_integrations` is where the durable +// answers live. +let lastPassResult = { at: null, ran: false, reason: 'no pass has run yet' } + +const lastPass = () => lastPassResult + +/** + * Ask the bot whether it can do this at all. + * + * Returns the bot's own answer plus a `ready` verdict, so the two callers — this + * pass and the admin controller's enable precondition — cannot disagree about + * what "ready" means by each deciding it themselves. + */ +async function preflight() { + const res = await botClient.voicePreflight() + if (!res || !res.ok) { + return { + ready: false, + connected: false, + reason: res && res.status === 503 ? 'the bot is not connected to Discord' : 'the bot could not be reached', + detail: (res && res.error) || null, + } + } + const data = res.data || {} + const missing = [] + if (!data.can_manage_channels) missing.push('Manage Channels') + if (!data.can_manage_roles) missing.push('Manage Roles') + return { + ready: missing.length === 0 && !!data.connected, + connected: !!data.connected, + missingPermissions: missing, + // The guild's REAL role count, not core's count of the roles it made. The + // 250-role cap is guild-wide and shared with every role the operator created + // themselves, so counting only ours would promise headroom that is not there. + roleCount: Number(data.role_count) || 0, + roleCap: settings.ROLE_CAP, + botRolePosition: Number(data.bot_role_position) || 0, + guildId: data.guild_id || null, + reason: missing.length ? `the bot is missing ${missing.join(' and ')} in this guild` : null, + } +} + +/** + * Provision or update one Team, and write down the result. + * + * The category comes in as an argument and can come back changed: the bot creates + * the `Teams` category on the first pass that needs one, and the id it reports is + * persisted by the caller. §7.3 said the bot creates it and gave the id nowhere to + * live — `team_integrations.team_id` is NOT NULL, so it cannot be a row in there — + * so it lands in settings, written by the server rather than typed by an admin. + */ +async function syncOne(item, { categoryRef, staffRoles }) { + const teamId = item.team.team_id + const memberRefs = await voice.memberRefs(teamId) + + const res = await botClient.voiceSync({ + teamId, + name: item.name, + categoryRef, + channelRef: item.team.external_ref, + roleRef: item.team.role_ref, + staffRoleRefs: staffRoles, + memberRefs, + maxMemberOps: voice.MEMBER_OPS_PER_PASS, + }) + + if (!res || !res.ok) { + const message = (res && res.data && res.data.message) || (res && res.error) || 'the bot could not be reached' + // The refs already on the row are preserved rather than cleared. A failed pass + // is core failing to CONFIRM the channel, not learning it is gone — clearing + // them would orphan a real channel and make the next pass create a second one. + await voice.record({ + teamId, + channelRef: item.team.external_ref, + roleRef: item.team.role_ref, + state: 'error', + lastError: message, + }) + log.warn('voice sync failed for a team', { teamId, name: item.name, message }) + return { ok: false, teamId, message } + } + + const data = res.data || {} + await voice.record({ + teamId, + channelRef: data.channel_id || null, + roleRef: data.role_id || null, + state: 'active', + // Clearing the window is what "the removal is cancelled" means for a Team that + // climbed back above the threshold inside it. + removeAfter: null, + lastError: null, + syncedAt: new Date(), + }) + if (item.recovering) { + log.info('voice removal cancelled; the team qualifies again', { teamId, name: item.name }) + } + return { + ok: true, + teamId, + created: !!(data.created && (data.created.channel || data.created.role)), + categoryRef: data.category_id || categoryRef, + pendingMemberOps: Number(data.members && data.members.pending) || 0, + } +} + +/** Tear one down after its window expired. */ +async function removeOne(entry) { + const teamId = entry.team.team_id + const res = await botClient.voiceRemove({ + channelRef: entry.team.external_ref, + roleRef: entry.team.role_ref, + }) + + if (!res || !res.ok) { + const message = (res && res.data && res.data.message) || (res && res.error) || 'the bot could not be reached' + await voice.record({ + teamId, + channelRef: entry.team.external_ref, + roleRef: entry.team.role_ref, + state: 'error', + // The window stays EXPIRED rather than being pushed out. A teardown that + // failed should be retried on the next pass, not granted another seven days + // every time it fails. + removeAfter: entry.team.remove_after, + lastError: message, + }) + log.warn('voice teardown failed', { teamId, message }) + return { ok: false, teamId, message } + } + + // The row goes with the resources. It exists to track a channel and a role, and + // a row tracking neither is a row that means nothing; a Team that qualifies + // again gets a fresh one. + await voice.forget(teamId) + log.info('voice channel removed', { teamId, reason: entry.reason }) + return { ok: true, teamId } +} + +/** + * One full pass. Callers use `request()`; this is the body it guards. + */ +async function runOnce(reason) { + const plan = await voice.plan() + if (!plan) return { ran: false, reason: 'voice channels are switched off' } + + // Suspension 2 (§7.3, verbatim): never on stale data. + // + // **Required here, inside the function, and it must stay that way.** The Team + // reconciler requires this module and `teams.model` requires the Team + // reconciler, so a top-level require closes the cycle + // teamSync → teamVoiceSync → teams.model → teamSync. Node resolves that by + // handing `teams.model` the reconciler's exports object as it stood mid-load, + // which is the empty one — `module.exports = {…}` at the bottom of that file + // REPLACES the object rather than filling it, so the binding never catches up. + // The visible symptom is not here: it is `teamSync.intervalSeconds is not a + // function` thrown out of `syncStatus()`, which is the freshness banner on every + // public Team page. + // eslint-disable-next-line global-require + const teams = require('../model/teams/teams.model') + const sync = await teams.syncStatus() + if (sync.stale) { + return { ran: false, reason: 'the team projection is stale; nothing was created, changed or removed' } + } + + // Suspension 3. + const flight = await preflight() + if (!flight.ready) { + return { ran: false, reason: flight.reason || 'the bot cannot manage channels or roles', preflight: flight } + } + + let categoryRef = plan.config.categoryRef + let created = 0 + let synced = 0 + let failed = 0 + let pendingMemberOps = 0 + + for (const item of plan.provision) { + // The cap is checked per Team rather than once, because every create consumes + // one and a pass that provisions ten Teams from a headroom of three has to + // stop after the third — not discover it in Discord's rejection. + if (!item.team.role_ref && flight.roleCount + created >= flight.roleCap) { + // eslint-disable-next-line no-await-in-loop + await voice.record({ + teamId: item.team.team_id, + channelRef: item.team.external_ref, + roleRef: null, + state: 'error', + lastError: `this guild is at Discord's limit of ${flight.roleCap} roles, so no role could be created for this team`, + }) + failed += 1 + continue + } + + // eslint-disable-next-line no-await-in-loop + const result = await syncOne(item, { categoryRef, staffRoles: plan.config.staffRoles }) + if (!result.ok) { + failed += 1 + continue + } + synced += 1 + if (result.created) created += 1 + pendingMemberOps += result.pendingMemberOps + if (result.categoryRef && result.categoryRef !== categoryRef) { + categoryRef = result.categoryRef + // eslint-disable-next-line no-await-in-loop + await settings.setCategoryRef(categoryRef).catch((err) => { + // Not fatal, but loud: the next pass would create a SECOND category and + // the guild would slowly fill with them. + log.error('the voice category id could not be stored; the next pass may create another', { + categoryRef, message: err.message, + }) + }) + } + } + + for (const entry of plan.scheduled) { + // eslint-disable-next-line no-await-in-loop + await voice.record({ + teamId: entry.team.team_id, + channelRef: entry.team.external_ref, + roleRef: entry.team.role_ref, + state: 'pending_removal', + removeAfter: entry.removeAfter, + lastError: null, + syncedAt: entry.team.synced_at, + }) + log.info('voice channel scheduled for removal', { + teamId: entry.team.team_id, reason: entry.reason, removeAfter: entry.removeAfter, + }) + } + + let removed = 0 + for (const entry of plan.removals) { + // eslint-disable-next-line no-await-in-loop + const result = await removeOne(entry) + if (result.ok) removed += 1 + else failed += 1 + } + + const summary = { + ran: true, + reason, + synced, + created, + scheduled: plan.scheduled.length, + removed, + failed, + pendingMemberOps, + } + log.info('voice pass complete', summary) + + // A Team whose membership diff was truncated is not finished. Asking for + // another pass is what makes a bounded pass converge rather than leave the + // remainder until the next reconcile fifteen minutes later. + if (pendingMemberOps > 0) rerun = true + + return summary +} + +/** Run now, awaited, with the lock held. The admin "sync now" button uses this. */ +async function passNow(reason = 'manual') { + if (running) { + rerun = true + return { ran: false, reason: 'a pass is already running', joined: true } + } + running = true + try { + const result = await runOnce(reason) + lastRunAt = Date.now() + lastPassResult = { at: new Date(), ...result } + return result + } catch (err) { + log.error('voice pass threw', { message: err.message, reason }) + lastPassResult = { at: new Date(), ran: false, reason: err.message } + return { ran: false, reason: err.message } + } finally { + running = false + if (rerun) { + rerun = false + request({ reason: 'continuation' }) + } + } +} + +/** + * Ask for a pass. Returns immediately and never rejects — this is what the Team + * reconciler calls, and a voice channel must never be able to slow down or fail + * the roster sync it hangs off. + */ +function request({ reason = 'reconcile' } = {}) { + if (debounceTimer) return + if (running) { + rerun = true + return + } + const since = Date.now() - lastRunAt + if (since >= DEBOUNCE_MS) { + passNow(reason).catch(() => {}) + return + } + debounceTimer = setTimeout(() => { + debounceTimer = null + passNow(reason).catch(() => {}) + }, DEBOUNCE_MS - since) + // Unreffed, like every other background timer here: a pending pass must not + // hold a shutdown open. + if (typeof debounceTimer.unref === 'function') debounceTimer.unref() +} + +/** + * Remove one Team's resources on an admin's say-so, ignoring the grace window. + * + * The window exists to stop CHURN — a Team crossing the threshold twice in a week + * should not lose its channel id — and an operator clicking remove is not churn. + * They also need this when voice has been switched off, which is the one state + * where no pass will ever reach the row. + */ +async function removeNow(teamId) { + const row = await voice.getForTeam(teamId) + if (!row) return { ok: false, status: 404, message: 'this team has no voice channel' } + const result = await removeOne({ team: { ...row, team_id: teamId }, reason: 'admin' }) + if (!result.ok) return { ok: false, status: 502, message: result.message } + return { ok: true } +} + +function stop() { + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = null +} + +// Test-only: module-level scheduling state has to be resettable between tests. +function _reset() { + stop() + running = false + rerun = false + lastRunAt = 0 + lastPassResult = { at: null, ran: false, reason: 'no pass has run yet' } +} + +module.exports = { + DEBOUNCE_MS, + preflight, + passNow, + request, + removeNow, + lastPass, + stop, + _reset, + // Exported for the reconciler's tests, which drive a pass directly rather than + // through the debounce. + runOnce, +} diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 727b1f1..38951f9 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -4963,6 +4963,180 @@ ] } }, + "/api/v1/admin/teams/voice": { + "get": { + "tags": [ + "Admin · Teams" + ], + "summary": "Team voice channel configuration and state (admin only)", + "description": "The settings, every provisioned channel with its state and last error, and the bot’s own preflight — whether it is connected, whether it holds Manage Channels and Manage Roles, and how close the guild is to Discord’s cap of 250 roles. Access is granted with a role per Team, so that cap is the ceiling on how many Teams can have voice at all.", + "responses": { + "200": { + "description": "Voice configuration and state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamVoiceConfig" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "tags": [ + "Admin · Teams" + ], + "summary": "Save the Team voice settings (admin only)", + "description": "Switching voice on is refused 422 while the bot cannot manage channels and roles in the guild — a setting that saves and then quietly does nothing is worse than one that will not save. Switching it off is never gated, and never tears anything down: existing channels stop being reconciled and are removed one at a time by an operator who means it.", + "responses": { + "200": { + "description": "The saved settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamVoiceSettings" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "422": { + "description": "The bot cannot manage channels or roles yet", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/admin/teams/voice/sync": { + "post": { + "tags": [ + "Admin · Teams" + ], + "summary": "Run a voice reconciliation now (admin only)", + "description": "Awaited, so the response carries the outcome. The three suspensions still apply — a manual pass will not run while voice is off, while the Team projection is stale, or while the bot cannot act — and the response says which one stopped it.", + "responses": { + "200": { + "description": "The pass result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamVoicePassResult" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/teams/voice/{teamId}": { + "delete": { + "tags": [ + "Admin · Teams" + ], + "summary": "Remove one Team’s voice channel and role (admin only)", + "description": "Immediate, ignoring the grace window: the window exists to stop churn on a Team that crosses the threshold twice in a week, and an operator pressing remove is not churn. The channel and the role go together — a role for a channel that no longer exists is a badge for nowhere.", + "parameters": [ + { + "name": "teamId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Removed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "That Team has no voice channel", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/admin/teams/{id}": { "get": { "tags": [ @@ -21923,6 +22097,536 @@ } } }, + "TeamVoiceSettings": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "The operator's voice controls. Access is granted with a role per Team, so Discord's guild-wide cap of 250 roles — not a per-channel overwrite budget — is the ceiling on how many Teams can have voice." + }, + "properties": { + "type": "object", + "properties": { + "enabled": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "Switching this off suspends the reconciler in both directions and leaves existing channels standing. A checkbox does not delete structure in somebody's guild; remove channels individually instead." + } + } + }, + "minMembers": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 5 + }, + "description": { + "type": "string", + "example": "Every active member counts, whatever they have linked." + } + } + }, + "graceDays": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 7 + }, + "description": { + "type": "string", + "example": "How long a Team keeps its channel after it stops qualifying. A Team that recovers inside the window keeps the same channel id." + } + } + }, + "categoryRef": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The parent category, created by the bot on the first pass that needs one and stored here." + } + } + }, + "staffRoles": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "description": { + "type": "string", + "example": "Roles allowed into every Team channel. Guild administrators already bypass overwrites, so this is for staff who are not administrators." + } + } + }, + "roleCap": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 250 + } + } + } + } + } + } + }, + "TeamVoicePreflight": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "The bot's own answer about whether it can do the job. Asked before voice may be switched on and again at the top of every pass — the operator invites the bot by hand, so nothing else in the system knows what permissions it was granted." + }, + "properties": { + "type": "object", + "properties": { + "ready": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + } + } + }, + "connected": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + } + } + }, + "missingPermissions": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Manage Roles" + } + } + } + } + }, + "roleCount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Roles in the guild, all of them — the cap is shared with every role the operator created themselves." + } + } + }, + "roleCap": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 250 + } + } + }, + "botRolePosition": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "The bot can only grant roles below its own. A bot at the bottom of the list creates roles it cannot hand to anybody." + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "TeamVoiceRow": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One Team's provisioned channel and role, as core last believed them." + }, + "properties": { + "type": "object", + "properties": { + "teamId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "teamName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "teamSlug": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "memberCount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "linkedCount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "channelRef": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "roleRef": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "state": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "none", + "active", + "pending_removal", + "error" + ], + "items": { + "type": "string" + } + } + } + }, + "removeAfter": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "lastError": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "syncedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "TeamVoiceConfig": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "platform": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "discord" + } + } + }, + "settings": { + "$ref": "#/components/schemas/TeamVoiceSettings" + }, + "preflight": { + "$ref": "#/components/schemas/TeamVoicePreflight" + }, + "rows": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/TeamVoiceRow" + } + } + }, + "lastPass": { + "$ref": "#/components/schemas/TeamVoicePassResult" + } + } + } + } + }, + "TeamVoicePassResult": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "What one reconciliation pass did, or why it did nothing. `ran: false` is the ordinary answer on a deployment with voice off, with a stale Team projection, or with a bot that cannot manage channels and roles — and the three read differently in `reason` because an operator fixes them in three different places." + }, + "properties": { + "type": "object", + "properties": { + "ran": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "synced": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "created": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "scheduled": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "removed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "pendingMemberOps": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Role grants a bounded pass could not fit. Non-zero asks for another pass rather than waiting out the interval." + } + } + } + } + } + } + }, "TeamModerationResult": { "type": "object", "properties": { diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index d83ac14..f9c6f71 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -1329,6 +1329,107 @@ const doc = { rows: { type: 'array', items: { $ref: '#/components/schemas/TeamIntegrationRow' } }, }, }, + TeamVoiceSettings: { + type: 'object', + description: + 'The operator\'s voice controls. Access is granted with a role per Team, so Discord\'s guild-wide cap of 250 roles — not a per-channel overwrite budget — is the ceiling on how many Teams can have voice.', + properties: { + enabled: { + type: 'boolean', + description: + 'Switching this off suspends the reconciler in both directions and leaves existing channels standing. A checkbox does not delete structure in somebody\'s guild; remove channels individually instead.', + }, + minMembers: { + type: 'integer', + example: 5, + description: 'Every active member counts, whatever they have linked.', + }, + graceDays: { + type: 'integer', + example: 7, + description: + 'How long a Team keeps its channel after it stops qualifying. A Team that recovers inside the window keeps the same channel id.', + }, + categoryRef: { + type: 'string', + nullable: true, + description: 'The parent category, created by the bot on the first pass that needs one and stored here.', + }, + staffRoles: { + type: 'array', + items: { type: 'string' }, + description: + 'Roles allowed into every Team channel. Guild administrators already bypass overwrites, so this is for staff who are not administrators.', + }, + roleCap: { type: 'integer', example: 250 }, + }, + }, + TeamVoicePreflight: { + type: 'object', + description: + 'The bot\'s own answer about whether it can do the job. Asked before voice may be switched on and again at the top of every pass — the operator invites the bot by hand, so nothing else in the system knows what permissions it was granted.', + properties: { + ready: { type: 'boolean' }, + connected: { type: 'boolean' }, + missingPermissions: { type: 'array', items: { type: 'string', example: 'Manage Roles' } }, + roleCount: { + type: 'integer', + description: 'Roles in the guild, all of them — the cap is shared with every role the operator created themselves.', + }, + roleCap: { type: 'integer', example: 250 }, + botRolePosition: { + type: 'integer', + description: + 'The bot can only grant roles below its own. A bot at the bottom of the list creates roles it cannot hand to anybody.', + }, + reason: { type: 'string', nullable: true }, + }, + }, + TeamVoiceRow: { + type: 'object', + description: 'One Team\'s provisioned channel and role, as core last believed them.', + properties: { + teamId: { type: 'integer' }, + teamName: { type: 'string' }, + teamSlug: { type: 'string' }, + memberCount: { type: 'integer' }, + linkedCount: { type: 'integer' }, + channelRef: { type: 'string', nullable: true }, + roleRef: { type: 'string', nullable: true }, + state: { type: 'string', enum: ['none', 'active', 'pending_removal', 'error'] }, + removeAfter: { type: 'string', format: 'date-time', nullable: true }, + lastError: { type: 'string', nullable: true }, + syncedAt: { type: 'string', format: 'date-time', nullable: true }, + }, + }, + TeamVoiceConfig: { + type: 'object', + properties: { + platform: { type: 'string', example: 'discord' }, + settings: { $ref: '#/components/schemas/TeamVoiceSettings' }, + preflight: { $ref: '#/components/schemas/TeamVoicePreflight' }, + rows: { type: 'array', items: { $ref: '#/components/schemas/TeamVoiceRow' } }, + lastPass: { $ref: '#/components/schemas/TeamVoicePassResult' }, + }, + }, + TeamVoicePassResult: { + type: 'object', + description: + 'What one reconciliation pass did, or why it did nothing. `ran: false` is the ordinary answer on a deployment with voice off, with a stale Team projection, or with a bot that cannot manage channels and roles — and the three read differently in `reason` because an operator fixes them in three different places.', + properties: { + ran: { type: 'boolean' }, + reason: { type: 'string', nullable: true }, + synced: { type: 'integer' }, + created: { type: 'integer' }, + scheduled: { type: 'integer' }, + removed: { type: 'integer' }, + failed: { type: 'integer' }, + pendingMemberOps: { + type: 'integer', + description: 'Role grants a bounded pass could not fit. Non-zero asks for another pass rather than waiting out the interval.', + }, + }, + }, TeamModerationResult: { type: 'object', description: diff --git a/server/test/teamVoice.test.js b/server/test/teamVoice.test.js new file mode 100644 index 0000000..f723011 --- /dev/null +++ b/server/test/teamVoice.test.js @@ -0,0 +1,270 @@ +// Team voice channels — the settings and the plan (docs/website/TEAMS.md §7.3, +// phase 9). +// +// The db layer is stubbed, so these are assertions about the RULES. What they +// protect, in order of how badly it would hurt to lose it: +// +// 1. **A hidden Team is never provisioned.** A Discord channel name is a +// game-sourced string published outside the site, which is the exact thing +// §2.8 exists to stop, and `reservedNames.js` already names "and eventually +// a Discord channel name" as one of the surfaces it protects. Losing this +// would put a name staff suppressed into somebody's guild. +// 2. **The threshold counts every active member**, not linked ones. §7.3 wrote +// `voice_min_linked_members`; the org lead settled it the other way, and the +// denormalised column the query reads makes the wrong answer easy to write. +// 3. **A Team that stops qualifying is SCHEDULED, not removed** — the grace +// window's whole purpose is that a Team hovering around the threshold does +// not delete-and-recreate its channel, changing the id and breaking every +// pinned link to it. +// 4. **A Team that recovers inside the window keeps its channel**, with the +// window cleared. +// 5. **Every settings read fails closed**, so a DB fault cannot switch voice on, +// widen the threshold, or shorten the grace window. +const { test, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const voiceDb = require('../src/model/teams/teamVoice.db') +const settingsDb = require('../src/model/settings/settings.db') +const model = require('../src/model/teams/teamVoice.model') +const settings = require('../src/model/teams/teamVoiceSettings.model') + +const saved = new Map() + +function patch(mod, name, fn) { + if (!saved.has(mod)) saved.set(mod, new Map()) + if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name]) + mod[name] = fn +} + +function restore() { + for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn + saved.clear() +} + +// One in-memory `settings` table and one `teams` list, driven through the same +// queries the real ones answer — including the gate conditions, because a stub +// that filtered in JavaScript would pass with SQL that never worked. +let store +let teams + +const qualifies = (t, minMembers) => t.status === 'active' && !t.hidden && t.member_count >= minMembers + +beforeEach(() => { + store = new Map() + teams = [] + + patch(settingsDb, 'get', async (key) => (store.has(key) ? store.get(key) : null)) + patch(settingsDb, 'set', async (key, value) => { store.set(key, value) }) + + patch(voiceDb, 'desiredTeams', async ({ minMembers }) => + teams.filter((t) => qualifies(t, minMembers)).map((t) => ({ ...t, team_id: t.id }))) + + patch(voiceDb, 'holdersWithoutClaim', async ({ minMembers }) => + teams + .filter((t) => (t.external_ref || t.role_ref) && !qualifies(t, minMembers)) + .map((t) => ({ ...t, team_id: t.id, team_status: t.status, team_hidden: t.hidden }))) +}) + +afterEach(restore) + +const team = (over = {}) => ({ + id: 1, + name: 'The Silver Hand', + display_name_override: null, + status: 'active', + hidden: 0, + member_count: 10, + external_ref: null, + role_ref: null, + state: 'none', + remove_after: null, + ...over, +}) + +const enable = () => { store.set('teams_voice_enabled', '1') } + +// ── The gate ─────────────────────────────────────────────────────────────── + +test('voice off is not an error: the plan is simply absent', async () => { + teams = [team()] + assert.equal(await model.plan(), null) +}) + +test('a hidden Team is never provisioned, however many members it has', async () => { + enable() + teams = [team({ hidden: 1, member_count: 400 })] + const plan = await model.plan() + assert.equal(plan.provision.length, 0) +}) + +test('a hidden Team that already HAS a channel loses it, on the grace window', async () => { + enable() + teams = [team({ hidden: 1, member_count: 400, external_ref: '900', role_ref: '901' })] + const plan = await model.plan() + assert.equal(plan.provision.length, 0) + assert.equal(plan.scheduled.length, 1) + assert.equal(plan.scheduled[0].reason, 'hidden') +}) + +test('an archived Team loses its channel, and the reason says so', async () => { + enable() + teams = [team({ status: 'archived', external_ref: '900', role_ref: '901' })] + const plan = await model.plan() + assert.equal(plan.scheduled[0].reason, 'archived') +}) + +test('the threshold counts every active member, not the linked ones', async () => { + enable() + store.set('teams_voice_min_members', '5') + // Six members, none of whom has linked anything. §7.3 wrote + // `voice_min_linked_members` and this is the settled reading of it: the operator + // is judging whether the Team is real, and link state answers a different + // question. `linked_count` must not be what the gate reads. + teams = [team({ member_count: 6, linked_count: 0 })] + const plan = await model.plan() + assert.equal(plan.provision.length, 1) +}) + +test('a Team below the threshold with no channel is simply absent from both lists', async () => { + enable() + store.set('teams_voice_min_members', '5') + teams = [team({ member_count: 2 })] + const plan = await model.plan() + assert.equal(plan.provision.length, 0) + assert.equal(plan.scheduled.length, 0) + assert.equal(plan.removals.length, 0) +}) + +// ── The grace window ─────────────────────────────────────────────────────── + +test('a Team that drops below the threshold is scheduled, never removed on the spot', async () => { + enable() + store.set('teams_voice_min_members', '5') + store.set('teams_voice_grace_days', '7') + teams = [team({ member_count: 2, external_ref: '900', role_ref: '901' })] + + const now = new Date('2026-08-19T00:00:00Z') + const plan = await model.plan({ now }) + assert.equal(plan.removals.length, 0) + assert.equal(plan.scheduled.length, 1) + assert.equal(plan.scheduled[0].reason, 'below_threshold') + assert.equal( + plan.scheduled[0].removeAfter.toISOString(), + new Date('2026-08-26T00:00:00Z').toISOString(), + ) +}) + +test('an expired window is what puts a Team in removals', async () => { + enable() + store.set('teams_voice_min_members', '5') + teams = [team({ + member_count: 2, + external_ref: '900', + role_ref: '901', + state: 'pending_removal', + remove_after: '2026-08-18T00:00:00Z', + })] + const plan = await model.plan({ now: new Date('2026-08-19T00:00:00Z') }) + assert.equal(plan.removals.length, 1) + assert.equal(plan.scheduled.length, 0) +}) + +test('an unexpired window leaves the row alone — no removal, no re-scheduling', async () => { + enable() + store.set('teams_voice_min_members', '5') + teams = [team({ + member_count: 2, + external_ref: '900', + role_ref: '901', + state: 'pending_removal', + remove_after: '2026-08-30T00:00:00Z', + })] + const plan = await model.plan({ now: new Date('2026-08-19T00:00:00Z') }) + assert.equal(plan.removals.length, 0) + // Not re-scheduled either: pushing the window out on every pass would mean it + // never expires. + assert.equal(plan.scheduled.length, 0) +}) + +test('a Team that recovers inside the window comes back as a provision, flagged as recovering', async () => { + enable() + store.set('teams_voice_min_members', '5') + teams = [team({ + member_count: 9, + external_ref: '900', + role_ref: '901', + state: 'pending_removal', + remove_after: '2026-08-30T00:00:00Z', + })] + const plan = await model.plan() + assert.equal(plan.provision.length, 1) + assert.equal(plan.provision[0].recovering, true) + assert.equal(plan.removals.length, 0) + assert.equal(plan.scheduled.length, 0) +}) + +// ── Names ────────────────────────────────────────────────────────────────── + +test('the display-name override is what reaches Discord, not the game name', async () => { + // §2.8.3 lets staff change what is DISPLAYED without touching identity. A + // channel is a display surface, so a Team whose name staff rewrote must not go + // on publishing the original one. + assert.equal(model.displayName({ id: 3, name: 'Bad Name', display_name_override: 'Renamed' }), 'Renamed') +}) + +test('a name of nothing but control characters falls back rather than reaching Discord empty', async () => { + const name = String.fromCharCode(1, 2, 3) + assert.equal(model.displayName({ team_id: 42, name }), 'team-42') +}) + +test('spaces and case survive: a voice channel is not a text channel', async () => { + assert.equal(model.sanitiseName(' The Silver Hand '), 'The Silver Hand') +}) + +test('a name longer than Discord takes is truncated, not rejected', async () => { + assert.equal(model.sanitiseName('x'.repeat(400)).length, model.CHANNEL_NAME_MAX) +}) + +// ── Settings ─────────────────────────────────────────────────────────────── + +test('every settings read fails closed when the database is unreachable', async () => { + patch(settingsDb, 'get', async () => { throw new Error('pool is down') }) + assert.equal(await settings.enabled(), false) + assert.equal(await settings.minMembers(), settings.MIN_MEMBERS_DEFAULT) + assert.equal(await settings.graceDays(), settings.GRACE_DAYS_DEFAULT) + assert.equal(await settings.categoryRef(), null) + assert.deepEqual(await settings.staffRoles(), []) +}) + +test('a stored threshold outside the allowed range is ignored, not obeyed', async () => { + store.set('teams_voice_min_members', '0') + assert.equal(await settings.minMembers(), settings.MIN_MEMBERS_DEFAULT) + store.set('teams_voice_min_members', 'banana') + assert.equal(await settings.minMembers(), settings.MIN_MEMBERS_DEFAULT) +}) + +test('a zero grace window is legitimate and is not confused with an unset one', async () => { + store.set('teams_voice_grace_days', '0') + assert.equal(await settings.graceDays(), 0) +}) + +test('a staff-role id that is not an id is refused on save, never silently dropped', async () => { + await assert.rejects( + () => settings.save({ staffRoles: ['123456789012345678', 'not-an-id'] }), + (err) => err.status === 400, + ) +}) + +test('staff roles round-trip through storage as a list', async () => { + await settings.save({ staffRoles: '123456789012345678, 987654321098765432' }) + assert.deepEqual(await settings.staffRoles(), ['123456789012345678', '987654321098765432']) +}) + +test('a category ref that is not a channel id is never stored', async () => { + await assert.rejects(() => settings.setCategoryRef('../../etc/passwd')) +}) + +test('a garbage category ref already in the database reads as unset', async () => { + store.set('teams_voice_category_ref', 'nonsense') + assert.equal(await settings.categoryRef(), null) +}) diff --git a/server/test/teamVoiceSync.test.js b/server/test/teamVoiceSync.test.js new file mode 100644 index 0000000..ede9010 --- /dev/null +++ b/server/test/teamVoiceSync.test.js @@ -0,0 +1,324 @@ +// The voice reconciler — what actually reaches Discord (TEAMS.md §7.3, phase 9). +// +// teamVoice.test.js proves the rules; this proves the pass that applies them, +// which is a different set of mistakes: +// +// 1. **The three suspensions.** Voice off, a stale Team projection, or a bot +// that cannot act each stop the pass ENTIRELY — in both directions. The +// stale one is §7.3 verbatim and is the whole reason the file is careful: a +// sidecar that has been down for an hour reports rosters that look exactly +// like "every Team lost its members", and a voice channel must never be +// destroyed because a sidecar was down. +// 2. **A per-Team failure does not abort the pass.** One Team whose channel a +// human deleted is one Team's problem, the same shape as §2.4's gate 3. +// 3. **A failed sync does not clear the refs it could not confirm.** Clearing +// them would orphan a real channel and make the next pass create a second. +// 4. **A failed teardown does not extend the window.** Granting another seven +// days every time a delete fails means it never happens. +// 5. **The role cap is checked per create.** A pass with headroom for three +// Teams must stop after the third rather than discover it in a rejection. +// 6. **The membership grant is the hop-3 set** — a Team member with no Discord +// identity cannot be handed a role, so the query, not `linked_count`, is what +// the pass sends. +const { test, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const sync = require('../src/utils/teamVoiceSync') +const voice = require('../src/model/teams/teamVoice.model') +const voiceDb = require('../src/model/teams/teamVoice.db') +const settings = require('../src/model/teams/teamVoiceSettings.model') +const teamsModel = require('../src/model/teams/teams.model') +const botClient = require('../src/utils/botInternalClient') + +const saved = new Map() + +function patch(mod, name, fn) { + if (!saved.has(mod)) saved.set(mod, new Map()) + if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name]) + mod[name] = fn +} + +function restore() { + for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn + saved.clear() +} + +let plan +let recorded +let forgotten +let calls +let stale +let flight + +const okPreflight = { + connected: true, can_manage_channels: true, can_manage_roles: true, role_count: 12, bot_role_position: 5, +} + +beforeEach(() => { + sync._reset() + recorded = [] + forgotten = [] + calls = { sync: [], remove: [], preflight: 0 } + stale = false + flight = { ok: true, status: 200, data: { ...okPreflight } } + plan = { config: { enabled: true, minMembers: 5, graceDays: 7, categoryRef: '500', staffRoles: [] }, provision: [], scheduled: [], removals: [] } + + patch(voice, 'plan', async () => plan) + patch(voice, 'memberRefs', async () => ['111111111111111111']) + patch(voice, 'record', async (row) => { recorded.push(row); return row }) + patch(voice, 'forget', async (teamId) => { forgotten.push(teamId) }) + patch(voice, 'getForTeam', async () => null) + patch(teamsModel, 'syncStatus', async () => ({ stale, lastSyncAt: new Date(), configured: true })) + patch(settings, 'setCategoryRef', async () => {}) + + patch(botClient, 'voicePreflight', async () => { calls.preflight += 1; return flight }) + patch(botClient, 'voiceSync', async (body) => { + calls.sync.push(body) + return { + ok: true, + status: 200, + data: { + category_id: body.categoryRef || '500', + channel_id: '900', + role_id: '901', + created: { channel: !body.channelRef, role: !body.roleRef }, + members: { added: 1, removed: 0, pending: 0 }, + }, + } + }) + patch(botClient, 'voiceRemove', async (body) => { calls.remove.push(body); return { ok: true, status: 200, data: {} } }) +}) + +afterEach(restore) + +const item = (over = {}) => ({ + team: { team_id: 1, external_ref: null, role_ref: null, remove_after: null, synced_at: null, ...over.team }, + name: 'The Silver Hand', + hasRow: false, + recovering: false, + ...over, +}) + +// ── The three suspensions ────────────────────────────────────────────────── + +test('voice off: the pass does not run, and makes no calls in either direction', async () => { + plan = null + const result = await sync.runOnce('test') + assert.equal(result.ran, false) + assert.equal(calls.preflight, 0) + assert.equal(calls.sync.length, 0) + assert.equal(calls.remove.length, 0) +}) + +test('a stale Team projection stops the pass before a single Discord call', async () => { + stale = true + plan.provision = [item()] + plan.removals = [{ team: { team_id: 2, external_ref: '900', role_ref: '901' }, reason: 'below_threshold' }] + + const result = await sync.runOnce('test') + assert.equal(result.ran, false) + assert.match(result.reason, /stale/) + // The removal half is the one that matters: nothing is destroyed on data core + // does not trust. + assert.equal(calls.remove.length, 0) + assert.equal(calls.sync.length, 0) + assert.equal(recorded.length, 0) +}) + +test('a bot missing Manage Roles stops the pass once, not forty times', async () => { + flight = { ok: true, status: 200, data: { ...okPreflight, can_manage_roles: false } } + plan.provision = [item(), item({ team: { team_id: 2 } })] + + const result = await sync.runOnce('test') + assert.equal(result.ran, false) + assert.match(result.reason, /Manage Roles/) + // No per-Team error rows: this is one deployment misconfiguration, and writing + // it into every Team's last_error would bury the one fact that matters. + assert.equal(recorded.length, 0) +}) + +test('a bot that is not connected reads as not connected, not as a permission problem', async () => { + flight = { ok: false, status: 503, error: 'bot responded 503' } + const result = await sync.preflight() + assert.equal(result.ready, false) + assert.match(result.reason, /not connected/) +}) + +// ── Provisioning ─────────────────────────────────────────────────────────── + +test('a new Team is created, and the pass sends the hop-3 member set', async () => { + patch(voice, 'memberRefs', async () => ['111111111111111111', '222222222222222222']) + plan.provision = [item()] + + const result = await sync.runOnce('test') + assert.equal(result.ran, true) + assert.equal(result.synced, 1) + assert.equal(result.created, 1) + assert.deepEqual(calls.sync[0].memberRefs, ['111111111111111111', '222222222222222222']) + assert.equal(recorded[0].state, 'active') + assert.equal(recorded[0].channelRef, '900') + assert.equal(recorded[0].roleRef, '901') +}) + +test('a recovering Team has its removal window cleared', async () => { + plan.provision = [item({ + recovering: true, + team: { team_id: 1, external_ref: '900', role_ref: '901', remove_after: '2026-08-30T00:00:00Z' }, + })] + await sync.runOnce('test') + assert.equal(recorded[0].state, 'active') + assert.equal(recorded[0].removeAfter, null) +}) + +test('a failed sync records the error and KEEPS the refs it could not confirm', async () => { + patch(botClient, 'voiceSync', async () => ({ ok: false, status: 400, data: { message: 'Missing Access' } })) + plan.provision = [item({ team: { team_id: 1, external_ref: '900', role_ref: '901' } })] + + const result = await sync.runOnce('test') + assert.equal(result.failed, 1) + assert.equal(recorded[0].state, 'error') + assert.equal(recorded[0].lastError, 'Missing Access') + // Cleared refs would orphan a real channel and make the next pass build a second. + assert.equal(recorded[0].channelRef, '900') + assert.equal(recorded[0].roleRef, '901') +}) + +test('one Team failing does not stop the others', async () => { + let n = 0 + patch(botClient, 'voiceSync', async (body) => { + n += 1 + if (n === 1) return { ok: false, status: 400, data: { message: 'Missing Access' } } + return { ok: true, status: 200, data: { channel_id: '9', role_id: '8', created: {}, members: { pending: 0 } } } + }) + plan.provision = [item(), item({ team: { team_id: 2 } }), item({ team: { team_id: 3 } })] + + const result = await sync.runOnce('test') + assert.equal(result.failed, 1) + assert.equal(result.synced, 2) +}) + +test('the role cap is enforced per create, before Discord is asked', async () => { + flight = { ok: true, status: 200, data: { ...okPreflight, role_count: settings.ROLE_CAP - 1 } } + plan.provision = [item(), item({ team: { team_id: 2 } })] + + const result = await sync.runOnce('test') + assert.equal(result.created, 1) + assert.equal(result.failed, 1) + const capped = recorded.find((r) => r.state === 'error') + assert.match(capped.lastError, /limit of 250 roles/) + // The second Team was never handed to the bot. + assert.equal(calls.sync.length, 1) +}) + +test('a Team that already HAS a role is synced even at the cap — the cap gates creates, not updates', async () => { + flight = { ok: true, status: 200, data: { ...okPreflight, role_count: settings.ROLE_CAP } } + plan.provision = [item({ team: { team_id: 1, external_ref: '900', role_ref: '901' } })] + + const result = await sync.runOnce('test') + assert.equal(result.synced, 1) + assert.equal(result.failed, 0) +}) + +test('a category the bot had to create is persisted, so the next pass does not make another', async () => { + let stored = null + patch(settings, 'setCategoryRef', async (value) => { stored = value }) + patch(botClient, 'voiceSync', async () => ({ + ok: true, + status: 200, + data: { category_id: '777', channel_id: '900', role_id: '901', created: { channel: true, role: true }, members: { pending: 0 } }, + })) + plan.config.categoryRef = null + plan.provision = [item()] + + await sync.runOnce('test') + assert.equal(stored, '777') +}) + +test('a truncated membership diff asks for another pass rather than waiting out the interval', async () => { + patch(botClient, 'voiceSync', async () => ({ + ok: true, + status: 200, + data: { channel_id: '900', role_id: '901', created: {}, members: { added: 50, removed: 0, pending: 30 } }, + })) + plan.provision = [item()] + + const result = await sync.runOnce('test') + assert.equal(result.pendingMemberOps, 30) +}) + +// ── Removal ──────────────────────────────────────────────────────────────── + +test('a scheduled removal writes the window and touches nothing in Discord', async () => { + const removeAfter = new Date('2026-08-26T00:00:00Z') + plan.scheduled = [{ team: { team_id: 4, external_ref: '900', role_ref: '901', synced_at: null }, removeAfter, reason: 'below_threshold' }] + + await sync.runOnce('test') + assert.equal(calls.remove.length, 0) + assert.equal(recorded[0].state, 'pending_removal') + assert.equal(recorded[0].removeAfter, removeAfter) +}) + +test('an expired removal deletes the channel AND the role, then forgets the row', async () => { + plan.removals = [{ team: { team_id: 4, external_ref: '900', role_ref: '901' }, reason: 'below_threshold' }] + + const result = await sync.runOnce('test') + assert.equal(result.removed, 1) + assert.deepEqual(calls.remove[0], { channelRef: '900', roleRef: '901' }) + assert.deepEqual(forgotten, [4]) +}) + +test('a failed teardown keeps the expired window instead of granting another seven days', async () => { + patch(botClient, 'voiceRemove', async () => ({ ok: false, status: 0, error: 'fetch failed' })) + const expired = '2026-08-18T00:00:00Z' + plan.removals = [{ team: { team_id: 4, external_ref: '900', role_ref: '901', remove_after: expired }, reason: 'archived' }] + + const result = await sync.runOnce('test') + assert.equal(result.failed, 1) + assert.equal(result.removed, 0) + assert.equal(recorded[0].state, 'error') + assert.equal(recorded[0].removeAfter, expired) + // The row survives, so the next pass retries the same teardown. + assert.deepEqual(forgotten, []) +}) + +// ── The admin's own removal ──────────────────────────────────────────────── + +test('an admin removal ignores the grace window entirely', async () => { + patch(voice, 'getForTeam', async () => ({ external_ref: '900', role_ref: '901', remove_after: null })) + const result = await sync.removeNow(7) + assert.equal(result.ok, true) + assert.deepEqual(calls.remove[0], { channelRef: '900', roleRef: '901' }) + assert.deepEqual(forgotten, [7]) +}) + +test('removing a Team that has no channel is a 404, not a silent success', async () => { + const result = await sync.removeNow(7) + assert.equal(result.ok, false) + assert.equal(result.status, 404) +}) + +// ── The pass never throws ────────────────────────────────────────────────── + +test('a pass that throws is reported, not raised — it hangs off a background timer', async () => { + patch(voice, 'plan', async () => { throw new Error('database is on fire') }) + const result = await sync.passNow('test') + assert.equal(result.ran, false) + assert.equal(result.reason, 'database is on fire') + assert.equal(sync.lastPass().ran, false) +}) + +test('a pass records what it concluded, for the panel', async () => { + plan.provision = [item()] + await sync.passNow('test') + const last = sync.lastPass() + assert.equal(last.ran, true) + assert.equal(last.synced, 1) + assert.ok(last.at instanceof Date) +}) + +test('the db layer is untouched by these tests — the queries are proved by their own file', () => { + // A guard against a future edit here reaching the real db module: every test in + // this file stubs the model, and one that did not would connect to the dead port + // the harness pins and hang. + assert.equal(typeof voiceDb.desiredTeams, 'function') +})