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

VolvoxLLC / volvox-bot / 22529724381

28 Feb 2026 09:48PM UTC coverage: 90.373% (-0.2%) from 90.535%
22529724381

Pull #153

github

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

4405 of 5157 branches covered (85.42%)

Branch coverage included in aggregate %.

205 of 230 new or added lines in 6 files covered. (89.13%)

1 existing line in 1 file now uncovered.

7545 of 8066 relevant lines covered (93.54%)

49.39 hits per line

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

86.87
/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 { safeSend } from '../utils/safeSend.js';
12
import { getNextCronRun } from './scheduler.js';
13

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

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

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

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

70
  if (reminder.recurring_cron) {
4✔
71
    embed.addFields({ name: 'Recurring', value: `\`${reminder.recurring_cron}\``, inline: true });
2✔
72
  }
73

74
  return embed;
4✔
75
}
76

77
/**
78
 * Send a reminder notification to the user.
79
 * Tries DM first, falls back to channel mention.
80
 *
81
 * @param {import('discord.js').Client} client - Discord client
82
 * @param {object} reminder - Reminder row from DB
83
 */
84
async function sendReminderNotification(client, reminder) {
85
  const embed = buildReminderEmbed(reminder);
4✔
86
  const components = [buildSnoozeButtons(reminder.id)];
4✔
87

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

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

125
/**
126
 * Check for due reminders and fire them.
127
 * Called by the scheduler every 60s.
128
 *
129
 * @param {import('discord.js').Client} client - Discord client
130
 */
131
export async function checkReminders(client) {
132
  const pool = getPool();
17✔
133
  if (!pool) return;
17!
134

135
  const { rows } = await pool.query(
17✔
136
    'SELECT * FROM reminders WHERE completed = false AND remind_at <= NOW()',
137
  );
138

139
  for (const reminder of rows) {
17✔
140
    try {
4✔
141
      await sendReminderNotification(client, reminder);
4✔
142

143
      if (reminder.recurring_cron) {
4✔
144
        // Recurring: schedule next run, don't mark completed
145
        try {
2✔
146
          const nextRun = getNextCronRun(reminder.recurring_cron, new Date());
2✔
147
          await pool.query('UPDATE reminders SET remind_at = $1 WHERE id = $2', [
2✔
148
            nextRun.toISOString(),
149
            reminder.id,
150
          ]);
151
          info('Recurring reminder rescheduled', {
1✔
152
            reminderId: reminder.id,
153
            nextRun: nextRun.toISOString(),
154
          });
155
        } catch (cronErr) {
156
          logError('Invalid recurring cron, marking completed', {
1✔
157
            reminderId: reminder.id,
158
            cron: reminder.recurring_cron,
159
            error: cronErr.message,
160
          });
161
          await pool.query('UPDATE reminders SET completed = true WHERE id = $1', [reminder.id]);
1✔
162
        }
163
      } else {
164
        // One-time: mark completed
165
        await pool.query('UPDATE reminders SET completed = true WHERE id = $1', [reminder.id]);
2✔
166
      }
167
    } catch (err) {
NEW
168
      logError('Failed to process reminder', { reminderId: reminder.id, error: err.message });
×
169
    }
170
  }
171
}
172

173
/**
174
 * Handle a reminder snooze button click.
175
 *
176
 * @param {import('discord.js').ButtonInteraction} interaction
177
 */
178
export async function handleReminderSnooze(interaction) {
179
  const match = interaction.customId.match(/^reminder_snooze_(\d+)_(15m|1h|tomorrow)$/);
5✔
180
  if (!match) return;
5✔
181

182
  const reminderId = Number.parseInt(match[1], 10);
4✔
183
  const duration = match[2];
4✔
184
  const snoozeMs = SNOOZE_DURATIONS[duration];
4✔
185

186
  const pool = getPool();
4✔
187

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

190
  if (rows.length === 0) {
4✔
191
    await interaction.reply({ content: '❌ Reminder not found.', ephemeral: true });
1✔
192
    return;
1✔
193
  }
194

195
  const reminder = rows[0];
3✔
196

197
  // Verify ownership
198
  if (reminder.user_id !== interaction.user.id) {
3✔
199
    await interaction.reply({ content: "❌ This isn't your reminder.", ephemeral: true });
1✔
200
    return;
1✔
201
  }
202

203
  const newRemindAt = new Date(Date.now() + snoozeMs);
2✔
204

205
  await pool.query(
2✔
206
    'UPDATE reminders SET remind_at = $1, completed = false, snoozed_count = snoozed_count + 1 WHERE id = $2',
207
    [newRemindAt.toISOString(), reminderId],
208
  );
209

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

212
  // Update the original message to show it was snoozed
213
  try {
2✔
214
    await interaction.update({
2✔
215
      content: `💤 Snoozed for ${labels[duration]}. I'll remind you <t:${Math.floor(newRemindAt.getTime() / 1000)}:R>.`,
216
      embeds: [],
217
      components: [],
218
    });
219
  } catch {
220
    await interaction.reply({
1✔
221
      content: `💤 Snoozed for ${labels[duration]}. I'll remind you <t:${Math.floor(newRemindAt.getTime() / 1000)}:R>.`,
222
      ephemeral: true,
223
    });
224
  }
225

226
  info('Reminder snoozed', { reminderId, duration, userId: interaction.user.id });
2✔
227
}
228

229
/**
230
 * Handle a reminder dismiss button click.
231
 *
232
 * @param {import('discord.js').ButtonInteraction} interaction
233
 */
234
export async function handleReminderDismiss(interaction) {
235
  const match = interaction.customId.match(/^reminder_dismiss_(\d+)$/);
3✔
236
  if (!match) return;
3✔
237

238
  const reminderId = Number.parseInt(match[1], 10);
2✔
239
  const pool = getPool();
2✔
240

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

243
  if (rows.length === 0) {
2!
NEW
244
    await interaction.reply({ content: '❌ Reminder not found.', ephemeral: true });
×
NEW
245
    return;
×
246
  }
247

248
  const reminder = rows[0];
2✔
249

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

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

257
  try {
1✔
258
    await interaction.update({
1✔
259
      content: '✅ Reminder dismissed.',
260
      embeds: [],
261
      components: [],
262
    });
263
  } catch {
NEW
264
    await interaction.reply({ content: '✅ Reminder dismissed.', ephemeral: true });
×
265
  }
266

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