-
Notifications
You must be signed in to change notification settings - Fork 1
Add reaction roles, autoresponder, moderation actions, and action log #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| import {AutocompleteInteraction, ChatInputCommandInteraction, EmbedBuilder, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder} from "discord.js"; | ||
| import {autoresponderDB, guildDB} from "../db"; | ||
| import Messages from "../util/messages"; | ||
| import Colors from "../util/colors"; | ||
|
|
||
|
|
||
| export default { | ||
| data: new SlashCommandBuilder() | ||
| .setName("autoresponder") | ||
| .setDescription("Manage automatic responses to keywords.") | ||
| .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) | ||
| .setContexts(InteractionContextType.Guild) | ||
| .addSubcommand(c => | ||
| c.setName("toggle").setDescription("Enable or disable the autoresponder module.") | ||
| .addBooleanOption(opt => | ||
| opt.setName("enable").setDescription("Enable or disable") | ||
| ) | ||
| ) | ||
| .addSubcommand(c => | ||
| c.setName("add").setDescription("Add an auto-response trigger.") | ||
| .addStringOption(opt => | ||
| opt.setName("trigger").setDescription("The keyword or phrase to trigger on.").setRequired(true) | ||
| ) | ||
| .addStringOption(opt => | ||
| opt.setName("response").setDescription("The response to send.").setRequired(true) | ||
| ) | ||
| .addStringOption(opt => | ||
| opt.setName("match_type").setDescription("How to match the trigger. Default: contains.") | ||
| .addChoices( | ||
| {name: "Contains", value: "contains"}, | ||
| {name: "Exact Match", value: "exact"}, | ||
| {name: "Starts With", value: "startsWith"}, | ||
| ) | ||
| ) | ||
| ) | ||
| .addSubcommand(c => | ||
| c.setName("remove").setDescription("Remove an auto-response trigger.") | ||
| .addStringOption(opt => | ||
| opt.setName("trigger").setDescription("The trigger to remove.").setRequired(true).setAutocomplete(true) | ||
| ) | ||
| ) | ||
| .addSubcommand(c => | ||
| c.setName("list").setDescription("List all auto-response triggers.") | ||
| ), | ||
|
|
||
|
|
||
| async execute(interaction: ChatInputCommandInteraction<"cached">) { | ||
| const command = interaction.options.getSubcommand(); | ||
| if (command === "toggle") return await this.toggle(interaction); | ||
| if (command === "add") return await this.add(interaction); | ||
| if (command === "remove") return await this.remove(interaction); | ||
| if (command === "list") return await this.list(interaction); | ||
| }, | ||
|
|
||
|
|
||
| async toggle(interaction: ChatInputCommandInteraction<"cached">) { | ||
| const toEnable = interaction.options.getBoolean("enable"); | ||
| const current = await guildDB.get(interaction.guild.id) ?? {}; | ||
| if (toEnable === null) return await interaction.reply(Messages.info(`Autoresponder is currently ${current.autoresponder ? "enabled" : "disabled"}.`, {ephemeral: true})); | ||
|
|
||
| current.autoresponder = toEnable; | ||
| await guildDB.set(interaction.guild.id, current); | ||
| await interaction.reply(Messages.success(`Autoresponder has been ${toEnable ? "enabled" : "disabled"}.`, {ephemeral: true})); | ||
| }, | ||
|
|
||
|
|
||
| async add(interaction: ChatInputCommandInteraction<"cached">) { | ||
| const trigger = interaction.options.getString("trigger", true).toLowerCase(); | ||
| const response = interaction.options.getString("response", true); | ||
| const matchType = (interaction.options.getString("match_type") ?? "contains") as "exact" | "contains" | "startsWith"; | ||
|
|
||
| const current = await autoresponderDB.get(interaction.guild.id) ?? []; | ||
| if (current.some(e => e.trigger === trigger)) { | ||
| return await interaction.reply(Messages.error(`A trigger for \`${trigger}\` already exists. Remove it first to update.`, {ephemeral: true})); | ||
| } | ||
|
|
||
| current.push({trigger, response, matchType}); | ||
| await autoresponderDB.set(interaction.guild.id, current); | ||
| await interaction.reply(Messages.success(`Auto-response added for trigger: \`${trigger}\` (match: ${matchType})`, {ephemeral: true})); | ||
| }, | ||
|
|
||
|
|
||
| async remove(interaction: ChatInputCommandInteraction<"cached">) { | ||
| const trigger = interaction.options.getString("trigger", true).toLowerCase(); | ||
| const current = await autoresponderDB.get(interaction.guild.id) ?? []; | ||
| const index = current.findIndex(e => e.trigger === trigger); | ||
|
|
||
| if (index === -1) return await interaction.reply(Messages.error(`No trigger found for \`${trigger}\`.`, {ephemeral: true})); | ||
|
|
||
| current.splice(index, 1); | ||
| await autoresponderDB.set(interaction.guild.id, current); | ||
| await interaction.reply(Messages.success(`Auto-response removed for trigger: \`${trigger}\``, {ephemeral: true})); | ||
| }, | ||
|
|
||
|
|
||
| async list(interaction: ChatInputCommandInteraction<"cached">) { | ||
| await interaction.deferReply({ephemeral: true}); | ||
| const current = await autoresponderDB.get(interaction.guild.id) ?? []; | ||
| if (!current.length) return await interaction.editReply(Messages.info("No auto-responses configured for this server.")); | ||
|
|
||
| const description = current | ||
| .map(e => `**\`${e.trigger}\`** (${e.matchType})\n↳ ${e.response.substring(0, 80)}${e.response.length > 80 ? "…" : ""}`) | ||
| .join("\n\n"); | ||
|
|
||
| const embed = new EmbedBuilder() | ||
| .setColor(Colors.Info) | ||
| .setTitle("Auto-Responses") | ||
| .setDescription(description); | ||
|
|
||
| await interaction.editReply({embeds: [embed]}); | ||
| }, | ||
|
|
||
|
|
||
| async autocomplete(interaction: AutocompleteInteraction<"cached">) { | ||
| const focused = interaction.options.getFocused().toLowerCase(); | ||
| const current = await autoresponderDB.get(interaction.guildId) ?? []; | ||
| const filtered = current.filter(e => e.trigger.startsWith(focused)).slice(0, 25); | ||
| await interaction.respond(filtered.map(e => ({name: e.trigger, value: e.trigger}))); | ||
| }, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,222 @@ | ||||||
| import {ChatInputCommandInteraction, EmbedBuilder, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder} from "discord.js"; | ||||||
| import {guildDB, warningsDB} from "../db"; | ||||||
| import Messages from "../util/messages"; | ||||||
| import Colors from "../util/colors"; | ||||||
| import type {Warning} from "../types"; | ||||||
|
|
||||||
|
|
||||||
| async function logModAction(interaction: ChatInputCommandInteraction<"cached">, action: string, targetId: string, reason: string) { | ||||||
| const settings = await guildDB.get(interaction.guild.id); | ||||||
| const logChannelId = settings?.modlog; | ||||||
| if (!logChannelId) return; | ||||||
| const logChannel = interaction.guild.channels.cache.get(logChannelId); | ||||||
| if (!logChannel?.isTextBased()) return; | ||||||
|
|
||||||
| const embed = new EmbedBuilder() | ||||||
| .setColor(Colors.Warn) | ||||||
| .setTitle(`Member ${action}`) | ||||||
| .addFields( | ||||||
| {name: "User", value: `<@${targetId}> (${targetId})`, inline: true}, | ||||||
| {name: "Moderator", value: `<@${interaction.user.id}>`, inline: true}, | ||||||
| {name: "Reason", value: reason} | ||||||
| ) | ||||||
| .setTimestamp(); | ||||||
|
|
||||||
| await logChannel.send({embeds: [embed]}).catch(console.error); | ||||||
|
||||||
| await logChannel.send({embeds: [embed]}).catch(console.error); | |
| await logChannel.send({embeds: [embed], allowedMentions: {parse: []}}).catch(console.error); |
Copilot
AI
Apr 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The command is gated by ModerateMembers, but the subcommands include kick/ban/unban which are separate permissions. As written, members who can timeout (but cannot ban/kick) may still be able to run these subcommands successfully. Add explicit permission checks per subcommand (e.g. KickMembers, BanMembers, ModerateMembers) and/or restructure commands so the default permission matches the most sensitive action.
| .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers) | |
| .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) |
Copilot
AI
Apr 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
interaction.guild.members.cache.get(user.id) can be undefined even when the user is in the guild (member not cached), causing false "user is not in this server" errors and preventing kick/timeout/untimeout. Prefer interaction.options.getMember("user") (for cached interactions) or await interaction.guild.members.fetch(user.id) and reuse that for capability checks like kickable/moderatable.
Copilot
AI
Apr 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PR description says all moderation actions emit to the modlog, but clearwarnings currently only deletes from storage and replies to the moderator. Consider logging this action (including target user and moderator) via logModAction for consistency/auditability.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The embed description is built from all triggers and can exceed Discord's 4096-char embed description limit in larger configs, causing the response to fail. Consider paging (you already have
src/paginator.ts) or truncating the list output.