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

TEAMS.md §7.3. Each qualifying Team gets a Discord voice channel of its own
and a role that opens it, kept in step by a reconciler that rides the Team
reconcile it already depends on.

Access is a per-Team ROLE, always. §7.3 designed per-member overwrites with
escalation to a role above ~90 members; the org lead settled on roles always
(2026-08-18), which deletes `voice_overwrite_max`, the escalation and the
`mode` column — and moves the ceiling. Overwrites are capped per channel, so
the old shape's limit was "how big can one Team be"; roles are capped per
guild at 250, so the new one is "how many Teams can have voice at all". That
is a limit an operator must be told about before they hit it, so the panel
reports it and the pass refuses the create rather than letting Discord do it.

Three things §7.3 named that this codebase does not have, all settled by
asking the operator because nothing in the data model can answer:

  - "the staff role" — there is no staff-role concept anywhere. Now a list of
    role ids the admin designates; empty is a normal answer, since guild
    administrators bypass overwrites and what is really missing is a way to
    let NON-admin staff in.
  - the parent category — §7.3 said the bot creates it and gave the id nowhere
    to live (`team_integrations.team_id` is NOT NULL). The bot creates it and
    the server stores the id in settings.
  - whether the bot can act at all — nothing has ever checked. The operator
    invites the bot by hand and no invite URL with a permission integer exists
    in the tree, so a deployment can be one unticked box from every call
    failing. A preflight is now a PRECONDITION to enabling (422), not a
    per-Team error discovered afterwards.

Two more, decided rather than asked:

  - the threshold counts every active member, not linked ones. §7.3 wrote
    `voice_min_linked_members`; the operator is judging whether a Team is real,
    and link state answers a different question.
  - hidden Teams are never provisioned. A channel name is a game-sourced string
    published outside the site, which is exactly §2.8's concern —
    reservedNames.js already names "and eventually a Discord channel name" as a
    surface it protects — so the screen that suppresses a Team's page suppresses
    its channel, and a Team that becomes hidden takes the grace window.

Turning voice OFF tears nothing down: the pass suspends in both directions and
the panel offers per-row removal. A checkbox must not delete structure in
somebody's guild.

Fixes a phase 8 defect that blocks this phase's own artifact: `npm run swagger`
has been unable to run on `edge` at all. `param('teamId').custom((v) => ... ||
/^[0-9]+$/.test(v))` makes swagger-autogen's parser run away — a regex literal
followed directly by `.test(`. Hoisted to a const, as modules.router.js
already does. Underneath it, `teams.router.js` sits exactly at that parser's
per-file limit: at twenty `teamsRouter.*` statements it dies, at nineteen it
generates, and one more statement of ANY shape tips it — an unannotated route
does, and so does a bare `use`. So the voice routes are their own router file
mounted from `admin/index.js`, and teams.router.js keeps its nineteen.

Also breaks a require cycle this phase would have introduced:
teamSync -> teamVoiceSync -> teams.model -> teamSync left `teams.model` holding
the reconciler's exports object as it stood mid-load — the empty one, since
`module.exports = {…}` replaces rather than fills. The symptom is not in the
new code: it is `teamSync.intervalSeconds is not a function` thrown out of
`syncStatus()`, the freshness banner on every public Team page.

Tests: 1160 server (+40), 53 bot (+21), 284 client (+21). Swagger, routes
manifest and guards regenerated; the guard shape of the four new routes is
byte-identical to the existing admin-only ones.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-18 23:49:28 -05:00
parent d1d56cf847
commit 61abb3ec89
26 changed files with 4214 additions and 4 deletions

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

