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,40 @@
// Roles/channels that bypass word/invite/spam filtering entirely (staff roles,
// bot-commands channels, etc.). Stored as CSV in guild_config rather than a
// separate table — short, rarely-changed lists.
const guildConfig = require('./guildConfig')
const ROLES_KEY = 'filter_allow_roles'
const CHANNELS_KEY = 'filter_allow_channels'
function parseCsv(value) {
return value ? value.split(',').filter(Boolean) : []
}
async function getRoles(guildId) {
return parseCsv(await guildConfig.get(guildId, ROLES_KEY))
}
async function getChannels(guildId) {
return parseCsv(await guildConfig.get(guildId, CHANNELS_KEY))
}
// Toggle: adds the id if absent, removes it if present. Returns the new state (true = now allowed).
async function toggleRole(guildId, roleId) {
const roles = await getRoles(guildId)
const idx = roles.indexOf(roleId)
if (idx === -1) roles.push(roleId)
else roles.splice(idx, 1)
await guildConfig.set(guildId, ROLES_KEY, roles.join(','))
return idx === -1
}
async function toggleChannel(guildId, channelId) {
const channels = await getChannels(guildId)
const idx = channels.indexOf(channelId)
if (idx === -1) channels.push(channelId)
else channels.splice(idx, 1)
await guildConfig.set(guildId, CHANNELS_KEY, channels.join(','))
return idx === -1
}
module.exports = { getRoles, getChannels, toggleRole, toggleChannel }

View File

@@ -0,0 +1,22 @@
const db = require('../db')
async function add({ guildId, word, severity, addedBy, addedByTag }) {
await db.query(
`INSERT INTO filter_words (guild_id, word, severity, added_by, added_by_tag)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE severity = VALUES(severity), added_by = VALUES(added_by), added_by_tag = VALUES(added_by_tag)`,
[guildId, word.toLowerCase(), severity || 'delete', addedBy || null, addedByTag || null],
)
}
// Returns true if a row was actually removed.
async function remove(guildId, word) {
const res = await db.query('DELETE FROM filter_words WHERE guild_id = ? AND word = ?', [guildId, word.toLowerCase()])
return Number(res.affectedRows || 0) > 0
}
async function list(guildId) {
return db.query('SELECT word, severity FROM filter_words WHERE guild_id = ? ORDER BY word ASC', [guildId])
}
module.exports = { add, remove, list }

View File

@@ -0,0 +1,47 @@
// Per-guild key/value config the bot owns (see guild_config in
// server/db/schema.sql). Generic get/set now; filters/schedules/role-menu
// config reuses this same table in later phases.
const db = require('../db')
const MOD_LOG_CHANNEL_KEY = 'mod_log_channel_id'
const AUTO_ROLE_KEY = 'auto_role_id'
const INVITE_CHANNEL_KEY = 'invite_channel_id'
const NEWS_CHANNEL_KEY = 'news_channel_id'
async function get(guildId, key) {
const rows = await db.query('SELECT value FROM guild_config WHERE guild_id = ? AND `key` = ? LIMIT 1', [guildId, key])
return rows[0] ? rows[0].value : null
}
async function set(guildId, key, value) {
await db.query(
`INSERT INTO guild_config (guild_id, \`key\`, value) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE value = VALUES(value)`,
[guildId, key, value],
)
}
const getModLogChannelId = (guildId) => get(guildId, MOD_LOG_CHANNEL_KEY)
const setModLogChannelId = (guildId, channelId) => set(guildId, MOD_LOG_CHANNEL_KEY, channelId)
const getAutoRoleId = (guildId) => get(guildId, AUTO_ROLE_KEY)
const setAutoRoleId = (guildId, roleId) => set(guildId, AUTO_ROLE_KEY, roleId)
const getInviteChannelId = (guildId) => get(guildId, INVITE_CHANNEL_KEY)
const setInviteChannelId = (guildId, channelId) => set(guildId, INVITE_CHANNEL_KEY, channelId)
const getNewsChannelId = (guildId) => get(guildId, NEWS_CHANNEL_KEY)
const setNewsChannelId = (guildId, channelId) => set(guildId, NEWS_CHANNEL_KEY, channelId)
module.exports = {
get,
set,
getModLogChannelId,
setModLogChannelId,
getAutoRoleId,
setAutoRoleId,
getInviteChannelId,
setInviteChannelId,
getNewsChannelId,
setNewsChannelId,
}

View File

@@ -0,0 +1,29 @@
const db = require('../db')
async function record({ guildId, channelId, inviteCode, triggeredBy, triggeredByTag }) {
const res = await db.query(
`INSERT INTO invite_log (guild_id, channel_id, invite_code, triggered_by, triggered_by_tag)
VALUES (?, ?, ?, ?, ?)`,
[guildId, channelId, inviteCode, triggeredBy || null, triggeredByTag || null],
)
return res.insertId
}
// The active (not-yet-revoked) invite for a guild, if any.
async function getCurrent(guildId) {
const rows = await db.query(
'SELECT * FROM invite_log WHERE guild_id = ? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1',
[guildId],
)
return rows[0] || null
}
async function markRevoked(id) {
await db.query('UPDATE invite_log SET revoked_at = NOW() WHERE id = ?', [id])
}
async function list(guildId, limit = 10) {
return db.query('SELECT * FROM invite_log WHERE guild_id = ? ORDER BY created_at DESC LIMIT ?', [guildId, limit])
}
module.exports = { record, getCurrent, markRevoked, list }

