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>
186 lines
7.7 KiB
JavaScript
186 lines
7.7 KiB
JavaScript
// Tiny fetch wrapper for calling the bot process's /internal/* API (shared
|
|
// secret, same pattern as requireInternalKey on both sides). Used by the
|
|
// Discord Bot admin controller to push config after a save and to poll live
|
|
// status for the admin panel. Never throws — callers get { ok: false, error }
|
|
// on any failure (bot unreachable, timeout, non-2xx) so an admin save/poll
|
|
// never 500s just because the bot container is down or restarting.
|
|
const log = require('./logger')('bot-internal-client')
|
|
|
|
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, timeoutMs = TIMEOUT_MS } = {}) {
|
|
const controller = new AbortController()
|
|
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
|
try {
|
|
const res = await fetch(`${BASE_URL}${path}`, {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Internal-Key': KEY,
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
signal: controller.signal,
|
|
})
|
|
if (!res.ok) {
|
|
// Include the numeric status + parsed body (if any) so callers — e.g. the
|
|
// announcement worker — can distinguish 503 (bot down, retry) from a config
|
|
// error. Non-JSON bodies just leave `data` null.
|
|
let data = null
|
|
try {
|
|
data = await res.json()
|
|
} catch {
|
|
// ignore — body already reported via status
|
|
}
|
|
return { ok: false, status: res.status, data, error: `bot responded ${res.status}` }
|
|
}
|
|
return { ok: true, status: res.status, data: await res.json() }
|
|
} catch (err) {
|
|
log.warn('bot internal call failed', { path, message: err.message })
|
|
return { ok: false, status: 0, error: err.message }
|
|
} finally {
|
|
clearTimeout(timeout)
|
|
}
|
|
}
|
|
|
|
// Push a config change (start/stop the bot's Discord client).
|
|
function pushConfig({ token, guildId, enabled }) {
|
|
return call('/internal/config', { method: 'POST', body: { token, guildId, enabled } })
|
|
}
|
|
|
|
// Live connection status, for the admin panel.
|
|
function getStatus() {
|
|
return call('/internal/status')
|
|
}
|
|
|
|
// Site -> bot: a news post was published, post it to the configured #news
|
|
// channel. Fire-and-forget from the caller's perspective — never throws, so
|
|
// a bot outage never breaks publishing a post.
|
|
function announce({ title, excerpt, url, imageUrl }) {
|
|
return call('/internal/announce', { method: 'POST', body: { title, excerpt, url, imageUrl } })
|
|
}
|
|
|
|
// Site -> bot: an approved appeal wants the underlying Discord action reversed
|
|
// (unban for a 'ban', clear the timeout for a 'mute'). Best-effort like every
|
|
// call here — never throws, so an approved appeal still resolves when the bot is
|
|
// down (the caller records reversal_status='failed' from `ok:false`).
|
|
function reverseModAction({ discordUserId, actionType, appealId }) {
|
|
return call('/internal/mod-reverse', {
|
|
method: 'POST',
|
|
body: { discord_user_id: discordUserId, action_type: actionType, appeal_id: appealId },
|
|
})
|
|
}
|
|
|
|
// Site -> bot: the registered slash-command set has moved, re-pull it
|
|
// (TEAMS.md §7.1). Best-effort like everything else here — a bot that is down
|
|
// re-pulls on its next `ready` anyway, so a missed nudge costs nothing but the
|
|
// delay until the bot reconnects.
|
|
//
|
|
// Its own endpoint rather than a field on pushConfig, whose body carries the
|
|
// DECRYPTED bot token: telling the bot that a module changed should not require
|
|
// reading a secret out of the database.
|
|
function refreshCommands() {
|
|
return call('/internal/refresh-commands', { method: 'POST', body: {} })
|
|
}
|
|
|
|
// Site -> bot: a Team notification the operator has configured a channel for
|
|
// (TEAMS.md §7.2). Best-effort and one-shot, unlike `announce`: a news post is a
|
|
// durable artifact whose Discord copy is expected to exist, so it rides the
|
|
// announce_jobs retry; a Team notification is the moment it describes, and a
|
|
// message that lands twenty minutes late is worse than one that never lands.
|
|
//
|
|
// The channel is chosen by the SITE and passed in, not looked up by the bot from
|
|
// guild_config the way `announce` finds #news. Which channel a Team's events go
|
|
// to is per-Team configuration that lives in team_integration_config, and a bot
|
|
// that resolved it would need a second copy of that table.
|
|
function teamNotify({ channelId, streamId, teamName, teamUrl, title, body, url }) {
|
|
return call('/internal/team-notify', {
|
|
method: 'POST',
|
|
body: { channel_id: channelId, stream: streamId, team_name: teamName, team_url: teamUrl, title, body, url },
|
|
})
|
|
}
|
|
|
|
// ── 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,
|
|
}
|