• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

VolvoxLLC / volvox-bot / 22531306485

28 Feb 2026 11:25PM UTC coverage: 90.135% (-0.4%) from 90.52%
22531306485

Pull #153

github

web-flow
Merge 59707c805 into d66e0f9e2
Pull Request #153: feat: add /remind command with natural language time parsing

4418 of 5187 branches covered (85.17%)

Branch coverage included in aggregate %.

290 of 337 new or added lines in 7 files covered. (86.05%)

2 existing lines in 1 file now uncovered.

7579 of 8123 relevant lines covered (93.3%)

49.03 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

71.74
/src/modules/reminderHandler.js
1
/**
2
 * Reminder Handler Module
3
 * Checks for due reminders, sends notifications, handles snooze buttons.
4
 *
5
 * @see https://github.com/VolvoxLLC/volvox-bot/issues/137
6
 */
7

8
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js';
9
import { getPool } from '../db.js';
10
import { info, error as logError, warn } from '../logger.js';
11
import { getConfig } from '../modules/config.js';
12
import { getNextCronRun } from '../utils/cronParser.js';
13
import { safeSend } from '../utils/safeSend.js';
14

15
/** Snooze durations in milliseconds, keyed by button suffix */
16
const SNOOZE_DURATIONS = {
41✔
17
  '15m': 15 * 60_000,
18
  '1h': 60 * 60_000,
19
  tomorrow: 24 * 60 * 60_000,
20
};
21

22
/** Max delivery failures before giving up on a reminder */
23
const MAX_DELIVERY_RETRIES = 3;
41✔
24

25
/**
26
 * Build snooze action row for a fired reminder.
27
 *
28
 * @param {number} reminderId - Reminder ID
29
 * @returns {ActionRowBuilder}
30
 */
31
export function buildSnoozeButtons(reminderId) {
32
  return new ActionRowBuilder().addComponents(
5✔
33
    new ButtonBuilder()
34
      .setCustomId(`reminder_snooze_${reminderId}_15m`)
35
      .setLabel('15m')
36
      .setStyle(ButtonStyle.Secondary),
37
    new ButtonBuilder()
38
      .setCustomId(`reminder_snooze_${reminderId}_1h`)
39
      .setLabel('1h')
40
      .setStyle(ButtonStyle.Secondary),
41
    new ButtonBuilder()
42
      .setCustomId(`reminder_snooze_${reminderId}_tomorrow`)
43
      .setLabel('Tomorrow')
44
      .setStyle(ButtonStyle.Secondary),
45
    new ButtonBuilder()
46
      .setCustomId(`reminder_dismiss_${reminderId}`)
47
      .setLabel('Dismiss')
48
      .setStyle(ButtonStyle.Danger),
49
  );
50
}
51

52
/**
53
 * Build the reminder notification embed.
54
 *
55
 * @param {object} reminder - Reminder row from DB
56
 * @returns {EmbedBuilder}
57
 */
58
function buildReminderEmbed(reminder) {
59
  const embed = new EmbedBuilder()
4✔
60
    .setTitle('⏰ Reminder')
61
    .setDescription(reminder.message)
62
    .setColor(0x5865f2)
63
    .setTimestamp(new Date(reminder.created_at))
64
    .setFooter({ text: `Reminder #${reminder.id}` });
65

66
  if (reminder.snoozed_count > 0) {
4!
NEW
67
    embed.addFields({
×
68
      name: 'Snoozed',
69
      value: `${reminder.snoozed_count} time${reminder.snoozed_count !== 1 ? 's' : ''}`,
×
70
      inline: true,
71
    });
72
  }
73

74
  if (reminder.recurring_cron) {
4✔
75
    embed.addFields({ name: 'Recurring', value: `\`${reminder.recurring_cron}\``, inline: true });
2✔
76
  }
77

78
  return embed;
4✔
79
}
80

81
/**
82
 * Send a reminder notification to the user.
83
 * Tries DM first, falls back to channel mention.
84
 *
85
 * @param {import('discord.js').Client} client - Discord client
86
 * @param {object} reminder - Reminder row from DB
87
 * @returns {Promise<boolean>} true if the notification was delivered, false if all attempts failed
88
 */
