Skip to content

Commit 18a119f

Browse files
authored
feat(bots/discord): add remind/unremind command (#51)
1 parent d34d3a5 commit 18a119f

File tree

4 files changed

+299
-0
lines changed

4 files changed

+299
-0
lines changed
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { EmbedBuilder, MessageFlags } from 'discord.js'
2+
import { eq } from 'drizzle-orm'
3+
import Command from '$/classes/Command'
4+
import { database } from '$/context'
5+
import { reminders } from '$/database/schemas'
6+
import { applyCommonEmbedStyles } from '$/utils/discord/embeds'
7+
import { durationToString, parseDuration } from '$/utils/duration'
8+
9+
export default new Command({
10+
name: 'remind',
11+
description: 'Set a reminder or list your reminders',
12+
type: Command.Type.ChatGuild,
13+
requirements: {
14+
defaultCondition: 'pass',
15+
},
16+
options: {
17+
message: {
18+
description: 'The reminder message',
19+
required: false,
20+
type: Command.OptionType.String,
21+
maxLength: 1000,
22+
},
23+
interval: {
24+
description: 'When to remind (e.g., 1d, 2h30m, 1w). Default: 1 day',
25+
required: false,
26+
type: Command.OptionType.String,
27+
},
28+
user: {
29+
description: 'The user to remind (defaults to yourself)',
30+
required: false,
31+
type: Command.OptionType.User,
32+
},
33+
},
34+
async execute({ logger }, interaction, { message, interval, user }) {
35+
// If no message is provided, list all reminders
36+
if (!message) {
37+
const userReminders = await database.query.reminders.findMany({
38+
where: eq(reminders.creatorId, interaction.user.id),
39+
})
40+
41+
if (userReminders.length === 0) {
42+
const embed = applyCommonEmbedStyles(
43+
new EmbedBuilder().setTitle('No Reminders').setDescription('You have no active reminders.'),
44+
false,
45+
true,
46+
true,
47+
)
48+
49+
await interaction.reply({
50+
embeds: [embed],
51+
flags: MessageFlags.Ephemeral,
52+
})
53+
return
54+
}
55+
56+
const reminderList = userReminders
57+
.map(r => {
58+
const targetStr = r.targetId === r.creatorId ? 'yourself' : `<@${r.targetId}>`
59+
return (
60+
`**${r.id}.** ${r.message.substring(0, 50)}${r.message.length > 50 ? '...' : ''}\n` +
61+
`-# For ${targetStr} • <t:${r.remindAt}:R> • Reminded ${r.count}x`
62+
)
63+
})
64+
.join('\n\n')
65+
66+
const embed = applyCommonEmbedStyles(
67+
new EmbedBuilder().setTitle('Your Reminders').setDescription(reminderList),
68+
false,
69+
true,
70+
true,
71+
)
72+
73+
await interaction.reply({
74+
embeds: [embed],
75+
flags: MessageFlags.Ephemeral,
76+
})
77+
return
78+
}
79+
80+
// Create a new reminder
81+
const targetUser = user ?? interaction.user
82+
const durationMs = parseDuration(interval ?? '1d', 'd')
83+
84+
if (durationMs <= 0 || !Number.isFinite(durationMs)) {
85+
const embed = applyCommonEmbedStyles(
86+
new EmbedBuilder()
87+
.setTitle('Invalid duration')
88+
.setDescription('Please provide a valid duration (e.g., 1d, 2h30m, 1w).')
89+
.setColor('Red'),
90+
false,
91+
false,
92+
false,
93+
)
94+
95+
await interaction.reply({
96+
embeds: [embed],
97+
flags: MessageFlags.Ephemeral,
98+
})
99+
return
100+
}
101+
102+
const now = Math.floor(Date.now() / 1000)
103+
const remindAt = now + Math.floor(durationMs / 1000)
104+
105+
const intervalSeconds = Math.floor(durationMs / 1000)
106+
const [inserted] = await database
107+
.insert(reminders)
108+
.values({
109+
creatorId: interaction.user.id,
110+
targetId: targetUser.id,
111+
guildId: interaction.guildId!,
112+
channelId: interaction.channelId,
113+
message: message,
114+
createdAt: now,
115+
remindAt: remindAt,
116+
intervalSeconds: intervalSeconds,
117+
count: 0,
118+
})
119+
.returning()
120+
121+
const reminderId = inserted?.id ?? 'unknown'
122+
123+
const targetStr = targetUser.id === interaction.user.id ? 'You' : targetUser.toString()
124+
125+
const embed = applyCommonEmbedStyles(
126+
new EmbedBuilder()
127+
.setTitle('Reminder set')
128+
.setDescription(
129+
`${targetStr} will be reminded <t:${remindAt}:R>.\n\n` +
130+
`**Message:** ${message}\n` +
131+
`-# Reminder ID: ${reminderId}`,
132+
),
133+
false,
134+
true,
135+
true,
136+
)
137+
138+
await interaction.reply({
139+
embeds: [embed],
140+
})
141+
142+
logger.info(
143+
`User ${interaction.user.tag} (${interaction.user.id}) set reminder #${reminderId} ` +
144+
`for ${targetUser.tag} (${targetUser.id}) in ${durationToString(durationMs)}`,
145+
)
146+
},
147+
})
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { EmbedBuilder, MessageFlags } from 'discord.js'
2+
import { eq } from 'drizzle-orm'
3+
import Command from '$/classes/Command'
4+
import CommandError, { CommandErrorType } from '$/classes/CommandError'
5+
import { database } from '$/context'
6+
import { reminders } from '$/database/schemas'
7+
import { applyCommonEmbedStyles } from '$/utils/discord/embeds'
8+
9+
export default new Command({
10+
name: 'unremind',
11+
description: 'Remove a reminder',
12+
type: Command.Type.ChatGuild,
13+
requirements: {
14+
defaultCondition: 'pass',
15+
},
16+
options: {
17+
id: {
18+
description: 'The reminder ID to remove',
19+
required: true,
20+
type: Command.OptionType.Integer,
21+
min: 1,
22+
},
23+
},
24+
async execute({ logger }, interaction, { id }) {
25+
const reminder = await database.query.reminders.findFirst({
26+
where: eq(reminders.id, id),
27+
})
28+
29+
if (!reminder) {
30+
throw new CommandError(CommandErrorType.InvalidArgument, `Reminder with ID **${id}** was not found.`)
31+
}
32+
33+
// Only the creator can remove the reminder
34+
if (reminder.creatorId !== interaction.user.id) {
35+
throw new CommandError(
36+
CommandErrorType.RequirementsNotMet,
37+
'You can only remove reminders that you created.',
38+
)
39+
}
40+
41+
await database.delete(reminders).where(eq(reminders.id, id))
42+
43+
const embed = applyCommonEmbedStyles(
44+
new EmbedBuilder()
45+
.setTitle('Reminder removed')
46+
.setDescription(
47+
`Removed reminder **#${id}**.\n\n` +
48+
`-# Message: ${reminder.message.substring(0, 100)}${reminder.message.length > 100 ? '...' : ''}`,
49+
),
50+
false,
51+
true,
52+
true,
53+
)
54+
55+
await interaction.reply({
56+
embeds: [embed],
57+
flags: MessageFlags.Ephemeral,
58+
})
59+
60+
logger.info(`User ${interaction.user.tag} (${interaction.user.id}) removed reminder #${id}`)
61+
},
62+
})

bots/discord/src/database/schemas.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
22
import type { InferSelectModel } from 'drizzle-orm'
33

4+
export const reminders = sqliteTable('reminders', {
5+
id: integer('id').primaryKey({ autoIncrement: true }),
6+
creatorId: text('creator').notNull(),
7+
targetId: text('target').notNull(),
8+
guildId: text('guild').notNull(),
9+
channelId: text('channel').notNull(),
10+
message: text('message').notNull(),
11+
createdAt: integer('created_at').notNull(),
12+
remindAt: integer('remind_at').notNull(),
13+
intervalSeconds: integer('interval_seconds').notNull(),
14+
count: integer('count').notNull().default(0),
15+
})
16+
17+
export type Reminder = InferSelectModel<typeof reminders>
18+
419
export const responses = sqliteTable('responses', {
520
replyId: text('reply').primaryKey().notNull(),
621
channelId: text('channel').notNull(),
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { type Client, EmbedBuilder } from 'discord.js'
2+
import { eq, lt } from 'drizzle-orm'
3+
import { database, logger } from '$/context'
4+
import { reminders } from '$/database/schemas'
5+
import { applyCommonEmbedStyles } from '$/utils/discord/embeds'
6+
import { on, withContext } from '$/utils/discord/events'
7+
8+
const REMINDER_CHECK_INTERVAL = 30_000 // Check every 30 seconds
9+
10+
export default withContext(on, 'ready', async (_, client) => {
11+
checkReminders(client)
12+
setInterval(() => checkReminders(client), REMINDER_CHECK_INTERVAL)
13+
})
14+
15+
async function checkReminders(client: Client) {
16+
logger.debug('Checking for due reminders...')
17+
18+
const now = Math.floor(Date.now() / 1000)
19+
const dueReminders = await database.query.reminders.findMany({
20+
where: lt(reminders.remindAt, now),
21+
})
22+
23+
for (const reminder of dueReminders) {
24+
try {
25+
logger.debug(`Processing reminder #${reminder.id} for ${reminder.targetId}`)
26+
27+
const guild = await client.guilds.fetch(reminder.guildId)
28+
const channel = await guild.channels.fetch(reminder.channelId)
29+
30+
if (!channel?.isTextBased()) {
31+
logger.warn(
32+
`Channel ${reminder.channelId} for reminder #${reminder.id} is not text-based or doesn't exist`,
33+
)
34+
await database.delete(reminders).where(eq(reminders.id, reminder.id))
35+
continue
36+
}
37+
38+
const newCount = reminder.count + 1
39+
const creatorMention = reminder.creatorId === reminder.targetId ? '' : ` (set by <@${reminder.creatorId}>)`
40+
41+
const embed = applyCommonEmbedStyles(
42+
new EmbedBuilder()
43+
.setTitle(`Reminder (#${newCount})`)
44+
.setDescription(reminder.message)
45+
.setFooter({
46+
text: `Set on ${new Date(reminder.createdAt * 1000).toLocaleDateString()}`,
47+
}),
48+
false,
49+
false,
50+
true,
51+
)
52+
53+
await channel.send({
54+
content: `<@${reminder.targetId}>${creatorMention}`,
55+
embeds: [embed],
56+
})
57+
58+
logger.info(
59+
`Sent reminder #${reminder.id} (count: ${newCount}) to ${reminder.targetId} in channel ${reminder.channelId}`,
60+
)
61+
62+
// Update count and schedule next reminder
63+
const nextRemindAt = now + reminder.intervalSeconds
64+
await database
65+
.update(reminders)
66+
.set({
67+
count: newCount,
68+
remindAt: nextRemindAt,
69+
})
70+
.where(eq(reminders.id, reminder.id))
71+
} catch (e) {
72+
logger.error(`Error while processing reminder #${reminder.id}:`, e)
73+
}
74+
}
75+
}

0 commit comments

Comments
 (0)