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

VolvoxLLC / volvox-bot / 22667312941

04 Mar 2026 11:25AM UTC coverage: 87.864% (+0.07%) from 87.794%
22667312941

push

github

web-flow
refactor: modularize events.js and add missing tests (#240)

5819 of 7025 branches covered (82.83%)

Branch coverage included in aggregate %.

258 of 322 new or added lines in 7 files covered. (80.12%)

42 existing lines in 5 files now uncovered.

9979 of 10955 relevant lines covered (91.09%)

236.24 hits per line

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

71.7
/src/modules/events/interactionCreate.js
1
/**
2
 * InteractionCreate Event Handlers
3
 * Handles all Discord interaction events (buttons, modals, select menus)
4
 */
5

6
import {
7
  ActionRowBuilder,
8
  ChannelType,
9
  Events,
10
  ModalBuilder,
11
  TextInputBuilder,
12
  TextInputStyle,
13
} from 'discord.js';
14
import { handleShowcaseModalSubmit, handleShowcaseUpvote } from '../../commands/showcase.js';
15
import { error as logError, warn } from '../../logger.js';
16
import { safeEditReply, safeReply } from '../../utils/safeSend.js';
17
import { handleHintButton, handleSolveButton } from '../challengeScheduler.js';
18
import { getConfig } from '../config.js';
19
import { handlePollVote } from '../pollHandler.js';
20
import { handleReminderDismiss, handleReminderSnooze } from '../reminderHandler.js';
21
import { handleReviewClaim } from '../reviewHandler.js';
22
import { closeTicket, getTicketConfig, openTicket } from '../ticketHandler.js';
23
import {
24
  handleRoleMenuSelection,
25
  handleRulesAcceptButton,
26
  ROLE_MENU_SELECT_ID,
27
  RULES_ACCEPT_BUTTON_ID,
28
} from '../welcomeOnboarding.js';
29

30
/**
31
 * Register an interactionCreate handler for poll vote buttons.
32
 * Listens for button clicks with customId matching `poll_vote_<pollId>_<optionIndex>`.
33
 *
34
 * @param {Client} client - Discord client instance
35
 */
36
export function registerPollButtonHandler(client) {
37
  client.on(Events.InteractionCreate, async (interaction) => {
7✔
38
    if (!interaction.isButton()) return;
6✔
39
    if (!interaction.customId.startsWith('poll_vote_')) return;
5✔
40

41
    // Gate on poll feature being enabled for this guild
42
    const guildConfig = getConfig(interaction.guildId);
4✔
43
    if (!guildConfig.poll?.enabled) return;
4!
44

45
    try {
4✔
46
      await handlePollVote(interaction);
4✔
47
    } catch (err) {
48
      logError('Poll vote handler failed', {
3✔
49
        customId: interaction.customId,
50
        userId: interaction.user?.id,
51
        error: err.message,
52
      });
53

54
      // Try to send an ephemeral error if we haven't replied yet
55
      if (!interaction.replied && !interaction.deferred) {
3✔
56
        try {
2✔
57
          await safeReply(interaction, {
2✔
58
            content: '❌ Something went wrong processing your vote.',
59
            ephemeral: true,
60
          });
61
        } catch {
62
          // Ignore — we tried
63
        }
64
      }
65
    }
66
  });
67
}
68

69
/**
70
 * Register an interactionCreate handler for review claim buttons.
71
 * Listens for button clicks with customId matching `review_claim_<id>`.
72
 *
73
 * @param {Client} client - Discord client instance
74
 */
75
export function registerReviewClaimHandler(client) {
76
  client.on(Events.InteractionCreate, async (interaction) => {
10✔
77
    if (!interaction.isButton()) return;
14✔
78
    if (!interaction.customId.startsWith('review_claim_')) return;
12✔
79

80
    // Gate on review feature being enabled for this guild
81
    const guildConfig = getConfig(interaction.guildId);
10✔
82
    if (!guildConfig.review?.enabled) return;
10✔
83

84
    try {
7✔
85
      await handleReviewClaim(interaction);
7✔
86
    } catch (err) {
87
      logError('Review claim handler failed', {
5✔
88
        customId: interaction.customId,
89
        userId: interaction.user?.id,
90
        error: err.message,
91
      });
92

93
      if (!interaction.replied && !interaction.deferred) {
5✔
94
        try {
3✔
95
          await safeReply(interaction, {
3✔
96
            content: '❌ Something went wrong processing your claim.',
97
            ephemeral: true,
98
          });
99
        } catch {
100
          // Ignore — we tried
101
        }
102
      }
103
    }
104
  });
105
}
106

107
/**
108
 * Register an interactionCreate handler for showcase upvote buttons.
109
 * Listens for button clicks with customId matching `showcase_upvote_<id>`.
110
 *
111
 * @param {Client} client - Discord client instance
112
 */
113
export function registerShowcaseButtonHandler(client) {
114
  client.on(Events.InteractionCreate, async (interaction) => {
7✔
115
    if (!interaction.isButton()) return;
11✔
116
    if (!interaction.customId.startsWith('showcase_upvote_')) return;
9✔
117

118
    // Gate on showcase feature being enabled for this guild
119
    const guildConfig = getConfig(interaction.guildId);
7✔
120
    if (guildConfig.showcase?.enabled === false) return;
7!
121

122
    let pool;
123
    try {
7✔
124
      pool = (await import('../../db.js')).getPool();
7✔
125
    } catch {
126
      try {
1✔
127
        await safeReply(interaction, {
1✔
128
          content: '❌ Database is not available.',
129
          ephemeral: true,
130
        });
131
      } catch {
132
        // Ignore
133
      }
134
      return;
1✔
135
    }
136

137
    try {
6✔
138
      await handleShowcaseUpvote(interaction, pool);
6✔
139
    } catch (err) {
140
      logError('Showcase upvote handler failed', {
4✔
141
        customId: interaction.customId,
142
        userId: interaction.user?.id,
143
        error: err.message,
144
      });
145

146
      try {
4✔
147
        const reply = interaction.deferred || interaction.replied ? safeEditReply : safeReply;
4✔
148
        await reply(interaction, {
4✔
149
          content: '❌ Something went wrong processing your upvote.',
150
          ephemeral: true,
151
        });
152
      } catch {
153
        // Ignore — we tried
154
      }
155
    }
156
  });
157
}
158

159
/**
160
 * Register an interactionCreate handler for showcase modal submissions.
161
 * Listens for modal submits with customId `showcase_submit_modal`.
162
 *
163
 * @param {Client} client - Discord client instance
164
 */
165
export function registerShowcaseModalHandler(client) {
166
  client.on(Events.InteractionCreate, async (interaction) => {
7✔
167
    if (!interaction.isModalSubmit()) return;
11✔
168
    if (interaction.customId !== 'showcase_submit_modal') return;
9✔
169

170
    // Gate on showcase feature being enabled for this guild
171
    const guildConfig = getConfig(interaction.guildId);
7✔
172
    if (guildConfig.showcase?.enabled === false) return;
7!
173

174
    let pool;
175
    try {
7✔
176
      pool = (await import('../../db.js')).getPool();
7✔
177
    } catch {
178
      try {
1✔
179
        await safeReply(interaction, {
1✔
180
          content: '❌ Database is not available.',
181
          ephemeral: true,
182
        });
183
      } catch {
184
        // Ignore
185
      }
186
      return;
1✔
187
    }
188

189
    try {
6✔
190
      await handleShowcaseModalSubmit(interaction, pool);
6✔
191
    } catch (err) {
192
      logError('Showcase modal error', { error: err.message });
4✔
193
      try {
4✔
194
        const reply = interaction.deferred || interaction.replied ? safeEditReply : safeReply;
4✔
195
        await reply(interaction, { content: '❌ Something went wrong.' });
4✔
196
      } catch (replyErr) {
NEW
197
        logError('Failed to send fallback reply', { error: replyErr?.message });
×
198
      }
199
    }
200
  });
201
}
202

203
/**
204
 * Register an interactionCreate handler for challenge solve and hint buttons.
205
 * Listens for button clicks with customId matching `challenge_solve_<index>` or `challenge_hint_<index>`.
206
 *
207
 * @param {Client} client - Discord client instance
208
 */
209
export function registerChallengeButtonHandler(client) {
210
  client.on(Events.InteractionCreate, async (interaction) => {
10✔
211
    if (!interaction.isButton()) return;
15✔
212

213
    const isSolve = interaction.customId.startsWith('challenge_solve_');
13✔
214
    const isHint = interaction.customId.startsWith('challenge_hint_');
13✔
215
    if (!isSolve && !isHint) return;
13✔
216

217
    // Gate on challenges feature being enabled for this guild
218
    const guildConfig = getConfig(interaction.guildId);
11✔
219
    if (!guildConfig.challenges?.enabled) return;
11!
220

221
    const prefix = isSolve ? 'challenge_solve_' : 'challenge_hint_';
11✔
222
    const indexStr = interaction.customId.slice(prefix.length);
15✔
223
    const challengeIndex = Number.parseInt(indexStr, 10);
15✔
224

225
    if (Number.isNaN(challengeIndex)) {
15✔
226
      warn('Invalid challenge button customId', { customId: interaction.customId });
2✔
227
      return;
2✔
228
    }
229

230
    try {
9✔
231
      if (isSolve) {
9✔
232
        await handleSolveButton(interaction, challengeIndex);
5✔
233
      } else {
234
        await handleHintButton(interaction, challengeIndex);
4✔
235
      }
236
    } catch (err) {
237
      logError('Challenge button handler failed', {
5✔
238
        customId: interaction.customId,
239
        userId: interaction.user?.id,
240
        error: err.message,
241
      });
242

243
      if (!interaction.replied && !interaction.deferred) {
5✔
244
        try {
3✔
245
          await safeReply(interaction, {
3✔
246
            content: '❌ Something went wrong. Please try again.',
247
            ephemeral: true,
248
          });
249
        } catch {
250
          // Ignore
251
        }
252
      }
253
    }
254
  });
255
}
256

257
/**
258
 * Register onboarding interaction handlers:
259
 * - Rules acceptance button
260
 * - Role selection menu
261
 *
262
 * @param {Client} client - Discord client instance
263
 */
264
export function registerWelcomeOnboardingHandlers(client) {
265
  client.on(Events.InteractionCreate, async (interaction) => {
1✔
NEW
266
    const guildId = interaction.guildId;
×
NEW
267
    if (!guildId) return;
×
268

NEW
269
    const guildConfig = getConfig(guildId);
×
NEW
270
    if (!guildConfig.welcome?.enabled) return;
×
271

NEW
272
    if (interaction.isButton() && interaction.customId === RULES_ACCEPT_BUTTON_ID) {
×
NEW
273
      try {
×
NEW
274
        await handleRulesAcceptButton(interaction, guildConfig);
×
275
      } catch (err) {
NEW
276
        logError('Rules acceptance handler failed', {
×
277
          guildId,
278
          userId: interaction.user?.id,
279
          error: err?.message,
280
        });
281

NEW
282
        try {
×
283
          // Handler already deferred, so we can safely edit
NEW
284
          await safeEditReply(interaction, {
×
285
            content: '❌ Failed to verify. Please ping an admin.',
286
          });
287
        } catch {
288
          // ignore
289
        }
290
      }
NEW
291
      return;
×
292
    }
293

NEW
294
    if (interaction.isStringSelectMenu() && interaction.customId === ROLE_MENU_SELECT_ID) {
×
NEW
295
      try {
×
NEW
296
        await handleRoleMenuSelection(interaction, guildConfig);
×
297
      } catch (err) {
NEW
298
        logError('Role menu handler failed', {
×
299
          guildId,
300
          userId: interaction.user?.id,
301
          error: err?.message,
302
        });
303

NEW
304
        try {
×
305
          // Handler already deferred, so we can safely edit
NEW
306
          await safeEditReply(interaction, {
×
307
            content: '❌ Failed to update roles. Please try again.',
308
          });
309
        } catch {
310
          // ignore
311
        }
312
      }
313
    }
314
  });
315
}
316

317
/**
318
 * Register an interactionCreate handler for reminder snooze/dismiss buttons.
319
 * Listens for button clicks with customId matching `reminder_snooze_<id>_<duration>`
320
 * or `reminder_dismiss_<id>`.
321
 *
322
 * @param {Client} client - Discord client instance
323
 */
324
export function registerReminderButtonHandler(client) {
325
  client.on(Events.InteractionCreate, async (interaction) => {
1✔
NEW
326
    if (!interaction.isButton()) return;
×
327

NEW
328
    const isSnooze = interaction.customId.startsWith('reminder_snooze_');
×
NEW
329
    const isDismiss = interaction.customId.startsWith('reminder_dismiss_');
×
NEW
330
    if (!isSnooze && !isDismiss) return;
×
331

332
    // Gate on reminders feature being enabled for this guild
NEW
333
    const guildConfig = getConfig(interaction.guildId);
×
NEW
334
    if (!guildConfig.reminders?.enabled) return;
×
335

NEW
336
    try {
×
NEW
337
      if (isSnooze) {
×
NEW
338
        await handleReminderSnooze(interaction);
×
339
      } else {
NEW
340
        await handleReminderDismiss(interaction);
×
341
      }
342
    } catch (err) {
NEW
343
      logError('Reminder button handler failed', {
×
344
        customId: interaction.customId,
345
        userId: interaction.user?.id,
346
        error: err.message,
347
      });
348

NEW
349
      if (!interaction.replied && !interaction.deferred) {
×
NEW
350
        try {
×
NEW
351
          await safeReply(interaction, {
×
352
            content: '❌ Something went wrong processing your request.',
353
            ephemeral: true,
354
          });
355
        } catch {
356
          // Ignore
357
        }
358
      }
359
    }
360
  });
361
}
362

363
/**
364
 * Register an interactionCreate handler for ticket open button clicks.
365
 * Listens for button clicks with customId `ticket_open` (from the persistent panel).
366
 * Shows a modal to collect the ticket topic, then opens the ticket.
367
 *
368
 * @param {Client} client - Discord client instance
369
 */
370
export function registerTicketOpenButtonHandler(client) {
371
  client.on(Events.InteractionCreate, async (interaction) => {
3✔
372
    if (!interaction.isButton()) return;
2!
373
    if (interaction.customId !== 'ticket_open') return;
2!
374

375
    const ticketConfig = getTicketConfig(interaction.guildId);
2✔
376
    if (!ticketConfig.enabled) {
2✔
377
      try {
1✔
378
        await safeReply(interaction, {
1✔
379
          content: '❌ The ticket system is not enabled on this server.',
380
          ephemeral: true,
381
        });
382
      } catch {
383
        // Ignore
384
      }
385
      return;
1✔
386
    }
387

388
    // Show a modal to collect the topic
389
    const modal = new ModalBuilder()
1✔
390
      .setCustomId('ticket_open_modal')
391
      .setTitle('Open Support Ticket');
392

393
    const topicInput = new TextInputBuilder()
1✔
394
      .setCustomId('ticket_topic')
395
      .setLabel('What do you need help with?')
396
      .setStyle(TextInputStyle.Paragraph)
397
      .setPlaceholder('Describe your issue...')
398
      .setMaxLength(200)
399
      .setRequired(false);
400

401
    const row = new ActionRowBuilder().addComponents(topicInput);
1✔
402
    modal.addComponents(row);
1✔
403

404
    try {
1✔
405
      await interaction.showModal(modal);
1✔
406
    } catch (err) {
NEW
407
      logError('Failed to show ticket modal', {
×
408
        userId: interaction.user?.id,
409
        error: err.message,
410
      });
411
    }
412
  });
413
}
414

415
/**
416
 * Register an interactionCreate handler for ticket modal submissions.
417
 * Listens for modal submits with customId `ticket_open_modal`.
418
 *
419
 * @param {Client} client - Discord client instance
420
 */
421
export function registerTicketModalHandler(client) {
422
  client.on(Events.InteractionCreate, async (interaction) => {
3✔
423
    if (!interaction.isModalSubmit()) return;
2!
424
    if (interaction.customId !== 'ticket_open_modal') return;
2!
425

426
    try {
2✔
427
      await interaction.deferReply({ ephemeral: true });
2✔
428
    } catch (err) {
NEW
429
      logError('Failed to defer ticket modal reply', {
×
430
        userId: interaction.user?.id,
431
        guildId: interaction.guildId,
432
        error: err?.message,
433
      });
NEW
434
      return;
×
435
    }
436

437
    const topic = interaction.fields.getTextInputValue('ticket_topic') || null;
2✔
438

439
    try {
2✔
440
      const { ticket, thread } = await openTicket(
2✔
441
        interaction.guild,
442
        interaction.user,
443
        topic,
444
        interaction.channelId,
445
      );
446

447
      await safeEditReply(interaction, {
1✔
448
        content: `✅ Ticket #${ticket.id} created! Head to <#${thread.id}>.`,
449
      });
450
    } catch (err) {
451
      logError('Ticket modal handler failed', {
1✔
452
        userId: interaction.user?.id,
453
        guildId: interaction.guildId,
454
        error: err?.message,
455
      });
456

457
      // We already successfully deferred, so use safeEditReply
458
      try {
1✔
459
        await safeEditReply(interaction, {
1✔
460
          content: '❌ An error occurred processing your ticket.',
461
        });
462
      } catch (replyErr) {
NEW
463
        logError('Failed to send fallback reply', { error: replyErr?.message });
×
464
      }
465
    }
466
  });
467
}
468

469
/**
470
 * Register an interactionCreate handler for ticket close button clicks.
471
 * Listens for button clicks with customId matching `ticket_close_<id>`.
472
 *
473
 * @param {Client} client - Discord client instance
474
 */
475
export function registerTicketCloseButtonHandler(client) {
476
  client.on(Events.InteractionCreate, async (interaction) => {
4✔
477
    if (!interaction.isButton()) return;
3!
478
    if (!interaction.customId.startsWith('ticket_close_')) return;
3!
479

480
    try {
3✔
481
      await interaction.deferReply({ ephemeral: true });
3✔
482
    } catch (err) {
NEW
483
      logError('Failed to defer ticket close reply', {
×
484
        userId: interaction.user?.id,
485
        guildId: interaction.guildId,
486
        error: err?.message,
487
      });
NEW
488
      return;
×
489
    }
490

491
    const ticketChannel = interaction.channel;
3✔
492
    const isThread = typeof ticketChannel?.isThread === 'function' && ticketChannel.isThread();
3✔
493
    const isTextChannel = ticketChannel?.type === ChannelType.GuildText;
3✔
494

495
    if (!isThread && !isTextChannel) {
3✔
496
      await safeEditReply(interaction, {
1✔
497
        content: '❌ This button can only be used inside a ticket channel or thread.',
498
      });
499
      return;
1✔
500
    }
501

502
    try {
2✔
503
      const ticket = await closeTicket(ticketChannel, interaction.user, 'Closed via button');
2✔
504
      await safeEditReply(interaction, {
1✔
505
        content: `✅ Ticket #${ticket.id} has been closed.`,
506
      });
507
    } catch (err) {
508
      logError('Ticket close handler failed', {
1✔
509
        userId: interaction.user?.id,
510
        guildId: interaction.guildId,
511
        channelId: ticketChannel?.id,
512
        error: err?.message,
513
      });
514

515
      // We already successfully deferred, so use safeEditReply
516
      try {
1✔
517
        await safeEditReply(interaction, {
1✔
518
          content: '❌ An error occurred while closing the ticket.',
519
        });
520
      } catch (replyErr) {
NEW
521
        logError('Failed to send fallback reply', { error: replyErr?.message });
×
522
      }
523
    }
524
  });
525
}
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