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