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