From ff7ff93ed8c102c249bb78611aed92c682e6b22d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 03:47:57 +0000 Subject: [PATCH 1/3] Initial plan From 9db9e57114758b06b413dd6fbf3a0391a55440e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 03:59:28 +0000 Subject: [PATCH 2/3] feat: add reaction roles, autoresponder, moderation actions, and action log Agent-Logs-Url: https://github.com/BetterDiscord/BetterDiscordBot/sessions/c9e5628b-6776-4bf2-8736-3caa5d142525 Co-authored-by: zerebos <6865942+zerebos@users.noreply.github.com> --- src/commands/autoresponder.ts | 120 ++++++++++++++++++ src/commands/mod.ts | 222 ++++++++++++++++++++++++++++++++++ src/commands/moderation.ts | 23 ++++ src/commands/reactionroles.ts | 135 +++++++++++++++++++++ src/db.ts | 7 +- src/events/actionlog.ts | 153 +++++++++++++++++++++++ src/events/autoresponder.ts | 29 +++++ src/events/reactionroles.ts | 72 +++++++++++ src/index.ts | 22 ++-- src/types/base.ts | 21 ++++ 10 files changed, 795 insertions(+), 9 deletions(-) create mode 100644 src/commands/autoresponder.ts create mode 100644 src/commands/mod.ts create mode 100644 src/commands/reactionroles.ts create mode 100644 src/events/actionlog.ts create mode 100644 src/events/autoresponder.ts create mode 100644 src/events/reactionroles.ts diff --git a/src/commands/autoresponder.ts b/src/commands/autoresponder.ts new file mode 100644 index 0000000..8a6e375 --- /dev/null +++ b/src/commands/autoresponder.ts @@ -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}))); + }, +}; diff --git a/src/commands/mod.ts b/src/commands/mod.ts new file mode 100644 index 0000000..7c01b05 --- /dev/null +++ b/src/commands/mod.ts @@ -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); +} + + +export default { + data: new SlashCommandBuilder() + .setName("mod") + .setDescription("Moderation actions.") + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers) + .setContexts(InteractionContextType.Guild) + .addSubcommand(c => + c.setName("kick").setDescription("Kick a member from the server.") + .addUserOption(opt => opt.setName("user").setDescription("The user to kick.").setRequired(true)) + .addStringOption(opt => opt.setName("reason").setDescription("Reason for the kick.")) + ) + .addSubcommand(c => + c.setName("ban").setDescription("Ban a user from the server.") + .addUserOption(opt => opt.setName("user").setDescription("The user to ban.").setRequired(true)) + .addStringOption(opt => opt.setName("reason").setDescription("Reason for the ban.")) + .addIntegerOption(opt => opt.setName("delete_days").setDescription("Days of messages to delete (0–7).").setMinValue(0).setMaxValue(7)) + ) + .addSubcommand(c => + c.setName("unban").setDescription("Unban a user from the server.") + .addStringOption(opt => opt.setName("user_id").setDescription("The user ID to unban.").setRequired(true)) + .addStringOption(opt => opt.setName("reason").setDescription("Reason for the unban.")) + ) + .addSubcommand(c => + c.setName("timeout").setDescription("Timeout (mute) a member.") + .addUserOption(opt => opt.setName("user").setDescription("The user to timeout.").setRequired(true)) + .addIntegerOption(opt => opt.setName("duration").setDescription("Duration in minutes.").setRequired(true).setMinValue(1).setMaxValue(40320)) + .addStringOption(opt => opt.setName("reason").setDescription("Reason for the timeout.")) + ) + .addSubcommand(c => + c.setName("untimeout").setDescription("Remove a timeout from a member.") + .addUserOption(opt => opt.setName("user").setDescription("The user to remove the timeout from.").setRequired(true)) + .addStringOption(opt => opt.setName("reason").setDescription("Reason for removing the timeout.")) + ) + .addSubcommand(c => + c.setName("warn").setDescription("Warn a member.") + .addUserOption(opt => opt.setName("user").setDescription("The user to warn.").setRequired(true)) + .addStringOption(opt => opt.setName("reason").setDescription("Reason for the warning.").setRequired(true)) + ) + .addSubcommand(c => + c.setName("warnings").setDescription("View warnings for a member.") + .addUserOption(opt => opt.setName("user").setDescription("The user to check.").setRequired(true)) + ) + .addSubcommand(c => + c.setName("clearwarnings").setDescription("Clear all warnings for a member.") + .addUserOption(opt => opt.setName("user").setDescription("The user to clear warnings for.").setRequired(true)) + ), + + + async execute(interaction: ChatInputCommandInteraction<"cached">) { + const command = interaction.options.getSubcommand(); + if (command === "kick") return await this.kick(interaction); + if (command === "ban") return await this.ban(interaction); + if (command === "unban") return await this.unban(interaction); + if (command === "timeout") return await this.timeout(interaction); + if (command === "untimeout") return await this.untimeout(interaction); + if (command === "warn") return await this.warn(interaction); + if (command === "warnings") return await this.warnings(interaction); + if (command === "clearwarnings") return await this.clearwarnings(interaction); + }, + + + async kick(interaction: ChatInputCommandInteraction<"cached">) { + const user = interaction.options.getUser("user", true); + const reason = interaction.options.getString("reason") ?? "No reason provided"; + const member = interaction.guild.members.cache.get(user.id); + + if (!member) return await interaction.reply(Messages.error("That user is not in this server.", {ephemeral: true})); + if (!member.kickable) return await interaction.reply(Messages.error("I cannot kick that user. They may have a higher role than me.", {ephemeral: true})); + + try { + await member.kick(reason); + await logModAction(interaction, "Kicked", user.id, reason); + await interaction.reply(Messages.success(`Successfully kicked **${user.tag}**.`)); + } + catch { + await interaction.reply(Messages.error("Failed to kick that user.", {ephemeral: true})); + } + }, + + + async ban(interaction: ChatInputCommandInteraction<"cached">) { + const user = interaction.options.getUser("user", true); + const reason = interaction.options.getString("reason") ?? "No reason provided"; + const deleteDays = interaction.options.getInteger("delete_days") ?? 0; + const member = interaction.guild.members.cache.get(user.id); + + if (member && !member.bannable) return await interaction.reply(Messages.error("I cannot ban that user. They may have a higher role than me.", {ephemeral: true})); + + try { + await interaction.guild.bans.create(user.id, {reason, deleteMessageSeconds: deleteDays * 86400}); + await logModAction(interaction, "Banned", user.id, reason); + await interaction.reply(Messages.success(`Successfully banned **${user.tag}**.`)); + } + catch { + await interaction.reply(Messages.error("Failed to ban that user.", {ephemeral: true})); + } + }, + + + async unban(interaction: ChatInputCommandInteraction<"cached">) { + const userId = interaction.options.getString("user_id", true); + const reason = interaction.options.getString("reason") ?? "No reason provided"; + + try { + await interaction.guild.bans.remove(userId, reason); + await logModAction(interaction, "Unbanned", userId, reason); + await interaction.reply(Messages.success(`Successfully unbanned user with ID **${userId}**.`)); + } + catch { + await interaction.reply(Messages.error("Failed to unban that user. They may not be banned.", {ephemeral: true})); + } + }, + + + async timeout(interaction: ChatInputCommandInteraction<"cached">) { + const user = interaction.options.getUser("user", true); + const durationMinutes = interaction.options.getInteger("duration", true); + const reason = interaction.options.getString("reason") ?? "No reason provided"; + const member = interaction.guild.members.cache.get(user.id); + + if (!member) return await interaction.reply(Messages.error("That user is not in this server.", {ephemeral: true})); + if (!member.moderatable) return await interaction.reply(Messages.error("I cannot timeout that user. They may have a higher role than me.", {ephemeral: true})); + + try { + await member.timeout(durationMinutes * 60 * 1000, reason); + await logModAction(interaction, "Timed Out", user.id, `${reason} (Duration: ${durationMinutes}m)`); + await interaction.reply(Messages.success(`Successfully timed out **${user.tag}** for ${durationMinutes} minute(s).`)); + } + catch { + await interaction.reply(Messages.error("Failed to timeout that user.", {ephemeral: true})); + } + }, + + + async untimeout(interaction: ChatInputCommandInteraction<"cached">) { + const user = interaction.options.getUser("user", true); + const reason = interaction.options.getString("reason") ?? "No reason provided"; + const member = interaction.guild.members.cache.get(user.id); + + if (!member) return await interaction.reply(Messages.error("That user is not in this server.", {ephemeral: true})); + + try { + await member.timeout(null, reason); + await logModAction(interaction, "Timeout Removed", user.id, reason); + await interaction.reply(Messages.success(`Successfully removed timeout from **${user.tag}**.`)); + } + catch { + await interaction.reply(Messages.error("Failed to remove timeout from that user.", {ephemeral: true})); + } + }, + + + async warn(interaction: ChatInputCommandInteraction<"cached">) { + const user = interaction.options.getUser("user", true); + const reason = interaction.options.getString("reason", true); + const key = `${interaction.guild.id}-${user.id}`; + const current: Warning[] = await warningsDB.get(key) ?? []; + + current.push({reason, moderatorId: interaction.user.id, timestamp: Date.now()}); + await warningsDB.set(key, current); + + await logModAction(interaction, `Warned (${current.length} total)`, user.id, reason); + await interaction.reply(Messages.success(`Warning issued to **${user.tag}**. They now have **${current.length}** warning(s).`)); + }, + + + async warnings(interaction: ChatInputCommandInteraction<"cached">) { + const user = interaction.options.getUser("user", true); + const key = `${interaction.guild.id}-${user.id}`; + const current: Warning[] = await warningsDB.get(key) ?? []; + + if (!current.length) return await interaction.reply(Messages.info(`**${user.tag}** has no warnings.`, {ephemeral: true})); + + const description = current + .map((w, i) => `**${i + 1}.** ${w.reason}\n*by <@${w.moderatorId}> on ${new Date(w.timestamp).toDateString()}*`) + .join("\n\n"); + + const embed = new EmbedBuilder() + .setColor(Colors.Warn) + .setTitle(`Warnings for ${user.tag}`) + .setThumbnail(user.displayAvatarURL()) + .setDescription(description); + + await interaction.reply({embeds: [embed], ephemeral: true}); + }, + + + async clearwarnings(interaction: ChatInputCommandInteraction<"cached">) { + const user = interaction.options.getUser("user", true); + const key = `${interaction.guild.id}-${user.id}`; + await warningsDB.delete(key); + await interaction.reply(Messages.success(`Cleared all warnings for **${user.tag}**.`, {ephemeral: true})); + }, +}; diff --git a/src/commands/moderation.ts b/src/commands/moderation.ts index f34a8f0..1c84fd3 100644 --- a/src/commands/moderation.ts +++ b/src/commands/moderation.ts @@ -35,6 +35,13 @@ export default { opt.setName("channel").setDescription("Where to log join/leave messages?").setRequired(false) .addChannelTypes(ChannelType.GuildText) ) + ) + .addSubcommand( + c => c.setName("actionlog").setDescription("Sets a channel to log server action events.") + .addChannelOption(opt => + opt.setName("channel").setDescription("Where to log server action events?").setRequired(false) + .addChannelTypes(ChannelType.GuildText) + ) ), async execute(interaction: ChatInputCommandInteraction<"cached">) { @@ -43,6 +50,7 @@ export default { if (command === "detectspam") return await this.detectspam(interaction); if (command === "modlog") return await this.modlog(interaction); if (command === "joinleave") return await this.joinleave(interaction); + if (command === "actionlog") return await this.actionlog(interaction); }, @@ -105,4 +113,19 @@ export default { } await interaction.reply(Messages.success(targetChannel ? `Join/leave set to <#${targetChannel.id}>!` : "Join/leave has been unset!", {ephemeral: true})); }, + + + async actionlog(interaction: ChatInputCommandInteraction<"cached">) { + const targetChannel = interaction.options.getChannel("channel"); + const current = await guildDB.get(interaction.guild.id) ?? {}; + if (targetChannel) { + current.actionlog = targetChannel.id; + await guildDB.set(interaction.guild.id, current); + } + else { + delete current.actionlog; + await guildDB.set(interaction.guild.id, current); + } + await interaction.reply(Messages.success(targetChannel ? `Action log set to <#${targetChannel.id}>!` : "Action log has been unset!", {ephemeral: true})); + }, }; diff --git a/src/commands/reactionroles.ts b/src/commands/reactionroles.ts new file mode 100644 index 0000000..c3eb045 --- /dev/null +++ b/src/commands/reactionroles.ts @@ -0,0 +1,135 @@ +import {ChannelType, ChatInputCommandInteraction, EmbedBuilder, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder} from "discord.js"; +import {reactionrolesDB} from "../db"; +import Messages from "../util/messages"; +import Colors from "../util/colors"; + + +/** + * Normalise an emoji string (as typed by a user) to a stable key. + * - Custom emoji "<:name:id>" or "" → just the numeric ID + * - Unicode emoji → the character itself (e.g. "👍") + */ +function normalizeEmojiString(emoji: string): string { + const match = emoji.match(/^$/); + return match ? match[1] : emoji; +} + + +export default { + data: new SlashCommandBuilder() + .setName("reactionroles") + .setDescription("Configure reaction roles for your server.") + .setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles) + .setContexts(InteractionContextType.Guild) + .addSubcommand(c => + c.setName("add").setDescription("Add a reaction role to a message.") + .addChannelOption(opt => + opt.setName("channel").setDescription("The channel the message is in.").setRequired(true) + .addChannelTypes(ChannelType.GuildText) + ) + .addStringOption(opt => + opt.setName("message_id").setDescription("The ID of the message.").setRequired(true) + ) + .addStringOption(opt => + opt.setName("emoji").setDescription("The emoji to react with.").setRequired(true) + ) + .addRoleOption(opt => + opt.setName("role").setDescription("The role to assign.").setRequired(true) + ) + ) + .addSubcommand(c => + c.setName("remove").setDescription("Remove a reaction role from a message.") + .addChannelOption(opt => + opt.setName("channel").setDescription("The channel the message is in.").setRequired(true) + .addChannelTypes(ChannelType.GuildText) + ) + .addStringOption(opt => + opt.setName("message_id").setDescription("The ID of the message.").setRequired(true) + ) + .addStringOption(opt => + opt.setName("emoji").setDescription("The emoji to remove.").setRequired(true) + ) + ) + .addSubcommand(c => + c.setName("list").setDescription("List all reaction roles in this server.") + ), + + + async execute(interaction: ChatInputCommandInteraction<"cached">) { + const command = interaction.options.getSubcommand(); + 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 add(interaction: ChatInputCommandInteraction<"cached">) { + await interaction.deferReply({ephemeral: true}); + const channelOption = interaction.options.getChannel("channel", true); + const messageId = interaction.options.getString("message_id", true); + const emojiInput = interaction.options.getString("emoji", true); + const role = interaction.options.getRole("role", true); + const normalizedEmoji = normalizeEmojiString(emojiInput); + + const textChannel = interaction.guild.channels.cache.get(channelOption.id); + if (!textChannel?.isTextBased()) return await interaction.editReply(Messages.error("Invalid channel.")); + + // Validate that the message exists and add the bot's reaction + try { + const targetMessage = await textChannel.messages.fetch(messageId); + await targetMessage.react(emojiInput); + } + catch { + return await interaction.editReply(Messages.error("Could not find that message or react with that emoji. Check the message ID and emoji are correct.")); + } + + const current = await reactionrolesDB.get(interaction.guild.id) ?? []; + + if (current.some(r => r.messageId === messageId && r.emoji === normalizedEmoji)) { + return await interaction.editReply(Messages.warn("A reaction role with that emoji already exists on that message.")); + } + + current.push({messageId, channelId: channelOption.id, emoji: normalizedEmoji, roleId: role.id}); + await reactionrolesDB.set(interaction.guild.id, current); + + await interaction.editReply(Messages.success(`Reaction role added! Reacting with ${emojiInput} on that message will assign <@&${role.id}>.`)); + }, + + + async remove(interaction: ChatInputCommandInteraction<"cached">) { + await interaction.deferReply({ephemeral: true}); + const channelOption = interaction.options.getChannel("channel", true); + const messageId = interaction.options.getString("message_id", true); + const emojiInput = interaction.options.getString("emoji", true); + const normalizedEmoji = normalizeEmojiString(emojiInput); + + const current = await reactionrolesDB.get(interaction.guild.id) ?? []; + const index = current.findIndex(r => r.messageId === messageId && r.channelId === channelOption.id && r.emoji === normalizedEmoji); + + if (index === -1) return await interaction.editReply(Messages.error("No reaction role found with that emoji on that message.")); + + current.splice(index, 1); + await reactionrolesDB.set(interaction.guild.id, current); + + await interaction.editReply(Messages.success("Reaction role removed successfully.")); + }, + + + async list(interaction: ChatInputCommandInteraction<"cached">) { + await interaction.deferReply({ephemeral: true}); + const current = await reactionrolesDB.get(interaction.guild.id) ?? []; + + if (!current.length) return await interaction.editReply(Messages.info("No reaction roles are configured for this server.")); + + const description = current + .map(r => `${r.emoji} → <@&${r.roleId}> (in <#${r.channelId}>, msg: \`${r.messageId}\`)`) + .join("\n"); + + const embed = new EmbedBuilder() + .setColor(Colors.Info) + .setTitle("Reaction Roles") + .setDescription(description); + + await interaction.editReply({embeds: [embed]}); + }, +}; diff --git a/src/db.ts b/src/db.ts index c0baebc..ee9e6b5 100644 --- a/src/db.ts +++ b/src/db.ts @@ -2,7 +2,7 @@ import path from "path"; import {fileURLToPath} from "url"; import Keyv from "keyv"; import Sqlite from "@keyv/sqlite"; -import type {BdWebAddon, CommandStats, GuildSettings, Tag} from "./types"; +import type {AutoResponderEntry, BdWebAddon, CommandStats, GuildSettings, ReactionRole, Tag, Warning} from "./types"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -20,4 +20,7 @@ export const selfrolesDB = new Keyv(sqliteStore, {namespace: "selfrole export const voicetextDB = new Keyv(sqliteStore, {namespace: "voicetext"}); export const statsDB = new Keyv(sqliteStore, {namespace: "stats"}); export const tagsDB = new Keyv>(sqliteStore, {namespace: "tags"}); -export const userInstallNotices = new Keyv(sqliteStore, {namespace: "userInstallNotices"}); \ No newline at end of file +export const userInstallNotices = new Keyv(sqliteStore, {namespace: "userInstallNotices"}); +export const reactionrolesDB = new Keyv(sqliteStore, {namespace: "reactionroles"}); +export const autoresponderDB = new Keyv(sqliteStore, {namespace: "autoresponder"}); +export const warningsDB = new Keyv(sqliteStore, {namespace: "warnings"}); \ No newline at end of file diff --git a/src/events/actionlog.ts b/src/events/actionlog.ts new file mode 100644 index 0000000..ec2b8b9 --- /dev/null +++ b/src/events/actionlog.ts @@ -0,0 +1,153 @@ +import {EmbedBuilder, Events, type Guild, type GuildBan, type GuildMember, type Message, type PartialGuildMember, type PartialMessage} from "discord.js"; +import {guildDB} from "../db"; +import Colors from "../util/colors"; + + +async function getLogChannel(guild: Guild) { + const settings = await guildDB.get(guild.id); + if (!settings?.actionlog) return null; + const channel = guild.channels.cache.get(settings.actionlog); + if (!channel?.isTextBased()) return null; + return channel; +} + + +export default [ + { + name: Events.MessageDelete, + + async execute(message: Message | PartialMessage) { + if (!message.inGuild() || message.author?.bot) return; + + const logChannel = await getLogChannel(message.guild); + if (!logChannel) return; + + const embed = new EmbedBuilder() + .setColor(Colors.Danger) + .setTitle("Message Deleted") + .addFields( + {name: "Author", value: message.author ? `<@${message.author.id}> (${message.author.tag})` : "Unknown", inline: true}, + {name: "Channel", value: `<#${message.channelId}>`, inline: true} + ) + .setFooter({text: `Message ID: ${message.id}`}) + .setTimestamp(); + + if (message.content) embed.setDescription(message.content.substring(0, 4096)); + + await logChannel.send({embeds: [embed]}).catch(console.error); + }, + }, + { + name: Events.MessageUpdate, + + async execute(oldMessage: Message | PartialMessage, newMessage: Message | PartialMessage) { + if (!newMessage.inGuild() || !newMessage.author || newMessage.author.bot) return; + if (oldMessage.content === newMessage.content) return; + + const logChannel = await getLogChannel(newMessage.guild); + if (!logChannel) return; + + const embed = new EmbedBuilder() + .setColor(Colors.Info) + .setTitle("Message Edited") + .setURL(newMessage.url) + .addFields( + {name: "Author", value: `<@${newMessage.author.id}> (${newMessage.author.tag})`, inline: true}, + {name: "Channel", value: `<#${newMessage.channelId}>`, inline: true}, + {name: "Before", value: oldMessage.content?.substring(0, 500) || "*Not available*"}, + {name: "After", value: newMessage.content?.substring(0, 500) || "*Empty*"} + ) + .setFooter({text: `Message ID: ${newMessage.id}`}) + .setTimestamp(); + + await logChannel.send({embeds: [embed]}).catch(console.error); + }, + }, + { + name: Events.GuildBanAdd, + + async execute(ban: GuildBan) { + const logChannel = await getLogChannel(ban.guild); + if (!logChannel) return; + + const embed = new EmbedBuilder() + .setColor(Colors.Danger) + .setTitle("Member Banned") + .setThumbnail(ban.user.displayAvatarURL()) + .addFields( + {name: "User", value: `<@${ban.user.id}> (${ban.user.tag})`, inline: true}, + {name: "Reason", value: ban.reason ?? "No reason provided"} + ) + .setFooter({text: `User ID: ${ban.user.id}`}) + .setTimestamp(); + + await logChannel.send({embeds: [embed]}).catch(console.error); + }, + }, + { + name: Events.GuildBanRemove, + + async execute(ban: GuildBan) { + const logChannel = await getLogChannel(ban.guild); + if (!logChannel) return; + + const embed = new EmbedBuilder() + .setColor(Colors.Success) + .setTitle("Member Unbanned") + .setThumbnail(ban.user.displayAvatarURL()) + .addFields( + {name: "User", value: `<@${ban.user.id}> (${ban.user.tag})`, inline: true} + ) + .setFooter({text: `User ID: ${ban.user.id}`}) + .setTimestamp(); + + await logChannel.send({embeds: [embed]}).catch(console.error); + }, + }, + { + name: Events.GuildMemberUpdate, + + async execute(oldMember: GuildMember | PartialGuildMember, newMember: GuildMember) { + const logChannel = await getLogChannel(newMember.guild); + if (!logChannel) return; + + // Nickname changes + if (oldMember.nickname !== newMember.nickname) { + const embed = new EmbedBuilder() + .setColor(Colors.Info) + .setTitle("Nickname Changed") + .addFields( + {name: "User", value: `<@${newMember.user.id}> (${newMember.user.tag})`, inline: true}, + {name: "Before", value: oldMember.nickname ?? "*None*", inline: true}, + {name: "After", value: newMember.nickname ?? "*None*", inline: true} + ) + .setFooter({text: `User ID: ${newMember.user.id}`}) + .setTimestamp(); + + await logChannel.send({embeds: [embed]}).catch(console.error); + } + + // Role changes — only reliable when the old member was fully cached + if (!oldMember.partial) { + const addedRoles = newMember.roles.cache.filter(r => !oldMember.roles.cache.has(r.id)); + const removedRoles = oldMember.roles.cache.filter(r => !newMember.roles.cache.has(r.id)); + + if (addedRoles.size || removedRoles.size) { + const embed = new EmbedBuilder() + .setColor(Colors.Info) + .setTitle("Member Roles Updated") + .addFields( + {name: "User", value: `<@${newMember.user.id}> (${newMember.user.tag})`, inline: true} + ) + .setFooter({text: `User ID: ${newMember.user.id}`}) + .setTimestamp(); + + if (addedRoles.size) embed.addFields({name: "Roles Added", value: addedRoles.map(r => `<@&${r.id}>`).join(", ")}); + if (removedRoles.size) embed.addFields({name: "Roles Removed", value: removedRoles.map(r => `<@&${r.id}>`).join(", ")}); + + await logChannel.send({embeds: [embed]}).catch(console.error); + } + } + }, + }, +]; diff --git a/src/events/autoresponder.ts b/src/events/autoresponder.ts new file mode 100644 index 0000000..d11f1c1 --- /dev/null +++ b/src/events/autoresponder.ts @@ -0,0 +1,29 @@ +import {Events, type Message} from "discord.js"; +import {autoresponderDB, guildDB} from "../db"; + + +export default { + name: Events.MessageCreate, + + async execute(message: Message) { + if (!message.inGuild() || message.author.bot) return; + + const guildSettings = await guildDB.get(message.guild.id); + if (!guildSettings?.autoresponder) return; + + const entries = await autoresponderDB.get(message.guild.id) ?? []; + if (!entries.length) return; + + const content = message.content.toLowerCase(); + const match = entries.find(entry => { + const trigger = entry.trigger; + if (entry.matchType === "exact") return content === trigger; + if (entry.matchType === "startsWith") return content.startsWith(trigger); + return content.includes(trigger); + }); + + if (!match) return; + + await message.reply(match.response).catch(console.error); + }, +}; diff --git a/src/events/reactionroles.ts b/src/events/reactionroles.ts new file mode 100644 index 0000000..4e5c1e8 --- /dev/null +++ b/src/events/reactionroles.ts @@ -0,0 +1,72 @@ +import {Events, type MessageReaction, type PartialMessageReaction, type PartialUser, type User} from "discord.js"; +import {reactionrolesDB} from "../db"; + + +/** + * Normalise a reaction emoji to the same key used when storing reaction roles. + * - Custom emoji → numeric ID + * - Unicode emoji → the character itself + */ +function normalizeReactionEmoji(emoji: MessageReaction["emoji"]): string { + return emoji.id ?? emoji.name ?? ""; +} + + +export default [ + { + name: Events.MessageReactionAdd, + + async execute(rawReaction: MessageReaction | PartialMessageReaction, rawUser: User | PartialUser) { + if (rawUser.bot) return; + + const reaction = rawReaction.partial ? await rawReaction.fetch().catch(() => null) : rawReaction; + if (!reaction) return; + + const user = rawUser.partial ? await rawUser.fetch().catch(() => null) : rawUser; + if (!user) return; + + if (!reaction.message.guildId) return; + + const entries = await reactionrolesDB.get(reaction.message.guildId) ?? []; + const normalizedEmoji = normalizeReactionEmoji(reaction.emoji); + const entry = entries.find(r => r.messageId === reaction.message.id && r.emoji === normalizedEmoji); + if (!entry) return; + + const guild = reaction.message.guild; + if (!guild) return; + + const member = await guild.members.fetch(user.id).catch(() => null); + if (!member) return; + + await member.roles.add(entry.roleId).catch(console.error); + }, + }, + { + name: Events.MessageReactionRemove, + + async execute(rawReaction: MessageReaction | PartialMessageReaction, rawUser: User | PartialUser) { + if (rawUser.bot) return; + + const reaction = rawReaction.partial ? await rawReaction.fetch().catch(() => null) : rawReaction; + if (!reaction) return; + + const user = rawUser.partial ? await rawUser.fetch().catch(() => null) : rawUser; + if (!user) return; + + if (!reaction.message.guildId) return; + + const entries = await reactionrolesDB.get(reaction.message.guildId) ?? []; + const normalizedEmoji = normalizeReactionEmoji(reaction.emoji); + const entry = entries.find(r => r.messageId === reaction.message.id && r.emoji === normalizedEmoji); + if (!entry) return; + + const guild = reaction.message.guild; + if (!guild) return; + + const member = await guild.members.fetch(user.id).catch(() => null); + if (!member) return; + + await member.roles.remove(entry.roleId).catch(console.error); + }, + }, +]; diff --git a/src/index.ts b/src/index.ts index 26b4ca8..2d11070 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,10 +13,14 @@ const client = new Client({ GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.GuildMessageReactions, + GatewayIntentBits.GuildModeration, GatewayIntentBits.DirectMessages ], partials: [ - Partials.Channel + Partials.Channel, + Partials.Message, + Partials.Reaction, ], presence: {activities: [{name: "Watching for spam", type: ActivityType.Custom}]} }); @@ -43,14 +47,18 @@ const eventFiles = fs.readdirSync(eventsPath).filter(file => file.endsWith(".ts" for (const file of eventFiles) { const filePath = path.join(eventsPath, file); - const event = await import(pathToFileURL(filePath).href) as {default: EventModule;}; + const event = await import(pathToFileURL(filePath).href) as {default: EventModule | EventModule[];}; // Handle both default and named exports const eventData = event.default || event; - if (eventData.once) { - client.once(eventData.name, (...args: Parameters) => eventData.execute(...args)); - } - else { - client.on(eventData.name, (...args: Parameters) => eventData.execute(...args)); + // Handle both single event modules and arrays of event modules + const eventList = Array.isArray(eventData) ? eventData : [eventData]; + for (const e of eventList) { + if (e.once) { + client.once(e.name, (...args: Parameters) => e.execute(...args)); + } + else { + client.on(e.name, (...args: Parameters) => e.execute(...args)); + } } } diff --git a/src/types/base.ts b/src/types/base.ts index 517cebd..b6077a5 100644 --- a/src/types/base.ts +++ b/src/types/base.ts @@ -42,6 +42,27 @@ export interface GuildSettings { detectspam?: boolean; modlog?: string; joinleave?: string; + actionlog?: string; + autoresponder?: boolean; +} + +export interface ReactionRole { + messageId: string; + channelId: string; + emoji: string; + roleId: string; +} + +export interface AutoResponderEntry { + trigger: string; + response: string; + matchType: "exact" | "contains" | "startsWith"; +} + +export interface Warning { + reason: string; + moderatorId: string; + timestamp: number; } export interface UserInstallNotice { From 3312ccb17485b104e1dedf34f12e5de3394335ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:47:52 +0000 Subject: [PATCH 3/3] feat: make action log events individually configurable Agent-Logs-Url: https://github.com/BetterDiscord/BetterDiscordBot/sessions/7f9c7049-cf46-4e7c-b033-be83da127060 Co-authored-by: zerebos <6865942+zerebos@users.noreply.github.com> --- src/commands/moderation.ts | 43 +++++++++++++++++++++++ src/events/actionlog.ts | 72 +++++++++++++++++++++----------------- src/types/base.ts | 3 ++ 3 files changed, 85 insertions(+), 33 deletions(-) diff --git a/src/commands/moderation.ts b/src/commands/moderation.ts index 1c84fd3..15a7321 100644 --- a/src/commands/moderation.ts +++ b/src/commands/moderation.ts @@ -1,6 +1,17 @@ import {ChannelType, ChatInputCommandInteraction, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder} from "discord.js"; import {guildDB} from "../db"; import Messages from "../util/messages"; +import type {ActionLogEvent} from "../types"; + + +const ACTION_LOG_EVENT_CHOICES: {name: string; value: ActionLogEvent;}[] = [ + {name: "Message Deleted", value: "message_delete"}, + {name: "Message Edited", value: "message_edit"}, + {name: "Member Banned", value: "member_ban"}, + {name: "Member Unbanned", value: "member_unban"}, + {name: "Nickname Changed", value: "nickname_change"}, + {name: "Role Changes", value: "role_change"}, +]; @@ -42,6 +53,16 @@ export default { opt.setName("channel").setDescription("Where to log server action events?").setRequired(false) .addChannelTypes(ChannelType.GuildText) ) + ) + .addSubcommand( + c => c.setName("actionlogevents").setDescription("Enable or disable individual action log event types.") + .addStringOption(opt => + opt.setName("event").setDescription("The event type to configure.").setRequired(true) + .addChoices(...ACTION_LOG_EVENT_CHOICES) + ) + .addBooleanOption(opt => + opt.setName("enable").setDescription("Enable or disable this event type.").setRequired(false) + ) ), async execute(interaction: ChatInputCommandInteraction<"cached">) { @@ -51,6 +72,7 @@ export default { if (command === "modlog") return await this.modlog(interaction); if (command === "joinleave") return await this.joinleave(interaction); if (command === "actionlog") return await this.actionlog(interaction); + if (command === "actionlogevents") return await this.actionlogevents(interaction); }, @@ -128,4 +150,25 @@ export default { } await interaction.reply(Messages.success(targetChannel ? `Action log set to <#${targetChannel.id}>!` : "Action log has been unset!", {ephemeral: true})); }, + + + async actionlogevents(interaction: ChatInputCommandInteraction<"cached">) { + const event = interaction.options.getString("event", true) as ActionLogEvent; + const toEnable = interaction.options.getBoolean("enable"); + const current = await guildDB.get(interaction.guild.id) ?? {}; + + // No value supplied → report current state + if (toEnable === null) { + const isEnabled = current.actionlogEvents?.[event] !== false; + const label = ACTION_LOG_EVENT_CHOICES.find(c => c.value === event)?.name ?? event; + return await interaction.reply(Messages.info(`**${label}** is currently ${isEnabled ? "enabled" : "disabled"}.`, {ephemeral: true})); + } + + current.actionlogEvents ??= {}; + current.actionlogEvents[event] = toEnable; + await guildDB.set(interaction.guild.id, current); + + const label = ACTION_LOG_EVENT_CHOICES.find(c => c.value === event)?.name ?? event; + await interaction.reply(Messages.success(`**${label}** has been ${toEnable ? "enabled" : "disabled"}.`, {ephemeral: true})); + }, }; diff --git a/src/events/actionlog.ts b/src/events/actionlog.ts index ec2b8b9..483e7c3 100644 --- a/src/events/actionlog.ts +++ b/src/events/actionlog.ts @@ -1,11 +1,14 @@ import {EmbedBuilder, Events, type Guild, type GuildBan, type GuildMember, type Message, type PartialGuildMember, type PartialMessage} from "discord.js"; import {guildDB} from "../db"; import Colors from "../util/colors"; +import type {ActionLogEvent} from "../types"; -async function getLogChannel(guild: Guild) { +async function getLogChannel(guild: Guild, event: ActionLogEvent) { const settings = await guildDB.get(guild.id); if (!settings?.actionlog) return null; + // An event is enabled unless explicitly set to false + if (settings.actionlogEvents?.[event] === false) return null; const channel = guild.channels.cache.get(settings.actionlog); if (!channel?.isTextBased()) return null; return channel; @@ -19,7 +22,7 @@ export default [ async execute(message: Message | PartialMessage) { if (!message.inGuild() || message.author?.bot) return; - const logChannel = await getLogChannel(message.guild); + const logChannel = await getLogChannel(message.guild, "message_delete"); if (!logChannel) return; const embed = new EmbedBuilder() @@ -44,7 +47,7 @@ export default [ if (!newMessage.inGuild() || !newMessage.author || newMessage.author.bot) return; if (oldMessage.content === newMessage.content) return; - const logChannel = await getLogChannel(newMessage.guild); + const logChannel = await getLogChannel(newMessage.guild, "message_edit"); if (!logChannel) return; const embed = new EmbedBuilder() @@ -67,7 +70,7 @@ export default [ name: Events.GuildBanAdd, async execute(ban: GuildBan) { - const logChannel = await getLogChannel(ban.guild); + const logChannel = await getLogChannel(ban.guild, "member_ban"); if (!logChannel) return; const embed = new EmbedBuilder() @@ -88,7 +91,7 @@ export default [ name: Events.GuildBanRemove, async execute(ban: GuildBan) { - const logChannel = await getLogChannel(ban.guild); + const logChannel = await getLogChannel(ban.guild, "member_unban"); if (!logChannel) return; const embed = new EmbedBuilder() @@ -108,46 +111,49 @@ export default [ name: Events.GuildMemberUpdate, async execute(oldMember: GuildMember | PartialGuildMember, newMember: GuildMember) { - const logChannel = await getLogChannel(newMember.guild); - if (!logChannel) return; - // Nickname changes if (oldMember.nickname !== newMember.nickname) { - const embed = new EmbedBuilder() - .setColor(Colors.Info) - .setTitle("Nickname Changed") - .addFields( - {name: "User", value: `<@${newMember.user.id}> (${newMember.user.tag})`, inline: true}, - {name: "Before", value: oldMember.nickname ?? "*None*", inline: true}, - {name: "After", value: newMember.nickname ?? "*None*", inline: true} - ) - .setFooter({text: `User ID: ${newMember.user.id}`}) - .setTimestamp(); - - await logChannel.send({embeds: [embed]}).catch(console.error); - } - - // Role changes — only reliable when the old member was fully cached - if (!oldMember.partial) { - const addedRoles = newMember.roles.cache.filter(r => !oldMember.roles.cache.has(r.id)); - const removedRoles = oldMember.roles.cache.filter(r => !newMember.roles.cache.has(r.id)); - - if (addedRoles.size || removedRoles.size) { + const logChannel = await getLogChannel(newMember.guild, "nickname_change"); + if (logChannel) { const embed = new EmbedBuilder() .setColor(Colors.Info) - .setTitle("Member Roles Updated") + .setTitle("Nickname Changed") .addFields( - {name: "User", value: `<@${newMember.user.id}> (${newMember.user.tag})`, inline: true} + {name: "User", value: `<@${newMember.user.id}> (${newMember.user.tag})`, inline: true}, + {name: "Before", value: oldMember.nickname ?? "*None*", inline: true}, + {name: "After", value: newMember.nickname ?? "*None*", inline: true} ) .setFooter({text: `User ID: ${newMember.user.id}`}) .setTimestamp(); - if (addedRoles.size) embed.addFields({name: "Roles Added", value: addedRoles.map(r => `<@&${r.id}>`).join(", ")}); - if (removedRoles.size) embed.addFields({name: "Roles Removed", value: removedRoles.map(r => `<@&${r.id}>`).join(", ")}); - await logChannel.send({embeds: [embed]}).catch(console.error); } } + + // Role changes — only reliable when the old member was fully cached + if (!oldMember.partial) { + const addedRoles = newMember.roles.cache.filter(r => !oldMember.roles.cache.has(r.id)); + const removedRoles = oldMember.roles.cache.filter(r => !newMember.roles.cache.has(r.id)); + + if (addedRoles.size || removedRoles.size) { + const logChannel = await getLogChannel(newMember.guild, "role_change"); + if (logChannel) { + const embed = new EmbedBuilder() + .setColor(Colors.Info) + .setTitle("Member Roles Updated") + .addFields( + {name: "User", value: `<@${newMember.user.id}> (${newMember.user.tag})`, inline: true} + ) + .setFooter({text: `User ID: ${newMember.user.id}`}) + .setTimestamp(); + + if (addedRoles.size) embed.addFields({name: "Roles Added", value: addedRoles.map(r => `<@&${r.id}>`).join(", ")}); + if (removedRoles.size) embed.addFields({name: "Roles Removed", value: removedRoles.map(r => `<@&${r.id}>`).join(", ")}); + + await logChannel.send({embeds: [embed]}).catch(console.error); + } + } + } }, }, ]; diff --git a/src/types/base.ts b/src/types/base.ts index b6077a5..f5615ed 100644 --- a/src/types/base.ts +++ b/src/types/base.ts @@ -43,9 +43,12 @@ export interface GuildSettings { modlog?: string; joinleave?: string; actionlog?: string; + actionlogEvents?: Partial>; autoresponder?: boolean; } +export type ActionLogEvent = "message_delete" | "message_edit" | "member_ban" | "member_unban" | "nickname_change" | "role_change"; + export interface ReactionRole { messageId: string; channelId: string;