// What Admin → Teams → Voice channels decides (TEAMS.md §7.3, phase 9). // // Extracted for the reason `teamIntegrations.js` is: the interesting parts are // decisions — when the panel refuses to let voice be switched on, how close the // guild is to running out of roles, what a row's state actually means to the // person reading it — and a decision written inline in JSX is one nothing can // assert on. // // **These rules MIRROR the server's and do not replace them.** The server refuses // to enable voice while the bot cannot manage channels and roles (422) whether or // not this file ever ran, and the reconciler applies the threshold and the grace // window regardless of what the screen says. What is here is so the screen agrees // with those answers before making the round trip. /** Wording for each state the server can report on a row. */ export const STATE_LABELS = { none: 'Not provisioned', active: 'Active', pending_removal: 'Scheduled for removal', error: 'Error', } export const stateLabel = (state) => STATE_LABELS[state] || state || 'Unknown' /** * Is the panel allowed to offer the enable switch? * * The preflight answers three separate questions and they fail differently: the * bot is not connected at all, it is connected but missing a permission, or it * could not be reached. An operator can act on each of those and they need * different actions, so the reason is passed through rather than flattened to a * boolean. */ export function enableBlockedReason(preflight) { if (!preflight) return 'The bot’s status is unknown.' if (!preflight.connected) return preflight.reason || 'The Discord bot is not connected.' if (preflight.missingPermissions && preflight.missingPermissions.length > 0) { return `The bot is missing ${preflight.missingPermissions.join(' and ')} in this guild.` } if (!preflight.ready) return preflight.reason || 'The bot cannot manage channels and roles yet.' return null } // Below this many free roles the panel starts saying so. Not a server rule and // deliberately not one: it is a warning, and the server's only hard behaviour is // to refuse the create that would exceed the cap. const HEADROOM_WARNING = 25 /** * How much room is left, and whether to say something about it. * * The 250-role cap is the ceiling this phase's shape brings with it. Access is a * per-Team role, so it is not "how big can a Team be" — the old overwrite design's * limit — but "how many Teams can have voice at all", and the difference matters * to an operator with sixty guilds on their shard. It is guild-wide and shared * with every role they created themselves, which is why the count comes from the * bot rather than from core's own rows. */ export function roleHeadroom(preflight) { if (!preflight || !preflight.roleCap) return null const used = Number(preflight.roleCount) || 0 const cap = Number(preflight.roleCap) const free = Math.max(0, cap - used) return { used, cap, free, tight: free <= HEADROOM_WARNING, exhausted: free === 0 } } /** How a row's grace window reads while it is running. */ export function removalCountdown(row, now = new Date()) { if (!row || row.state !== 'pending_removal' || !row.removeAfter) return null const ms = new Date(row.removeAfter).getTime() - now.getTime() if (ms <= 0) return 'due for removal on the next pass' const days = Math.floor(ms / 86400000) if (days >= 1) return `in ${days} day${days === 1 ? '' : 's'}` const hours = Math.max(1, Math.round(ms / 3600000)) return `in ${hours} hour${hours === 1 ? '' : 's'}` } /** * Parse the staff-role field an operator types. * * Comma-separated ids, because that is what a person copying role ids out of * Discord ends up with. Validated rather than filtered, mirroring the server: a * quietly dropped id is a settings screen showing a save that did not happen. */ export function parseStaffRoles(text) { const parts = String(text || '') .split(',') .map((part) => part.trim()) .filter(Boolean) const bad = parts.filter((part) => !/^[0-9]{5,32}$/.test(part)) return { roles: parts, invalid: bad } } export const formatStaffRoles = (roles) => (roles || []).join(', ') /** * The sentence under the enable switch, which changes meaning with the state. * * "Off" is not "nothing is provisioned": switching voice off suspends the * reconciler in BOTH directions and leaves existing channels in place, which is * deliberate — a checkbox must not delete structure in somebody's guild — but it * is also surprising unless the screen says so. */ export function statusSummary(settings, rows) { const provisioned = (rows || []).filter((row) => row.channelRef).length if (!settings || !settings.enabled) { return provisioned > 0 ? `Off. ${provisioned} channel${provisioned === 1 ? '' : 's'} remain in Discord and are no longer being kept in step — remove them below if they are not wanted.` : 'Off. No channels are provisioned.' } return `On. Teams with at least ${settings.minMembers} member${settings.minMembers === 1 ? '' : 's'} get a voice channel and a role; ${provisioned} provisioned.` }