const discordManager = require('../discord/discordManager') const newsAnnounce = require('../discord/newsAnnounce') const teamNotify = require('../discord/teamNotify') 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 }) } } module.exports = { setConfig, getStatus: getStatusHandler, announce, reverseModAction, refreshCommands, teamNotify: teamNotifyHandler, }