89
async function sendReminderNotification(client, reminder) {
90
  const embed = buildReminderEmbed(reminder);
4✔
91
  const components = [buildSnoozeButtons(reminder.id)];
4✔
92

93
  // Try DM first
94
  try {
4✔
95
    const user = await client.users.fetch(reminder.user_id);
4✔
96
    await user.send({ embeds: [embed], components });
3✔
97
    info('Reminder sent via DM', { reminderId: reminder.id, userId: reminder.user_id });
3✔
98
    return true;
3✔
99
  } catch {
100
    // DM failed — fall back to channel mention
101
  }
102

103
  // Fallback: channel mention
104
  try {
1✔
105
    const channel = await client.channels.fetch(reminder.channel_id).catch(() => null);
1✔
106
    if (channel) {
1!
107
      await safeSend(channel, {
1✔
108
        content: `<@${reminder.user_id}>`,
109
        embeds: [embed],
110
        components,
111
      });
112
      info('Reminder sent via channel', {
1✔
113
        reminderId: reminder.id,
114
        channelId: reminder.channel_id,
115
      });
116
      return true;
1✔
117
    } else {
NEW
118
      warn('Reminder channel not found', {
×
119
        reminderId: reminder.id,
120
        channelId: reminder.channel_id,
121
      });
122
    }
123
  } catch (err) {
NEW
124
    logError('Failed to send reminder notification', {
×
125
      reminderId: reminder.id,
126
      error: err.message,
127
    });
128
  }
129

NEW
130
  return false;
×
131
}
132

133
/**
134
 * Check for due reminders and fire them.
135
 * Called by the scheduler every 60s.
136
 *
137
 * @param {import('discord.js').Client} client - Discord client
138
 */
139
export async function checkReminders(client) {
140
  const pool = getPool();
17✔
141
  if (!pool) return;
17!
142

143
  const { rows } = await pool.query(
17✔
144
    'SELECT * FROM reminders WHERE completed = false AND remind_at <= NOW()',
145
  );
146

147
  for (const reminder of rows) {
17✔
148
    try {
4✔
149
      // Check if reminders are enabled for this guild
150
      const guildConfig = getConfig(reminder.guild_id);
4✔
151
      if (!guildConfig?.reminders?.enabled) {
4!
NEW
152
        info('Reminders disabled for guild, skipping', {
×
153
          reminderId: reminder.id,
154
          guildId: reminder.guild_id,
155
        });
NEW
156
        continue;
×
157
      }
158

159
      const delivered = await sendReminderNotification(client, reminder);
4✔
160

161
      if (!delivered) {
4!
162
        // Increment failure count and check against retry limit
NEW
163
        const currentCount = reminder.failed_delivery_count ?? 0;
×
NEW
164
        const newCount = currentCount + 1;
×
165

NEW
166
        if (newCount >= MAX_DELIVERY_RETRIES) {
×
NEW
167
          warn('Reminder delivery failed max times, marking completed', {
×
168
            reminderId: reminder.id,
169
            attempts: newCount,
170
          });
NEW
171
          await pool.query(
×
172
            'UPDATE reminders SET completed = true, failed_delivery_count = $1 WHERE id = $2',
173
            [newCount, reminder.id],
174
          );
175
        } else {
NEW
176
          info('Reminder delivery failed, will retry next poll', {
×
177
            reminderId: reminder.id,
178
            attempt: newCount,
179
          });
NEW
180
          await pool.query('UPDATE reminders SET failed_delivery_count = $1 WHERE id = $2', [
×
181
            newCount,
182
            reminder.id,
183
          ]);
184
        }
NEW
185
        continue;
×
186
      }
187

188
      if (reminder.recurring_cron) {
4✔
189
        // Recurring: schedule next run, don't mark completed
190
        try {
2✔
191
          const nextRun = getNextCronRun(reminder.recurring_cron, new Date());
2✔
192
          await pool.query('UPDATE reminders SET remind_at = $1 WHERE id = $2', [
2✔
193
            nextRun.toISOString(),
194
            reminder.id,
195
          ]);
196
          info('Recurring reminder rescheduled', {
1✔
197
            reminderId: reminder.id,
198
            nextRun: nextRun.toISOString(),
199
          });
200
        } catch (cronErr) {
201
          logError('Invalid recurring cron, marking completed', {
1✔
202
            reminderId: reminder.id,
203
            cron: reminder.recurring_cron,
204
            error: cronErr.message,
205
          });
206
          await pool.query('UPDATE reminders SET completed = true WHERE id = $1', [reminder.id]);
1✔
207
        }
208
      } else {
209
        // One-time: mark completed
210
        await pool.query('UPDATE reminders SET completed = true WHERE id = $1', [reminder.id]);
2✔
211
      }
212
    } catch (err) {
NEW
213
      logError('Failed to process reminder', { reminderId: reminder.id, error: err.message });
×
214
    }
215
  }
216
}
217

218
/**
219
 * Handle a reminder snooze button click.
220
 *
221
 * @param {import('discord.js').ButtonInteraction} interaction
222
 */