@@ -0,0 +1,364 @@
// ── The bot's half of Team voice channels (TEAMS.md §7.3, phase 9) ────────
//
// Nothing here talks to Discord. `fakeGuild` records the calls, and the
// assertions are about the four things this side genuinely owns — the ones the
// site cannot decide because it cannot see the guild:
//
// 1. **The overwrite set.** @everyone denied, the Team's role allowed, each
// configured staff role allowed — and a staff role the operator has since
// deleted is FILTERED, because Discord rejects the whole set for one bad id
// and that would take the Team's own grant down with it.
// 2. **The membership diff is bounded and the remainder is reported.** Each
// grant is its own API call; an unbounded first pass on a large guild
// outlives its own timeout, which is the one failure that leaves the site
// not knowing what was applied.
// 3. **A member who linked Discord but never joined the guild is skipped
// silently.** That is §2.6 hop 3 without hop 4 — an ordinary state, not an
// error, and certainly not a hundred log lines.
// 4. **A missing target is success.** A teardown that finds its channel already
// deleted has reached the desired end state; a sync that finds one deleted
// simply creates it again.
const { test } = require('node:test')
const assert = require('node:assert/strict')
const { ChannelType, PermissionFlagsBits } = require('discord.js')
const teamVoice = require('../src/discord/teamVoice')
const EVERYONE = 'guild-everyone'
function fakeMember(id, { canGrant = true } = {}) {
const roles = new Set()
return {
id,
roles: {
cache: roles,
add: async (role) => {
if (!canGrant) throw new Error('Missing Permissions')
roles.add(role.id)
},
remove: async (role) => { roles.delete(role.id) },
},
}
}
function fakeGuild({
members = [],
roles = [],
channels = [],
botPermissions = [PermissionFlagsBits.ManageChannels, PermissionFlagsBits.ManageRoles],
} = {}) {
const memberMap = new Map(members.map((m) => [m.id, m]))
const roleMap = new Map(roles.map((r) => [r.id, r]))
const channelMap = new Map(channels.map((c) => [c.id, c]))
const created = { roles: [], channels: [] }
let nextId = 1000
const guild = {
id: 'guild-1',
created,
roles: {
everyone: { id: EVERYONE },
cache: roleMap,
fetch: async (id) => roleMap.get(id) || null,
create: async (opts) => {
const role = {
id: String(nextId++),
name: opts.name,
members: [],
setName: async (name) => { role.name = name },
delete: async () => { roleMap.delete(role.id) },
}
roleMap.set(role.id, role)
created.roles.push(opts)
return role
},
},
channels: {
cache: channelMap,
fetch: async (id) => channelMap.get(id) || null,
create: async (opts) => {
const channel = {
id: String(nextId++),
name: opts.name,
type: opts.type,
parentId: opts.parent || null,
overwrites: opts.permissionOverwrites || [],
permissionOverwrites: {
set: async (list) => { channel.overwrites = list },
},
setParent: async (parentId) => { channel.parentId = parentId },
setName: async (name) => { channel.name = name },
delete: async () => { channelMap.delete(channel.id) },
}
channelMap.set(channel.id, channel)
created.channels.push(opts)
return channel
},
},
members: {
me: { permissions: { has: (bit) => botPermissions.includes(bit) }, roles: { highest: { position: 7 } } },
cache: memberMap,
fetch: async () => memberMap,
},
}
return guild
}
const fakeClient = (guild) => ({ guilds: { fetch: async () => guild } })
const voiceChannel = (id, over = {}) => {
const channel = {
id,
name: 'The Silver Hand',
type: ChannelType.GuildVoice,
parentId: '500',
overwrites: [],
permissionOverwrites: { set: async (list) => { channel.overwrites = list } },
setParent: async (parentId) => { channel.parentId = parentId },
setName: async (name) => { channel.name = name },
delete: async () => {},
...over,
}
return channel
}
const category = (id = '500') => ({ id, type: ChannelType.GuildCategory })
const role = (id, name = 'The Silver Hand', members = []) => {
const r = {
id,
name,
members,
setName: async (next) => { r.name = next },
delete: async () => {},
}
return r
}
// ── Preflight ──────────────────────────────────────────────────────────────
test('preflight reports both permissions and the guild-wide role count', async () => {
const guild = fakeGuild({ roles: [role('1'), role('2')] })
const result = await teamVoice.preflight(fakeClient(guild), 'guild-1')
assert.equal(result.can_manage_channels, true)
assert.equal(result.can_manage_roles, true)
// The GUILD's roles, not ours. The 250 cap is shared with everything the
// operator made themselves, so counting only ours would promise headroom that
// is not there.
assert.equal(result.role_count, 2)
assert.equal(result.bot_role_position, 7)
})
test('preflight reports a missing permission rather than throwing', async () => {
const guild = fakeGuild({ botPermissions: [PermissionFlagsBits.ManageChannels] })
const result = await teamVoice.preflight(fakeClient(guild), 'guild-1')
assert.equal(result.can_manage_channels, true)
assert.equal(result.can_manage_roles, false)
})
// ── Overwrites ─────────────────────────────────────────────────────────────
test('the overwrite set denies @everyone and allows the Team role', () => {
const guild = fakeGuild()
const list = teamVoice.overwritesFor(guild, role('900'), [])
assert.equal(list.length, 2)
assert.equal(list[0].id, EVERYONE)
assert.deepEqual(list[0].deny, teamVoice.ACCESS_BITS)
assert.equal(list[1].id, '900')
assert.deepEqual(list[1].allow, teamVoice.ACCESS_BITS)
})
test('a configured staff role that still exists gets an allow', () => {
const staff = role('777', 'Moderators')
const guild = fakeGuild({ roles: [staff] })
const list = teamVoice.overwritesFor(guild, role('900'), ['777'])
assert.equal(list.length, 3)
assert.equal(list[2].id, '777')
})
test('a staff role deleted in Discord is skipped, not sent — it would void the whole set', () => {
const guild = fakeGuild({ roles: [] })
const list = teamVoice.overwritesFor(guild, role('900'), ['deleted-1'])
assert.equal(list.length, 2)
assert.ok(!list.some((o) => o.id === 'deleted-1'))
})
// ── Ensure ─────────────────────────────────────────────────────────────────
test('a missing category is created; an existing one is reused', async () => {
const guild = fakeGuild()
const made = await teamVoice.ensureCategory(guild, null)
assert.equal(guild.created.channels.length, 1)
assert.equal(guild.created.channels[0].type, ChannelType.GuildCategory)
const again = await teamVoice.ensureCategory(guild, made.id)
assert.equal(again.id, made.id)
assert.equal(guild.created.channels.length, 1)
})
test('a category id pointing at something that is not a category makes a new one', async () => {
const guild = fakeGuild({ channels: [voiceChannel('700')] })
await teamVoice.ensureCategory(guild, '700')
assert.equal(guild.created.channels.length, 1)
})
test('the Team role is created not mentionable and not hoisted', async () => {
const guild = fakeGuild()
const { role: made, created } = await teamVoice.ensureRole(guild, null, 'The Silver Hand')
assert.equal(created, true)
assert.equal(made.name, 'The Silver Hand')
// A Team with two hundred members must not become a way to ping them all, or a
// second copy of the member list down the sidebar.
assert.equal(guild.created.roles[0].mentionable, false)
assert.equal(guild.created.roles[0].hoist, false)
})
test('a renamed Team renames its role rather than making a second', async () => {
const existing = role('900', 'Old Name')
const guild = fakeGuild({ roles: [existing] })
const { role: made, created } = await teamVoice.ensureRole(guild, '900', 'New Name')
assert.equal(created, false)
assert.equal(made.name, 'New Name')
assert.equal(guild.created.roles.length, 0)
})
test('a rename Discord refuses does not fail the pass — access matters more than a label', async () => {
const existing = role('900', 'Old Name')
existing.setName = async () => { throw new Error('rate limited') }
const guild = fakeGuild({ roles: [existing] })
const { role: made } = await teamVoice.ensureRole(guild, '900', 'New Name')
assert.equal(made.id, '900')
})
test('a channel a human deleted is simply created again', async () => {
const guild = fakeGuild()
const { channel, created } = await teamVoice.ensureChannel(guild, 'gone-1', {
name: 'The Silver Hand', category: category(), role: role('900'), staffRoleIds: [],
})
assert.equal(created, true)
assert.equal(channel.type, ChannelType.GuildVoice)
assert.equal(channel.parentId, '500')
})
test('an existing channel has its overwrites re-asserted every pass', async () => {
const existing = voiceChannel('600')
const guild = fakeGuild({ channels: [existing] })
const { created } = await teamVoice.ensureChannel(guild, '600', {
name: 'The Silver Hand', category: category(), role: role('900'), staffRoleIds: [],
})
assert.equal(created, false)
// Re-setting rather than diffing is what repairs a channel somebody edited by
// hand.
assert.equal(existing.overwrites.length, 2)
})
test('a channel that is no longer a voice channel is left alone and a new one made', async () => {
const text = voiceChannel('600', { type: ChannelType.GuildText })
const guild = fakeGuild({ channels: [text] })
const { channel, created } = await teamVoice.ensureChannel(guild, '600', {
name: 'The Silver Hand', category: category(), role: role('900'), staffRoleIds: [],
})
assert.equal(created, true)
assert.notEqual(channel.id, '600')
})
// ── Membership ─────────────────────────────────────────────────────────────
test('the role is granted to the members the site named', async () => {
const alice = fakeMember('a')
const bob = fakeMember('b')
const guild = fakeGuild({ members: [alice, bob] })
const teamRole = role('900', 'The Silver Hand', [])
const result = await teamVoice.syncRoleMembers(guild, teamRole, ['a', 'b'], 50)
assert.equal(result.added, 2)
assert.equal(result.removed, 0)
assert.equal(result.pending, 0)
})
test('a member who left the Team has the role taken away', async () => {
const alice = fakeMember('a')
const bob = fakeMember('b')
const guild = fakeGuild({ members: [alice, bob] })
const teamRole = role('900', 'The Silver Hand', [alice, bob])
const result = await teamVoice.syncRoleMembers(guild, teamRole, ['a'], 50)
assert.equal(result.added, 0)
assert.equal(result.removed, 1)
})
test('a member who linked Discord but never joined the guild is skipped without an error', async () => {
const guild = fakeGuild({ members: [] })
const result = await teamVoice.syncRoleMembers(guild, role('900', 'x', []), ['not-in-guild'], 50)
assert.equal(result.added, 0)
assert.equal(result.pending, 0)
})
test('the diff is bounded and the remainder is REPORTED, not dropped', async () => {
const members = Array.from({ length: 10 }, (_, i) => fakeMember(`m${i}`))
const guild = fakeGuild({ members })
const result = await teamVoice.syncRoleMembers(guild, role('900', 'x', []), members.map((m) => m.id), 4)
assert.equal(result.added, 4)
assert.equal(result.pending, 6)
})
test('one member the bot cannot touch does not cost the other forty-nine', async () => {
const ok1 = fakeMember('a')
const nope = fakeMember('b', { canGrant: false })
const ok2 = fakeMember('c')
const guild = fakeGuild({ members: [ok1, nope, ok2] })
const result = await teamVoice.syncRoleMembers(guild, role('900', 'x', []), ['a', 'b', 'c'], 50)
assert.equal(result.added, 2)
})
// ── Teardown ───────────────────────────────────────────────────────────────
test('a teardown deletes the channel and the role together', async () => {
const channel = voiceChannel('600')
const teamRole = role('900')
let deletedChannel = false
let deletedRole = false
channel.delete = async () => { deletedChannel = true }
teamRole.delete = async () => { deletedRole = true }
const guild = fakeGuild({ channels: [channel], roles: [teamRole] })
const result = await teamVoice.removeTeamVoice(fakeClient(guild), 'guild-1', { channelId: '600', roleId: '900' })
assert.equal(deletedChannel, true)
assert.equal(deletedRole, true)
assert.equal(result.channel_deleted, true)
assert.equal(result.role_deleted, true)
})
test('a teardown whose target is already gone is success, not a failure to retry forever', async () => {
const guild = fakeGuild({ channels: [], roles: [] })
const result = await teamVoice.removeTeamVoice(fakeClient(guild), 'guild-1', { channelId: 'gone', roleId: 'gone' })
assert.equal(result.channel_deleted, false)
assert.equal(result.role_deleted, false)
})
// ── The whole thing ────────────────────────────────────────────────────────
test('a first sync creates the category, the role and the channel, and grants the members', async () => {
const alice = fakeMember('a')
const guild = fakeGuild({ members: [alice] })
const result = await teamVoice.syncTeamVoice(fakeClient(guild), 'guild-1', {
teamId: 1,
name: 'The Silver Hand',
categoryId: null,
channelId: null,
roleId: null,
staffRoleIds: [],
memberIds: ['a'],
maxMemberOps: 50,
})
assert.equal(result.created.channel, true)
assert.equal(result.created.role, true)
assert.ok(result.category_id)
assert.ok(result.channel_id)
assert.ok(result.role_id)
assert.equal(result.members.added, 1)
})