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

VolvoxLLC / volvox-bot / 22530578657

28 Feb 2026 10:40PM UTC coverage: 90.209% (-0.3%) from 90.535%
22530578657

Pull #153

github

web-flow
Merge 76329cdce into 665b74e59
Pull Request #153: feat: add /remind command with natural language time parsing

4409 of 5169 branches covered (85.3%)

Branch coverage included in aggregate %.

261 of 302 new or added lines in 7 files covered. (86.42%)

5 existing lines in 1 file now uncovered.

7550 of 8088 relevant lines covered (93.35%)

49.26 hits per line

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

72.93
/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 === false) {
4!
NEW
152
        info('Reminders disabled for guild, skipping', { reminderId: reminder.id, guildId: reminder.guild_id });
×
NEW
153
        continue;
×
154
      }
155

156
      const delivered = await sendReminderNotification(client, reminder);
4✔
157

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

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

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

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

224
  const reminderId = Number.parseInt(match[1], 10);
4✔
225
  const duration = match[2];
4✔
226
  const snoozeMs = SNOOZE_DURATIONS[duration];
4✔
227

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

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

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

244
  const reminder = rows[0];
3✔
245

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

252
  const newRemindAt = new Date(Date.now() + snoozeMs);
2✔
253

254
  await pool.query(
2✔
255
    'UPDATE reminders SET remind_at = $1, completed = false, snoozed_count = snoozed_count + 1 WHERE id = $2',
256
    [newRemindAt.toISOString(), reminderId],
257
  );
258

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

261
  // Update the original message to show it was snoozed
262
  try {
2✔
263
    await interaction.update({
2✔
264
      content: `💤 Snoozed for ${labels[duration]}. I'll remind you <t:${Math.floor(newRemindAt.getTime() / 1000)}:R>.`,
265
      embeds: [],
266
      components: [],
267
    });
268
  } catch {
269
    await interaction.reply({
1✔
270
      content: `💤 Snoozed for ${labels[duration]}. I'll remind you <t:${Math.floor(newRemindAt.getTime() / 1000)}:R>.`,
271
      ephemeral: true,
272
    });
273
  }
274

275
  info('Reminder snoozed', { reminderId, duration, userId: interaction.user.id });
2✔
276
}
277

278
/**
279
 * Handle a reminder dismiss button click.
280
 *
281
 * @param {import('discord.js').ButtonInteraction} interaction
282
 */
283
export async function handleReminderDismiss(interaction) {
284
  const match = interaction.customId.match(/^reminder_dismiss_(\d+)$/);
3✔
285
  if (!match) return;
3✔
286

287
  const reminderId = Number.parseInt(match[1], 10);
2✔
288
  const pool = getPool();
2✔
289
  if (!pool) {
2!
NEW
290
    await interaction.reply({
×
291
      content: '❌ Database unavailable. Please try again later.',
292
      ephemeral: true,
293
    });
NEW
294
    return;
×
295
  }
296

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

299
  if (rows.length === 0) {
2!
NEW
300
    await interaction.reply({ content: '❌ Reminder not found.', ephemeral: true });
×
NEW
301
    return;
×
302
  }
303

304
  const reminder = rows[0];
2✔
305

306
  if (reminder.user_id !== interaction.user.id) {
2✔
307
    await interaction.reply({ content: "❌ This isn't your reminder.", ephemeral: true });
1✔
308
    return;
1✔
309
  }
310

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

313
  try {
1✔
314
    await interaction.update({
1✔
315
      content: '✅ Reminder dismissed.',
316
      embeds: [],
317
      components: [],
318
    });
319
  } catch {
NEW
320
    await interaction.reply({ content: '✅ Reminder dismissed.', ephemeral: true });
×
321
  }
322

323
  info('Reminder dismissed', { reminderId, userId: interaction.user.id });
1✔
324
}
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