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:
12
bot/src/app.js
Normal file
12
bot/src/app.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const express = require('express')
|
||||
|
||||
const internalRouter = require('./internal/internal.routes')
|
||||
|
||||
const app = express()
|
||||
|
||||
app.use(express.json())
|
||||
|
||||
app.get('/health', (req, res) => res.json({ status: 'ok' }))
|
||||
app.use('/internal', internalRouter)
|
||||
|
||||
module.exports = app
|
||||
38
bot/src/bootstrap.js
vendored
Normal file
38
bot/src/bootstrap.js
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
// Runs once at process start, before the internal Express server is
|
||||
// considered ready. Fetches current config from the main site (token,
|
||||
// guildId, enabled) and reconnects immediately if enabled — so a bot
|
||||
// container restart (crash, `docker compose restart`, host reboot) self-heals
|
||||
// without any admin-panel interaction. Node 20's built-in fetch is used; no
|
||||
// extra HTTP client dependency needed for a single startup call.
|
||||
const discordManager = require('./discord/discordManager')
|
||||
const createLogger = require('./utils/logger')
|
||||
|
||||
const log = createLogger('bootstrap')
|
||||
|
||||
async function bootstrap() {
|
||||
const siteUrl = process.env.SITE_INTERNAL_URL
|
||||
const key = process.env.BOT_INTERNAL_KEY
|
||||
if (!siteUrl || !key) {
|
||||
log.warn('SITE_INTERNAL_URL or BOT_INTERNAL_KEY not set — skipping boot-time config fetch, staying disconnected until the admin panel pushes config')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(siteUrl, { headers: { 'X-Internal-Key': key } })
|
||||
if (!res.ok) {
|
||||
log.error('boot-time config fetch failed', { status: res.status })
|
||||
return
|
||||
}
|
||||
const config = await res.json()
|
||||
if (config.enabled) {
|
||||
log.info('boot-time config says enabled — reconnecting', { guildId: config.guildId })
|
||||
await discordManager.start({ token: config.token, guildId: config.guildId })
|
||||
} else {
|
||||
log.info('boot-time config says disabled — staying disconnected')
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('boot-time config fetch errored', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = bootstrap
|
||||
41
bot/src/db.js
Normal file
41
bot/src/db.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// DB pool for the bot's OWN tables (guild_config, mod_actions, warnings) —
|
||||
// mirrors server/src/utils/db.js. The bot never reads/writes any table it
|
||||
// doesn't own; site-owned tables (users, bot_config, etc.) are reached only
|
||||
// through the internal API, never directly. Schema for these tables lives in
|
||||
// server/db/schema.sql (same physical database, ensured by the main server on
|
||||
// boot) — there's no separate migration tool to justify a second database for
|
||||
// a single-guild v1 bot.
|
||||
const mariadb = require('mariadb')
|
||||
|
||||
const pool = mariadb.createPool({
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'uomysticmoon',
|
||||
connectionLimit: 5,
|
||||
insertIdAsNumber: true,
|
||||
bigIntAsNumber: true,
|
||||
decimalAsNumber: true,
|
||||
// The driver defaults to 'local' — silently serializing bound JS Date
|
||||
// params using the HOST MACHINE's local offset instead of the DB session's
|
||||
// timezone (discovered via temp_roles.expires_at coming back hours off in
|
||||
// dev, CDT vs the container's UTC). 'auto' negotiates the actual session
|
||||
// timezone so Date round-trips correctly regardless of host TZ.
|
||||
timezone: 'auto',
|
||||
})
|
||||
|
||||
async function query(sql, params) {
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
return await conn.query(sql, params)
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function close() {
|
||||
await pool.end()
|
||||
}
|
||||
|
||||
module.exports = { query, close }
|
||||
49
bot/src/discord/commands/announce.command.js
Normal file
49
bot/src/discord/commands/announce.command.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const siteApiClient = require('../../site/siteApiClient')
|
||||
const newsAnnounce = require('../newsAnnounce')
|
||||
|
||||
function siteOrigin() {
|
||||
const base = process.env.SITE_PUBLIC_URL || 'http://localhost:3000/api/v1/public'
|
||||
return new URL(base).origin
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'announce',
|
||||
description: 'Re-post or boost an existing news item.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{ name: 'post', description: 'News post id or slug', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const idOrSlug = interaction.options.getString('post', true)
|
||||
await interaction.deferReply({ ephemeral: true })
|
||||
|
||||
const result = await siteApiClient.getNewsPost(idOrSlug)
|
||||
if (result.maintenance) {
|
||||
await interaction.editReply({ content: `Can't reach the site right now: ${result.message || 'maintenance mode'}` })
|
||||
return
|
||||
}
|
||||
if (!result.ok) {
|
||||
await interaction.editReply({ content: `Couldn't find that news post ("${idOrSlug}").` })
|
||||
return
|
||||
}
|
||||
|
||||
const post = result.data
|
||||
const origin = siteOrigin()
|
||||
try {
|
||||
await newsAnnounce.postAnnounce(interaction.client, interaction.guildId, {
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
url: `${origin}/site/news`,
|
||||
// image_url is stored relative — Discord embeds require an absolute URL.
|
||||
imageUrl: post.image_url ? new URL(post.image_url, origin).toString() : null,
|
||||
})
|
||||
await interaction.editReply({ content: `Posted "${post.title}" to the news channel.` })
|
||||
} catch (err) {
|
||||
await interaction.editReply({ content: `Couldn't post: ${err.message}` })
|
||||
}
|
||||
},
|
||||
}
|
||||
30
bot/src/discord/commands/autorole.command.js
Normal file
30
bot/src/discord/commands/autorole.command.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const guildConfig = require('../../model/guildConfig')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'autorole',
|
||||
description: 'View or set the role automatically assigned to new members on join.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'role',
|
||||
description: 'Role to auto-assign on join. Omit to view the current setting.',
|
||||
type: ApplicationCommandOptionType.Role,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const role = interaction.options.getRole('role')
|
||||
if (!role) {
|
||||
const currentId = await guildConfig.getAutoRoleId(interaction.guildId)
|
||||
const content = currentId ? `Auto-role is set to <@&${currentId}>.` : 'No auto-role is set yet.'
|
||||
await interaction.reply({ content, ephemeral: true })
|
||||
return
|
||||
}
|
||||
await guildConfig.setAutoRoleId(interaction.guildId, role.id)
|
||||
await interaction.reply({ content: `Auto-role set to ${role}. New members will get this automatically.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
34
bot/src/discord/commands/ban.command.js
Normal file
34
bot/src/discord/commands/ban.command.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const modLog = require('../modLog')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'ban',
|
||||
description: 'Ban a member from the server.',
|
||||
default_member_permissions: PermissionFlagsBits.BanMembers.toString(),
|
||||
options: [
|
||||
{ name: 'user', description: 'Member to ban', type: ApplicationCommandOptionType.User, required: true },
|
||||
{ name: 'reason', description: 'Reason for the ban', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const user = interaction.options.getUser('user', true)
|
||||
const reason = interaction.options.getString('reason', true)
|
||||
|
||||
if (user.id === interaction.user.id) {
|
||||
await interaction.reply({ content: "You can't ban yourself.", ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
const member = interaction.guild.members.cache.get(user.id)
|
||||
if (member && !member.bannable) {
|
||||
await interaction.reply({ content: "I don't have permission to ban that member (role hierarchy).", ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
await interaction.guild.members.ban(user, { reason })
|
||||
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'ban', target: user, staffUser: interaction.user, reason })
|
||||
await interaction.reply({ content: `Banned ${user.tag}.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
73
bot/src/discord/commands/filter.command.js
Normal file
73
bot/src/discord/commands/filter.command.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const filterWords = require('../../model/filterWords')
|
||||
const filterCache = require('../../filter/filterCache')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'filter',
|
||||
description: 'Manage the banned-word filter.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'add',
|
||||
description: 'Add a word to the filter.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'word', description: 'Word or phrase to ban', type: ApplicationCommandOptionType.String, required: true },
|
||||
{
|
||||
name: 'severity',
|
||||
description: 'Auto-action when triggered (default: delete)',
|
||||
type: ApplicationCommandOptionType.String,
|
||||
required: false,
|
||||
choices: [
|
||||
{ name: 'Delete only', value: 'delete' },
|
||||
{ name: 'Delete + warn', value: 'warn' },
|
||||
{ name: 'Delete + mute (10m)', value: 'mute' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'remove',
|
||||
description: 'Remove a word from the filter.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'word', description: 'Word or phrase to remove', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
description: 'List all filtered words.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const sub = interaction.options.getSubcommand()
|
||||
|
||||
if (sub === 'add') {
|
||||
const word = interaction.options.getString('word', true)
|
||||
const severity = interaction.options.getString('severity') || 'delete'
|
||||
await filterWords.add({ guildId: interaction.guildId, word, severity, addedBy: interaction.user.id, addedByTag: interaction.user.tag })
|
||||
await filterCache.refresh(interaction.guildId)
|
||||
await interaction.reply({ content: `Added "${word}" to the filter (${severity}).`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'remove') {
|
||||
const word = interaction.options.getString('word', true)
|
||||
const removed = await filterWords.remove(interaction.guildId, word)
|
||||
await filterCache.refresh(interaction.guildId)
|
||||
await interaction.reply({ content: removed ? `Removed "${word}" from the filter.` : `"${word}" wasn't in the filter.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'list') {
|
||||
const words = await filterWords.list(interaction.guildId)
|
||||
const content = words.length === 0 ? 'The filter list is empty.' : words.map((w) => `${w.word} (${w.severity})`).join('\n')
|
||||
await interaction.reply({ content, ephemeral: true })
|
||||
}
|
||||
},
|
||||
}
|
||||
61
bot/src/discord/commands/filterallow.command.js
Normal file
61
bot/src/discord/commands/filterallow.command.js
Normal file
@@ -0,0 +1,61 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const filterAllowlist = require('../../model/filterAllowlist')
|
||||
const filterCache = require('../../filter/filterCache')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'filterallow',
|
||||
description: 'Manage roles/channels that bypass the filter entirely.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'role',
|
||||
description: 'Toggle a role in/out of the filter bypass list.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [{ name: 'role', description: 'Role to toggle', type: ApplicationCommandOptionType.Role, required: true }],
|
||||
},
|
||||
{
|
||||
name: 'channel',
|
||||
description: 'Toggle a channel in/out of the filter bypass list.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [{ name: 'channel', description: 'Channel to toggle', type: ApplicationCommandOptionType.Channel, required: true }],
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
description: 'Show current filter bypass roles/channels.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const sub = interaction.options.getSubcommand()
|
||||
|
||||
if (sub === 'role') {
|
||||
const role = interaction.options.getRole('role', true)
|
||||
const nowAllowed = await filterAllowlist.toggleRole(interaction.guildId, role.id)
|
||||
await filterCache.refresh(interaction.guildId)
|
||||
await interaction.reply({ content: `${role} is ${nowAllowed ? 'now' : 'no longer'} bypassing the filter.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'channel') {
|
||||
const channel = interaction.options.getChannel('channel', true)
|
||||
const nowAllowed = await filterAllowlist.toggleChannel(interaction.guildId, channel.id)
|
||||
await filterCache.refresh(interaction.guildId)
|
||||
await interaction.reply({ content: `${channel} is ${nowAllowed ? 'now' : 'no longer'} bypassing the filter.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'list') {
|
||||
const [roles, channels] = await Promise.all([
|
||||
filterAllowlist.getRoles(interaction.guildId),
|
||||
filterAllowlist.getChannels(interaction.guildId),
|
||||
])
|
||||
const roleText = roles.length ? roles.map((id) => `<@&${id}>`).join(', ') : 'none'
|
||||
const channelText = channels.length ? channels.map((id) => `<#${id}>`).join(', ') : 'none'
|
||||
await interaction.reply({ content: `Bypass roles: ${roleText}\nBypass channels: ${channelText}`, ephemeral: true })
|
||||
}
|
||||
},
|
||||
}
|
||||
31
bot/src/discord/commands/index.js
Normal file
31
bot/src/discord/commands/index.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// Command registry. Each module exports { data, execute } — `data` is the
|
||||
// slash-command definition pushed to Discord (registerCommands), `execute` is
|
||||
// the interactionCreate handler (dispatch). Adding a new command is just
|
||||
// adding a file here — discordManager.js never needs to change.
|
||||
const commands = [
|
||||
require('./ping.command'),
|
||||
require('./modlog.command'),
|
||||
require('./ban.command'),
|
||||
require('./kick.command'),
|
||||
require('./mute.command'),
|
||||
require('./warn.command'),
|
||||
require('./warnings.command'),
|
||||
require('./filter.command'),
|
||||
require('./filterallow.command'),
|
||||
require('./schedule.command'),
|
||||
require('./rolemenu.command'),
|
||||
require('./autorole.command'),
|
||||
require('./role.command'),
|
||||
require('./roles.command'),
|
||||
require('./invite.command'),
|
||||
require('./news.command'),
|
||||
require('./announce.command'),
|
||||
require('./wiki.command'),
|
||||
]
|
||||
|
||||
const byName = new Map(commands.map((c) => [c.data.name, c]))
|
||||
|
||||
module.exports = {
|
||||
all: commands,
|
||||
get: (name) => byName.get(name),
|
||||
}
|
||||
85
bot/src/discord/commands/invite.command.js
Normal file
85
bot/src/discord/commands/invite.command.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
|
||||
|
||||
const guildConfig = require('../../model/guildConfig')
|
||||
const inviteLog = require('../../model/inviteLog')
|
||||
const inviteRotator = require('../../invites/inviteRotator')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'invite',
|
||||
description: 'Manage the auto-rotating primary server invite.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'channel',
|
||||
description: 'View or set the channel new invites are created in.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{
|
||||
name: 'channel',
|
||||
description: 'Channel to create invites in. Omit to view the current setting.',
|
||||
type: ApplicationCommandOptionType.Channel,
|
||||
channel_types: [ChannelType.GuildText],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'rotate',
|
||||
description: 'Revoke the current invite and generate a new one now.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
name: 'log',
|
||||
description: 'Show recent invite rotation history.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const sub = interaction.options.getSubcommand()
|
||||
|
||||
if (sub === 'channel') {
|
||||
const channel = interaction.options.getChannel('channel')
|
||||
if (!channel) {
|
||||
const currentId = await guildConfig.getInviteChannelId(interaction.guildId)
|
||||
const content = currentId ? `Invites are created in <#${currentId}>.` : 'No invite channel is set yet.'
|
||||
await interaction.reply({ content, ephemeral: true })
|
||||
return
|
||||
}
|
||||
await guildConfig.setInviteChannelId(interaction.guildId, channel.id)
|
||||
await interaction.reply({ content: `Invite channel set to ${channel}.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'rotate') {
|
||||
await interaction.deferReply({ ephemeral: true })
|
||||
try {
|
||||
const invite = await inviteRotator.rotate(interaction.client, interaction.guildId, {
|
||||
triggeredBy: interaction.user.id,
|
||||
triggeredByTag: interaction.user.tag,
|
||||
})
|
||||
await interaction.editReply({ content: `New invite: https://discord.gg/${invite.code}` })
|
||||
} catch (err) {
|
||||
await interaction.editReply({ content: `Couldn't rotate the invite: ${err.message}` })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'log') {
|
||||
const rows = await inviteLog.list(interaction.guildId, 10)
|
||||
if (rows.length === 0) {
|
||||
await interaction.reply({ content: 'No invite rotations logged yet.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
const lines = rows.map((r) => {
|
||||
const who = r.triggered_by_tag || 'automatic (scheduled)'
|
||||
const status = r.revoked_at ? `revoked ${new Date(r.revoked_at).toLocaleString()}` : 'active'
|
||||
return `\`${r.invite_code}\` — by ${who} on ${new Date(r.created_at).toLocaleString()} (${status})`
|
||||
})
|
||||
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
|
||||
}
|
||||
},
|
||||
}
|
||||
38
bot/src/discord/commands/kick.command.js
Normal file
38
bot/src/discord/commands/kick.command.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const modLog = require('../modLog')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'kick',
|
||||
description: 'Kick a member from the server.',
|
||||
default_member_permissions: PermissionFlagsBits.KickMembers.toString(),
|
||||
options: [
|
||||
{ name: 'user', description: 'Member to kick', type: ApplicationCommandOptionType.User, required: true },
|
||||
{ name: 'reason', description: 'Reason for the kick', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const user = interaction.options.getUser('user', true)
|
||||
const reason = interaction.options.getString('reason', true)
|
||||
|
||||
if (user.id === interaction.user.id) {
|
||||
await interaction.reply({ content: "You can't kick yourself.", ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
const member = interaction.guild.members.cache.get(user.id)
|
||||
if (!member) {
|
||||
await interaction.reply({ content: 'That user is not a member of this server.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
if (!member.kickable) {
|
||||
await interaction.reply({ content: "I don't have permission to kick that member (role hierarchy).", ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
await member.kick(reason)
|
||||
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'kick', target: user, staffUser: interaction.user, reason })
|
||||
await interaction.reply({ content: `Kicked ${user.tag}.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
33
bot/src/discord/commands/modlog.command.js
Normal file
33
bot/src/discord/commands/modlog.command.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
|
||||
|
||||
const guildConfig = require('../../model/guildConfig')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'modlog',
|
||||
description: 'View or set the mod-log channel (ban/kick/mute/warn actions post here).',
|
||||
// Configuration, not a moderation action — gated to Manage Server rather
|
||||
// than the ModerateMembers bit the action commands use.
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'channel',
|
||||
description: 'Channel to post mod-log entries to. Omit to view the current setting.',
|
||||
type: ApplicationCommandOptionType.Channel,
|
||||
channel_types: [ChannelType.GuildText],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const channel = interaction.options.getChannel('channel')
|
||||
if (!channel) {
|
||||
const currentId = await guildConfig.getModLogChannelId(interaction.guildId)
|
||||
const content = currentId ? `Mod-log channel is set to <#${currentId}>.` : 'No mod-log channel is set yet.'
|
||||
await interaction.reply({ content, ephemeral: true })
|
||||
return
|
||||
}
|
||||
await guildConfig.setModLogChannelId(interaction.guildId, channel.id)
|
||||
await interaction.reply({ content: `Mod-log channel set to ${channel}.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
49
bot/src/discord/commands/mute.command.js
Normal file
49
bot/src/discord/commands/mute.command.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const modLog = require('../modLog')
|
||||
const { parseDuration, MAX_TIMEOUT_MS } = require('../../utils/duration')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'mute',
|
||||
description: 'Timeout a member for a duration (e.g. 10m, 2h, 1d).',
|
||||
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
|
||||
options: [
|
||||
{ name: 'user', description: 'Member to mute', type: ApplicationCommandOptionType.User, required: true },
|
||||
{ name: 'duration', description: 'e.g. 30s, 10m, 2h, 1d (max 28d)', type: ApplicationCommandOptionType.String, required: true },
|
||||
{ name: 'reason', description: 'Reason for the mute', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const user = interaction.options.getUser('user', true)
|
||||
const durationInput = interaction.options.getString('duration', true)
|
||||
const reason = interaction.options.getString('reason', true)
|
||||
|
||||
if (user.id === interaction.user.id) {
|
||||
await interaction.reply({ content: "You can't mute yourself.", ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
const ms = parseDuration(durationInput)
|
||||
if (!ms) {
|
||||
await interaction.reply({ content: 'Invalid duration — use a number plus s/m/h/d, e.g. `10m`, `2h`, `1d`.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
const clampedMs = Math.min(ms, MAX_TIMEOUT_MS)
|
||||
|
||||
const member = interaction.guild.members.cache.get(user.id)
|
||||
if (!member) {
|
||||
await interaction.reply({ content: 'That user is not a member of this server.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
if (!member.moderatable) {
|
||||
await interaction.reply({ content: "I don't have permission to timeout that member (role hierarchy).", ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
await member.timeout(clampedMs, reason)
|
||||
const durationSeconds = Math.round(clampedMs / 1000)
|
||||
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'mute', target: user, staffUser: interaction.user, reason, durationSeconds })
|
||||
await interaction.reply({ content: `Muted ${user.tag} for ${durationInput}.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
31
bot/src/discord/commands/news.command.js
Normal file
31
bot/src/discord/commands/news.command.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
|
||||
|
||||
const guildConfig = require('../../model/guildConfig')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'news',
|
||||
description: 'View or set the channel news posts are announced to.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'channel',
|
||||
description: 'Channel for news announcements. Omit to view the current setting.',
|
||||
type: ApplicationCommandOptionType.Channel,
|
||||
channel_types: [ChannelType.GuildText],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const channel = interaction.options.getChannel('channel')
|
||||
if (!channel) {
|
||||
const currentId = await guildConfig.getNewsChannelId(interaction.guildId)
|
||||
const content = currentId ? `News channel is set to <#${currentId}>.` : 'No news channel is set yet.'
|
||||
await interaction.reply({ content, ephemeral: true })
|
||||
return
|
||||
}
|
||||
await guildConfig.setNewsChannelId(interaction.guildId, channel.id)
|
||||
await interaction.reply({ content: `News channel set to ${channel}.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
15
bot/src/discord/commands/ping.command.js
Normal file
15
bot/src/discord/commands/ping.command.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const { PermissionFlagsBits } = require('discord.js')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'ping',
|
||||
description: 'Health-check — replies pong if the bot is alive and staff-permitted.',
|
||||
// Restricted by default to members with Moderate Members — proves slash
|
||||
// commands can be permission-gated via Discord's own permission model,
|
||||
// per the spec's "restrict staff commands via Discord's permission system".
|
||||
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
|
||||
},
|
||||
async execute(interaction) {
|
||||
await interaction.reply({ content: 'pong', ephemeral: true })
|
||||
},
|
||||
}
|
||||
71
bot/src/discord/commands/role.command.js
Normal file
71
bot/src/discord/commands/role.command.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const tempRoles = require('../../model/tempRoles')
|
||||
const { parseDuration } = require('../../utils/duration')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'role',
|
||||
description: 'Assign or remove a role for a single member.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageRoles.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'add',
|
||||
description: 'Add a role to a member, optionally temporary.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'user', description: 'Member', type: ApplicationCommandOptionType.User, required: true },
|
||||
{ name: 'role', description: 'Role to add', type: ApplicationCommandOptionType.Role, required: true },
|
||||
{ name: 'duration', description: 'Optional — makes this temporary, e.g. 1h, 2d, 7d', type: ApplicationCommandOptionType.String, required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'remove',
|
||||
description: 'Remove a role from a member.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'user', description: 'Member', type: ApplicationCommandOptionType.User, required: true },
|
||||
{ name: 'role', description: 'Role to remove', type: ApplicationCommandOptionType.Role, required: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const sub = interaction.options.getSubcommand()
|
||||
const user = interaction.options.getUser('user', true)
|
||||
const role = interaction.options.getRole('role', true)
|
||||
const member = interaction.guild.members.cache.get(user.id)
|
||||
|
||||
if (!member) {
|
||||
await interaction.reply({ content: 'That user is not a member of this server.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'add') {
|
||||
await member.roles.add(role.id)
|
||||
const durationInput = interaction.options.getString('duration')
|
||||
if (!durationInput) {
|
||||
await interaction.reply({ content: `Added ${role} to ${user.tag}.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
const ms = parseDuration(durationInput)
|
||||
if (!ms) {
|
||||
await interaction.reply({
|
||||
content: `Added ${role}, but "${durationInput}" isn't a valid duration so it won't expire automatically. Use e.g. 1h, 2d, 7d.`,
|
||||
ephemeral: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
const expiresAt = new Date(Date.now() + ms)
|
||||
await tempRoles.add({ guildId: interaction.guildId, userId: user.id, roleId: role.id, expiresAt, createdBy: interaction.user.id })
|
||||
await interaction.reply({ content: `Added ${role} to ${user.tag} until ${expiresAt.toLocaleString()}.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'remove') {
|
||||
await member.roles.remove(role.id)
|
||||
await tempRoles.remove(interaction.guildId, user.id, role.id)
|
||||
await interaction.reply({ content: `Removed ${role} from ${user.tag}.`, ephemeral: true })
|
||||
}
|
||||
},
|
||||
}
|
||||
85
bot/src/discord/commands/rolemenu.command.js
Normal file
85
bot/src/discord/commands/rolemenu.command.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const {
|
||||
PermissionFlagsBits,
|
||||
ApplicationCommandOptionType,
|
||||
ChannelType,
|
||||
EmbedBuilder,
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
} = require('discord.js')
|
||||
|
||||
const roleMenus = require('../../model/roleMenus')
|
||||
|
||||
// Capped at 5 roles per menu — a single Discord action row holds at most 5
|
||||
// buttons, and one row keeps this a single simple slash command instead of
|
||||
// needing a multi-step builder/modal flow.
|
||||
const MAX_ROLES = 5
|
||||
|
||||
// role1/label1 are declared inline in `data` (ahead of the optional
|
||||
// `description` option, per Discord's required-before-optional rule) — this
|
||||
// generates the rest, all optional.
|
||||
function roleOptions(from, to) {
|
||||
const opts = []
|
||||
for (let i = from; i <= to; i++) {
|
||||
opts.push({ name: `role${i}`, description: `Role #${i}`, type: ApplicationCommandOptionType.Role, required: false })
|
||||
opts.push({ name: `label${i}`, description: `Button label for role #${i} (default: role name)`, type: ApplicationCommandOptionType.String, required: false })
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'rolemenu',
|
||||
description: 'Post a button menu for self-assignable roles (up to 5).',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
// Discord requires all required options before any optional ones across
|
||||
// the whole array — role1 (required) must come before description
|
||||
// (optional), even though they read more naturally in the other order.
|
||||
options: [
|
||||
{ name: 'channel', description: 'Channel to post the menu in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true },
|
||||
{ name: 'title', description: 'Menu title', type: ApplicationCommandOptionType.String, required: true },
|
||||
{ name: 'role1', description: 'Role #1', type: ApplicationCommandOptionType.Role, required: true },
|
||||
{ name: 'description', description: 'Menu description', type: ApplicationCommandOptionType.String, required: false },
|
||||
{ name: 'label1', description: 'Button label for role #1 (default: role name)', type: ApplicationCommandOptionType.String, required: false },
|
||||
...roleOptions(2, MAX_ROLES),
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const channel = interaction.options.getChannel('channel', true)
|
||||
const title = interaction.options.getString('title', true)
|
||||
const description = interaction.options.getString('description') || undefined
|
||||
|
||||
const entries = []
|
||||
for (let i = 1; i <= MAX_ROLES; i++) {
|
||||
const role = interaction.options.getRole(`role${i}`)
|
||||
if (!role) continue
|
||||
const label = interaction.options.getString(`label${i}`) || role.name
|
||||
entries.push({ roleId: role.id, label })
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
await interaction.reply({ content: 'Provide at least one role (role1).', ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder().setTitle(title).setColor(0x6a8fc2)
|
||||
if (description) embed.setDescription(description)
|
||||
|
||||
const row = new ActionRowBuilder().addComponents(
|
||||
entries.map((e) =>
|
||||
new ButtonBuilder().setCustomId(`rolemenu:${e.roleId}`).setLabel(e.label).setStyle(ButtonStyle.Secondary),
|
||||
),
|
||||
)
|
||||
|
||||
const message = await channel.send({ embeds: [embed], components: [row] })
|
||||
await roleMenus.add({
|
||||
guildId: interaction.guildId,
|
||||
channelId: channel.id,
|
||||
messageId: message.id,
|
||||
mapping: entries,
|
||||
createdBy: interaction.user.id,
|
||||
})
|
||||
|
||||
await interaction.reply({ content: `Role menu posted in ${channel}.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
68
bot/src/discord/commands/roles.command.js
Normal file
68
bot/src/discord/commands/roles.command.js
Normal file
@@ -0,0 +1,68 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
// Bulk targeting is "by existing role" only — the spec also mentions an
|
||||
// explicit list of members, but Discord slash commands have no multi-user
|
||||
// picker, so that variant is deferred rather than faked with a handful of
|
||||
// user1..user5 options that would feel arbitrary and cramped.
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'roles',
|
||||
description: 'Bulk role operations across members who share an existing role.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageRoles.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'bulk-assign',
|
||||
description: 'Add a role to every member who has another role.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'has-role', description: 'Members with this role are targeted', type: ApplicationCommandOptionType.Role, required: true },
|
||||
{ name: 'add-role', description: 'Role to add to those members', type: ApplicationCommandOptionType.Role, required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'bulk-remove',
|
||||
description: 'Remove a role from every member who has another role.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'has-role', description: 'Members with this role are targeted', type: ApplicationCommandOptionType.Role, required: true },
|
||||
{ name: 'remove-role', description: 'Role to remove from those members', type: ApplicationCommandOptionType.Role, required: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const sub = interaction.options.getSubcommand()
|
||||
// Fetching every member + looping role updates can easily exceed
|
||||
// Discord's 3-second initial-response window.
|
||||
await interaction.deferReply({ ephemeral: true })
|
||||
|
||||
const hasRole = interaction.options.getRole('has-role', true)
|
||||
const members = await interaction.guild.members.fetch()
|
||||
const targets = members.filter((m) => m.roles.cache.has(hasRole.id))
|
||||
|
||||
if (sub === 'bulk-assign') {
|
||||
const addRole = interaction.options.getRole('add-role', true)
|
||||
let count = 0
|
||||
for (const member of targets.values()) {
|
||||
if (!member.roles.cache.has(addRole.id)) {
|
||||
await member.roles.add(addRole.id).catch(() => {})
|
||||
count++
|
||||
}
|
||||
}
|
||||
await interaction.editReply({ content: `Added ${addRole} to ${count} member(s) who have ${hasRole}.` })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'bulk-remove') {
|
||||
const removeRole = interaction.options.getRole('remove-role', true)
|
||||
let count = 0
|
||||
for (const member of targets.values()) {
|
||||
if (member.roles.cache.has(removeRole.id)) {
|
||||
await member.roles.remove(removeRole.id).catch(() => {})
|
||||
count++
|
||||
}
|
||||
}
|
||||
await interaction.editReply({ content: `Removed ${removeRole} from ${count} member(s) who have ${hasRole}.` })
|
||||
}
|
||||
},
|
||||
}
|
||||
119
bot/src/discord/commands/schedule.command.js
Normal file
119
bot/src/discord/commands/schedule.command.js
Normal file
@@ -0,0 +1,119 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
|
||||
const cron = require('node-cron')
|
||||
|
||||
const scheduledMessages = require('../../model/scheduledMessages')
|
||||
const scheduler = require('../../scheduler/scheduler')
|
||||
const { parseDuration } = require('../../utils/duration')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'schedule',
|
||||
description: 'Manage recurring and one-off scheduled channel messages.',
|
||||
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||
options: [
|
||||
{
|
||||
name: 'recurring',
|
||||
description: 'Schedule a recurring message on a cron schedule.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'channel', description: 'Channel to post in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true },
|
||||
{ name: 'cron', description: 'Cron expression, e.g. "0 9 * * 5" (Fridays 9am)', type: ApplicationCommandOptionType.String, required: true },
|
||||
{ name: 'message', description: 'Message content to post', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'once',
|
||||
description: 'Schedule a one-off message for a future time.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [
|
||||
{ name: 'channel', description: 'Channel to post in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true },
|
||||
{ name: 'in', description: 'When to post, e.g. 30m, 2h, 1d', type: ApplicationCommandOptionType.String, required: true },
|
||||
{ name: 'message', description: 'Message content to post', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'remove',
|
||||
description: 'Remove a scheduled message by id.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [{ name: 'id', description: 'Scheduled message id (see /schedule list)', type: ApplicationCommandOptionType.Integer, required: true }],
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
description: 'List all scheduled messages.',
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const sub = interaction.options.getSubcommand()
|
||||
|
||||
if (sub === 'recurring') {
|
||||
const channel = interaction.options.getChannel('channel', true)
|
||||
const cronExpr = interaction.options.getString('cron', true)
|
||||
const message = interaction.options.getString('message', true)
|
||||
if (!cron.validate(cronExpr)) {
|
||||
await interaction.reply({ content: `"${cronExpr}" isn't a valid cron expression.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
const id = await scheduledMessages.addRecurring({
|
||||
guildId: interaction.guildId,
|
||||
channelId: channel.id,
|
||||
content: message,
|
||||
cronExpression: cronExpr,
|
||||
createdBy: interaction.user.id,
|
||||
createdByTag: interaction.user.tag,
|
||||
})
|
||||
await scheduler.refresh()
|
||||
await interaction.reply({ content: `Scheduled recurring message #${id} in ${channel} on \`${cronExpr}\`.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'once') {
|
||||
const channel = interaction.options.getChannel('channel', true)
|
||||
const inInput = interaction.options.getString('in', true)
|
||||
const message = interaction.options.getString('message', true)
|
||||
const ms = parseDuration(inInput)
|
||||
if (!ms) {
|
||||
await interaction.reply({ content: 'Invalid time — use a number plus s/m/h/d, e.g. `30m`, `2h`, `1d`.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
const runAt = new Date(Date.now() + ms)
|
||||
const id = await scheduledMessages.addOnce({
|
||||
guildId: interaction.guildId,
|
||||
channelId: channel.id,
|
||||
content: message,
|
||||
runAt,
|
||||
createdBy: interaction.user.id,
|
||||
createdByTag: interaction.user.tag,
|
||||
})
|
||||
await interaction.reply({ content: `Scheduled one-off message #${id} in ${channel} for ${runAt.toLocaleString()}.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'remove') {
|
||||
const id = interaction.options.getInteger('id', true)
|
||||
const removed = await scheduledMessages.remove(interaction.guildId, id)
|
||||
await scheduler.refresh()
|
||||
await interaction.reply({ content: removed ? `Removed scheduled message #${id}.` : `No scheduled message #${id} found.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'list') {
|
||||
const rows = await scheduledMessages.list(interaction.guildId)
|
||||
if (rows.length === 0) {
|
||||
await interaction.reply({ content: 'No scheduled messages.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
const lines = rows.map((r) => {
|
||||
const kind = r.cron_expression
|
||||
? `cron \`${r.cron_expression}\``
|
||||
: r.sent_at
|
||||
? `sent ${new Date(r.sent_at).toLocaleString()}`
|
||||
: `due ${new Date(r.run_at).toLocaleString()}`
|
||||
return `**#${r.id}** <#${r.channel_id}> — ${kind}${r.enabled ? '' : ' (disabled)'}`
|
||||
})
|
||||
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
|
||||
}
|
||||
},
|
||||
}
|
||||
40
bot/src/discord/commands/warn.command.js
Normal file
40
bot/src/discord/commands/warn.command.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const modLog = require('../modLog')
|
||||
const warnings = require('../../model/warnings')
|
||||
|
||||
// Escalation (e.g. "3 active warns -> auto-mute for X hours") and warning
|
||||
// decay/expiry are in the original spec but deferred past this phase — this
|
||||
// just records the warning and posts it to the mod-log, matching the
|
||||
// "Suggested Build Order" step 2 scope (core moderation).
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'warn',
|
||||
description: 'Log a warning against a member.',
|
||||
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
|
||||
options: [
|
||||
{ name: 'user', description: 'Member to warn', type: ApplicationCommandOptionType.User, required: true },
|
||||
{ name: 'reason', description: 'Reason for the warning', type: ApplicationCommandOptionType.String, required: true },
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const user = interaction.options.getUser('user', true)
|
||||
const reason = interaction.options.getString('reason', true)
|
||||
|
||||
if (user.id === interaction.user.id) {
|
||||
await interaction.reply({ content: "You can't warn yourself.", ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
await warnings.add({
|
||||
guildId: interaction.guildId,
|
||||
targetUserId: user.id,
|
||||
targetTag: user.tag,
|
||||
staffUserId: interaction.user.id,
|
||||
staffTag: interaction.user.tag,
|
||||
reason,
|
||||
})
|
||||
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'warn', target: user, staffUser: interaction.user, reason })
|
||||
await interaction.reply({ content: `Warned ${user.tag}.`, ephemeral: true })
|
||||
},
|
||||
}
|
||||
34
bot/src/discord/commands/warnings.command.js
Normal file
34
bot/src/discord/commands/warnings.command.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const { PermissionFlagsBits, ApplicationCommandOptionType, EmbedBuilder } = require('discord.js')
|
||||
|
||||
const warnings = require('../../model/warnings')
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'warnings',
|
||||
description: "List a member's active warnings.",
|
||||
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
|
||||
options: [
|
||||
{ name: 'user', description: 'Member to look up', type: ApplicationCommandOptionType.User, required: true },
|
||||
],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const user = interaction.options.getUser('user', true)
|
||||
const rows = await warnings.listActive(interaction.guildId, user.id)
|
||||
|
||||
if (rows.length === 0) {
|
||||
await interaction.reply({ content: `${user.tag} has no active warnings.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0xe0b070)
|
||||
.setTitle(`Warnings — ${user.tag}`)
|
||||
.setDescription(
|
||||
rows
|
||||
.map((w, i) => `**${i + 1}.** ${w.reason || '(no reason given)'} — by ${w.staff_tag || 'unknown'} on ${new Date(w.created_at).toLocaleDateString()}`)
|
||||
.join('\n'),
|
||||
)
|
||||
|
||||
await interaction.reply({ embeds: [embed], ephemeral: true })
|
||||
},
|
||||
}
|
||||
40
bot/src/discord/commands/wiki.command.js
Normal file
40
bot/src/discord/commands/wiki.command.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const { ApplicationCommandOptionType } = require('discord.js')
|
||||
|
||||
const siteApiClient = require('../../site/siteApiClient')
|
||||
|
||||
// Public command — no default_member_permissions restriction. Read-only:
|
||||
// searches wiki titles/content and links to the best match. Never posts to or
|
||||
// edits the wiki. Category-scoped search (spec's optional "/wiki spells
|
||||
// fireball") is deferred — the site's public search endpoint currently
|
||||
// ignores category filters whenever a text query is given.
|
||||
function siteOrigin() {
|
||||
const base = process.env.SITE_PUBLIC_URL || 'http://localhost:3000/api/v1/public'
|
||||
return new URL(base).origin
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'wiki',
|
||||
description: 'Search the wiki.',
|
||||
options: [{ name: 'query', description: 'What to search for', type: ApplicationCommandOptionType.String, required: true }],
|
||||
},
|
||||
async execute(interaction) {
|
||||
const query = interaction.options.getString('query', true)
|
||||
await interaction.deferReply()
|
||||
|
||||
const result = await siteApiClient.searchWiki(query)
|
||||
if (result.maintenance) {
|
||||
await interaction.editReply({ content: `The wiki is unavailable right now: ${result.message || 'maintenance mode'}` })
|
||||
return
|
||||
}
|
||||
if (!result.ok || !result.data || result.data.length === 0) {
|
||||
await interaction.editReply({ content: `No wiki results for "${query}".` })
|
||||
return
|
||||
}
|
||||
|
||||
const best = result.data[0]
|
||||
const url = `${siteOrigin()}/wiki/${best.slug}`
|
||||
const content = best.excerpt ? `**${best.title}**\n${best.excerpt}\n${url}` : `**${best.title}**\n${url}`
|
||||
await interaction.editReply({ content })
|
||||
},
|
||||
}
|
||||
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 }
|
||||
19
bot/src/discord/guildMemberAdd.js
Normal file
19
bot/src/discord/guildMemberAdd.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// Auto-role on join. Requires the Server Members privileged intent (already
|
||||
// enabled in the Discord Developer Portal per the Phase 1 setup notes).
|
||||
const guildConfig = require('../model/guildConfig')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('autorole')
|
||||
|
||||
async function handleGuildMemberAdd(member) {
|
||||
try {
|
||||
const roleId = await guildConfig.getAutoRoleId(member.guild.id)
|
||||
if (!roleId) return
|
||||
await member.roles.add(roleId)
|
||||
log.info('auto-role assigned', { userId: member.id, roleId })
|
||||
} catch (err) {
|
||||
log.warn('auto-role assignment failed', { userId: member.id, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleGuildMemberAdd }
|
||||
92
bot/src/discord/messageFilter.js
Normal file
92
bot/src/discord/messageFilter.js
Normal file
@@ -0,0 +1,92 @@
|
||||
// messageCreate orchestration: allowlist bypass -> invite link -> banned word
|
||||
// -> spam/mass-mention/mass-emoji. Invite/spam triggers always delete + warn
|
||||
// (no severity tiers for those, unlike the word filter) — kept simple per the
|
||||
// spec's "start simple" guidance. Filter-triggered mutes use a fixed 10-minute
|
||||
// duration; per-severity-configurable durations are a future refinement.
|
||||
const filterCache = require('../filter/filterCache')
|
||||
const { findMatch } = require('../filter/normalize')
|
||||
const inviteFilter = require('../filter/inviteFilter')
|
||||
const spamFilter = require('../filter/spamFilter')
|
||||
const warnings = require('../model/warnings')
|
||||
const modLog = require('./modLog')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('filter')
|
||||
|
||||
const FILTER_MUTE_SECONDS = 600 // 10 minutes
|
||||
|
||||
function botActor(client) {
|
||||
return { id: client.user.id, tag: client.user.tag }
|
||||
}
|
||||
|
||||
async function isBypassed(message, cache) {
|
||||
if (cache.allowChannels.has(message.channelId)) return true
|
||||
const memberRoles = message.member ? message.member.roles.cache : null
|
||||
if (memberRoles && [...memberRoles.keys()].some((id) => cache.allowRoles.has(id))) return true
|
||||
return false
|
||||
}
|
||||
|
||||
async function applyWarnAction(message, reason) {
|
||||
const staff = botActor(message.client)
|
||||
await warnings.add({
|
||||
guildId: message.guildId,
|
||||
targetUserId: message.author.id,
|
||||
targetTag: message.author.tag,
|
||||
staffUserId: staff.id,
|
||||
staffTag: staff.tag,
|
||||
reason,
|
||||
})
|
||||
await modLog.record({ client: message.client, guildId: message.guildId, actionType: 'warn', target: message.author, staffUser: staff, reason })
|
||||
}
|
||||
|
||||
async function applyMuteAction(message, reason) {
|
||||
const staff = botActor(message.client)
|
||||
if (message.member && message.member.moderatable) {
|
||||
await message.member.timeout(FILTER_MUTE_SECONDS * 1000, reason)
|
||||
}
|
||||
await modLog.record({
|
||||
client: message.client,
|
||||
guildId: message.guildId,
|
||||
actionType: 'mute',
|
||||
target: message.author,
|
||||
staffUser: staff,
|
||||
reason,
|
||||
durationSeconds: FILTER_MUTE_SECONDS,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleMessageCreate(message) {
|
||||
if (message.author.bot || !message.guildId) return
|
||||
|
||||
try {
|
||||
const cache = await filterCache.getOrLoad(message.guildId)
|
||||
if (await isBypassed(message, cache)) return
|
||||
|
||||
if (await inviteFilter.containsForeignInvite(message)) {
|
||||
await message.delete().catch(() => {})
|
||||
await applyWarnAction(message, 'Posted a Discord invite link')
|
||||
return
|
||||
}
|
||||
|
||||
const match = findMatch(message.content, cache.words)
|
||||
if (match) {
|
||||
await message.delete().catch(() => {})
|
||||
if (match.severity === 'mute') await applyMuteAction(message, `Filtered word: ${match.word}`)
|
||||
else if (match.severity === 'warn') await applyWarnAction(message, `Filtered word: ${match.word}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
spamFilter.isRateLimited(message.guildId, message.author.id) ||
|
||||
spamFilter.isMassMention(message) ||
|
||||
spamFilter.isMassEmoji(message.content)
|
||||
) {
|
||||
await message.delete().catch(() => {})
|
||||
await applyWarnAction(message, 'Automated spam detection (rate limit / mass mention / mass emoji)')
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('messageFilter failed', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleMessageCreate }
|
||||
53
bot/src/discord/modLog.js
Normal file
53
bot/src/discord/modLog.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// Shared by every moderation command (ban/kick/mute/warn): writes the audit
|
||||
// row and posts the embed to the configured mod-log channel. Takes `client`
|
||||
// as a parameter (from interaction.client) rather than importing
|
||||
// discordManager directly, to avoid a require cycle (discordManager -> commands
|
||||
// -> modLog -> discordManager).
|
||||
const { EmbedBuilder } = require('discord.js')
|
||||
|
||||
const db = require('../db')
|
||||
const guildConfig = require('../model/guildConfig')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('modlog')
|
||||
|
||||
const COLOR = { ban: 0xd98b84, kick: 0xe0b070, mute: 0xe0b070, warn: 0xe0b070 }
|
||||
|
||||
async function record({ client, guildId, actionType, target, staffUser, reason, durationSeconds }) {
|
||||
await db.query(
|
||||
`INSERT INTO mod_actions (guild_id, action_type, target_user_id, target_tag, staff_user_id, staff_tag, reason, duration_seconds)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[guildId, actionType, target.id, target.tag || null, staffUser.id, staffUser.tag || null, reason || null, durationSeconds || null],
|
||||
)
|
||||
|
||||
try {
|
||||
const channelId = await guildConfig.getModLogChannelId(guildId)
|
||||
if (!channelId) return
|
||||
const channel = await client.channels.fetch(channelId)
|
||||
if (!channel || !channel.isTextBased()) return
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(COLOR[actionType] || 0x9aa5b1)
|
||||
.setTitle(actionType.toUpperCase())
|
||||
.addFields(
|
||||
{ name: 'Target', value: `${target.tag || target.id} (${target.id})`, inline: true },
|
||||
{ name: 'Staff', value: `${staffUser.tag || staffUser.id} (${staffUser.id})`, inline: true },
|
||||
)
|
||||
.setTimestamp()
|
||||
if (reason) embed.addFields({ name: 'Reason', value: reason })
|
||||
if (durationSeconds) embed.addFields({ name: 'Duration', value: formatDuration(durationSeconds), inline: true })
|
||||
|
||||
await channel.send({ embeds: [embed] })
|
||||
} catch (err) {
|
||||
log.warn('failed to post mod-log embed', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (seconds % 86400 === 0) return `${seconds / 86400}d`
|
||||
if (seconds % 3600 === 0) return `${seconds / 3600}h`
|
||||
if (seconds % 60 === 0) return `${seconds / 60}m`
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
module.exports = { record }
|
||||
26
bot/src/discord/newsAnnounce.js
Normal file
26
bot/src/discord/newsAnnounce.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// Shared by the /internal/announce webhook (site publishes a news post) and
|
||||
// the manual /announce command (staff re-posts/boosts an existing one) — so
|
||||
// both paths produce an identical embed.
|
||||
const { EmbedBuilder } = require('discord.js')
|
||||
|
||||
const guildConfig = require('../model/guildConfig')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('news')
|
||||
|
||||
async function postAnnounce(client, guildId, { title, excerpt, url, imageUrl }) {
|
||||
const channelId = await guildConfig.getNewsChannelId(guildId)
|
||||
if (!channelId) throw new Error('No news channel configured — set one with /news first.')
|
||||
|
||||
const channel = await client.channels.fetch(channelId)
|
||||
if (!channel || !channel.isTextBased()) throw new Error('Configured news channel is missing or not text-based.')
|
||||
|
||||
const embed = new EmbedBuilder().setColor(0x6a8fc2).setTitle(title).setURL(url)
|
||||
if (excerpt) embed.setDescription(excerpt)
|
||||
if (imageUrl) embed.setImage(imageUrl)
|
||||
|
||||
await channel.send({ embeds: [embed] })
|
||||
log.info('news announced', { title, channelId })
|
||||
}
|
||||
|
||||
module.exports = { postAnnounce }
|
||||
41
bot/src/discord/roleMenuHandler.js
Normal file
41
bot/src/discord/roleMenuHandler.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// Button-based self-assignable role menus. customId is `rolemenu:<roleId>` —
|
||||
// the message's own id (not known until after it's sent, so it can't be
|
||||
// embedded in the customId itself) is instead used to look up the tracked
|
||||
// role_menus row and confirm the clicked roleId is really part of that
|
||||
// menu's mapping, so a stale/foreign button can't toggle an untracked role.
|
||||
const roleMenus = require('../model/roleMenus')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('rolemenu')
|
||||
|
||||
const PREFIX = 'rolemenu:'
|
||||
|
||||
// Returns true if this handler owned the interaction (caller should stop
|
||||
// looking for another handler), false if it's not a role-menu button at all.
|
||||
async function handleInteraction(interaction) {
|
||||
if (!interaction.isButton() || !interaction.customId.startsWith(PREFIX)) return false
|
||||
|
||||
const roleId = interaction.customId.slice(PREFIX.length)
|
||||
try {
|
||||
const menu = await roleMenus.getByMessageId(interaction.message.id)
|
||||
if (!menu || !menu.mapping.some((m) => m.roleId === roleId)) {
|
||||
await interaction.reply({ content: 'This role menu is no longer valid.', ephemeral: true })
|
||||
return true
|
||||
}
|
||||
|
||||
const member = interaction.member
|
||||
if (member.roles.cache.has(roleId)) {
|
||||
await member.roles.remove(roleId)
|
||||
await interaction.reply({ content: `Removed <@&${roleId}>.`, ephemeral: true })
|
||||
} else {
|
||||
await member.roles.add(roleId)
|
||||
await interaction.reply({ content: `Added <@&${roleId}>.`, ephemeral: true })
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('role menu toggle failed', { message: err.message })
|
||||
await interaction.reply({ content: 'Something went wrong toggling that role.', ephemeral: true }).catch(() => {})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
module.exports = { handleInteraction }
|
||||
31
bot/src/filter/filterCache.js
Normal file
31
bot/src/filter/filterCache.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// In-memory per-guild filter state (word list + allowlist), loaded at startup
|
||||
// and refreshed on config change — the messageCreate handler runs on every
|
||||
// message, so it must never hit the DB per message (per the spec's
|
||||
// performance note).
|
||||
const filterWords = require('../model/filterWords')
|
||||
const filterAllowlist = require('../model/filterAllowlist')
|
||||
|
||||
const cache = new Map() // guildId -> { words, allowRoles: Set, allowChannels: Set }
|
||||
|
||||
async function load(guildId) {
|
||||
const [words, roles, channels] = await Promise.all([
|
||||
filterWords.list(guildId),
|
||||
filterAllowlist.getRoles(guildId),
|
||||
filterAllowlist.getChannels(guildId),
|
||||
])
|
||||
const entry = { words, allowRoles: new Set(roles), allowChannels: new Set(channels) }
|
||||
cache.set(guildId, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
// Lazy-loads on first access per guild (e.g. the first message after boot).
|
||||
async function getOrLoad(guildId) {
|
||||
return cache.get(guildId) || load(guildId)
|
||||
}
|
||||
|
||||
// Called by /filter and /filterallow after any mutation.
|
||||
function refresh(guildId) {
|
||||
return load(guildId)
|
||||
}
|
||||
|
||||
module.exports = { getOrLoad, refresh }
|
||||
23
bot/src/filter/inviteFilter.js
Normal file
23
bot/src/filter/inviteFilter.js
Normal file
@@ -0,0 +1,23 @@
|
||||
// Detects Discord invite links and blocks any that don't resolve to the
|
||||
// current guild (anti-raid/anti-advertising). An invite that fails to resolve
|
||||
// (expired/invalid/vanity-only) is treated as foreign too — safer default
|
||||
// than silently letting an unresolvable link through.
|
||||
const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-zA-Z0-9-]+)/gi
|
||||
|
||||
async function containsForeignInvite(message) {
|
||||
const matches = [...message.content.matchAll(INVITE_REGEX)]
|
||||
if (matches.length === 0) return false
|
||||
|
||||
for (const match of matches) {
|
||||
const code = match[1]
|
||||
try {
|
||||
const invite = await message.client.fetchInvite(code)
|
||||
if (invite.guild?.id !== message.guildId) return true
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
module.exports = { containsForeignInvite }
|
||||
33
bot/src/filter/normalize.js
Normal file
33
bot/src/filter/normalize.js
Normal file
@@ -0,0 +1,33 @@
|
||||
// Basic obfuscation-resistant normalization for the word filter: lowercase,
|
||||
// common leetspeak substitutions, and collapsing 3+ repeated characters
|
||||
// ("sooooo" -> "so") to one. Deliberately simple per the spec ("start simple,
|
||||
// leave room to tighten later") — spaced-out letters ("b a d") and more exotic
|
||||
// unicode lookalikes aren't handled yet.
|
||||
const SUBS = { 4: 'a', '@': 'a', 3: 'e', 1: 'i', '!': 'i', 0: 'o', $: 's', 5: 's', 7: 't' }
|
||||
const SUB_CHARS = /[4@31!05$7]/g
|
||||
|
||||
function normalize(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(SUB_CHARS, (ch) => SUBS[ch] || ch)
|
||||
.replace(/(.)\1{2,}/g, '$1')
|
||||
}
|
||||
|
||||
function escapeRegex(str) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
// Word-boundary match against already-normalized text. `word` is normalized
|
||||
// here too, so callers can pass the raw stored value.
|
||||
function matches(normalizedText, word) {
|
||||
const pattern = new RegExp(`\\b${escapeRegex(normalize(word))}\\b`, 'i')
|
||||
return pattern.test(normalizedText)
|
||||
}
|
||||
|
||||
// Returns the first matching filter_words row ({word, severity}) or null.
|
||||
function findMatch(content, words) {
|
||||
const normalizedText = normalize(content)
|
||||
return words.find((w) => matches(normalizedText, w.word)) || null
|
||||
}
|
||||
|
||||
module.exports = { normalize, matches, findMatch }
|
||||
42
bot/src/filter/spamFilter.js
Normal file
42
bot/src/filter/spamFilter.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// Basic in-memory spam/rate-limit detection. Per-user message-rate tracking is
|
||||
// the only stateful piece here (mass-mention/mass-emoji are per-message
|
||||
// counts) — kept in memory rather than the DB since this runs on every
|
||||
// message and needs to be fast.
|
||||
const RATE_LIMIT_COUNT = 5
|
||||
const RATE_LIMIT_WINDOW_MS = 5000
|
||||
const MENTION_THRESHOLD = 5
|
||||
const EMOJI_THRESHOLD = 10
|
||||
const SWEEP_INTERVAL_MS = 5 * 60 * 1000
|
||||
|
||||
const history = new Map() // `${guildId}:${userId}` -> timestamps[]
|
||||
|
||||
function isRateLimited(guildId, userId) {
|
||||
const key = `${guildId}:${userId}`
|
||||
const now = Date.now()
|
||||
const timestamps = (history.get(key) || []).filter((t) => now - t < RATE_LIMIT_WINDOW_MS)
|
||||
timestamps.push(now)
|
||||
history.set(key, timestamps)
|
||||
return timestamps.length > RATE_LIMIT_COUNT
|
||||
}
|
||||
|
||||
function isMassMention(message) {
|
||||
return message.mentions.users.size + message.mentions.roles.size > MENTION_THRESHOLD
|
||||
}
|
||||
|
||||
const EMOJI_REGEX = /<a?:\w+:\d+>|\p{Extended_Pictographic}/gu
|
||||
|
||||
function isMassEmoji(content) {
|
||||
const count = (content.match(EMOJI_REGEX) || []).length
|
||||
return count > EMOJI_THRESHOLD
|
||||
}
|
||||
|
||||
// Periodic cleanup so `history` doesn't grow unbounded over a long-running
|
||||
// process — drops any key with no recent activity.
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [key, timestamps] of history) {
|
||||
if (timestamps.every((t) => now - t >= RATE_LIMIT_WINDOW_MS)) history.delete(key)
|
||||
}
|
||||
}, SWEEP_INTERVAL_MS).unref()
|
||||
|
||||
module.exports = { isRateLimited, isMassMention, isMassEmoji }
|
||||
50
bot/src/internal/internal.controller.js
Normal file
50
bot/src/internal/internal.controller.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const discordManager = require('../discord/discordManager')
|
||||
const newsAnnounce = require('../discord/newsAnnounce')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('internal')
|
||||
|
||||
// POST /internal/config — called by the main server right after an admin
|
||||
// saves the Discord Bot panel, and by the bot's own bootstrap on startup
|
||||
// (via a GET to the server for the current config, then this same start/stop
|
||||
// logic locally). Body: { token, guildId, enabled }.
|
||||
async function setConfig(req, res) {
|
||||
const { token, guildId, enabled } = req.body || {}
|
||||
try {
|
||||
if (enabled) {
|
||||
if (!token || !guildId) {
|
||||
return res.status(400).json({ message: 'token and guildId are required when enabled' })
|
||||
}
|
||||
await discordManager.start({ token, guildId })
|
||||
} else {
|
||||
await discordManager.stop()
|
||||
}
|
||||
return res.json(discordManager.getStatus())
|
||||
} catch (err) {
|
||||
log.error('setConfig failed', { message: err.message })
|
||||
// Still 200 with an error status — the caller (admin panel) should surface
|
||||
// discordManager's status/statusDetail rather than treat this as a 5xx.
|
||||
return res.json(discordManager.getStatus())
|
||||
}
|
||||
}
|
||||
|
||||
// GET /internal/status — live connection state, polled by the admin panel.
|
||||
function getStatusHandler(req, res) {
|
||||
return res.json(discordManager.getStatus())
|
||||
}
|
||||
|
||||
// POST /internal/announce — called by the main server right after a news
|
||||
// post is published. Body: { title, excerpt, url, imageUrl }.
|
||||
async function announce(req, res) {
|
||||
const connection = discordManager.getConnection()
|
||||
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
|
||||
try {
|
||||
await newsAnnounce.postAnnounce(connection.client, connection.guildId, req.body || {})
|
||||
return res.json({ posted: true })
|
||||
} catch (err) {
|
||||
log.warn('announce failed', { message: err.message })
|
||||
return res.status(400).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { setConfig, getStatus: getStatusHandler, announce }
|
||||
14
bot/src/internal/internal.routes.js
Normal file
14
bot/src/internal/internal.routes.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const express = require('express')
|
||||
|
||||
const requireInternalKey = require('./requireInternalKey')
|
||||
const ctrl = require('./internal.controller')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.use(requireInternalKey)
|
||||
|
||||
router.post('/config', ctrl.setConfig)
|
||||
router.get('/status', ctrl.getStatus)
|
||||
router.post('/announce', ctrl.announce)
|
||||
|
||||
module.exports = router
|
||||
19
bot/src/internal/requireInternalKey.js
Normal file
19
bot/src/internal/requireInternalKey.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// Gate for the bot's /internal/* API. The only caller is the main UOMysticmoon
|
||||
// server, over the private compose network — never expose this route through
|
||||
// the public reverse proxy. Timing-safe compare so response time can't be used
|
||||
// to brute-force the shared secret one byte at a time.
|
||||
const crypto = require('crypto')
|
||||
|
||||
function requireInternalKey(req, res, next) {
|
||||
const expected = process.env.BOT_INTERNAL_KEY || ''
|
||||
const provided = req.get('X-Internal-Key') || ''
|
||||
|
||||
const a = Buffer.from(expected)
|
||||
const b = Buffer.from(provided)
|
||||
const match = expected.length > 0 && a.length === b.length && crypto.timingSafeEqual(a, b)
|
||||
|
||||
if (!match) return res.status(401).json({ message: 'Unauthorized' })
|
||||
return next()
|
||||
}
|
||||
|
||||
module.exports = requireInternalKey
|
||||
37
bot/src/invites/inviteRotator.js
Normal file
37
bot/src/invites/inviteRotator.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Shared by both /invite rotate and the weekly cron job (inviteScheduler.js)
|
||||
// so manual and automatic rotations log identically. maxAge is set to match
|
||||
// the rotation cadence as defense-in-depth: if the scheduled rotation were
|
||||
// ever to silently stop running, the invite still expires on its own instead
|
||||
// of staying live forever.
|
||||
const guildConfig = require('../model/guildConfig')
|
||||
const inviteLog = require('../model/inviteLog')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('invites')
|
||||
|
||||
const ROTATION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 // 7 days
|
||||
|
||||
async function rotate(client, guildId, { triggeredBy, triggeredByTag } = {}) {
|
||||
const channelId = await guildConfig.getInviteChannelId(guildId)
|
||||
if (!channelId) throw new Error('No invite channel configured — set one with /invite channel first.')
|
||||
|
||||
const channel = await client.channels.fetch(channelId)
|
||||
if (!channel || !channel.isTextBased()) throw new Error('Configured invite channel is missing or not text-based.')
|
||||
|
||||
const current = await inviteLog.getCurrent(guildId)
|
||||
if (current) {
|
||||
try {
|
||||
await channel.guild.invites.delete(current.invite_code, 'Invite rotation')
|
||||
} catch (err) {
|
||||
log.warn('failed to revoke previous invite (may already be gone)', { message: err.message })
|
||||
}
|
||||
await inviteLog.markRevoked(current.id)
|
||||
}
|
||||
|
||||
const invite = await channel.createInvite({ maxAge: ROTATION_MAX_AGE_SECONDS, unique: true, reason: 'Invite rotation' })
|
||||
await inviteLog.record({ guildId, channelId, inviteCode: invite.code, triggeredBy, triggeredByTag })
|
||||
log.info('invite rotated', { code: invite.code, triggeredBy: triggeredByTag || 'automatic (scheduled)' })
|
||||
return invite
|
||||
}
|
||||
|
||||
module.exports = { rotate }
|
||||
31
bot/src/invites/inviteScheduler.js
Normal file
31
bot/src/invites/inviteScheduler.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// Weekly automatic invite rotation (Sundays at midnight). A missing invite
|
||||
// channel config just skips quietly (warn-logged) — most guilds won't set
|
||||
// this up on day one, and that shouldn't spam errors every week until they do.
|
||||
const cron = require('node-cron')
|
||||
|
||||
const inviteRotator = require('./inviteRotator')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('invites')
|
||||
|
||||
let task = null
|
||||
|
||||
function start(client, guildId) {
|
||||
task = cron.schedule('0 0 * * 0', async () => {
|
||||
try {
|
||||
await inviteRotator.rotate(client, guildId, {})
|
||||
} catch (err) {
|
||||
log.warn('scheduled invite rotation skipped', { message: err.message })
|
||||
}
|
||||
})
|
||||
log.info('invite rotation scheduler started')
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (task) {
|
||||
task.stop()
|
||||
task = null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { start, stop }
|
||||
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 }
|
||||
48
bot/src/roles/tempRoleSweeper.js
Normal file
48
bot/src/roles/tempRoleSweeper.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// Once-a-minute sweep for expired temp_roles: removes the Discord role (best
|
||||
// effort — the member/guild/role may already be gone) then deletes the row
|
||||
// regardless, so a stale row can never block future re-grants of the same
|
||||
// role to the same member.
|
||||
const cron = require('node-cron')
|
||||
|
||||
const tempRoles = require('../model/tempRoles')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('temproles')
|
||||
|
||||
let client = null
|
||||
let task = null
|
||||
|
||||
async function sweep() {
|
||||
try {
|
||||
const expired = await tempRoles.listExpired()
|
||||
for (const row of expired) {
|
||||
try {
|
||||
const guild = await client.guilds.fetch(row.guild_id)
|
||||
const member = await guild.members.fetch(row.user_id).catch(() => null)
|
||||
if (member) await member.roles.remove(row.role_id).catch(() => {})
|
||||
} catch (err) {
|
||||
log.warn('failed to remove expired temp role', { message: err.message, roleId: row.role_id, userId: row.user_id })
|
||||
} finally {
|
||||
await tempRoles.removeById(row.id)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('temp role sweep failed', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
function start(discordClient) {
|
||||
client = discordClient
|
||||
task = cron.schedule('* * * * *', sweep)
|
||||
log.info('temp role sweeper started')
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (task) {
|
||||
task.stop()
|
||||
task = null
|
||||
}
|
||||
client = null
|
||||
}
|
||||
|
||||
module.exports = { start, stop }
|
||||
83
bot/src/scheduler/scheduler.js
Normal file
83
bot/src/scheduler/scheduler.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// Recurring + one-off scheduled channel messages. Recurring rows are each
|
||||
// registered as their own node-cron task; one-off rows are picked up by a
|
||||
// once-a-minute sweep that checks for anything due and marks it sent so it
|
||||
// never reposts. Needs a live discord.js Client to actually send — wired up
|
||||
// by discordManager.js (start() once the client is ready, stop() alongside
|
||||
// client teardown).
|
||||
const cron = require('node-cron')
|
||||
|
||||
const scheduledMessages = require('../model/scheduledMessages')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('scheduler')
|
||||
|
||||
let discordClient = null
|
||||
const recurringTasks = new Map() // id -> node-cron ScheduledTask
|
||||
let sweepTask = null
|
||||
|
||||
async function sendToChannel(channelId, content) {
|
||||
try {
|
||||
const channel = await discordClient.channels.fetch(channelId)
|
||||
if (!channel || !channel.isTextBased()) {
|
||||
log.warn('scheduled message skipped — channel missing or not text-based', { channelId })
|
||||
return
|
||||
}
|
||||
await channel.send({ content })
|
||||
log.info('sent scheduled message', { channelId })
|
||||
} catch (err) {
|
||||
log.warn('failed to send scheduled message', { channelId, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecurring() {
|
||||
for (const task of recurringTasks.values()) task.stop()
|
||||
recurringTasks.clear()
|
||||
|
||||
const rows = await scheduledMessages.listEnabledRecurring()
|
||||
for (const row of rows) {
|
||||
if (!cron.validate(row.cron_expression)) {
|
||||
log.warn('skipping scheduled message with invalid cron expression', { id: row.id, cron: row.cron_expression })
|
||||
continue
|
||||
}
|
||||
const task = cron.schedule(row.cron_expression, () => sendToChannel(row.channel_id, row.content))
|
||||
recurringTasks.set(row.id, task)
|
||||
}
|
||||
log.info('loaded recurring scheduled messages', { count: recurringTasks.size })
|
||||
}
|
||||
|
||||
async function sweepDueOneOff() {
|
||||
try {
|
||||
const due = await scheduledMessages.listDueOneOff()
|
||||
for (const row of due) {
|
||||
await sendToChannel(row.channel_id, row.content)
|
||||
await scheduledMessages.markSent(row.id)
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('one-off sweep failed', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function start(client) {
|
||||
discordClient = client
|
||||
await loadRecurring()
|
||||
sweepTask = cron.schedule('* * * * *', sweepDueOneOff)
|
||||
log.info('scheduler started')
|
||||
}
|
||||
|
||||
// Called by /schedule after any add/remove so changes apply without a restart.
|
||||
async function refresh() {
|
||||
if (!discordClient) return
|
||||
await loadRecurring()
|
||||
}
|
||||
|
||||
function stop() {
|
||||
for (const task of recurringTasks.values()) task.stop()
|
||||
recurringTasks.clear()
|
||||
if (sweepTask) {
|
||||
sweepTask.stop()
|
||||
sweepTask = null
|
||||
}
|
||||
discordClient = null
|
||||
}
|
||||
|
||||
module.exports = { start, stop, refresh }
|
||||
52
bot/src/server.js
Normal file
52
bot/src/server.js
Normal file
@@ -0,0 +1,52 @@
|
||||
require('dotenv').config()
|
||||
|
||||
const app = require('./app')
|
||||
const bootstrap = require('./bootstrap')
|
||||
const createLogger = require('./utils/logger')
|
||||
const discordManager = require('./discord/discordManager')
|
||||
const pkg = require('../package.json')
|
||||
|
||||
const log = createLogger('server')
|
||||
const PORT = Number(process.env.PORT) || 4100
|
||||
const HOST = '0.0.0.0'
|
||||
|
||||
async function start() {
|
||||
log.info(`starting UOMysticmoon bot v${pkg.version}`, {
|
||||
node: process.version,
|
||||
logFile: createLogger.logFilePath || 'disabled (console only)',
|
||||
})
|
||||
|
||||
const server = app.listen(PORT, HOST, () => {
|
||||
log.info(`internal API listening on http://${HOST}:${PORT}`)
|
||||
})
|
||||
|
||||
await bootstrap()
|
||||
|
||||
setupShutdown(server)
|
||||
}
|
||||
|
||||
function setupShutdown(server) {
|
||||
let closing = false
|
||||
const shutdown = async (signal) => {
|
||||
if (closing) return
|
||||
closing = true
|
||||
log.warn(`${signal} received — shutting down gracefully`)
|
||||
server.close(() => log.info('internal API closed'))
|
||||
await discordManager.stop()
|
||||
await createLogger.close()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'))
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
||||
process.on('unhandledRejection', (reason) => log.error('unhandledRejection', { reason: String(reason) }))
|
||||
process.on('uncaughtException', (err) => {
|
||||
log.error('uncaughtException', err)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
start().catch((err) => {
|
||||
log.error('failed to start bot', err)
|
||||
process.exit(1)
|
||||
})
|
||||
42
bot/src/site/siteApiClient.js
Normal file
42
bot/src/site/siteApiClient.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// Read-only client for the main site's PUBLIC API (no shared secret — this is
|
||||
// the same unauthenticated data any visitor's browser can fetch). Used by
|
||||
// /wiki (search) and /announce (re-post an existing news item). Distinct from
|
||||
// botInternalClient.js, which is the shared-secret-gated server<->bot channel.
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('site-api')
|
||||
|
||||
const BASE_URL = (process.env.SITE_PUBLIC_URL || 'http://localhost:3000/api/v1/public').replace(/\/+$/, '')
|
||||
const TIMEOUT_MS = 5000
|
||||
|
||||
async function call(path) {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}${path}`, { signal: controller.signal })
|
||||
const data = await res.json().catch(() => null)
|
||||
// Public content routes 503 with this shape while the site is in
|
||||
// maintenance mode (see server/src/middleware/siteMode.js) — surface it
|
||||
// distinctly so commands can show a clear message instead of a generic error.
|
||||
if (res.status === 503 && data?.mode === 'maintenance') {
|
||||
return { ok: false, maintenance: true, message: data.message }
|
||||
}
|
||||
if (!res.ok) return { ok: false, error: `site responded ${res.status}` }
|
||||
return { ok: true, data }
|
||||
} catch (err) {
|
||||
log.warn('site API call failed', { path, message: err.message })
|
||||
return { ok: false, error: err.message }
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
function getNewsPost(idOrSlug) {
|
||||
return call(`/posts/news/${encodeURIComponent(idOrSlug)}`)
|
||||
}
|
||||
|
||||
function searchWiki(query) {
|
||||
return call(`/wiki?q=${encodeURIComponent(query)}`)
|
||||
}
|
||||
|
||||
module.exports = { getNewsPost, searchWiki }
|
||||
16
bot/src/utils/duration.js
Normal file
16
bot/src/utils/duration.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// Parses simple duration strings ("30s", "10m", "2h", "1d") to milliseconds.
|
||||
// Returns null for anything unparseable. Discord's own timeout API caps at 28
|
||||
// days — callers should clamp to MAX_TIMEOUT_MS rather than trust user input.
|
||||
const UNIT_MS = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }
|
||||
|
||||
const MAX_TIMEOUT_MS = 28 * 86_400_000
|
||||
|
||||
function parseDuration(input) {
|
||||
if (!input) return null
|
||||
const match = /^(\d+)\s*(s|m|h|d)$/i.exec(input.trim())
|
||||
if (!match) return null
|
||||
const [, amount, unit] = match
|
||||
return Number(amount) * UNIT_MS[unit.toLowerCase()]
|
||||
}
|
||||
|
||||
module.exports = { parseDuration, MAX_TIMEOUT_MS }
|
||||
97
bot/src/utils/logger.js
Normal file
97
bot/src/utils/logger.js
Normal file
@@ -0,0 +1,97 @@
|
||||
// Dual-transport logger: writes to the console AND to a log file.
|
||||
// Levels: error | warn | info | debug.
|
||||
// LOG_LEVEL console verbosity (default info)
|
||||
// FILE_LOG_LEVEL file verbosity (default debug — keep a full record on disk)
|
||||
// LOG_TO_FILE enable file logging (default true)
|
||||
// LOG_DIR log directory (default <bot>/logs)
|
||||
// LOG_FILE log file name (default bot.log)
|
||||
//
|
||||
// Copied from server/src/utils/logger.js rather than shared — the bot is an
|
||||
// independently deployable process with its own package.json/Dockerfile.
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 }
|
||||
|
||||
const consoleThreshold = LEVELS[(process.env.LOG_LEVEL || 'info').toLowerCase()] ?? LEVELS.info
|
||||
const fileThreshold = LEVELS[(process.env.FILE_LOG_LEVEL || 'debug').toLowerCase()] ?? LEVELS.debug
|
||||
|
||||
// Color only on an interactive TTY — never in files or Docker logs.
|
||||
const useColor = Boolean(process.stdout.isTTY) && process.env.NO_COLOR == null
|
||||
const COLOR = { error: '\x1b[31m', warn: '\x1b[33m', info: '\x1b[36m', debug: '\x1b[90m' }
|
||||
const RESET = '\x1b[0m'
|
||||
|
||||
// ── File transport ────────────────────────────────────────────────────
|
||||
const fileEnabled = (process.env.LOG_TO_FILE || 'true').toLowerCase() !== 'false'
|
||||
let fileStream = null
|
||||
let logFilePath = null
|
||||
|
||||
if (fileEnabled) {
|
||||
try {
|
||||
const dir = process.env.LOG_DIR || path.join(__dirname, '..', '..', 'logs')
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
logFilePath = path.join(dir, process.env.LOG_FILE || 'bot.log')
|
||||
fileStream = fs.createWriteStream(logFilePath, { flags: 'a' })
|
||||
fileStream.on('error', (err) => {
|
||||
process.stderr.write(`[logger] file logging disabled: ${err.message}\n`)
|
||||
fileStream = null
|
||||
})
|
||||
} catch (err) {
|
||||
process.stderr.write(`[logger] could not open log file: ${err.message}\n`)
|
||||
fileStream = null
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(meta) {
|
||||
if (meta == null) return ''
|
||||
if (typeof meta === 'string') return meta
|
||||
if (meta instanceof Error) return JSON.stringify({ message: meta.message, stack: meta.stack })
|
||||
try {
|
||||
return JSON.stringify(meta)
|
||||
} catch {
|
||||
return String(meta)
|
||||
}
|
||||
}
|
||||
|
||||
function emit(level, tag, msg, meta) {
|
||||
const levelNum = LEVELS[level]
|
||||
if (levelNum === undefined) return
|
||||
|
||||
const ts = new Date().toISOString()
|
||||
const lvl = level.toUpperCase().padEnd(5)
|
||||
const label = tag ? ` [${tag}]` : ''
|
||||
const metaStr = meta === undefined ? '' : ` ${fmt(meta)}`
|
||||
const plain = `${ts} ${lvl}${label} ${msg}${metaStr}`
|
||||
|
||||
// Console transport
|
||||
if (levelNum <= consoleThreshold) {
|
||||
const line = useColor ? `${COLOR[level] || ''}${plain}${RESET}` : plain
|
||||
const stream = level === 'error' || level === 'warn' ? process.stderr : process.stdout
|
||||
stream.write(`${line}\n`)
|
||||
}
|
||||
|
||||
// File transport (plain text, no color)
|
||||
if (fileStream && levelNum <= fileThreshold) {
|
||||
fileStream.write(`${plain}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
function createLogger(tag) {
|
||||
return {
|
||||
error: (msg, meta) => emit('error', tag, msg, meta),
|
||||
warn: (msg, meta) => emit('warn', tag, msg, meta),
|
||||
info: (msg, meta) => emit('info', tag, msg, meta),
|
||||
debug: (msg, meta) => emit('debug', tag, msg, meta),
|
||||
}
|
||||
}
|
||||
|
||||
// Flush and close the file stream (called on graceful shutdown).
|
||||
createLogger.close = () =>
|
||||
new Promise((resolve) => {
|
||||
if (fileStream) fileStream.end(resolve)
|
||||
else resolve()
|
||||
})
|
||||
|
||||
createLogger.emit = emit
|
||||
createLogger.logFilePath = logFilePath
|
||||
module.exports = createLogger
|
||||
Reference in New Issue
Block a user