Files
website/bot/src/internal/internal.controller.js
wtclaude 11b4368b57
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / bot-tests (pull_request) Successful in 33s
PR Checks / server-tests (pull_request) Successful in 10m49s
feat(teams): phase 8 — the notifications bridge, and the gate §7.2 could not check
The same Team event as §6, delivered a third time: push, email, and now a
Discord channel the operator configured. Not a second pipeline — teamNotify.js
already computed the recipient set once, so the bridge is a sink beside the two
that were there.

The design's gate has no data source. §7.2 bridges an event only if "its
visibility is public, or its destination channel is configured for a
members-only Team context". The four team.* streams carry no visibility; forum
threads have no public/members column because a forum is members-only by
construction; and core cannot see a Discord channel's permissions. So §7.2's own
example config names exactly the two events that are never public.

The gate is therefore an attributed operator acknowledgement, in the shape
teams_forum_uploads_ack already uses. It is a precondition — 422, not a quiet
drop at delivery — it is re-asked at delivery as well as at the save, and
changing the channel clears it, because an acknowledgement is about a
destination and cannot survive the destination changing underneath it.

The design's DDL cannot hold its own default row: MariaDB coerces every PRIMARY
KEY column to NOT NULL, so `team_id NULL` — the deployment-wide default every
override overrides — is unrepresentable. Proved on a real MariaDB (error 1048).
Replaced with a surrogate id, a generated team_key AS IFNULL(team_id, 0) in the
unique key, and the foreign key the original had no room for.

One-shot, not queued: "identical to announce and mod-reverse" names two
different reliability models, and a Team notification is the moment it
describes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 20:25:30 -05:00

164 lines
6.6 KiB
JavaScript

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