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