const discordManager = require('../discord/discordManager') const newsAnnounce = require('../discord/newsAnnounce') const teamNotify = require('../discord/teamNotify') const teamVoice = require('../discord/teamVoice') const modLog = require('../discord/modLog') const createLogger = require('../utils/logger') const log = createLogger('internal') const REVERSIBLE = new Set(['ban', 'mute']) // discord.js REST error code for removing a ban that no longer exists. const UNKNOWN_BAN = 10026 // POST /internal/config — called by the main server right after an admin // saves the Discord Bot panel, and by the bot's own bootstrap on startup // (via a GET to the server for the current config, then this same start/stop // logic locally). Body: { token, guildId, enabled }. async function setConfig(req, res) { const { token, guildId, enabled } = req.body || {} try { if (enabled) { if (!token || !guildId) { return res.status(400).json({ message: 'token and guildId are required when enabled' }) } await discordManager.start({ token, guildId }) } else { await discordManager.stop() } return res.json(discordManager.getStatus()) } catch (err) { log.error('setConfig failed', { message: err.message }) // Still 200 with an error status — the caller (admin panel) should surface // discordManager's status/statusDetail rather than treat this as a 5xx. return res.json(discordManager.getStatus()) } } // GET /internal/status — live connection state, polled by the admin panel. function getStatusHandler(req, res) { return res.json(discordManager.getStatus()) } // POST /internal/announce — called by the main server right after a news // post is published. Body: { title, excerpt, url, imageUrl }. async function announce(req, res) { const connection = discordManager.getConnection() if (!connection) return res.status(503).json({ message: 'Bot is not connected' }) try { await newsAnnounce.postAnnounce(connection.client, connection.guildId, req.body || {}) return res.json({ posted: true }) } catch (err) { log.warn('announce failed', { message: err.message }) return res.status(400).json({ message: err.message }) } } // POST /internal/mod-reverse — called by the main server when a staffer APPROVES // a moderation appeal (Phase 6d). Body: { discord_user_id, action_type, appeal_id }. // Reverses the Discord action: 'ban' → lift the ban, 'mute' → clear the timeout. // Idempotent-friendly: an already-lifted ban ("Unknown Ban") or a member who has // left the guild is treated as success (the desired end state already holds). async function reverseModAction(req, res) { const { discord_user_id: discordUserId, action_type: actionType, appeal_id: appealId } = req.body || {} if (!REVERSIBLE.has(actionType)) { return res.status(400).json({ message: 'action_type must be ban or mute' }) } const connection = discordManager.getConnection() if (!connection) return res.status(503).json({ message: 'Bot is not connected' }) const reason = `Appeal #${appealId} approved` try { const guild = await connection.client.guilds.fetch(connection.guildId) if (actionType === 'ban') { try { await guild.bans.remove(discordUserId, reason) } catch (err) { // Unknown Ban → already unbanned; anything else is a real failure. if (err.code !== UNKNOWN_BAN) throw err } } else { // mute: clear the timeout. If the member has left, there's nothing to clear. const member = await guild.members.fetch(discordUserId).catch(() => null) if (member) await member.timeout(null, reason) } await modLog.postReversal({ client: connection.client, guildId: connection.guildId, actionType, discordUserId, appealId, }) return res.json({ reversed: true }) } catch (err) { log.error('mod-reverse failed', { message: err.message, actionType, discordUserId }) return res.status(500).json({ message: err.message }) } } // POST /internal/refresh-commands — the app's nudge that its registered // slash-command set has moved (TEAMS.md §7.1). No body: the bot re-pulls // `/internal/commands` and re-registers only if the set actually changed, so the // nudge stays a cheap thing the app can send on every module state change. // // Deliberately its OWN endpoint rather than riding on /internal/config, which // carries the decrypted bot token: saying "commands changed" should not require // the app to read a secret out of the database. // // Answers 200 even when disconnected — there is no application to register // against until the bot logs in, and `ready` pulls again anyway. A 5xx here // would make an ordinary module install look like a failure in the admin panel. async function refreshCommands(req, res) { try { const result = await discordManager.refreshCommands() return res.json({ ok: true, ...result }) } catch (err) { log.error('refresh-commands failed', { message: err.message }) return res.json({ ok: false, error: err.message }) } } // POST /internal/team-notify — a Team notification the site has already decided // belongs in a channel (TEAMS.md §7.2). Body: { channel_id, stream, team_name, // team_url, title, body, url }. // // **The site chose the channel and the site checked the access.** Whether // members-only forum text may reach this channel is an acknowledgement recorded // against team_integration_config, and re-deciding it here would mean the bot // holding a copy of a policy it cannot see the inputs to. // // 503 when disconnected and 400 for a channel the bot cannot post to, matching // /internal/announce — the caller is one-shot and best-effort and only logs the // difference, but an operator debugging a silent channel needs the two to read // differently in the bot's log. async function teamNotifyHandler(req, res) { const connection = discordManager.getConnection() if (!connection) return res.status(503).json({ message: 'Bot is not connected' }) const { channel_id: channelId, stream, team_name: teamName, team_url: teamUrl, title, body, url } = req.body || {} if (!channelId || !stream) { return res.status(400).json({ message: 'channel_id and stream are required' }) } try { await teamNotify.postTeamNotification(connection.client, { channelId, stream, teamName, teamUrl, title, body, url }) return res.json({ posted: true }) } catch (err) { log.warn('team-notify failed', { message: err.message, stream, channelId }) return res.status(400).json({ message: err.message }) } } // ── Voice channels (TEAMS.md §7.3, phase 9) ──────────────────────────────── // GET /internal/team-voice/preflight — can this bot do the job at all? // // Its own endpoint, and the app asks it BEFORE letting an operator switch voice // on. §7.3 assumed the bot could manage channels and roles; nothing in this // project has ever checked, because the operator invites the bot by hand and // there is no invite URL with a permission integer anywhere in the tree. Without // this the first symptom of an unticked box is every Team recording its own // identical error, which reads like forty problems instead of one. async function voicePreflight(req, res) { const connection = discordManager.getConnection() if (!connection) return res.status(503).json({ connected: false, message: 'Bot is not connected' }) try { return res.json(await teamVoice.preflight(connection.client, connection.guildId)) } catch (err) { log.warn('voice preflight failed', { message: err.message }) return res.status(400).json({ connected: true, message: err.message }) } } // POST /internal/team-voice/sync — make one Team's channel, role and role // membership match what the site sent. // // The site sends DESIRED STATE and this works out the calls, which is the // opposite of the split every other endpoint here uses. The decisions are all // still the site's; what is here is the comparison against live guild state, // which only this process can see. async function voiceSync(req, res) { const connection = discordManager.getConnection() if (!connection) return res.status(503).json({ message: 'Bot is not connected' }) const { team_id: teamId, name, category_id: categoryId, channel_id: channelId, role_id: roleId, staff_role_ids: staffRoleIds, member_ids: memberIds, max_member_ops: maxMemberOps, } = req.body || {} if (!name) return res.status(400).json({ message: 'name is required' }) try { const result = await teamVoice.syncTeamVoice(connection.client, connection.guildId, { teamId, name, categoryId: categoryId || null, channelId: channelId || null, roleId: roleId || null, staffRoleIds: Array.isArray(staffRoleIds) ? staffRoleIds.map(String) : [], memberIds: Array.isArray(memberIds) ? memberIds.map(String) : [], maxMemberOps: Number(maxMemberOps) > 0 ? Number(maxMemberOps) : 50, }) return res.json(result) } catch (err) { // 400 rather than 500, matching /internal/announce: from the app's side this // is "Discord refused", which is a condition it records against the Team and // retries next pass — not a bug in this process. log.warn('voice sync failed', { message: err.message, teamId, name }) return res.status(400).json({ message: err.message }) } } // POST /internal/team-voice/remove — the grace window expired, or an admin said so. async function voiceRemove(req, res) { const connection = discordManager.getConnection() if (!connection) return res.status(503).json({ message: 'Bot is not connected' }) const { channel_id: channelId, role_id: roleId } = req.body || {} try { const result = await teamVoice.removeTeamVoice(connection.client, connection.guildId, { channelId, roleId }) return res.json(result) } catch (err) { log.warn('voice remove failed', { message: err.message, channelId, roleId }) return res.status(400).json({ message: err.message }) } } module.exports = { setConfig, getStatus: getStatusHandler, announce, reverseModAction, refreshCommands, teamNotify: teamNotifyHandler, voicePreflight, voiceSync, voiceRemove, }