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

View File

@@ -10,9 +10,9 @@ const BASE_URL = process.env.BOT_INTERNAL_URL || 'http://localhost:4100'
const KEY = process.env.BOT_INTERNAL_KEY || ''
const TIMEOUT_MS = 4000
async function call(path, { method = 'GET', body } = {}) {
async function call(path, { method = 'GET', body, timeoutMs = TIMEOUT_MS } = {}) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetch(`${BASE_URL}${path}`, {
method,
@@ -101,4 +101,85 @@ function teamNotify({ channelId, streamId, teamName, teamUrl, title, body, url }
})
}
module.exports = { pushConfig, getStatus, announce, reverseModAction, refreshCommands, teamNotify }
// ── Voice channels (TEAMS.md §7.3, phase 9) ────────────────────────────────
//
// **These three take a longer budget than everything above.** The default 4s is
// sized for "post a message" and "read a status"; one voice pass for one Team can
// create a role, create a channel, write its overwrites and then apply up to
// MEMBER_OPS_PER_PASS role grants, each of which is its own Discord call under its
// own rate limit. Timing out mid-pass is the one failure that leaves core not
// knowing what was applied, so the budget is generous and the WORK is bounded
// instead — the caller caps the operations per pass and the bot reports what it
// could not finish.
const VOICE_TIMEOUT_MS = 30000
/**
* Does the bot have what §7.3 needs? Asked BEFORE an operator can switch voice
* on, and again at the start of every pass.
*
* §7.3 assumed the bot could manage channels and roles. Nothing in this codebase
* has ever checked: the operator invites the bot by hand and no invite URL with a
* permission integer exists anywhere in the tree, so a deployment can be one
* unticked box away from every call failing. Answering that question early turns
* a per-Team `state='error'` discovered later into a refusal the operator reads
* while they are still looking at the setting.
*/
function voicePreflight() {
return call('/internal/team-voice/preflight')
}
/**
* Bring one Team's channel, role and role membership to the state core wants.
*
* Core sends the desired state and the bot works out the calls, which is the
* opposite of the split everywhere else in this file — and it is deliberate. The
* DECISIONS are all core's (who qualifies, who may enter, what it is called); the
* diff is not a decision, it is a comparison against live guild state that only
* the bot can see, and doing it here would mean shipping the whole guild's role
* membership over the wire to compare it and shipping the answer back.
*/
function voiceSync({ teamId, name, categoryRef, channelRef, roleRef, staffRoleRefs, memberRefs, maxMemberOps }) {
return call('/internal/team-voice/sync', {
method: 'POST',
timeoutMs: VOICE_TIMEOUT_MS,
body: {
team_id: teamId,
name,
category_id: categoryRef || null,
channel_id: channelRef || null,
role_id: roleRef || null,
staff_role_ids: staffRoleRefs || [],
member_ids: memberRefs || [],
max_member_ops: maxMemberOps,
},
})
}
/**
* Delete a Team's channel and role after the grace window.
*
* Both refs in one call because they are one lifecycle: a teardown that removed
* the channel and left the role would leave every member wearing a badge for a
* place that no longer exists. Either may already be gone — the bot treats a
* missing target as success, since the desired end state holds.
*/
function voiceRemove({ channelRef, roleRef }) {
return call('/internal/team-voice/remove', {
method: 'POST',
timeoutMs: VOICE_TIMEOUT_MS,
body: { channel_id: channelRef || null, role_id: roleRef || null },
})
}
module.exports = {
pushConfig,
getStatus,
announce,
reverseModAction,
refreshCommands,
teamNotify,
voicePreflight,
voiceSync,
voiceRemove,
VOICE_TIMEOUT_MS,
}

View File

@@ -0,0 +1,406 @@
// ── The integration reconciler ─────────────────────────────────────────────
//
// TEAMS.md §7.3, phase 9. One pass: read what core wants, ask the bot to make
// Discord match, write down what happened. It rides the Team reconciler — §7.3's
// "after a successful Team reconcile" — because the input to every decision here
// is the projection that reconcile just refreshed.
//
// **It never destroys anything on data core does not trust.** Three suspensions,
// and they are the whole reason this file is careful:
//
// 1. Voice switched off → the pass does not run AT ALL, in either
// direction. A toggle must not delete guild
// structure; see `teamVoice.model.plan`.
// 2. The projection is stale → skip entirely (§7.3, verbatim). A sidecar that
// has been down for an hour reports rosters core
// cannot vouch for, and "every Team lost its
// members" is exactly what that looks like from
// here. A voice channel is never destroyed
// because a sidecar was down.
// 3. The bot cannot act → skip, and say why once. Missing ManageChannels
// is not forty Teams each failing individually;
// it is one deployment misconfiguration, and
// writing it into forty `last_error` columns
// would bury the one fact that matters.
//
// **Failures are per-Team and never abort the pass.** One Team whose channel a
// human deleted, or whose name Discord rejected, records `state='error'` with the
// message and is retried next pass; the other Teams are reconciled normally. This
// is the same shape as the Team reconciler's gate 3 and for the same reason — one
// Team's problem is not the other Teams' problem.
//
// **Nothing here throws.** It is a background job hanging off another background
// job; a rejection would surface as an unhandled rejection in a timer rather than
// as anything an operator could act on. What an operator can act on is in
// `team_integrations.last_error` and in this module's `lastPass()`.
const voice = require('../model/teams/teamVoice.model')
const settings = require('../model/teams/teamVoiceSettings.model')
const botClient = require('./botInternalClient')
const log = require('./logger')('team-voice')
// At most one pass per 30s, matching the Team reconciler's debounce. Every Team
// reconcile asks for a pass and a flapping sidecar can produce a run a second;
// without this, so could this.
const DEBOUNCE_MS = 30_000
let running = false
let rerun = false
let lastRunAt = 0
let debounceTimer = null
// What the last pass concluded, for the admin panel. In process rather than in a
// table on purpose: it describes a run, not a fact about the deployment, and a
// restart genuinely does invalidate it. `team_integrations` is where the durable
// answers live.
let lastPassResult = { at: null, ran: false, reason: 'no pass has run yet' }
const lastPass = () => lastPassResult
/**
* Ask the bot whether it can do this at all.
*
* Returns the bot's own answer plus a `ready` verdict, so the two callers — this
* pass and the admin controller's enable precondition — cannot disagree about
* what "ready" means by each deciding it themselves.
*/
async function preflight() {
const res = await botClient.voicePreflight()
if (!res || !res.ok) {
return {
ready: false,
connected: false,
reason: res && res.status === 503 ? 'the bot is not connected to Discord' : 'the bot could not be reached',
detail: (res && res.error) || null,
}
}
const data = res.data || {}
const missing = []
if (!data.can_manage_channels) missing.push('Manage Channels')
if (!data.can_manage_roles) missing.push('Manage Roles')
return {
ready: missing.length === 0 && !!data.connected,
connected: !!data.connected,
missingPermissions: missing,
// The guild's REAL role count, not core's count of the roles it made. The
// 250-role cap is guild-wide and shared with every role the operator created
// themselves, so counting only ours would promise headroom that is not there.
roleCount: Number(data.role_count) || 0,
roleCap: settings.ROLE_CAP,
botRolePosition: Number(data.bot_role_position) || 0,
guildId: data.guild_id || null,
reason: missing.length ? `the bot is missing ${missing.join(' and ')} in this guild` : null,
}
}
/**
* Provision or update one Team, and write down the result.
*
* The category comes in as an argument and can come back changed: the bot creates
* the `Teams` category on the first pass that needs one, and the id it reports is
* persisted by the caller. §7.3 said the bot creates it and gave the id nowhere to
* live — `team_integrations.team_id` is NOT NULL, so it cannot be a row in there —
* so it lands in settings, written by the server rather than typed by an admin.
*/
async function syncOne(item, { categoryRef, staffRoles }) {
const teamId = item.team.team_id
const memberRefs = await voice.memberRefs(teamId)
const res = await botClient.voiceSync({
teamId,
name: item.name,
categoryRef,
channelRef: item.team.external_ref,
roleRef: item.team.role_ref,
staffRoleRefs: staffRoles,
memberRefs,
maxMemberOps: voice.MEMBER_OPS_PER_PASS,
})
if (!res || !res.ok) {
const message = (res && res.data && res.data.message) || (res && res.error) || 'the bot could not be reached'
// The refs already on the row are preserved rather than cleared. A failed pass
// is core failing to CONFIRM the channel, not learning it is gone — clearing
// them would orphan a real channel and make the next pass create a second one.
await voice.record({
teamId,
channelRef: item.team.external_ref,
roleRef: item.team.role_ref,
state: 'error',
lastError: message,
})
log.warn('voice sync failed for a team', { teamId, name: item.name, message })
return { ok: false, teamId, message }
}
const data = res.data || {}
await voice.record({
teamId,
channelRef: data.channel_id || null,
roleRef: data.role_id || null,
state: 'active',
// Clearing the window is what "the removal is cancelled" means for a Team that
// climbed back above the threshold inside it.
removeAfter: null,
lastError: null,
syncedAt: new Date(),
})
if (item.recovering) {
log.info('voice removal cancelled; the team qualifies again', { teamId, name: item.name })
}
return {
ok: true,
teamId,
created: !!(data.created && (data.created.channel || data.created.role)),
categoryRef: data.category_id || categoryRef,
pendingMemberOps: Number(data.members && data.members.pending) || 0,
}
}
/** Tear one down after its window expired. */
async function removeOne(entry) {
const teamId = entry.team.team_id
const res = await botClient.voiceRemove({
channelRef: entry.team.external_ref,
roleRef: entry.team.role_ref,
})
if (!res || !res.ok) {
const message = (res && res.data && res.data.message) || (res && res.error) || 'the bot could not be reached'
await voice.record({
teamId,
channelRef: entry.team.external_ref,
roleRef: entry.team.role_ref,
state: 'error',
// The window stays EXPIRED rather than being pushed out. A teardown that
// failed should be retried on the next pass, not granted another seven days
// every time it fails.
removeAfter: entry.team.remove_after,
lastError: message,
})
log.warn('voice teardown failed', { teamId, message })
return { ok: false, teamId, message }
}
// The row goes with the resources. It exists to track a channel and a role, and
// a row tracking neither is a row that means nothing; a Team that qualifies
// again gets a fresh one.
await voice.forget(teamId)
log.info('voice channel removed', { teamId, reason: entry.reason })
return { ok: true, teamId }
}
/**
* One full pass. Callers use `request()`; this is the body it guards.
*/
async function runOnce(reason) {
const plan = await voice.plan()
if (!plan) return { ran: false, reason: 'voice channels are switched off' }
// Suspension 2 (§7.3, verbatim): never on stale data.
//
// **Required here, inside the function, and it must stay that way.** The Team
// reconciler requires this module and `teams.model` requires the Team
// reconciler, so a top-level require closes the cycle
// teamSync → teamVoiceSync → teams.model → teamSync. Node resolves that by
// handing `teams.model` the reconciler's exports object as it stood mid-load,
// which is the empty one — `module.exports = {…}` at the bottom of that file
// REPLACES the object rather than filling it, so the binding never catches up.
// The visible symptom is not here: it is `teamSync.intervalSeconds is not a
// function` thrown out of `syncStatus()`, which is the freshness banner on every
// public Team page.
// eslint-disable-next-line global-require
const teams = require('../model/teams/teams.model')
const sync = await teams.syncStatus()
if (sync.stale) {
return { ran: false, reason: 'the team projection is stale; nothing was created, changed or removed' }
}
// Suspension 3.
const flight = await preflight()
if (!flight.ready) {
return { ran: false, reason: flight.reason || 'the bot cannot manage channels or roles', preflight: flight }
}
let categoryRef = plan.config.categoryRef
let created = 0
let synced = 0
let failed = 0
let pendingMemberOps = 0
for (const item of plan.provision) {
// The cap is checked per Team rather than once, because every create consumes
// one and a pass that provisions ten Teams from a headroom of three has to
// stop after the third — not discover it in Discord's rejection.
if (!item.team.role_ref && flight.roleCount + created >= flight.roleCap) {
// eslint-disable-next-line no-await-in-loop
await voice.record({
teamId: item.team.team_id,
channelRef: item.team.external_ref,
roleRef: null,
state: 'error',
lastError: `this guild is at Discord's limit of ${flight.roleCap} roles, so no role could be created for this team`,
})
failed += 1
continue
}
// eslint-disable-next-line no-await-in-loop
const result = await syncOne(item, { categoryRef, staffRoles: plan.config.staffRoles })
if (!result.ok) {
failed += 1
continue
}
synced += 1
if (result.created) created += 1
pendingMemberOps += result.pendingMemberOps
if (result.categoryRef && result.categoryRef !== categoryRef) {
categoryRef = result.categoryRef
// eslint-disable-next-line no-await-in-loop
await settings.setCategoryRef(categoryRef).catch((err) => {
// Not fatal, but loud: the next pass would create a SECOND category and
// the guild would slowly fill with them.
log.error('the voice category id could not be stored; the next pass may create another', {
categoryRef, message: err.message,
})
})
}
}
for (const entry of plan.scheduled) {
// eslint-disable-next-line no-await-in-loop
await voice.record({
teamId: entry.team.team_id,
channelRef: entry.team.external_ref,
roleRef: entry.team.role_ref,
state: 'pending_removal',
removeAfter: entry.removeAfter,
lastError: null,
syncedAt: entry.team.synced_at,
})
log.info('voice channel scheduled for removal', {
teamId: entry.team.team_id, reason: entry.reason, removeAfter: entry.removeAfter,
})
}
let removed = 0
for (const entry of plan.removals) {
// eslint-disable-next-line no-await-in-loop
const result = await removeOne(entry)
if (result.ok) removed += 1
else failed += 1
}
const summary = {
ran: true,
reason,
synced,
created,
scheduled: plan.scheduled.length,
removed,
failed,
pendingMemberOps,
}
log.info('voice pass complete', summary)
// A Team whose membership diff was truncated is not finished. Asking for
// another pass is what makes a bounded pass converge rather than leave the
// remainder until the next reconcile fifteen minutes later.
if (pendingMemberOps > 0) rerun = true
return summary
}
/** Run now, awaited, with the lock held. The admin "sync now" button uses this. */
async function passNow(reason = 'manual') {
if (running) {
rerun = true
return { ran: false, reason: 'a pass is already running', joined: true }
}
running = true
try {
const result = await runOnce(reason)
lastRunAt = Date.now()
lastPassResult = { at: new Date(), ...result }
return result
} catch (err) {
log.error('voice pass threw', { message: err.message, reason })
lastPassResult = { at: new Date(), ran: false, reason: err.message }
return { ran: false, reason: err.message }
} finally {
running = false
if (rerun) {
rerun = false
request({ reason: 'continuation' })
}
}
}
/**
* Ask for a pass. Returns immediately and never rejects — this is what the Team
* reconciler calls, and a voice channel must never be able to slow down or fail
* the roster sync it hangs off.
*/
function request({ reason = 'reconcile' } = {}) {
if (debounceTimer) return
if (running) {
rerun = true
return
}
const since = Date.now() - lastRunAt
if (since >= DEBOUNCE_MS) {
passNow(reason).catch(() => {})
return
}
debounceTimer = setTimeout(() => {
debounceTimer = null
passNow(reason).catch(() => {})
}, DEBOUNCE_MS - since)
// Unreffed, like every other background timer here: a pending pass must not
// hold a shutdown open.
if (typeof debounceTimer.unref === 'function') debounceTimer.unref()
}
/**
* Remove one Team's resources on an admin's say-so, ignoring the grace window.
*
* The window exists to stop CHURN — a Team crossing the threshold twice in a week
* should not lose its channel id — and an operator clicking remove is not churn.
* They also need this when voice has been switched off, which is the one state
* where no pass will ever reach the row.
*/
async function removeNow(teamId) {
const row = await voice.getForTeam(teamId)
if (!row) return { ok: false, status: 404, message: 'this team has no voice channel' }
const result = await removeOne({ team: { ...row, team_id: teamId }, reason: 'admin' })
if (!result.ok) return { ok: false, status: 502, message: result.message }
return { ok: true }
}
function stop() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = null
}
// Test-only: module-level scheduling state has to be resettable between tests.
function _reset() {
stop()
running = false
rerun = false
lastRunAt = 0
lastPassResult = { at: null, ran: false, reason: 'no pass has run yet' }
}
module.exports = {
DEBOUNCE_MS,
preflight,
passNow,
request,
removeNow,
lastPass,
stop,
_reset,
// Exported for the reconciler's tests, which drive a pass directly rather than
// through the debounce.
runOnce,
}