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

@@ -34,6 +34,7 @@ const teamProvider = require('./teamProvider')
const moderation = require('./teamModeration.model')
const activity = require('./teamActivity.model')
const teamNotify = require('../../utils/teamNotify')
const teamVoiceSync = require('../../utils/teamVoiceSync')
const { slugify, uniqueSlug } = require('./teamSlug')
const settings = require('../settings/settings.model')
const log = require('../../utils/logger')('teams')
@@ -423,6 +424,14 @@ async function runOnce(reason) {
const rehidden = await moderation.rescreen(moduleId)
await teamsDb.recordSuccess(moduleId)
// §7.3's "after a successful Team reconcile": the voice reconciler runs off the
// projection this run just refreshed. Requested rather than awaited — it makes
// Discord calls, and a roster sync must never be slowed down, failed or held
// open by an integration hanging off it. It has its own debounce and its own
// suspensions (including the stale check, which is why it re-reads the state
// this run just wrote rather than trusting that it was called from a good one).
teamVoiceSync.request({ reason: 'reconcile' })
log.info('reconcile complete', {
trigger: reason, created, renamed, archived, rosters, rehidden, total: answer.teams.length,
})

View File

@@ -0,0 +1,201 @@
// SQL for per-Team external resources — today, the Discord voice channel
// (TEAMS.md §7.3, phase 9).
//
// Two queries carry the phase. `desiredTeams` is what SHOULD have a channel and
// `holdersWithoutClaim` is what HAS one and should not; the reconciler is the
// difference between them, and keeping both as single queries is what stops a
// pass from being one round trip per Team before it has made a single Discord
// call.
//
// **`discordSubjectsFor` is the whole identity chain in one statement** (§2.6):
// team_members → users → user_identities. A member with no site account has no
// row to join, and a member with a site account but no Discord identity drops out
// at the second join — which is exactly right, because a role can only be granted
// to somebody Discord knows about. Nothing else in the phase is allowed to
// shortcut this with `teams.linked_count`, which counts hop 1 and is always the
// larger number.
const { query } = require('../../utils/db')
// The provider id a Discord identity is stored under. Matches `auth_providers.id`
// and the built-in provider in `auth/providers/discord.provider.js`; a constant
// rather than a literal because it appears in two queries and a typo in either
// would silently return an empty grant set — a Team whose channel nobody can
// enter, with no error anywhere.
const DISCORD_PROVIDER = 'discord'
const COLUMNS = `
i.id, i.team_id, i.platform, i.resource, i.external_ref, i.role_ref,
i.state, i.remove_after, i.last_error, i.synced_at, i.updated_at`
/**
* Every Team that qualifies for a resource, with its integration row if it has
* one.
*
* The three conditions are §7.3's provisioning gate and §2.8's publication rule
* together:
*
* - `status = 'active'` — an archived Team is a record, not a place to talk.
* - `hidden = 0` — the channel is NAMED after the Team, and a Discord channel
* name is a game-sourced string published outside the site. A hidden Team's
* name is suppressed on every public surface; a voice channel would be the
* one place it still appeared.
* - `member_count >= ?` — the operator's threshold, counting ALL active members
* regardless of what they have linked (org lead, 2026-08-18). §7.3 wrote
* `voice_min_linked_members`; the number an operator is actually judging is
* "is this Team real", and link state answers a different question.
*
* LEFT JOIN rather than two queries: the reconciler needs "should have, and does
* it" as one answer, and a Team with no row yet is the create case.
*/
async function desiredTeams({ platform, resource, minMembers }) {
return query(
`SELECT t.id AS team_id, t.name, t.display_name_override, t.slug, t.abbr,
t.member_count, t.linked_count, ${COLUMNS}
FROM teams t
LEFT JOIN team_integrations i
ON i.team_id = t.id AND i.platform = ? AND i.resource = ?
WHERE t.status = 'active' AND t.hidden = 0 AND t.member_count >= ?
ORDER BY t.id`,
[platform, resource, Number(minMembers)],
)
}
/**
* Rows that hold a resource for a Team that no longer qualifies.
*
* The mirror of `desiredTeams`, and deliberately not its negation in JavaScript:
* a Team can stop qualifying by being archived, by being hidden, by losing
* members, or by having its row deleted out from under core, and enumerating
* those in a filter would mean re-deriving the gate in a second place that could
* disagree with the first.
*
* Rows already in 'pending_removal' are included — the grace window is decided by
* the caller, which needs to see them to know whether one has expired.
*/
async function holdersWithoutClaim({ platform, resource, minMembers }) {
return query(
`SELECT t.id AS team_id, t.name, t.display_name_override, t.status, t.hidden,
t.member_count, ${COLUMNS}
FROM team_integrations i
JOIN teams t ON t.id = i.team_id
WHERE i.platform = ? AND i.resource = ?
AND (i.external_ref IS NOT NULL OR i.role_ref IS NOT NULL)
AND (t.status <> 'active' OR t.hidden = 1 OR t.member_count < ?)
ORDER BY t.id`,
[platform, resource, Number(minMembers)],
)
}
/**
* The Discord user ids of a Team's members — hop 3 of §2.6, and the only set a
* role can be granted to.
*
* DISTINCT because a user could in principle hold two rows for the same provider
* across a provider rename; the unique key prevents it for one (provider,
* subject) pair, not for one user with two subjects. Two role-adds for the same
* person is harmless and one duplicate in a diff is a phantom removal next pass,
* which is not.
*/
async function discordSubjectsFor(teamId) {
const rows = await query(
`SELECT DISTINCT ui.subject
FROM team_members m
JOIN user_identities ui ON ui.user_id = m.user_id AND ui.provider = ?
WHERE m.team_id = ? AND m.status = 'active' AND m.user_id IS NOT NULL
ORDER BY ui.subject`,
[DISCORD_PROVIDER, Number(teamId)],
)
return rows.map((row) => String(row.subject))
}
/** Every row for a platform, with the Team's name — the admin panel's listing. */
async function listForPlatform(platform, resource) {
return query(
`SELECT ${COLUMNS}, t.name AS team_name, t.slug AS team_slug,
t.display_name_override, t.status AS team_status, t.hidden AS team_hidden,
t.member_count, t.linked_count
FROM team_integrations i
JOIN teams t ON t.id = i.team_id
WHERE i.platform = ? AND i.resource = ?
ORDER BY t.name`,
[platform, resource],
)
}
async function getForTeam(teamId, platform, resource) {
const rows = await query(
`SELECT ${COLUMNS} FROM team_integrations i
WHERE i.team_id = ? AND i.platform = ? AND i.resource = ? LIMIT 1`,
[Number(teamId), platform, resource],
)
return rows[0] || null
}
/**
* Write what the reconciler believes after a pass.
*
* A full upsert of the mutable columns rather than a patch, because every caller
* has just decided all of them together: a pass that created a channel knows the
* state, the refs, the error (none) and the stamp, and letting it write three of
* the four would leave the fourth describing a previous pass.
*
* `remove_after` is written explicitly on every call, `NULL` included — a Team
* that climbs back above the threshold inside its window has to have the window
* cleared, and an upsert that skipped NULLs would leave it armed.
*/
async function upsert({ teamId, platform, resource, externalRef, roleRef, state, removeAfter, lastError, syncedAt }) {
await query(
`INSERT INTO team_integrations
(team_id, platform, resource, external_ref, role_ref, state, remove_after, last_error, synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
external_ref = VALUES(external_ref),
role_ref = VALUES(role_ref),
state = VALUES(state),
remove_after = VALUES(remove_after),
last_error = VALUES(last_error),
synced_at = VALUES(synced_at)`,
[
Number(teamId),
platform,
resource,
externalRef || null,
roleRef || null,
state,
removeAfter || null,
lastError ? String(lastError).slice(0, 500) : null,
syncedAt || null,
],
)
return getForTeam(teamId, platform, resource)
}
async function remove(teamId, platform, resource) {
const res = await query(
'DELETE FROM team_integrations WHERE team_id = ? AND platform = ? AND resource = ?',
[Number(teamId), platform, resource],
)
return Number(res && res.affectedRows) || 0
}
/** How many rows currently hold a role — the input to the 250-role ceiling. */
async function roleCount(platform) {
const rows = await query(
'SELECT COUNT(*) AS n FROM team_integrations WHERE platform = ? AND role_ref IS NOT NULL',
[platform],
)
return Number(rows[0] && rows[0].n) || 0
}
module.exports = {
DISCORD_PROVIDER,
desiredTeams,
holdersWithoutClaim,
discordSubjectsFor,
listForPlatform,
getForTeam,
upsert,
remove,
roleCount,
}

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

View File

@@ -0,0 +1,256 @@
// ── The operator's voice controls ──────────────────────────────────────────
//
// TEAMS.md §7.3, phase 9. Five `settings` keys, in their own file for the reason
// `teamForumSettings` is: two of them are not ordinary keys. `teams_voice_enabled`
// has a server-side precondition (the bot must actually be able to manage channels
// and roles — §7.3 assumed it could and the tree has never checked), and
// `teams_voice_category_ref` is written by the SERVER after the bot reports what
// it created, not by the admin who is looking at the form.
//
// teams_voice_enabled '0' | '1' default '0' — off
// teams_voice_min_members 1 … 10000 default 5
// teams_voice_grace_days 0 … 90 default 7
// teams_voice_category_ref a channel id absent until the bot makes one
// teams_voice_staff_roles CSV of role ids empty by default
//
// **Every read fails closed**, the same bargain the forum settings take: a DB
// fault reports voice off, which costs a pass that does nothing and is repeated
// fifteen minutes later. Failing open would mean creating guild structure on the
// strength of a query that did not answer.
//
// **`teams_voice_staff_roles` exists because "the staff role" does not.** §7.3
// grants the staff role an overwrite on every Team channel; this codebase has no
// staff-role concept at all — `guild_config` knows a news channel, a modlog
// channel, an autorole and a filter allowlist, and none of them means "staff".
// Guild administrators bypass channel overwrites anyway, so what is actually
// missing is a way to let NON-admin staff in, and only the operator can say which
// of their Discord roles those are. Empty is a legitimate and common answer.
const settingsDb = require('../settings/settings.db')
const ENABLED_KEY = 'teams_voice_enabled'
const MIN_MEMBERS_KEY = 'teams_voice_min_members'
const GRACE_DAYS_KEY = 'teams_voice_grace_days'
const CATEGORY_KEY = 'teams_voice_category_ref'
const STAFF_ROLES_KEY = 'teams_voice_staff_roles'
const MIN_MEMBERS_DEFAULT = 5
const MIN_MEMBERS_MAX = 10000
const GRACE_DAYS_DEFAULT = 7
const GRACE_DAYS_MAX = 90
// Discord's guild-wide role cap. It is the ceiling on how many Teams can have
// voice at all, and it is here rather than in the bot because the admin panel has
// to be able to say "you are at 231 of 250" BEFORE a create fails — §7.3's error
// state per Team is a diagnosis, not a warning.
//
// The number is Discord's and core cannot read it; a guild that gets a different
// one is a guild where this warns early, which is the harmless direction.
const ROLE_CAP = 250
// The same shape `teamIntegration.model` validates a channel with. Core treats
// every Discord id as opaque and only checks it could be one.
const SNOWFLAKE_RE = /^[0-9]{5,32}$/
/** Is voice provisioning switched on? Fail closed. */
async function enabled() {
try {
return String(await settingsDb.get(ENABLED_KEY)) === '1'
} catch {
return false
}
}
/**
* The membership threshold, counting every active member (org lead, 2026-08-18).
*
* Fails closed to the DEFAULT rather than to zero, unlike the forum's edit window:
* zero here would mean "provision every Team including the one-person ones", which
* is the expensive direction against a 250-role cap. The default is the
* conservative answer, not the permissive one.
*/
async function minMembers() {
try {
const raw = await settingsDb.get(MIN_MEMBERS_KEY)
if (raw == null || raw === '') return MIN_MEMBERS_DEFAULT
const n = Number(raw)
if (!Number.isFinite(n) || n < 1 || n > MIN_MEMBERS_MAX) return MIN_MEMBERS_DEFAULT
return Math.floor(n)
} catch {
return MIN_MEMBERS_DEFAULT
}
}
/**
* How long a Team keeps its channel after it stops qualifying (§7.3).
*
* `0` is legitimate and means "remove on the next pass" — an operator who would
* rather not have stale channels lying about. A DB fault reports the default, so a
* transient error can never turn the window off and delete something early; the
* grace window's whole job is to not act in a hurry.
*/
async function graceDays() {
try {
const raw = await settingsDb.get(GRACE_DAYS_KEY)
if (raw == null || raw === '') return GRACE_DAYS_DEFAULT
const n = Number(raw)
if (!Number.isFinite(n) || n < 0 || n > GRACE_DAYS_MAX) return GRACE_DAYS_DEFAULT
return Math.floor(n)
} catch {
return GRACE_DAYS_DEFAULT
}
}
/**
* The parent category every Team channel is created under, or null.
*
* Not an admin field. The bot creates the category on the first pass that needs
* one and reports the id back; the server stores it here so the next pass reuses
* it instead of making a second. An operator who wants a different category
* deletes this value (or the category) and the next pass makes a fresh one — which
* is why it is exposed read-only in the panel with a clear button rather than as a
* text input somebody could point at a channel that is not a category.
*/
async function categoryRef() {
try {
const value = await settingsDb.get(CATEGORY_KEY)
const text = String(value || '').trim()
return SNOWFLAKE_RE.test(text) ? text : null
} catch {
return null
}
}
async function setCategoryRef(value, actorId = null) {
const text = String(value || '').trim()
if (text && !SNOWFLAKE_RE.test(text)) throw new Error('category ref must be a numeric channel id')
return settingsDb.set(CATEGORY_KEY, text || null, actorId)
}
/**
* The roles that see every Team voice channel, in addition to that Team's own.
*
* Stored as CSV for the same reason `filter_allow_roles` is — `settings.value` is
* a VARCHAR and a JSON array in it buys nothing when the elements are numeric ids.
* Unreadable entries are DROPPED rather than rejected on read: a hand-edited row
* with one bad id should cost that id, not every staff grant on the deployment.
*/
async function staffRoles() {
try {
const raw = await settingsDb.get(STAFF_ROLES_KEY)
return parseRoles(raw)
} catch {
return []
}
}
function parseRoles(raw) {
return String(raw || '')
.split(',')
.map((part) => part.trim())
.filter((part) => SNOWFLAKE_RE.test(part))
}
/**
* Validate an operator-supplied staff-role list on the way IN, where a typo can
* still be reported to the person who made it.
*
* Rejected rather than filtered, the same call the bridge's event list makes: a
* silently-dropped id is a settings screen that shows you saved something you did
* not, and a role that was supposed to see every Team channel and does not is a
* failure nobody would think to look for.
*/
function normaliseStaffRoles(input) {
const parts = Array.isArray(input)
? input
: String(input == null ? '' : input).split(',')
const seen = []
for (const part of parts) {
const id = String(part || '').trim()
if (!id) continue
if (!SNOWFLAKE_RE.test(id)) {
const err = new Error(`not a role id: ${id}`)
err.status = 400
throw err
}
if (!seen.includes(id)) seen.push(id)
}
return seen
}
/** Everything the reconciler and the admin panel both need, in one read. */
async function all() {
const [on, min, grace, category, staff] = await Promise.all([
enabled(), minMembers(), graceDays(), categoryRef(), staffRoles(),
])
return {
enabled: on,
minMembers: min,
graceDays: grace,
categoryRef: category,
staffRoles: staff,
roleCap: ROLE_CAP,
}
}
/**
* Persist an admin's save. Returns the settings as they now read, so the panel
* renders what was stored rather than what was typed.
*
* The enable PRECONDITION is not here: it needs the bot, and a settings module
* that reached across to another process to validate a write would be impossible
* to test and surprising to read. The controller asks the bot and refuses, in the
* same shape §7.2's acknowledgement refuses — 422 before the write, never a quiet
* failure after it.
*/
async function save({ enabled: on, minMembers: min, graceDays: grace, staffRoles: staff }, actorId = null) {
const next = {}
if (on !== undefined) next[ENABLED_KEY] = on ? '1' : '0'
if (min !== undefined) {
const n = Number(min)
if (!Number.isInteger(n) || n < 1 || n > MIN_MEMBERS_MAX) {
const err = new Error(`minimum members must be between 1 and ${MIN_MEMBERS_MAX}`)
err.status = 400
throw err
}
next[MIN_MEMBERS_KEY] = String(n)
}
if (grace !== undefined) {
const n = Number(grace)
if (!Number.isInteger(n) || n < 0 || n > GRACE_DAYS_MAX) {
const err = new Error(`the grace window must be between 0 and ${GRACE_DAYS_MAX} days`)
err.status = 400
throw err
}
next[GRACE_DAYS_KEY] = String(n)
}
if (staff !== undefined) next[STAFF_ROLES_KEY] = normaliseStaffRoles(staff).join(',')
for (const [key, value] of Object.entries(next)) {
// eslint-disable-next-line no-await-in-loop
await settingsDb.set(key, value, actorId)
}
return all()
}
module.exports = {
ENABLED_KEY,
MIN_MEMBERS_KEY,
GRACE_DAYS_KEY,
CATEGORY_KEY,
STAFF_ROLES_KEY,
MIN_MEMBERS_DEFAULT,
GRACE_DAYS_DEFAULT,
ROLE_CAP,
enabled,
minMembers,
graceDays,
categoryRef,
setCategoryRef,
staffRoles,
parseRoles,
normaliseStaffRoles,
all,
save,
}

View File

@@ -32,6 +32,7 @@ const discordBotRouter = require('./discordBot.router')
const settingsRouter = require('./settings.router')
const modulesRouter = require('./modules.router')
const teamsRouter = require('./teams.router')
const teamsVoiceRouter = require('./teamsVoice.router')
const dashboardRouter = require('./dashboard.router')
const adminRouter = express.Router()
@@ -84,6 +85,16 @@ adminRouter.use('/modules', modulesRouter)
// queue. The three actions that PUBLISH untrusted game-sourced strings are gated
// per request inside the controller, not per route — a moderator may call them,
// and calling them files a request rather than applying one (TEAMS.md §2.9).
// Voice channels (TEAMS.md §7.3, phase 9) are mounted at the more specific prefix
// FIRST, so /teams/voice/* never reaches the teams router's `/:id`.
//
// They live out here rather than inside `teams.router.js` beside the bridge they
// belong with, for a mechanical reason worth recording: that file sits exactly at
// swagger-autogen's per-file limit. At twenty `teamsRouter.*` statements
// `npm run swagger` dies with "invalid array length — heap out of memory"; at
// nineteen it generates. One more statement of any shape tips it, a mount
// included, so the mount is here and the file keeps its nineteen.
adminRouter.use('/teams/voice', teamsVoiceRouter)
adminRouter.use('/teams', teamsRouter)
// The two singletons that own no path segment of their own: GET /dashboard and

View File

@@ -17,6 +17,9 @@ const forumDb = require('../../../model/teams/teamForum.db')
const forumUploadsModel = require('../../../model/teams/teamForumUploads.model')
const forumSettings = require('../../../model/teams/teamForumSettings.model')
const integration = require('../../../model/teams/teamIntegration.model')
const voice = require('../../../model/teams/teamVoice.model')
const voiceSettings = require('../../../model/teams/teamVoiceSettings.model')
const voiceSync = require('../../../utils/teamVoiceSync')
const log = require('../../../utils/logger')('teams')
@@ -347,7 +350,124 @@ async function deleteIntegrationConfig(req, res) {
}
}
// ── Voice channels (§7.3, phase 9) — admin only ────────────────────────────
//
// Admin-only for the same reason the bridge is: this creates and destroys
// structure in somebody's Discord guild, which is deployment configuration and
// not the kind of decision §2.9 files a request for.
/**
* Everything the panel renders, in one call: the settings, the live rows, and
* the bot's own answer about whether it can do the job.
*
* The preflight is here rather than behind a separate endpoint the panel polls,
* because it is not a detail — an operator whose bot lacks Manage Roles has a
* screen full of controls that cannot work, and finding that out needs to be the
* first thing on the page rather than the result of pressing something.
*/
async function voiceConfig(req, res) {
try {
const [config, rows, flight] = await Promise.all([
voiceSettings.all(),
voice.list(),
// Never fatal: a bot container that is down must not take the settings
// screen with it, since fixing the settings may be exactly why the operator
// came. `preflight` already turns every failure into a `ready: false`.
voiceSync.preflight().catch((err) => ({ ready: false, connected: false, reason: err.message })),
])
return res.json({
platform: voice.PLATFORM,
settings: config,
preflight: flight,
rows,
lastPass: voiceSync.lastPass(),
})
} catch (err) {
return fail(res, err, 'voice config')
}
}
/**
* Save the settings, with one precondition.
*
* **Switching voice ON is refused 422 while the bot cannot act.** The same shape
* §7.2's acknowledgement takes, and for the same reason: a setting that saves and
* then quietly does nothing is worse than one that will not save. Turning it OFF
* is never gated — an operator disabling a feature because it is misbehaving must
* not be blocked by the misbehaviour.
*/
async function saveVoiceConfig(req, res) {
try {
const turningOn = req.body.enabled === true && !(await voiceSettings.enabled())
if (turningOn) {
const flight = await voiceSync.preflight()
if (!flight.ready) {
return res.status(422).json({
message: flight.reason || 'the bot cannot manage channels and roles in this guild yet',
code: 'voice_preflight_failed',
preflight: flight,
})
}
}
const config = await voiceSettings.save(req.body, req.user.id)
await activity.log({
req,
action: 'team.voice.settings',
detail:
`${req.user.username} (#${req.user.id}) saved the Team voice settings: `
+ `${config.enabled ? 'enabled' : 'disabled'}, minimum ${config.minMembers} members, `
+ `${config.graceDays}-day grace window, ${config.staffRoles.length} staff role(s)`,
})
// A save that just switched it on should not wait fifteen minutes for the
// first channel to appear.
if (config.enabled) voiceSync.request({ reason: 'settings saved' })
return res.json(config)
} catch (err) {
if (err.status) return res.status(err.status).json({ message: err.message, code: err.code })
return fail(res, err, 'save voice config')
}
}
/** Run a pass now, awaited, so the operator gets the outcome and not a promise. */
async function voicePass(req, res) {
try {
return res.json(await voiceSync.passNow('admin'))
} catch (err) {
return fail(res, err, 'voice pass')
}
}
/**
* Remove one Team's channel and role now, ignoring the grace window.
*
* The window exists to stop churn on a Team crossing the threshold twice in a
* week; an operator pressing remove is not churn. It is also the only way to
* clean up while voice is switched off, which is the one state where no pass will
* ever reach the row.
*/
async function removeVoice(req, res) {
try {
const teamId = Number(req.params.teamId)
const result = await voiceSync.removeNow(teamId)
if (!result.ok) return res.status(result.status || 400).json({ message: result.message })
await activity.log({
req,
action: 'team.voice.remove',
detail: `${req.user.username} (#${req.user.id}) removed the voice channel and role for Team #${teamId}`,
})
return res.json({ ok: true })
} catch (err) {
return fail(res, err, 'remove voice')
}
}
module.exports = {
voiceConfig,
saveVoiceConfig,
voicePass,
removeVoice,
integrationConfig,
saveIntegrationConfig,
deleteIntegrationConfig,

View File

@@ -27,6 +27,14 @@ const teamsRouter = express.Router()
// where a Team's events leave the site for is not the §2.9 kind of decision a
// moderator files a request for; it is deployment configuration, and it sits with
// the role that already holds the bot token.
// Hoisted rather than written inline, and it has to stay that way: a regex
// LITERAL followed directly by `.test(` makes swagger-autogen's static parser run
// away, and `npm run swagger` dies with "invalid array length — heap out of
// memory" instead of generating a spec. Phase 8 shipped it inline and left the
// generator unable to run at all; the same regex reached through a const (the
// idiom `modules.router.js` already uses) parses fine.
const TEAM_ID = /^[0-9]+$/
const adminOnly = requireRole('admin')
// ── Literal paths, first ───────────────────────────────────────────────────
@@ -173,7 +181,7 @@ teamsRouter.delete(
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
/* #swagger.responses[404] = { description: 'Nothing configured for that Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
param('teamId').custom((v) => v === 'default' || /^[0-9]+$/.test(v)),
param('teamId').custom((v) => v === 'default' || TEAM_ID.test(v)),
validate,
ctrl.deleteIntegrationConfig,
)

View File

@@ -0,0 +1,93 @@
// Admin · Teams · Voice channels (TEAMS.md §7.3, phase 9).
//
// Mounted at /api/v1/admin/teams/voice by `admin/index.js`, which has already
// applied `noindex, isLoggedIn, staffOnly` above it — and which mounts this
// prefix BEFORE `/teams`, so these paths never reach the teams router's `/:id`.
// Every route here adds `adminOnly` on top: this creates and destroys structure
// in somebody's Discord guild, which is deployment configuration and not the §2.9
// kind of decision a moderator files a request for.
//
// **Its own file for a mechanical reason, and the reason is worth recording.**
// These four routes belong beside the notification bridge's three in
// `teams.router.js`, and they started there. That file sits exactly at
// swagger-autogen's per-file limit: at twenty `teamsRouter.*` statements
// `npm run swagger` dies with "invalid array length — heap out of memory", and at
// nineteen it generates. ONE more statement of any shape tips it — a route with no
// annotations at all does, and so does a bare `use`, which is why the mount is in
// `admin/index.js` rather than here in the file it logically belongs to. The same
// probe route added to `discordBot.router.js` generates fine, so the limit is
// per-file and not tree-wide.
//
// So: if this file grows, split it again rather than moving it back.
const express = require('express')
const { body, param } = require('express-validator')
const ctrl = require('./teams.controller')
const validate = require('../../../middleware/validate')
const { requireRole } = require('../../../utils/auth')
const voiceRouter = express.Router()
const adminOnly = requireRole('admin')
// `/sync` before `/:teamId`, the same first-match-wins rule the parent file
// follows: a `:teamId` declared first would turn the pass into a lookup for a Team
// whose id is "sync".
voiceRouter.get(
'/',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Team voice channel configuration and state (admin only)'
// #swagger.description = 'The settings, every provisioned channel with its state and last error, and the bots own preflight — whether it is connected, whether it holds Manage Channels and Manage Roles, and how close the guild is to Discords cap of 250 roles. Access is granted with a role per Team, so that cap is the ceiling on how many Teams can have voice at all.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Voice configuration and state', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamVoiceConfig" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
ctrl.voiceConfig,
)
voiceRouter.put(
'/',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Save the Team voice settings (admin only)'
// #swagger.description = 'Switching voice on is refused 422 while the bot cannot manage channels and roles in the guild — a setting that saves and then quietly does nothing is worse than one that will not save. Switching it off is never gated, and never tears anything down: existing channels stop being reconciled and are removed one at a time by an operator who means it.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The saved settings', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamVoiceSettings" } } } } */
/* #swagger.responses[422] = { description: 'The bot cannot manage channels or roles yet', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('enabled').optional().isBoolean().toBoolean(),
body('minMembers').optional().isInt({ min: 1, max: 10000 }).toInt(),
body('graceDays').optional().isInt({ min: 0, max: 90 }).toInt(),
body('staffRoles').optional({ nullable: true }),
validate,
ctrl.saveVoiceConfig,
)
voiceRouter.post(
'/sync',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Run a voice reconciliation now (admin only)'
// #swagger.description = 'Awaited, so the response carries the outcome. The three suspensions still apply — a manual pass will not run while voice is off, while the Team projection is stale, or while the bot cannot act — and the response says which one stopped it.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The pass result', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamVoicePassResult" } } } } */
adminOnly,
ctrl.voicePass,
)
voiceRouter.delete(
'/:teamId',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Remove one Teams voice channel and role (admin only)'
// #swagger.description = 'Immediate, ignoring the grace window: the window exists to stop churn on a Team that crosses the threshold twice in a week, and an operator pressing remove is not churn. The channel and the role go together — a role for a channel that no longer exists is a badge for nowhere.'
// #swagger.parameters['teamId'] = { in: 'path', required: true, schema: { type: 'integer' } }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
/* #swagger.responses[404] = { description: 'That Team has no voice channel', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
param('teamId').isInt({ min: 1 }).toInt(),
validate,
ctrl.removeVoice,
)
module.exports = voiceRouter

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