feat: allow button-only commands; force user to agree to rules
willow hai@wlo.moe
Sun, 11 Jan 2026 17:17:21 +0000
6 files changed,
99 insertions(+),
24 deletions(-)
M
src/database/schemas.ts
→
src/database/schemas.ts
@@ -34,7 +34,9 @@ {
user_id: { type: String, required: true }, guild_id: { type: String, required: true, ref: "Guild" }, cat_points: { type: Number, default: 0 }, + /** @deprecated */ has_reported: { type: Boolean, default: false }, + last_report: { type: Date, default: null }, }, { timestamps: true }, );
M
src/events/interactionCreate.ts
→
src/events/interactionCreate.ts
@@ -10,7 +10,7 @@ async execute(client: Client, interaction: BaseInteraction) {
if (interaction.isCommand()) { const command = commands.get(interaction.commandName); logger.info(`received command ${interaction.commandName}`); - if (!command) return; + if (!command || !command.execute) return; try { await command.execute(client, interaction); } catch (error) {@@ -29,7 +29,7 @@ const [commandName, ..._args] = interaction.customId.split(":");
const command = commands.get(commandName); if (!command || !command.buttonExecute) { return interaction.reply({ - content: "couldn't find the associated command!", + content: `couldn't find the associated command (${commandName})`, flags: ["Ephemeral"], }); }
M
src/events/messageCreate.ts
→
src/events/messageCreate.ts
@@ -3,7 +3,10 @@ import { GuildModel, UserModel } from "@/database/schemas";
import lily from "@/utils/logging"; import vision from "@google-cloud/vision"; import { + ActionRowBuilder, type Attachment, + ButtonBuilder, + ButtonStyle, ChannelType, type Client, Events,@@ -14,6 +17,10 @@ type ImageResult = {
image: Attachment; isCat: boolean; }; + +const MS_IN_DAY = 1000 * 60 * 60 * 24; +const REPORT_COOLDOWN_DAYS = 28; +const REPORT_COOLDOWN = MS_IN_DAY * REPORT_COOLDOWN_DAYS; const logger = lily.child(["google-cloud", "vision"]);@@ -82,14 +89,13 @@ }
async function handleReportChannel(message: Message) { if (!message.guild) return; - const guildData = await GuildModel.findOne({ guild_id: message.guild.id }); if ( - !guildData || - !guildData.report_channel || + !guildData?.report_channel || message.channel.id !== guildData.report_channel - ) + ) { return; + } let user = await UserModel.findOne({ user_id: message.author.id,@@ -101,18 +107,33 @@ user = new UserModel({
user_id: message.author.id, guild_id: message.guild.id, }); + } else { + const lastReport = user.last_report; + if (lastReport) { + const timeSinceLastReport = Date.now() - lastReport.getTime(); + if (timeSinceLastReport < REPORT_COOLDOWN) return; + } } - if (!user.has_reported) { - user.has_reported = true; - await user.save(); + user.last_report = new Date(); + await user.save(); - if (guildData.report_message) { - await message.reply({ - content: guildData.report_message, - allowedMentions: { users: [message.author.id] }, - }); - } + if (guildData.report_message) { + const agreeButton = new ActionRowBuilder<ButtonBuilder>().addComponents( + new ButtonBuilder() + .setCustomId("report:agree") + .setLabel("I confirm that my report follows the rules") + .setStyle(ButtonStyle.Primary), + ); + + await message.reply({ + content: guildData.report_message.replace( + "{user}", + `<@${message.author.id}>`, + ), + allowedMentions: { users: [message.author.id] }, + components: [agreeButton], + }); } }
M
src/handlers/interactions.ts
→
src/handlers/interactions.ts
@@ -14,17 +14,30 @@ const processFile = async (fileUrl: string) => {
const { default: interaction } = await import(fileUrl); if (!interaction) return; - if (!interaction.data) { + if ( + !interaction.data && + !interaction.buttonExecute && + !interaction.modalExecute + ) { logger.info(`${fileUrl} does not have a data property, skipping`); return; } + let commandName = interaction.data?.name; + + if (!commandName) { + const urlPath = new URL(fileUrl).pathname; + const filename = urlPath.split("/").pop(); + commandName = filename?.replace(".ts", ""); + } + if (!commandName) return; + if (interaction.enabled === false) { - logger.info(`${interaction.data.name} was disabled, skipping`); + logger.info(`${commandName} was disabled, skipping`); return; } - commands.set(interaction.data.name, interaction); + commands.set(commandName, interaction); return interaction; };@@ -58,11 +71,13 @@ }
} async function registerCommands(client: Client) { - const commands = await getCommands(); - if (commands.length === 0) return logger.warn("no commands found"); + await getCommands(); + const slashCommands = commands + .map((command) => command.data) + .filter((data): data is ApplicationCommandData => !!data); - const interactions = commands.map((command) => command.data); - await registerInteractions(client, interactions); + if (slashCommands.length === 0) return logger.warn("no commands found"); + await registerInteractions(client, slashCommands); } export default registerCommands;
A
src/interactions/commands/report.ts
@@ -0,0 +1,36 @@
+import type { ICommand } from "@/types"; +import { + ActionRowBuilder, + ButtonBuilder, + type ButtonInteraction, + ButtonStyle, + type Client, +} from "discord.js"; + +export async function buttonExecute( + client: Client, + interaction: ButtonInteraction, +) { + const message = interaction.message; + await interaction.reply({ + content: "Thank you.", + flags: ["Ephemeral"], + }); + + const agreedButton = new ActionRowBuilder<ButtonBuilder>().addComponents( + new ButtonBuilder() + .setCustomId("report:agreed") + .setLabel("I confirm that my report follows the rules") + .setDisabled(true) + .setStyle(ButtonStyle.Success), + ); + + await message.edit({ + content: message.content, + components: [agreedButton], + }); +} + +export default { + buttonExecute, +} satisfies ICommand;
M
src/types.ts
→
src/types.ts
@@ -15,8 +15,9 @@ once?: boolean;
} export interface ICommand { - data: ApplicationCommandData; - execute: (client: Client, interaction: BaseInteraction) => Promise<void>; + data?: ApplicationCommandData; + execute?: (client: Client, interaction: BaseInteraction) => Promise<void>; + enabled?: boolean; autocomplete?: (interaction: AutocompleteInteraction) => Promise<void>; buttonExecute?: (