View File

@@ -0,0 +1,17 @@
const db = require('../db')
async function add({ guildId, channelId, messageId, mapping, createdBy }) {
await db.query(
`INSERT INTO role_menus (guild_id, channel_id, message_id, mapping, created_by)
VALUES (?, ?, ?, ?, ?)`,
[guildId, channelId, messageId, JSON.stringify(mapping), createdBy || null],
)
}
async function getByMessageId(messageId) {
const rows = await db.query('SELECT * FROM role_menus WHERE message_id = ? LIMIT 1', [messageId])
if (!rows[0]) return null
return { ...rows[0], mapping: JSON.parse(rows[0].mapping) }
}
module.exports = { add, getByMessageId }

View File

@@ -0,0 +1,57 @@
const db = require('../db')
async function addRecurring({ guildId, channelId, content, cronExpression, createdBy, createdByTag }) {
const res = await db.query(
`INSERT INTO scheduled_messages (guild_id, channel_id, content, cron_expression, created_by, created_by_tag)
VALUES (?, ?, ?, ?, ?, ?)`,
[guildId, channelId, content, cronExpression, createdBy || null, createdByTag || null],
)
return res.insertId
}
async function addOnce({ guildId, channelId, content, runAt, createdBy, createdByTag }) {
const res = await db.query(
`INSERT INTO scheduled_messages (guild_id, channel_id, content, run_at, created_by, created_by_tag)
VALUES (?, ?, ?, ?, ?, ?)`,
[guildId, channelId, content, runAt, createdBy || null, createdByTag || null],
)
return res.insertId
}
// Returns true if a row was actually removed (scoped to the guild so one
// guild can't remove another's rows).
async function remove(guildId, id) {
const res = await db.query('DELETE FROM scheduled_messages WHERE id = ? AND guild_id = ?', [id, guildId])
return Number(res.affectedRows || 0) > 0
}
async function list(guildId) {
return db.query(
`SELECT id, channel_id, content, cron_expression, run_at, enabled, sent_at FROM scheduled_messages
WHERE guild_id = ? ORDER BY id ASC`,
[guildId],
)
}
// All enabled recurring rows across every guild the bot serves — v1 only
// ever has one, but the scheduler doesn't need to special-case that.
async function listEnabledRecurring() {
return db.query(
`SELECT id, guild_id, channel_id, content, cron_expression FROM scheduled_messages
WHERE cron_expression IS NOT NULL AND enabled = 1`,
)
}
// One-off rows due to post right now.
async function listDueOneOff() {
return db.query(
`SELECT id, guild_id, channel_id, content FROM scheduled_messages
WHERE run_at IS NOT NULL AND sent_at IS NULL AND enabled = 1 AND run_at <= NOW()`,
)
}
async function markSent(id) {
await db.query('UPDATE scheduled_messages SET sent_at = NOW() WHERE id = ?', [id])
}
module.exports = { addRecurring, addOnce, remove, list, listEnabledRecurring, listDueOneOff, markSent }

View File

@@ -0,0 +1,26 @@
const db = require('../db')
// Upsert — re-granting the same temp role refreshes its expiry instead of
// creating a duplicate row (see UNIQUE(guild,user,role) in schema.sql).
async function add({ guildId, userId, roleId, expiresAt, createdBy }) {
await db.query(
`INSERT INTO temp_roles (guild_id, user_id, role_id, expires_at, created_by)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE expires_at = VALUES(expires_at), created_by = VALUES(created_by)`,
[guildId, userId, roleId, expiresAt, createdBy || null],
)
}
async function remove(guildId, userId, roleId) {
await db.query('DELETE FROM temp_roles WHERE guild_id = ? AND user_id = ? AND role_id = ?', [guildId, userId, roleId])
}
async function listExpired() {
return db.query('SELECT id, guild_id, user_id, role_id FROM temp_roles WHERE expires_at <= NOW()')
}
async function removeById(id) {
await db.query('DELETE FROM temp_roles WHERE id = ?', [id])
}
module.exports = { add, remove, listExpired, removeById }

26
bot/src/model/warnings.js Normal file
View File

@@ -0,0 +1,26 @@
// Standing warnings (separate from mod_actions so /warnings can list a
// user's active warnings). expires_at is always NULL for now — decay/escalation
// (e.g. "3 active warns -> auto-mute") is deferred past Phase 2, see
// warn.command.js.
const db = require('../db')
async function add({ guildId, targetUserId, targetTag, staffUserId, staffTag, reason }) {
await db.query(
`INSERT INTO warnings (guild_id, target_user_id, target_tag, staff_user_id, staff_tag, reason)
VALUES (?, ?, ?, ?, ?, ?)`,
[guildId, targetUserId, targetTag || null, staffUserId, staffTag || null, reason || null],
)
}
// Active = not expired. Every row is active today since expires_at is never
// set, but the query is written to already respect it once decay lands.
async function listActive(guildId, targetUserId) {
return db.query(
`SELECT id, reason, staff_tag, created_at FROM warnings
WHERE guild_id = ? AND target_user_id = ? AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY created_at DESC`,
[guildId, targetUserId],
)
}
module.exports = { add, listActive }