Add Discord bot (moderation, filters, scheduling, roles, invites, site integration)

Standalone bot/ service (its own package.json/Dockerfile) managed entirely
through a new admin-only Discord Bot panel — token stored encrypted in the
DB and pushed to the bot process in-memory, never an env var. Built in
phases, each independently verified against a live Discord guild:

- Bot skeleton: gateway connection, internal shared-secret API, self-heals
  on its own restart by pulling config from the site
- Moderation core: /ban /kick /mute /warn /warnings + mod-log channel
- Word/invite/spam filtering with leetspeak-resistant normalization and a
  staff role/channel allowlist
- Scheduled messages: recurring (cron) and one-off channel posts
- Role assignment: button role menus, auto-role on join, temp roles,
  bulk role ops
- Auto-rotating primary invite with an audit log
- Site integration: news-publish -> Discord announce webhook, manual
  /announce, read-only /wiki search

Also fixes a pre-existing bug in both DB pools (server + bot): the mariadb
driver defaulted to timezone 'local', silently mis-serializing bound Date
params by the host's local offset instead of the DB's UTC session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 15:54:41 -05:00
parent 0318d6fe9f
commit 7a21cc636c
77 changed files with 4800 additions and 3 deletions

View File

@@ -0,0 +1,28 @@
const { query } = require('../../utils/db')
const COLS =
'id, guild_id, bot_token_enc, application_id, enabled, status, status_detail, last_connected_at, updated_by, created_at, updated_at'
// Singleton row (id = 1). Returns null until the admin saves it for the first time.
async function get() {
const rows = await query(`SELECT ${COLS} FROM bot_config WHERE id = 1 LIMIT 1`)
return rows[0] || null
}
// Upsert the singleton row. `fields` are column values already prepared by the
// model (token pre-encrypted). Only the provided columns are written/updated.
async function upsert(fields) {
const cols = Object.keys(fields)
const vals = cols.map((c) => fields[c])
const insertCols = ['id', ...cols].map((c) => `\`${c}\``).join(', ')
const placeholders = ['1', ...cols.map(() => '?')].join(', ')
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
await query(
`INSERT INTO bot_config (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
vals,
)
return get()
}
module.exports = { get, upsert }

View File

@@ -0,0 +1,73 @@
// Discord bot config store (Phase 1). Mirrors the authProviders model split:
// the DB layer only ever sees ciphertext, and only getWithToken() (used
// internally to push config to the bot process / to the bot-config internal
// endpoint) decrypts it. The admin-facing getSafe() never includes the token.
const db = require('./botConfig.db')
const secretBox = require('../../utils/secretBox')
function toSafe(row) {
if (!row) {
return {
guildId: null,
applicationId: null,
enabled: false,
hasToken: false,
status: 'disconnected',
statusDetail: null,
lastConnectedAt: null,
}
}
return {
guildId: row.guild_id || null,
applicationId: row.application_id || null,
enabled: Boolean(row.enabled),
hasToken: Boolean(row.bot_token_enc),
status: row.status || 'disconnected',
statusDetail: row.status_detail || null,
lastConnectedAt: row.last_connected_at || null,
}
}
async function getSafe() {
return toSafe(await db.get())
}
// Decrypted token included — server-side only (pushing config to the bot, or
// serving the shared-secret-gated /internal/bot-config route).
async function getWithToken() {
const row = await db.get()
if (!row) return null
return { ...toSafe(row), token: row.bot_token_enc ? secretBox.decrypt(row.bot_token_enc) : null }
}
// Save admin-supplied config. `token` undefined or '' means "leave the
// existing token unchanged" (same convention as authProviders.save).
async function save({ guildId, applicationId, token, enabled, updatedBy }) {
const fields = {}
if (guildId !== undefined) fields.guild_id = guildId
if (applicationId !== undefined) fields.application_id = applicationId
if (token) fields.bot_token_enc = secretBox.encrypt(token)
if (enabled !== undefined) fields.enabled = enabled ? 1 : 0
if (updatedBy !== undefined) fields.updated_by = updatedBy
const row = await db.upsert(fields)
return toSafe(row)
}
// Mirror the bot's last-reported status into the DB so the admin panel has
// something to show even if the bot is briefly unreachable.
async function recordStatus({ status, statusDetail, lastConnectedAt }) {
const fields = {}
if (status !== undefined) fields.status = status
if (statusDetail !== undefined) fields.status_detail = statusDetail
// lastConnectedAt arrives over HTTP as a JSON-serialized ISO string (e.g.
// "2026-07-04T18:49:51.429Z") — MariaDB's DATETIME parser rejects the "T"/
// "Z"/milliseconds in that format. Convert to a real Date so the mariadb
// driver formats it correctly on the wire.
if (lastConnectedAt !== undefined) fields.last_connected_at = lastConnectedAt ? new Date(lastConnectedAt) : null
if (Object.keys(fields).length === 0) return getSafe()
const row = await db.upsert(fields)
return toSafe(row)
}
module.exports = { getSafe, getWithToken, save, recordStatus }