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>
120 lines
5.1 KiB
JavaScript
120 lines
5.1 KiB
JavaScript
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 })
|
|
}
|
|
},
|
|
}
|