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:
28
server/src/model/botConfig/botConfig.db.js
Normal file
28
server/src/model/botConfig/botConfig.db.js
Normal 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 }
|
||||
73
server/src/model/botConfig/botConfig.model.js
Normal file
73
server/src/model/botConfig/botConfig.model.js
Normal 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 }
|
||||
Reference in New Issue
Block a user