Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions src/commands/autoresponder.ts
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);

Comment on lines +101 to +109

Copilot AI Apr 24, 2026

Copy link

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.

Copilot uses AI. Check for mistakes.
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})));
},
};
222 changes: 222 additions & 0 deletions src/commands/mod.ts
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);

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When sending modlog embeds, consider setting allowedMentions: { parse: [] } (and optionally disable replying-user mentions elsewhere) so user-provided reason text or IDs in fields can’t trigger @everyone/role/user pings in the log channel.

Suggested change
await logChannel.send({embeds: [embed]}).catch(console.error);
await logChannel.send({embeds: [embed], allowedMentions: {parse: []}}).catch(console.error);

Copilot uses AI. Check for mistakes.
}


export default {
data: new SlashCommandBuilder()
.setName("mod")
.setDescription("Moderation actions.")
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)

Copilot AI Apr 24, 2026

Copy link

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.

Suggested change
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)

Copilot uses AI. Check for mistakes.
.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}));
Comment on lines +93 to +96

Copilot AI Apr 24, 2026

Copy link

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 uses AI. Check for mistakes.

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}));
},
Comment on lines +216 to +221

Copilot AI Apr 24, 2026

Copy link

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.

Copilot uses AI. Check for mistakes.
};
Loading