feat(teams): phase 9 — one voice channel per Team, granted by a role #159
315
bot/src/discord/teamVoice.js
Normal file
315
bot/src/discord/teamVoice.js
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
364
bot/test/teamVoice.test.js
Normal 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)
|
||||
})
|
||||
@@ -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
112
client/src/lib/teamVoice.js
Normal 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 bot’s status is unknown.'
|
||||
if (!preflight.connected) return preflight.reason || 'The Discord bot is not connected.'
|
||||
if (preflight.missingPermissions && preflight.missingPermissions.length > 0) {
|
||||
return `The bot is missing ${preflight.missingPermissions.join(' and ')} in this guild.`
|
||||
}
|
||||
if (!preflight.ready) return preflight.reason || 'The bot cannot manage channels and roles yet.'
|
||||
return null
|
||||
}
|
||||
|
||||
// Below this many free roles the panel starts saying so. Not a server rule and
|
||||
// deliberately not one: it is a warning, and the server's only hard behaviour is
|
||||
// to refuse the create that would exceed the cap.
|
||||
const HEADROOM_WARNING = 25
|
||||
|
||||
/**
|
||||
* How much room is left, and whether to say something about it.
|
||||
*
|
||||
* The 250-role cap is the ceiling this phase's shape brings with it. Access is a
|
||||
* per-Team role, so it is not "how big can a Team be" — the old overwrite design's
|
||||
* limit — but "how many Teams can have voice at all", and the difference matters
|
||||
* to an operator with sixty guilds on their shard. It is guild-wide and shared
|
||||
* with every role they created themselves, which is why the count comes from the
|
||||
* bot rather than from core's own rows.
|
||||
*/
|
||||
export function roleHeadroom(preflight) {
|
||||
if (!preflight || !preflight.roleCap) return null
|
||||
const used = Number(preflight.roleCount) || 0
|
||||
const cap = Number(preflight.roleCap)
|
||||
const free = Math.max(0, cap - used)
|
||||
return { used, cap, free, tight: free <= HEADROOM_WARNING, exhausted: free === 0 }
|
||||
}
|
||||
|
||||
/** How a row's grace window reads while it is running. */
|
||||
export function removalCountdown(row, now = new Date()) {
|
||||
if (!row || row.state !== 'pending_removal' || !row.removeAfter) return null
|
||||
const ms = new Date(row.removeAfter).getTime() - now.getTime()
|
||||
if (ms <= 0) return 'due for removal on the next pass'
|
||||
const days = Math.floor(ms / 86400000)
|
||||
if (days >= 1) return `in ${days} day${days === 1 ? '' : 's'}`
|
||||
const hours = Math.max(1, Math.round(ms / 3600000))
|
||||
return `in ${hours} hour${hours === 1 ? '' : 's'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the staff-role field an operator types.
|
||||
*
|
||||
* Comma-separated ids, because that is what a person copying role ids out of
|
||||
* Discord ends up with. Validated rather than filtered, mirroring the server: a
|
||||
* quietly dropped id is a settings screen showing a save that did not happen.
|
||||
*/
|
||||
export function parseStaffRoles(text) {
|
||||
const parts = String(text || '')
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
const bad = parts.filter((part) => !/^[0-9]{5,32}$/.test(part))
|
||||
return { roles: parts, invalid: bad }
|
||||
}
|
||||
|
||||
export const formatStaffRoles = (roles) => (roles || []).join(', ')
|
||||
|
||||
/**
|
||||
* The sentence under the enable switch, which changes meaning with the state.
|
||||
*
|
||||
* "Off" is not "nothing is provisioned": switching voice off suspends the
|
||||
* reconciler in BOTH directions and leaves existing channels in place, which is
|
||||
* deliberate — a checkbox must not delete structure in somebody's guild — but it
|
||||
* is also surprising unless the screen says so.
|
||||
*/
|
||||
export function statusSummary(settings, rows) {
|
||||
const provisioned = (rows || []).filter((row) => row.channelRef).length
|
||||
if (!settings || !settings.enabled) {
|
||||
return provisioned > 0
|
||||
? `Off. ${provisioned} channel${provisioned === 1 ? '' : 's'} remain in Discord and are no longer being kept in step — remove them below if they are not wanted.`
|
||||
: 'Off. No channels are provisioned.'
|
||||
}
|
||||
return `On. Teams with at least ${settings.minMembers} member${settings.minMembers === 1 ? '' : 's'} get a voice channel and a role; ${provisioned} provisioned.`
|
||||
}
|
||||
256
client/src/routes/admin/views/TeamVoice.jsx
Normal file
256
client/src/routes/admin/views/TeamVoice.jsx
Normal 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 Team’s 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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
152
client/test/teamVoice.test.js
Normal file
152
client/test/teamVoice.test.js
Normal 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/)
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
213
server/src/model/teams/teamVoice.db.js
Normal file
213
server/src/model/teams/teamVoice.db.js
Normal 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,
|
||||
}
|
||||
235
server/src/model/teams/teamVoice.model.js
Normal file
235
server/src/model/teams/teamVoice.model.js
Normal 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,
|
||||
}
|
||||
256
server/src/model/teams/teamVoiceSettings.model.js
Normal file
256
server/src/model/teams/teamVoiceSettings.model.js
Normal 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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
93
server/src/router/v1/admin/teamsVoice.router.js
Normal file
93
server/src/router/v1/admin/teamsVoice.router.js
Normal 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 bot’s own preflight — whether it is connected, whether it holds Manage Channels and Manage Roles, and how close the guild is to Discord’s cap of 250 roles. Access is granted with a role per Team, so that cap is the ceiling on how many Teams can have voice at all.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Voice configuration and state', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamVoiceConfig" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
ctrl.voiceConfig,
|
||||
)
|
||||
|
||||
voiceRouter.put(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Save the Team voice settings (admin only)'
|
||||
// #swagger.description = 'Switching voice on is refused 422 while the bot cannot manage channels and roles in the guild — a setting that saves and then quietly does nothing is worse than one that will not save. Switching it off is never gated, and never tears anything down: existing channels stop being reconciled and are removed one at a time by an operator who means it.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The saved settings', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamVoiceSettings" } } } } */
|
||||
/* #swagger.responses[422] = { description: 'The bot cannot manage channels or roles yet', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('enabled').optional().isBoolean().toBoolean(),
|
||||
body('minMembers').optional().isInt({ min: 1, max: 10000 }).toInt(),
|
||||
body('graceDays').optional().isInt({ min: 0, max: 90 }).toInt(),
|
||||
body('staffRoles').optional({ nullable: true }),
|
||||
validate,
|
||||
ctrl.saveVoiceConfig,
|
||||
)
|
||||
|
||||
voiceRouter.post(
|
||||
'/sync',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Run a voice reconciliation now (admin only)'
|
||||
// #swagger.description = 'Awaited, so the response carries the outcome. The three suspensions still apply — a manual pass will not run while voice is off, while the Team projection is stale, or while the bot cannot act — and the response says which one stopped it.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The pass result', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamVoicePassResult" } } } } */
|
||||
adminOnly,
|
||||
ctrl.voicePass,
|
||||
)
|
||||
|
||||
voiceRouter.delete(
|
||||
'/:teamId',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Remove one Team’s voice channel and role (admin only)'
|
||||
// #swagger.description = 'Immediate, ignoring the grace window: the window exists to stop churn on a Team that crosses the threshold twice in a week, and an operator pressing remove is not churn. The channel and the role go together — a role for a channel that no longer exists is a badge for nowhere.'
|
||||
// #swagger.parameters['teamId'] = { in: 'path', required: true, schema: { type: 'integer' } }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'That Team has no voice channel', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('teamId').isInt({ min: 1 }).toInt(),
|
||||
validate,
|
||||
ctrl.removeVoice,
|
||||
)
|
||||
|
||||
module.exports = voiceRouter
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
430
server/src/utils/teamVoiceSync.js
Normal file
430
server/src/utils/teamVoiceSync.js
Normal 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,
|
||||
}
|
||||
@@ -4963,6 +4963,180 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/teams/voice": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Teams"
|
||||
],
|
||||
"summary": "Team voice channel configuration and state (admin only)",
|
||||
"description": "The settings, every provisioned channel with its state and last error, and the bot’s own preflight — whether it is connected, whether it holds Manage Channels and Manage Roles, and how close the guild is to Discord’s cap of 250 roles. Access is granted with a role per Team, so that cap is the ceiling on how many Teams can have voice at all.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Voice configuration and state",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TeamVoiceConfig"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Admin role required",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"Admin · Teams"
|
||||
],
|
||||
"summary": "Save the Team voice settings (admin only)",
|
||||
"description": "Switching voice on is refused 422 while the bot cannot manage channels and roles in the guild — a setting that saves and then quietly does nothing is worse than one that will not save. Switching it off is never gated, and never tears anything down: existing channels stop being reconciled and are removed one at a time by an operator who means it.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The saved settings",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TeamVoiceSettings"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"422": {
|
||||
"description": "The bot cannot manage channels or roles yet",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"example": "any"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/teams/voice/sync": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin · Teams"
|
||||
],
|
||||
"summary": "Run a voice reconciliation now (admin only)",
|
||||
"description": "Awaited, so the response carries the outcome. The three suspensions still apply — a manual pass will not run while voice is off, while the Team projection is stale, or while the bot cannot act — and the response says which one stopped it.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The pass result",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TeamVoicePassResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/teams/voice/{teamId}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Admin · Teams"
|
||||
],
|
||||
"summary": "Remove one Team’s voice channel and role (admin only)",
|
||||
"description": "Immediate, ignoring the grace window: the window exists to stop churn on a Team that crosses the threshold twice in a week, and an operator pressing remove is not churn. The channel and the role go together — a role for a channel that no longer exists is a badge for nowhere.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "teamId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Removed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/OkResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"404": {
|
||||
"description": "That Team has no voice channel",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/teams/{id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -21923,6 +22097,536 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TeamVoiceSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The operator's voice controls. Access is granted with a role per Team, so Discord's guild-wide cap of 250 roles — not a per-channel overwrite budget — is the ceiling on how many Teams can have voice."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Switching this off suspends the reconciler in both directions and leaves existing channels standing. A checkbox does not delete structure in somebody's guild; remove channels individually instead."
|
||||
}
|
||||
}
|
||||
},
|
||||
"minMembers": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 5
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Every active member counts, whatever they have linked."
|
||||
}
|
||||
}
|
||||
},
|
||||
"graceDays": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 7
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "How long a Team keeps its channel after it stops qualifying. A Team that recovers inside the window keeps the same channel id."
|
||||
}
|
||||
}
|
||||
},
|
||||
"categoryRef": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The parent category, created by the bot on the first pass that needs one and stored here."
|
||||
}
|
||||
}
|
||||
},
|
||||
"staffRoles": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Roles allowed into every Team channel. Guild administrators already bypass overwrites, so this is for staff who are not administrators."
|
||||
}
|
||||
}
|
||||
},
|
||||
"roleCap": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 250
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TeamVoicePreflight": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The bot's own answer about whether it can do the job. Asked before voice may be switched on and again at the top of every pass — the operator invites the bot by hand, so nothing else in the system knows what permissions it was granted."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ready": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connected": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missingPermissions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "Manage Roles"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"roleCount": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Roles in the guild, all of them — the cap is shared with every role the operator created themselves."
|
||||
}
|
||||
}
|
||||
},
|
||||
"roleCap": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 250
|
||||
}
|
||||
}
|
||||
},
|
||||
"botRolePosition": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The bot can only grant roles below its own. A bot at the bottom of the list creates roles it cannot hand to anybody."
|
||||
}
|
||||
}
|
||||
},
|
||||
"reason": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TeamVoiceRow": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "One Team's provisioned channel and role, as core last believed them."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"teamId": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"teamName": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"teamSlug": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"memberCount": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkedCount": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"channelRef": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"roleRef": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"enum": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"none",
|
||||
"active",
|
||||
"pending_removal",
|
||||
"error"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"removeAfter": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"example": "date-time"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"lastError": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"syncedAt": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"example": "date-time"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TeamVoiceConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"platform": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "discord"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/TeamVoiceSettings"
|
||||
},
|
||||
"preflight": {
|
||||
"$ref": "#/components/schemas/TeamVoicePreflight"
|
||||
},
|
||||
"rows": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TeamVoiceRow"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lastPass": {
|
||||
"$ref": "#/components/schemas/TeamVoicePassResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TeamVoicePassResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "What one reconciliation pass did, or why it did nothing. `ran: false` is the ordinary answer on a deployment with voice off, with a stale Team projection, or with a bot that cannot manage channels and roles — and the three read differently in `reason` because an operator fixes them in three different places."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ran": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reason": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"synced": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"created": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scheduled": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"removed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"failed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pendingMemberOps": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Role grants a bounded pass could not fit. Non-zero asks for another pass rather than waiting out the interval."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TeamModerationResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
325
server/test/teamVoice.test.js
Normal file
325
server/test/teamVoice.test.js
Normal 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)
|
||||
})
|
||||
347
server/test/teamVoiceSync.test.js
Normal file
347
server/test/teamVoiceSync.test.js
Normal 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')
|
||||
})
|
||||
Reference in New Issue
Block a user