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>
69 lines
2.9 KiB
JavaScript
69 lines
2.9 KiB
JavaScript
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}.` })
|
|
}
|
|
},
|
|
}
|