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:
40
bot/src/model/filterAllowlist.js
Normal file
40
bot/src/model/filterAllowlist.js
Normal 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 }
|
||||
22
bot/src/model/filterWords.js
Normal file
22
bot/src/model/filterWords.js
Normal 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 }
|
||||
47
bot/src/model/guildConfig.js
Normal file
47
bot/src/model/guildConfig.js
Normal 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,
|
||||
}
|
||||
29
bot/src/model/inviteLog.js
Normal file
29
bot/src/model/inviteLog.js
Normal 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 }
|
||||
17
bot/src/model/roleMenus.js
Normal file
17
bot/src/model/roleMenus.js
Normal 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 }
|
||||
57
bot/src/model/scheduledMessages.js
Normal file
57
bot/src/model/scheduledMessages.js
Normal 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 }
|
||||
26
bot/src/model/tempRoles.js
Normal file
26
bot/src/model/tempRoles.js
Normal 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
26
bot/src/model/warnings.js
Normal 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 }
|
||||
Reference in New Issue
Block a user