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