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:
135
bot/src/discord/discordManager.js
Normal file
135
bot/src/discord/discordManager.js
Normal file
@@ -0,0 +1,135 @@
|
||||
// Owns the single discord.js Client instance for this process: lifecycle
|
||||
// (start/stop/status) and slash-command registration/dispatch. Command
|
||||
// definitions themselves live in ./commands — this file only wires them up.
|
||||
const { Client, GatewayIntentBits, REST, Routes } = require('discord.js')
|
||||
|
||||
const createLogger = require('../utils/logger')
|
||||
const commands = require('./commands')
|
||||
const messageFilter = require('./messageFilter')
|
||||
const scheduler = require('../scheduler/scheduler')
|
||||
const roleMenuHandler = require('./roleMenuHandler')
|
||||
const { handleGuildMemberAdd } = require('./guildMemberAdd')
|
||||
const tempRoleSweeper = require('../roles/tempRoleSweeper')
|
||||
const inviteScheduler = require('../invites/inviteScheduler')
|
||||
|
||||
const log = createLogger('discord')
|
||||
|
||||
let client = null
|
||||
let guildId = null
|
||||
let status = 'disconnected' // disconnected | connecting | connected | error
|
||||
let statusDetail = null
|
||||
let lastConnectedAt = null
|
||||
|
||||
async function registerCommands(applicationId, targetGuildId) {
|
||||
const rest = new REST({ version: '10' }).setToken(client.token)
|
||||
await rest.put(Routes.applicationGuildCommands(applicationId, targetGuildId), {
|
||||
body: commands.all.map((c) => c.data),
|
||||
})
|
||||
log.info('registered guild slash commands', { guildId: targetGuildId, count: commands.all.length })
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (!client) {
|
||||
status = 'disconnected'
|
||||
statusDetail = null
|
||||
return
|
||||
}
|
||||
scheduler.stop()
|
||||
tempRoleSweeper.stop()
|
||||
inviteScheduler.stop()
|
||||
try {
|
||||
await client.destroy()
|
||||
} catch (err) {
|
||||
log.warn('error while destroying client', { message: err.message })
|
||||
}
|
||||
client = null
|
||||
status = 'disconnected'
|
||||
statusDetail = null
|
||||
log.info('discord client disconnected')
|
||||
}
|
||||
|
||||
// start({ token, guildId }) — (re)connects. Always stops any existing client
|
||||
// first so re-saving config or toggling Enabled off/on is idempotent.
|
||||
async function start({ token, guildId: gid }) {
|
||||
await stop()
|
||||
guildId = gid
|
||||
status = 'connecting'
|
||||
statusDetail = null
|
||||
|
||||
// GuildMessages + MessageContent (Phase 3, filter) and GuildMembers
|
||||
// (Phase 5, auto-role + bulk role ops) are all privileged — must be enabled
|
||||
// in the Discord Developer Portal, see the Phase 1 setup notes.
|
||||
client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
GatewayIntentBits.GuildMembers,
|
||||
],
|
||||
})
|
||||
|
||||
client.once('ready', async () => {
|
||||
try {
|
||||
await registerCommands(client.application.id, guildId)
|
||||
await scheduler.start(client)
|
||||
tempRoleSweeper.start(client)
|
||||
inviteScheduler.start(client, guildId)
|
||||
status = 'connected'
|
||||
statusDetail = null
|
||||
lastConnectedAt = new Date()
|
||||
log.info('discord client ready', { user: client.user?.tag, guildId })
|
||||
} catch (err) {
|
||||
status = 'error'
|
||||
statusDetail = `startup failed: ${err.message}`
|
||||
log.error('post-login startup failed (commands/scheduler/temp-roles/invites)', { message: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
client.on('interactionCreate', async (interaction) => {
|
||||
if (await roleMenuHandler.handleInteraction(interaction)) return
|
||||
if (!interaction.isChatInputCommand()) return
|
||||
const command = commands.get(interaction.commandName)
|
||||
if (!command) return
|
||||
try {
|
||||
await command.execute(interaction)
|
||||
} catch (err) {
|
||||
log.error('command execution failed', { command: interaction.commandName, message: err.message })
|
||||
const payload = { content: 'Something went wrong running that command.', ephemeral: true }
|
||||
if (interaction.replied || interaction.deferred) await interaction.followUp(payload)
|
||||
else await interaction.reply(payload)
|
||||
}
|
||||
})
|
||||
|
||||
client.on('messageCreate', messageFilter.handleMessageCreate)
|
||||
client.on('guildMemberAdd', handleGuildMemberAdd)
|
||||
|
||||
client.on('error', (err) => {
|
||||
status = 'error'
|
||||
statusDetail = err.message
|
||||
log.error('discord client error', { message: err.message })
|
||||
})
|
||||
|
||||
try {
|
||||
await client.login(token)
|
||||
} catch (err) {
|
||||
status = 'error'
|
||||
statusDetail = err.message
|
||||
client = null
|
||||
log.error('discord login failed', { message: err.message })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function getStatus() {
|
||||
return { status, statusDetail, guildId, lastConnectedAt }
|
||||
}
|
||||
|
||||
// For code that needs the live client + which guild it's connected to (the
|
||||
// /internal/announce handler, slash commands already get both from the
|
||||
// interaction itself so they don't need this). Returns null if disconnected.
|
||||
function getConnection() {
|
||||
if (!client || status !== 'connected') return null
|
||||
return { client, guildId }
|
||||
}
|
||||
|
||||
module.exports = { start, stop, getStatus, getConnection }
|
||||
Reference in New Issue
Block a user