feat(teams): phase 9 — one voice channel per Team, granted by a role #159

Merged
whitlocktech merged 2 commits from feature/teams-phase9-voice-channels into edge 2026-08-19 05:21:18 +00:00
26 changed files with 4328 additions and 4 deletions

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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

364
bot/test/teamVoice.test.js Normal file
View File

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

View File

@@ -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')

112
client/src/lib/teamVoice.js Normal file
View File

@@ -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 bots 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.`
}

View File

@@ -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 (
<section style={{ marginTop: 34, maxWidth: 760 }}>
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Voice channels</h2>
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>}
</section>
)
}
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 (
<section style={{ marginTop: 34, maxWidth: 760 }}>
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Voice channels</h2>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 14px' }}>
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.
</p>
{blocked && (
<p className="sans" style={{ color: '#e0b877', fontSize: '0.82rem' }}>
{blocked} Voice channels cannot be switched on until that is fixed.
</p>
)}
{headroom && (
<p className="sans dim" style={{ fontSize: '0.78rem' }}>
{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.`
: '.'}
</p>
)}
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>}
{notice && <p className="sans" style={{ color: '#8fbf7a', fontSize: '0.82rem' }}>{notice}</p>}
<p className="sans" style={{ fontSize: '0.8rem' }}>{statusSummary(config.settings, config.rows)}</p>
<div style={{ marginTop: 14, borderTop: '1px solid rgba(255,255,255,0.12)', paddingTop: 16 }}>
<label className="sans" style={{ display: 'block', marginBottom: 12, fontSize: '0.82rem' }}>
<input
type="checkbox"
checked={draft.enabled}
disabled={busy || (!!blocked && !draft.enabled)}
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
/>
{' '}Provision voice channels for Teams
</label>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Minimum members</span>
<input
className="input"
type="number"
min="1"
max="10000"
value={draft.minMembers}
disabled={busy}
onChange={(e) => setDraft({ ...draft, minMembers: e.target.value })}
/>
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
Every active member counts, whether or not they have linked an account.
</span>
</label>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Grace window (days)</span>
<input
className="input"
type="number"
min="0"
max="90"
value={draft.graceDays}
disabled={busy}
onChange={(e) => setDraft({ ...draft, graceDays: e.target.value })}
/>
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
How long a Team keeps its channel after it stops qualifying. A Team that recovers inside the
window keeps the same channel; zero removes it on the next pass.
</span>
</label>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Staff roles</span>
<input
className="input"
type="text"
value={draft.staffRoles}
disabled={busy}
placeholder="role id, role id"
onChange={(e) => setDraft({ ...draft, staffRoles: e.target.value })}
/>
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
Roles that can see and join every Teams channel. Guild administrators already can, so this
is for staff who are not administrators. Leave empty if there are none.
</span>
</label>
<button type="button" className="btn" disabled={busy} onClick={save}>Save</button>
<button type="button" className="btn-ghost" disabled={busy} onClick={runPass}>Sync now</button>
</div>
{config.rows.length > 0 && (
<table className="table" style={{ marginTop: 18 }}>
<thead>
<tr><th>Team</th><th>Members</th><th>Channel</th><th>State</th><th /></tr>
</thead>
<tbody>
{config.rows.map((row) => (
<tr key={row.teamId}>
<td>{row.teamName}</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>{row.memberCount}</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>
{row.channelRef || <span className="dim">none</span>}
</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>
{stateLabel(row.state)}
{removalCountdown(row) && (
<span className="dim" style={{ display: 'block' }}>{removalCountdown(row)}</span>
)}
{row.lastError && (
<span style={{ display: 'block', color: '#e08b77' }}>{row.lastError}</span>
)}
</td>
<td>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => remove(row)}>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{config.lastPass && config.lastPass.at && (
<p className="sans dim" style={{ fontSize: '0.74rem', marginTop: 10 }}>
Last pass {new Date(config.lastPass.at).toLocaleString()}
{config.lastPass.ran ? '' : ` — nothing was done: ${config.lastPass.reason}`}
</p>
)}
</section>
)
}

View File

@@ -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' && <TeamIntegrations />}
{role === 'admin' && <TeamVoice />}
<SyncPanel sync={data} syncState={data.syncState} onResync={resync} busy={busy} />
<ReviewQueue rows={review} role={role} onAct={act} busy={busy} />

View File

@@ -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/)
})

View File

@@ -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.

View File

@@ -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",

View File

@@ -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"

View File

@@ -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,
})

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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,
)

View File

@@ -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 bots own preflight — whether it is connected, whether it holds Manage Channels and Manage Roles, and how close the guild is to Discords 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 Teams 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

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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 bots own preflight — whether it is connected, whether it holds Manage Channels and Manage Roles, and how close the guild is to Discords 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 Teams 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": {

View File

@@ -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:

View File

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

View File

@@ -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')
})