diff --git a/apps/bot/src/commands/utility/reminder/reminder.ts b/apps/bot/src/commands/utility/reminder/reminder.ts index c4cfd65..b40f864 100644 --- a/apps/bot/src/commands/utility/reminder/reminder.ts +++ b/apps/bot/src/commands/utility/reminder/reminder.ts @@ -143,7 +143,7 @@ export class ReminderCommand extends Command { } await removeReminder(id); - await unscheduleReminder(id); + await unscheduleReminder(reminder); await interaction.reply({ embeds: [successEmbed(`${emojis.rightArrow2} Reminder removed.`)], flags: MessageFlags.Ephemeral, diff --git a/apps/bot/src/interaction-handlers/reminder/snoozeHandler.ts b/apps/bot/src/interaction-handlers/reminder/snoozeHandler.ts new file mode 100644 index 0000000..e02672a --- /dev/null +++ b/apps/bot/src/interaction-handlers/reminder/snoozeHandler.ts @@ -0,0 +1,119 @@ +// QuestBot: A free and open-source Discord Bot. +// Copyright(C) 2026 Vantern +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { InteractionHandler, InteractionHandlerTypes } from '@sapphire/framework'; +import { + type ButtonInteraction, + LabelBuilder, + MessageFlags, + ModalBuilder, + StringSelectMenuBuilder, + TextInputBuilder, + TextInputStyle, +} from 'discord.js'; +import ms, { type StringValue } from 'ms'; +import { scheduleReminder } from '#lib/reminderScheduler.js'; +import { snoozeReminder } from '#lib/reminders.js'; +import { emojis } from '#utils/emoji.js'; + +const SNOOZE_PRESETS = [ + { label: '5 minutes', value: '5m' }, + { label: '15 minutes', value: '15m' }, + { label: '1 hour', value: '1h' }, + { label: '3 hours', value: '3h' }, + { label: '1 day', value: '1d' }, +]; + +interface SnoozeTarget { + reminderId: string; + userId: string; +} + +export class ReminderSnoozeHandler extends InteractionHandler { + public constructor(ctx: InteractionHandler.LoaderContext, options: InteractionHandler.Options) { + super(ctx, { + ...options, + interactionHandlerType: InteractionHandlerTypes.Button, + }); + } + + public override parse(interaction: ButtonInteraction) { + const [action, reminderId, userId] = interaction.customId.split(':'); + if (action !== 'reminder-snooze') return this.none(); + + return this.some({ reminderId, userId }); + } + + public async run(interaction: ButtonInteraction, { reminderId, userId }: SnoozeTarget) { + if (interaction.user.id !== userId) { + await interaction.reply({ + content: `${emojis.rightArrow2} You can't snooze others' reminders.`, + flags: MessageFlags.Ephemeral, + }); + return; + } + + const modalId = `reminder-snooze-modal:${interaction.id}`; + await interaction.showModal( + new ModalBuilder() + .setCustomId(modalId) + .setTitle('Snooze reminder') + .addLabelComponents( + new LabelBuilder() + .setLabel('Snooze for') + .setStringSelectMenuComponent( + new StringSelectMenuBuilder().setCustomId('duration').setRequired(false).addOptions(SNOOZE_PRESETS), + ), + new LabelBuilder() + .setLabel('Or a custom duration (max 7d)') + .setTextInputComponent( + new TextInputBuilder() + .setCustomId('custom') + .setStyle(TextInputStyle.Short) + .setPlaceholder('2.5h') + .setMaxLength(20) + .setRequired(false), + ), + ), + ); + + const submitted = await interaction + .awaitModalSubmit({ filter: (m) => m.customId === modalId, time: 120_000 }) + .catch(() => null); + if (!submitted?.isFromMessage()) return; + + const custom = submitted.fields.getTextInputValue('custom').trim(); + const [preset] = submitted.fields.getStringSelectValues('duration'); + const input = custom || preset; + const duration = input ? ms(input as StringValue) : undefined; + + // 7d max duration + if (!duration || duration < 0 || duration > 604_800_000) { + await submitted.reply({ + content: `${emojis.rightArrow2} Pick a preset or enter a duration up to 7d.`, + flags: MessageFlags.Ephemeral, + }); + return; + } + + const remindAt = new Date(Date.now() + duration); + const reminder = await snoozeReminder(reminderId, userId, remindAt); + if (!reminder) { + await submitted.update({ components: [] }); + await submitted.followUp({ + content: `${emojis.rightArrow2} This reminder can no longer be snoozed.`, + flags: MessageFlags.Ephemeral, + }); + return; + } + + await scheduleReminder(reminder); + + const unix = Math.floor(remindAt.getTime() / 1000); + await submitted.update({ + content: `${submitted.message.content}\n-# snoozed, reminding you again `, + components: [], + }); + } +} diff --git a/apps/bot/src/lib/purgeScheduler.ts b/apps/bot/src/lib/purgeScheduler.ts index 243ee18..2d0dd5f 100644 --- a/apps/bot/src/lib/purgeScheduler.ts +++ b/apps/bot/src/lib/purgeScheduler.ts @@ -4,6 +4,7 @@ import { logger } from './logger.js'; import { createQueue } from './queue.js'; +import { clearExpiredReminders } from './reminders.js'; import { purgeDeletedServers } from './servers.js'; import { purgeExpiredWarns } from './warns.js'; @@ -11,9 +12,10 @@ export function initPurgeScheduler(): void { const queue = createQueue('purge', async () => { await purgeExpiredWarns(); await purgeDeletedServers(); + await clearExpiredReminders(); }); queue - .upsertJobScheduler('daily-purge', { pattern: '0 3 * * *' }, { name: 'purge' }) + .upsertJobScheduler('daily-purge', { pattern: '0 3 * * *' }, { name: 'purge' }) // 3am purge .catch((err) => logger.error(err)); } diff --git a/apps/bot/src/lib/reminderScheduler.ts b/apps/bot/src/lib/reminderScheduler.ts index 2601756..8cc692b 100644 --- a/apps/bot/src/lib/reminderScheduler.ts +++ b/apps/bot/src/lib/reminderScheduler.ts @@ -8,13 +8,18 @@ import { emojis } from '#utils/emoji.js'; import { getShardInfo, type ShardInfo, shardOwns } from '#utils/sharding.js'; import { logger } from './logger.js'; import { createShardQueue } from './queue.js'; -import { removeReminder } from './reminders.js'; +import { buildSnoozeRow, claimReminder } from './reminders.js'; interface ReminderJob { id: string; userId: string; channelId: string | null; message: string; + remindAt: number; +} + +function jobId(reminder: { id: string; remindAt: Date }) { + return `${reminder.id}-${reminder.remindAt.getTime()}`; } let queue: ReturnType> | undefined; @@ -30,7 +35,8 @@ export function initReminderScheduler(client: Client): void { async function reconcile(shard: ShardInfo): Promise { const pending = await prisma.$queryRaw` SELECT * FROM "Reminder" - WHERE ${shardOwns(Prisma.sql`COALESCE("guildId", "userId")::bigint`, shard)} + WHERE "firedAt" IS NULL + AND ${shardOwns(Prisma.sql`COALESCE("guildId", "userId")::bigint`, shard)} `; for (const reminder of pending) { @@ -47,48 +53,34 @@ export async function scheduleReminder(reminder: { }): Promise { if (!queue) return; - await unscheduleReminder(reminder.id); - - const delay = Math.max(0, reminder.remindAt.getTime() - Date.now()); + const remindAt = reminder.remindAt.getTime(); await queue.add( 'remind', - { id: reminder.id, userId: reminder.userId, channelId: reminder.channelId, message: reminder.message }, - { jobId: reminder.id, delay, removeOnComplete: true, removeOnFail: true }, + { id: reminder.id, userId: reminder.userId, channelId: reminder.channelId, message: reminder.message, remindAt }, + { jobId: jobId(reminder), delay: Math.max(0, remindAt - Date.now()), removeOnComplete: true, removeOnFail: true }, ); } -export async function unscheduleReminder(reminderId: string): Promise { - const job = await queue?.getJob(reminderId); +export async function unscheduleReminder(reminder: { id: string; remindAt: Date }): Promise { + const job = await queue?.getJob(jobId(reminder)); await job?.remove().catch(() => {}); } async function handleReminder(client: Client, reminder: ReminderJob) { - let sent = false; - if (reminder.channelId) { - const channel = client.channels.cache.get(reminder.channelId); - if (channel?.isSendable()) { - await channel.send({ - content: `${emojis.rightArrow2} <@${reminder.userId}> reminder: ${reminder.message}`, - allowedMentions: { users: [reminder.userId] }, - }); - sent = true; - } - } - if (!sent) { - await dmUser(client, reminder.userId, reminder.message); - } + if (!(await claimReminder(reminder.id, new Date(reminder.remindAt)))) return; - await removeReminder(reminder.id).catch(() => {}); -} + const payload = { + content: `${emojis.rightArrow2} <@${reminder.userId}> reminder: ${reminder.message}`, + allowedMentions: { users: [reminder.userId] }, + components: [buildSnoozeRow(reminder.id, reminder.userId)], + }; -async function dmUser(client: Client, userId: string, message: string) { - const user = await client.users.fetch(userId).catch(() => null); - if (!user) return; + const channel = reminder.channelId ? client.channels.cache.get(reminder.channelId) : undefined; + if (channel?.isSendable()) { + await channel.send(payload); + return; + } - await user - .send({ - content: `${emojis.rightArrow2} <@${userId}> reminder: ${message}`, - allowedMentions: { users: [userId] }, - }) - .catch(() => {}); + const user = await client.users.fetch(reminder.userId).catch(() => null); + await user?.send(payload).catch(() => {}); } diff --git a/apps/bot/src/lib/reminders.ts b/apps/bot/src/lib/reminders.ts index dd3e68e..f470fdc 100644 --- a/apps/bot/src/lib/reminders.ts +++ b/apps/bot/src/lib/reminders.ts @@ -3,6 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { prisma } from '@questbot/database'; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; import { LIMITS_ENABLED, LimitError } from './limits.js'; export async function createReminder( @@ -14,7 +15,7 @@ export async function createReminder( channelId?: string, ) { if (LIMITS_ENABLED) { - const reminderCount = await prisma.reminder.count({ where: { userId } }); + const reminderCount = await prisma.reminder.count({ where: { userId, firedAt: null } }); if (reminderCount >= 5) { throw new LimitError('You can only have up to 5 reminders.'); @@ -38,7 +39,7 @@ export async function createReminder( export async function getReminders(userId: string, guildId?: string) { return prisma.reminder.findMany({ - where: { userId, guildId }, + where: { userId, guildId, firedAt: null }, orderBy: { remindAt: 'asc' }, }); } @@ -54,3 +55,32 @@ export async function clearReminders(guildId: string, userId: string) { export async function getReminder(reminderId: string) { return prisma.reminder.findUnique({ where: { id: reminderId } }); } + +export async function claimReminder(reminderId: string, remindAt: Date) { + const { count } = await prisma.reminder.updateMany({ + where: { id: reminderId, remindAt, firedAt: null }, + data: { firedAt: new Date() }, + }); + return count > 0; +} + +export async function snoozeReminder(reminderId: string, userId: string, remindAt: Date) { + const [reminder] = await prisma.reminder.updateManyAndReturn({ + where: { id: reminderId, userId, firedAt: { gte: new Date(Date.now() - 86_400_000) } }, // 1d window for snoozing + data: { remindAt, firedAt: null }, + }); + return reminder ?? null; +} + +export async function clearExpiredReminders() { + return prisma.reminder.deleteMany({ where: { firedAt: { lt: new Date(Date.now() - 86_400_000) } } }); // ^ see above +} + +export function buildSnoozeRow(reminderId: string, userId: string) { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`reminder-snooze:${reminderId}:${userId}`) + .setLabel('Snooze') + .setStyle(ButtonStyle.Secondary), + ); +} diff --git a/apps/bot/src/listeners/guildMemberRemove.ts b/apps/bot/src/listeners/guildMemberRemove.ts index 42384d1..155bd6f 100644 --- a/apps/bot/src/listeners/guildMemberRemove.ts +++ b/apps/bot/src/listeners/guildMemberRemove.ts @@ -24,7 +24,7 @@ export class GuildMemberRemoveListener extends Listener logger.error(err)), clearReminders(member.guild.id, member.id).catch((err) => logger.error(err)), - ...reminders.map((reminder) => unscheduleReminder(reminder.id).catch((err) => logger.error(err))), + ...reminders.map((reminder) => unscheduleReminder(reminder).catch((err) => logger.error(err))), ]); const banEntry = await getRecentAuditLogEntry(member.guild, AuditLogEvent.MemberBanAdd, member.id); diff --git a/packages/database/package.json b/packages/database/package.json index aa9b879..26461cf 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -12,10 +12,10 @@ } }, "scripts": { - "build": "prisma generate && tsc", + "build": "prisma generate --no-hints && tsc", "db:push": "prisma db push", - "db:generate": "dotenv -e ../../.env -- prisma generate", - "postinstall": "prisma generate" + "db:generate": "dotenv -e ../../.env -- prisma generate --no-hints", + "postinstall": "prisma generate --no-hints" }, "dependencies": { "@prisma/adapter-pg": "^7.10.0", diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index e8242a9..504c213 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -144,6 +144,7 @@ model Reminder { channelId String? message String remindAt DateTime + firedAt DateTime? createdAt DateTime @default(now()) server Server? @relation(fields: [guildId], references: [id], onDelete: Cascade)