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>
316 lines
12 KiB
JavaScript
316 lines
12 KiB
JavaScript
// 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,
|
|
}
|