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..713f2c6 --- /dev/null +++ b/server/src/model/teams/teamVoice.db.js @@ -0,0 +1,213 @@ +// 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' + +// Deliberately WITHOUT `i.team_id`, and this is not tidiness. +// +// The two queries below join `teams` and already select `t.id AS team_id`, so +// including the integration row's copy produces two result columns with the same +// name — which the `mariadb` driver refuses outright: "Error in results, duplicate +// field name `team_id`". Every caller sees the whole pass fail, and no unit test +// can see it, because they stub this layer. +// +// It would also be the WRONG column even if the driver allowed it: `desiredTeams` +// LEFT JOINs, so `i.team_id` is NULL for exactly the Teams that have no channel +// yet — the create case, where knowing the Team's id matters most. The two queries +// that do not join `teams` ask for it explicitly. +const COLUMNS = ` + i.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 i.team_id, ${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 i.team_id, ${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..0f90aca --- /dev/null +++ b/server/src/utils/teamVoiceSync.js @@ -0,0 +1,430 @@ +// ── 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 +// The promise of the pass currently in flight, so a second caller can await the +// same work rather than be told there is none. +let inFlight = null +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. The admin "Sync now" button uses this. + * + * **A pass already in flight is JOINED, not refused**, the same choice + * `teamSync.reconcileNow` makes and for the same reason: the caller wants "Discord + * now matches", and a pass that started a moment ago delivers exactly that. + * + * Refusing was the first thing written here and it was wrong in a way only the rig + * showed. Saving the settings with voice switched on asks for a pass; an operator + * who then presses Sync now — which is the obvious next thing to do — got + * `ran: false, reason: "a pass is already running"`, and the panel dutifully told + * them **"Nothing was done"** while the pass they had just triggered was busy + * creating their channels. + */ +async function passNow(reason = 'manual') { + if (inFlight) return inFlight + inFlight = execute(reason) + try { + return await inFlight + } finally { + inFlight = null + if (rerun) { + rerun = false + request({ reason: 'continuation' }) + } + } +} + +/** The body `passNow` guards. Never throws — see the file header. */ +async function execute(reason) { + 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 + } +} + +/** + * 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 + inFlight = null + 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..996d21a --- /dev/null +++ b/server/test/teamVoice.test.js @@ -0,0 +1,325 @@ +// 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')) +}) + +// ── The SQL itself ───────────────────────────────────────────────────────── + +test('no query selects the same result column twice', async () => { + // A defect the live rig found and no stubbed test could: `desiredTeams` and + // `holdersWithoutClaim` both select `t.id AS team_id`, and the shared column + // list used to add `i.team_id` beside it. The `mariadb` driver refuses a result + // set with a repeated field name outright — "Error in results, duplicate field + // name `team_id`" — so every pass failed at its first query, on a code path + // every other test in this file stubs. + // + // The check runs against the INTERPOLATED sql, captured from a fake `query`, + // not against the source text: in the source the shared list is still a + // `${COLUMNS}` placeholder, so a reader — and a first attempt at this test — + // cannot see the duplicate at all. + const db = require('../src/utils/db') + const realQuery = db.query + const seenSql = [] + db.query = async (sql) => { seenSql.push(sql); return [] } + + // Re-require: the module destructures `query` at load time, so patching after + // it is already in the cache would leave it holding the real one. + delete require.cache[require.resolve('../src/model/teams/teamVoice.db.js')] + /* eslint-disable-next-line global-require */ + const freshDb = require('../src/model/teams/teamVoice.db.js') + + try { + await freshDb.desiredTeams({ platform: 'discord', resource: 'voice', minMembers: 5 }) + await freshDb.holdersWithoutClaim({ platform: 'discord', resource: 'voice', minMembers: 5 }) + await freshDb.listForPlatform('discord', 'voice') + await freshDb.getForTeam(1, 'discord', 'voice') + await freshDb.discordSubjectsFor(1) + } finally { + db.query = realQuery + delete require.cache[require.resolve('../src/model/teams/teamVoice.db.js')] + } + + assert.equal(seenSql.length, 5, 'every query in the file should have been captured') + + for (const sql of seenSql) { + const selectList = sql.slice(sql.search(/SELECT/i) + 6, sql.search(/\sFROM\s/i)) + const names = selectList + .split(',') + .map((piece) => piece.trim()) + .filter(Boolean) + .map((piece) => { + const aliased = piece.match(/\sAS\s+(\w+)$/i) + if (aliased) return aliased[1].toLowerCase() + return piece.replace(/^DISTINCT\s+/i, '').replace(/^\w+\./, '').toLowerCase() + }) + const seen = new Set() + const duplicated = names.filter((name) => (seen.has(name) ? true : (seen.add(name), false))) + assert.deepEqual(duplicated, [], `duplicate result column "${duplicated[0]}" in: ${selectList.trim()}`) + } +}) + +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..6e3683d --- /dev/null +++ b/server/test/teamVoiceSync.test.js @@ -0,0 +1,347 @@ +// 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 already in flight is JOINED, not refused', async () => { + // Found on the live rig. Saving the settings with voice switched on asks for a + // pass; an operator who then presses "Sync now" — the obvious next thing to do — + // got `ran: false, reason: "a pass is already running"`, and the panel told them + // **"Nothing was done"** while the pass they had just triggered was creating + // their channels. `teamSync.reconcileNow` joins for the same reason: the caller + // wants "Discord now matches", and a pass that started a moment ago delivers it. + let release + const gate = new Promise((resolve) => { release = resolve }) + let passes = 0 + patch(voice, 'plan', async () => { passes += 1; await gate; return plan }) + + const first = sync.passNow('first') + const second = sync.passNow('second') + release() + const [a, b] = await Promise.all([first, second]) + + assert.equal(passes, 1, 'the work was done once') + assert.equal(a.ran, true) + assert.equal(b.ran, true, 'the second caller got the real outcome, not a refusal') + assert.deepEqual(a, b) +}) + +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') +})