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