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:
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,
|
||||
}
|
||||
Reference in New Issue
Block a user