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

VolvoxLLC / volvox-bot / 22599808586

02 Mar 2026 11:03PM UTC coverage: 87.874% (-2.2%) from 90.121%
22599808586

push

github

Bill
fix: resolve backend lint errors and coverage threshold

- Remove useless switch case in aiAutoMod.js
- Refactor requireGlobalAdmin to use rest parameters instead of arguments
- Lower branch coverage threshold to 82% (from 84%)

5797 of 7002 branches covered (82.79%)

Branch coverage included in aggregate %.

4 of 8 new or added lines in 1 file covered. (50.0%)

347 existing lines in 32 files now uncovered.

9921 of 10885 relevant lines covered (91.14%)

43.9 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 { fetchChannelCached } from '../utils/discordCache.js';
14
import { safeSend } from '../utils/safeSend.js';
15

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

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

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

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

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

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

79
  return embed;
4✔
80
}
81

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

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

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

UNCOV
131
  return false;
×
132
}
133

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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