223
export async function handleReminderSnooze(interaction) {
224
  const match = interaction.customId.match(/^reminder_snooze_(\d+)_(15m|1h|tomorrow)$/);
5✔
225
  if (!match) return;
5✔
226

227
  const reminderId = Number.parseInt(match[1], 10);
4✔
228
  const duration = match[2];
4✔
229
  const snoozeMs = SNOOZE_DURATIONS[duration];
4✔
230

231
  const pool = getPool();
4✔
232
  if (!pool) {
4!
NEW
233
    await interaction.reply({
×
234
      content: '❌ Database unavailable. Please try again later.',
235
      ephemeral: true,
236
    });
NEW
237
    return;
×
238
  }
239

240
  const { rows } = await pool.query('SELECT * FROM reminders WHERE id = $1', [reminderId]);
4✔
241

242
  if (rows.length === 0) {
4✔
243
    await interaction.reply({ content: '❌ Reminder not found.', ephemeral: true });
1✔
244
    return;
1✔
245
  }
246

247
  const reminder = rows[0];
3✔
248

249
  // Verify ownership
250
  if (reminder.user_id !== interaction.user.id) {
3✔
251
    await interaction.reply({ content: "❌ This isn't your reminder.", ephemeral: true });
1✔
252
    return;
1✔
253
  }
254

255
  // Guard: do not reactivate already-completed reminders (stale snooze buttons)
256
  if (reminder.completed) {
2!
NEW
257
    await interaction.reply({
×
258
      content: '❌ This reminder has already been completed.',
259
      ephemeral: true,
260
    });
NEW
261
    return;
×
262
  }
263

264
  const newRemindAt = new Date(Date.now() + snoozeMs);
2✔
265

266
  await pool.query(
2✔
267
    'UPDATE reminders SET remind_at = $1, completed = false, snoozed_count = snoozed_count + 1 WHERE id = $2',
268
    [newRemindAt.toISOString(), reminderId],
269
  );
270

271
  const labels = { '15m': '15 minutes', '1h': '1 hour', tomorrow: 'tomorrow' };
2✔
272

273
  // Update the original message to show it was snoozed
274
  try {
2✔
275
    await interaction.update({
2✔
276
      content: `💤 Snoozed for ${labels[duration]}. I'll remind you <t:${Math.floor(newRemindAt.getTime() / 1000)}:R>.`,
277
      embeds: [],
278
      components: [],
279
    });
280
  } catch {
281
    await interaction.reply({
1✔
282
      content: `💤 Snoozed for ${labels[duration]}. I'll remind you <t:${Math.floor(newRemindAt.getTime() / 1000)}:R>.`,
283
      ephemeral: true,
284
    });
285
  }
286

287
  info('Reminder snoozed', { reminderId, duration, userId: interaction.user.id });
2✔
288
}
289

290
/**
291
 * Handle a reminder dismiss button click.
292
 *
293
 * @param {import('discord.js').ButtonInteraction} interaction
294
 */
295
export async function handleReminderDismiss(interaction) {
296
  const match = interaction.customId.match(/^reminder_dismiss_(\d+)$/);
3✔
297
  if (!match) return;
3✔
298

299
  const reminderId = Number.parseInt(match[1], 10);
2✔
300
  const pool = getPool();
2✔
301
  if (!pool) {
2!
NEW
302
    await interaction.reply({
×
303
      content: '❌ Database unavailable. Please try again later.',
304
      ephemeral: true,
305
    });
NEW
306
    return;
×
307
  }
308

309
  const { rows } = await pool.query('SELECT * FROM reminders WHERE id = $1', [reminderId]);
2✔
310

311
  if (rows.length === 0) {
2!
NEW
312
    await interaction.reply({ content: '❌ Reminder not found.', ephemeral: true });
×
NEW
313
    return;
×
314
  }
315

316
  const reminder = rows[0];
2✔
317

318
  if (reminder.user_id !== interaction.user.id) {
2✔
319
    await interaction.reply({ content: "❌ This isn't your reminder.", ephemeral: true });
1✔
320
    return;
1✔
321
  }
322

323
  await pool.query('UPDATE reminders SET completed = true WHERE id = $1', [reminderId]);
1✔
324

325
  try {
1✔
326
    await interaction.update({
1✔
327
      content: '✅ Reminder dismissed.',
328
      embeds: [],
329
      components: [],
330
    });
331
  } catch {
NEW
332
    await interaction.reply({ content: '✅ Reminder dismissed.', ephemeral: true });
×
333
  }
334

335
  info('Reminder dismissed', { reminderId, userId: interaction.user.id });
1✔
336
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc