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