Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/bot/src/commands/utility/reminder/reminder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
119 changes: 119 additions & 0 deletions apps/bot/src/interaction-handlers/reminder/snoozeHandler.ts
Original file line number Diff line number Diff line change
@@ -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 <t:${unix}:R>`,
components: [],
});
}
}
4 changes: 3 additions & 1 deletion apps/bot/src/lib/purgeScheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@

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';

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));
}
60 changes: 26 additions & 34 deletions apps/bot/src/lib/reminderScheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createShardQueue<ReminderJob>> | undefined;
Expand All @@ -30,7 +35,8 @@ export function initReminderScheduler(client: Client): void {
async function reconcile(shard: ShardInfo): Promise<void> {
const pending = await prisma.$queryRaw<Prisma.ReminderModel[]>`
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) {
Expand All @@ -47,48 +53,34 @@ export async function scheduleReminder(reminder: {
}): Promise<void> {
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<void> {
const job = await queue?.getJob(reminderId);
export async function unscheduleReminder(reminder: { id: string; remindAt: Date }): Promise<void> {
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(() => {});
}
34 changes: 32 additions & 2 deletions apps/bot/src/lib/reminders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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.');
Expand All @@ -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' },
});
}
Expand All @@ -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<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`reminder-snooze:${reminderId}:${userId}`)
.setLabel('Snooze')
.setStyle(ButtonStyle.Secondary),
);
}
2 changes: 1 addition & 1 deletion apps/bot/src/listeners/guildMemberRemove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export class GuildMemberRemoveListener extends Listener<typeof Events.GuildMembe
await Promise.all([
removeAfk(member.guild.id, member.id).catch((err) => 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);
Expand Down
6 changes: 3 additions & 3 deletions packages/database/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/database/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading