APRK01 commited on
Commit
fbe00fc
Β·
1 Parent(s): 05eb716

feat: complete interactive mass drop system

Browse files
src/events/interactionCreate.js CHANGED
@@ -1,16 +1,24 @@
1
  const { handleTicketButton } = require('../systems/tickets');
2
  const { handleDropButton } = require('../systems/drops');
 
3
 
4
  module.exports = {
5
  name: 'interactionCreate',
6
  async execute(client, interaction) {
7
- if (!interaction.isButton()) return;
 
 
 
 
 
8
 
9
  // Try drop buttons first (DM interactions)
10
- const dropHandled = await handleDropButton(interaction);
11
- if (dropHandled) return;
 
12
 
13
- // Then ticket buttons (server interactions)
14
- await handleTicketButton(interaction, client);
 
15
  },
16
  };
 
1
  const { handleTicketButton } = require('../systems/tickets');
2
  const { handleDropButton } = require('../systems/drops');
3
+ const { handleMassDropInteraction } = require('../systems/massdrop');
4
 
5
  module.exports = {
6
  name: 'interactionCreate',
7
  async execute(client, interaction) {
8
+ // Handle select menus and modals as well
9
+ if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return;
10
+
11
+ // Try mass drop interactions (buttons, dropdowns, modals)
12
+ const massDropHandled = await handleMassDropInteraction(interaction);
13
+ if (massDropHandled) return;
14
 
15
  // Try drop buttons first (DM interactions)
16
+ if (interaction.isButton()) {
17
+ const dropHandled = await handleDropButton(interaction);
18
+ if (dropHandled) return;
19
 
20
+ // Then ticket buttons (server interactions)
21
+ await handleTicketButton(interaction, client);
22
+ }
23
  },
24
  };
src/events/messageCreate.js CHANGED
@@ -2,6 +2,7 @@ const { ChannelType } = require('discord.js');
2
  const { errorEmbed, infoEmbed, successEmbed, createEmbed } = require('../utils/embeds');
3
  const { logCommand } = require('../systems/logger');
4
  const { startDropSession, hasSession, getPrompt, handleDropMessage, canDrop } = require('../systems/drops');
 
5
  const { stmts } = require('../database');
6
  const { Colors } = require('../config');
7
 
@@ -57,6 +58,18 @@ module.exports = {
57
  }
58
  }
59
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  // ── Whitelisted user: only allow "drop" ──
61
  if (userId !== OWNER_ID) {
62
  if (content === 'drop') {
@@ -94,6 +107,14 @@ module.exports = {
94
  return;
95
  }
96
 
 
 
 
 
 
 
 
 
97
  // Whitelist management
98
  if (content.startsWith('whitelist ') && !content.startsWith('whitelist <')) {
99
  const args = content.split(' ').slice(1);
 
2
  const { errorEmbed, infoEmbed, successEmbed, createEmbed } = require('../utils/embeds');
3
  const { logCommand } = require('../systems/logger');
4
  const { startDropSession, hasSession, getPrompt, handleDropMessage, canDrop } = require('../systems/drops');
5
+ const { startMassDropSession, hasMassSession, getMassPrompt, handleMassDropMessage } = require('../systems/massdrop');
6
  const { stmts } = require('../database');
7
  const { Colors } = require('../config');
8
 
 
58
  }
59
  }
60
 
61
+ // ── Handle active mass drop session ──
62
+ if (hasMassSession(userId)) {
63
+ try {
64
+ const handled = await handleMassDropMessage(message);
65
+ if (handled) return;
66
+ } catch (err) {
67
+ console.error('[Mass Drop Error]', err);
68
+ await message.reply({ content: `❌ Mass Drop error: ${err.message}` }).catch(() => { });
69
+ return;
70
+ }
71
+ }
72
+
73
  // ── Whitelisted user: only allow "drop" ──
74
  if (userId !== OWNER_ID) {
75
  if (content === 'drop') {
 
107
  return;
108
  }
109
 
110
+ // Mass Drop (no rate limit for owner)
111
+ if (content === 'massdrop') {
112
+ await logCommand(client, 'massdrop');
113
+ const session = startMassDropSession(userId);
114
+ await message.reply(getMassPrompt(session));
115
+ return;
116
+ }
117
+
118
  // Whitelist management
119
  if (content.startsWith('whitelist ') && !content.startsWith('whitelist <')) {
120
  const args = content.split(' ').slice(1);
src/systems/massdrop.js ADDED
@@ -0,0 +1,425 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { ActionRowBuilder, ButtonBuilder, ButtonStyle, ModalBuilder, TextInputBuilder, TextInputStyle, StringSelectMenuBuilder, StringSelectMenuOptionBuilder } = require('discord.js');
2
+ const { createEmbed, errorEmbed } = require('../utils/embeds');
3
+ const { Colors } = require('../config');
4
+ const { Octokit } = require('@octokit/rest');
5
+ const fetch = require('node-fetch');
6
+ const { stmts } = require('../database');
7
+ const { buildDropEmbed } = require('./drops');
8
+
9
+ // Holds active mass drop sessions
10
+ // Map structure: userId => { step, config: { status, about }, files: [] }
11
+ const massDropSessions = new Map();
12
+
13
+ /**
14
+ * Initializes a new mass drop session for the user
15
+ * Starts at the 'config' step where they set the default About text.
16
+ */
17
+ function startMassDropSession(userId) {
18
+ const session = {
19
+ step: 'config_status',
20
+ config: {
21
+ status: 'unchecked',
22
+ about: 'enjoy the drop :))'
23
+ },
24
+ files: [] // Array of { title, type, url, description, status, name }
25
+ };
26
+ massDropSessions.set(userId, session);
27
+ return session;
28
+ }
29
+
30
+ /**
31
+ * Checks if user is in an active mass drop session
32
+ */
33
+ function hasMassSession(userId) {
34
+ return massDropSessions.has(userId);
35
+ }
36
+
37
+ /**
38
+ * Generates the UI prompt based on the current step of the mass drop session
39
+ */
40
+ function getMassPrompt(session) {
41
+ switch (session.step) {
42
+ case 'config_status':
43
+ return {
44
+ embeds: [createEmbed({
45
+ title: 'πŸ“¦ Mass Drop Initialization',
46
+ description: 'Let\'s set up the defaults for this batch of drops.\n\nFirst, what should the **Default Verification Status** be for all files?',
47
+ color: Colors.PRIMARY
48
+ })],
49
+ components: [
50
+ new ActionRowBuilder().addComponents(
51
+ new ButtonBuilder().setCustomId('mass_checked').setLabel('Verified / Checked').setStyle(ButtonStyle.Success).setEmoji('βœ…'),
52
+ new ButtonBuilder().setCustomId('mass_unchecked').setLabel('Unchecked / Unknown').setStyle(ButtonStyle.Secondary).setEmoji('❓'),
53
+ new ButtonBuilder().setCustomId('mass_cancel').setLabel('Cancel Mass Drop').setStyle(ButtonStyle.Danger)
54
+ )
55
+ ]
56
+ };
57
+ case 'config_about':
58
+ return {
59
+ embeds: [createEmbed({
60
+ title: 'πŸ“ Set Default Description',
61
+ description: `Status set to: **${session.config.status === 'checked' ? 'βœ… Verified' : '❓ Unchecked'}**\n\nPlease reply with the **Default Description / About** text for this batch.\n*(Type your message normally, or click Skip to use "enjoy the drop :))")*`,
62
+ color: Colors.PRIMARY
63
+ })],
64
+ components: [
65
+ new ActionRowBuilder().addComponents(
66
+ new ButtonBuilder().setCustomId('mass_skip_about').setLabel('Use Default ("enjoy the drop :))")').setStyle(ButtonStyle.Secondary),
67
+ new ButtonBuilder().setCustomId('mass_cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger)
68
+ )
69
+ ]
70
+ };
71
+ case 'listening':
72
+ return {
73
+ embeds: [createEmbed({
74
+ title: 'πŸ“₯ Listening for Drops',
75
+ description: `**Configuration Complete!**\n> Status: ${session.config.status === 'checked' ? 'βœ…' : '❓'}\n> Description: \`${session.config.about}\`\n\n**Drop as many files as you want here.** You can upload them one by one or in chunks.\nWhen you are done uploading everything, click **Finish Uploading**.`,
76
+ color: Colors.SUCCESS
77
+ })],
78
+ components: [
79
+ new ActionRowBuilder().addComponents(
80
+ new ButtonBuilder().setCustomId('mass_finish').setLabel('Finish Uploading').setStyle(ButtonStyle.Primary).setEmoji('πŸ“¦'),
81
+ new ButtonBuilder().setCustomId('mass_cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger)
82
+ )
83
+ ]
84
+ };
85
+ // The dashboard step is handled dynamically during interactions
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Generates the unified Mass Drop Dashboard summarizing all files,
91
+ * along with the dropdown menu to edit individual items.
92
+ */
93
+ function generateDashboard(session) {
94
+ if (session.files.length === 0) {
95
+ return {
96
+ embeds: [createEmbed({
97
+ title: '❌ Empty Batch',
98
+ description: 'You did not upload any files. The mass drop session has been cancelled.',
99
+ color: Colors.WARNING
100
+ })],
101
+ components: []
102
+ };
103
+ }
104
+
105
+ let descriptionObj = '### πŸ“¦ Pending Drops\n\n';
106
+ session.files.forEach((file, index) => {
107
+ descriptionObj += `**${index + 1}.** [${file.status === 'checked' ? 'βœ…' : '❓'}] **${file.title}**\n`;
108
+ });
109
+ descriptionObj += `\n*Total Files: ${session.files.length}*\n\n> ✏️ Use the dropdown below to edit individual titles/descriptions.\n> πŸš€ Click **Deploy** when you are ready to post them all.`;
110
+
111
+ const embed = createEmbed({
112
+ title: 'πŸŽ›οΈ Mass Drop Dashboard',
113
+ description: descriptionObj,
114
+ color: Colors.PRIMARY
115
+ });
116
+
117
+ // Create the dropdown menu for editing
118
+ const options = session.files.map((file, index) => {
119
+ return new StringSelectMenuOptionBuilder()
120
+ .setLabel(`${index + 1}. ${file.title.substring(0, 50)}`)
121
+ .setDescription(`Status: ${file.status}`)
122
+ .setValue(`mass_edit_${index}`)
123
+ // Emoji optional: .setEmoji(file.status === 'checked' ? 'βœ…' : '❓')
124
+ });
125
+
126
+ // Discord limits dropdowns to 25 options per exact menu.
127
+ // If they drop >25, we'll slice it for the UI to prevent a crash,
128
+ // though the core engine still processes all 50+.
129
+ const safeOptions = options.slice(0, 25);
130
+
131
+ const row1 = new ActionRowBuilder().addComponents(
132
+ new StringSelectMenuBuilder()
133
+ .setCustomId('mass_select_edit')
134
+ .setPlaceholder('✏️ Select a drop to edit its details...')
135
+ .addOptions(safeOptions)
136
+ );
137
+
138
+ const row2 = new ActionRowBuilder().addComponents(
139
+ new ButtonBuilder().setCustomId('mass_deploy').setLabel(`Deploy ${session.files.length} Drops`).setStyle(ButtonStyle.Success).setEmoji('πŸš€'),
140
+ new ButtonBuilder().setCustomId('mass_cancel').setLabel('Cancel All').setStyle(ButtonStyle.Danger)
141
+ );
142
+
143
+ return { embeds: [embed], components: [row1, row2] };
144
+ }
145
+
146
+ /**
147
+ * Handles incoming messages while a mass drop session is active
148
+ */
149
+ async function handleMassDropMessage(message) {
150
+ const userId = message.author.id;
151
+ const session = massDropSessions.get(userId);
152
+ if (!session) return false;
153
+
154
+ // We only care about text messages if we're in the config_about step.
155
+ // Otherwise, we only care about attachments in the listening step.
156
+
157
+ if (session.step === 'config_about') {
158
+ const content = message.content.trim();
159
+ if (content) {
160
+ session.config.about = content;
161
+ session.step = 'listening';
162
+ await message.reply(getMassPrompt(session));
163
+ } else {
164
+ // Ignored, must be text
165
+ return true;
166
+ }
167
+ }
168
+ else if (session.step === 'listening') {
169
+ // Collect any attachments dropped
170
+ if (message.attachments.size > 0) {
171
+ message.attachments.forEach(attachment => {
172
+ session.files.push({
173
+ title: attachment.name, // Default title is the raw filename
174
+ name: attachment.name,
175
+ url: attachment.url,
176
+ size: attachment.size,
177
+ description: session.config.about,
178
+ status: session.config.status,
179
+ isExternal: false
180
+ });
181
+ });
182
+
183
+ // Brief confirmation logic
184
+ await message.react('βœ…').catch(() => { });
185
+ } else {
186
+ // If they just type text while listening, we might want to allow external links
187
+ const content = message.content.trim();
188
+ if (content.startsWith('http')) {
189
+ const urlParts = new URL(content);
190
+ const filename = urlParts.pathname.split('/').pop() || `file_${Date.now()}`;
191
+
192
+ session.files.push({
193
+ title: filename,
194
+ name: filename,
195
+ url: content,
196
+ size: 0,
197
+ description: session.config.about,
198
+ status: session.config.status,
199
+ isExternal: true
200
+ });
201
+ await message.react('πŸ”—').catch(() => { });
202
+ }
203
+ }
204
+ }
205
+ else if (session.step === 'deploying') {
206
+ const channelId = message.content.replace(/[<#>]/g, '');
207
+
208
+ try {
209
+ const guild = message.client.guilds.cache.first();
210
+ const channel = await guild.channels.fetch(channelId);
211
+ if (!channel) throw new Error('Channel not found');
212
+
213
+ const processingMsg = await message.reply({ content: `⏳ *Deploying **${session.files.length}** drops to GitHub proxy and posting to <#${channelId}>...*\n> This may take a while depending on file sizes.` });
214
+ const octokit = new Octokit({ auth: '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' });
215
+ const [owner, repo] = 'APRK01/WSB-Storage'.split('/');
216
+
217
+ let successCount = 0;
218
+ let failCount = 0;
219
+
220
+ for (const fileConf of session.files) {
221
+ try {
222
+ // 1. Download to buffer
223
+ const fileRes = await fetch(fileConf.url);
224
+ const fileBuffer = await fileRes.buffer();
225
+
226
+ // 2. Create individual release
227
+ const releaseTitle = `Drop: ${fileConf.title.replace(/[^a-zA-Z0-9 -]/g, '')} - ${Date.now()}`;
228
+ const release = await octokit.rest.repos.createRelease({
229
+ owner,
230
+ repo,
231
+ tag_name: `drop-${Date.now()}-${Math.floor(Math.random() * 1000)}`,
232
+ name: releaseTitle,
233
+ body: `Auto-generated drop upload for WSB.\n\nDescription: ${fileConf.description}`
234
+ });
235
+
236
+ // 3. Upload asset
237
+ const uploadRes = await octokit.rest.repos.uploadReleaseAsset({
238
+ owner,
239
+ repo,
240
+ release_id: release.data.id,
241
+ name: fileConf.name,
242
+ data: fileBuffer,
243
+ headers: {
244
+ 'content-type': 'application/octet-stream',
245
+ 'content-length': fileBuffer.length
246
+ }
247
+ });
248
+
249
+ // 4. Dispatch Embed to Channel
250
+ const permanentUrl = uploadRes.data.browser_download_url;
251
+
252
+ const finalEmbed = buildDropEmbed({
253
+ title: fileConf.title,
254
+ status: fileConf.status,
255
+ about: fileConf.description,
256
+ file: fileConf,
257
+ image: null // Mass drops don't currently support combo-image drops
258
+ });
259
+
260
+ const finalRow = new ActionRowBuilder().addComponents(
261
+ new ButtonBuilder()
262
+ .setLabel('πŸ“₯ Download Drop')
263
+ .setStyle(ButtonStyle.Link)
264
+ .setURL(permanentUrl)
265
+ );
266
+
267
+ await channel.send({
268
+ embeds: [finalEmbed],
269
+ components: [finalRow]
270
+ });
271
+
272
+ successCount++;
273
+ stmts.logDrop.run(userId, fileConf.title, channelId);
274
+
275
+ } catch (pushErr) {
276
+ console.error(`[Mass Drop File Error] ${fileConf.title}:`, pushErr);
277
+ failCount++;
278
+ }
279
+ }
280
+
281
+ activeSessions = require('./drops').activeSessions;
282
+ activeSessions?.delete(userId); // Safety catch
283
+ massDropSessions.delete(userId);
284
+
285
+ await processingMsg.edit({ content: `βœ… **Mass Drop Complete!**\n> Successfully deployed: **${successCount}** files.\n> Failed: **${failCount}** files.\nPosted to <#${channelId}>.` });
286
+
287
+ } catch (err) {
288
+ await message.reply({ content: `❌ Failed to prep deployment: ${err.message}\nPlease send a valid channel ID.` });
289
+ }
290
+ }
291
+
292
+ return true; // We consumed the message
293
+ }
294
+
295
+ /**
296
+ * Handle all interactive components (buttons, select menus, modals)
297
+ * related to the Mass Drop system.
298
+ */
299
+ async function handleMassDropInteraction(interaction) {
300
+ const userId = interaction.user.id;
301
+ const session = massDropSessions.get(userId);
302
+ if (!session) return false;
303
+
304
+ // ── BUTTONS ──
305
+ if (interaction.isButton()) {
306
+ const { customId } = interaction;
307
+
308
+ if (customId === 'mass_cancel') {
309
+ massDropSessions.delete(userId);
310
+ await interaction.update({
311
+ embeds: [createEmbed({ title: '❌ Mass Drop Cancelled', color: Colors.ACCENT })],
312
+ components: [],
313
+ });
314
+ return true;
315
+ }
316
+
317
+ if (customId === 'mass_checked' || customId === 'mass_unchecked') {
318
+ session.config.status = customId === 'mass_checked' ? 'checked' : 'unchecked';
319
+ session.step = 'config_about';
320
+ await interaction.update(getMassPrompt(session));
321
+ return true;
322
+ }
323
+
324
+ if (customId === 'mass_skip_about') {
325
+ session.step = 'listening';
326
+ await interaction.update(getMassPrompt(session));
327
+ return true;
328
+ }
329
+
330
+ if (customId === 'mass_finish') {
331
+ session.step = 'dashboard';
332
+ await interaction.update(generateDashboard(session));
333
+ return true;
334
+ }
335
+
336
+ if (customId === 'mass_deploy') {
337
+ session.step = 'deploying';
338
+ await interaction.update({
339
+ embeds: [createEmbed({
340
+ title: 'πŸš€ Ready to Deploy',
341
+ description: `> Send the **channel ID** where these **${session.files.length}** drops should be posted sequentially.\n\nYou can right-click a channel β†’ Copy Channel ID.`,
342
+ color: Colors.INFO
343
+ })],
344
+ components: []
345
+ });
346
+ return true;
347
+ }
348
+ }
349
+
350
+ // ── DROPDOWN MENUS ──
351
+ if (interaction.isStringSelectMenu() && interaction.customId === 'mass_select_edit') {
352
+ const selectedValue = interaction.values[0]; // e.g. mass_edit_0
353
+ const itemIndex = parseInt(selectedValue.split('_').pop(), 10);
354
+ const file = session.files[itemIndex];
355
+
356
+ // Pop up a modal for the user to edit Title/Description
357
+ const modal = new ModalBuilder()
358
+ .setCustomId(`mass_modal_${itemIndex}`)
359
+ .setTitle(`Edit Drop #${itemIndex + 1}`);
360
+
361
+ const titleInput = new TextInputBuilder()
362
+ .setCustomId('edit_title')
363
+ .setLabel('Drop Title')
364
+ .setStyle(TextInputStyle.Short)
365
+ .setRequired(true)
366
+ .setValue(file.title);
367
+
368
+ const descInput = new TextInputBuilder()
369
+ .setCustomId('edit_desc')
370
+ .setLabel('Description / About')
371
+ .setStyle(TextInputStyle.Paragraph)
372
+ .setRequired(false)
373
+ .setValue(file.description);
374
+
375
+ // Status Toggle (Since modals don't support checkboxes yet,
376
+ // we use a short text input that accepts yes/no, verified/unverified, etc)
377
+ const statusInput = new TextInputBuilder()
378
+ .setCustomId('edit_status')
379
+ .setLabel('Status (checked/unchecked)')
380
+ .setStyle(TextInputStyle.Short)
381
+ .setRequired(true)
382
+ .setValue(file.status);
383
+
384
+ modal.addComponents(
385
+ new ActionRowBuilder().addComponents(titleInput),
386
+ new ActionRowBuilder().addComponents(statusInput),
387
+ new ActionRowBuilder().addComponents(descInput)
388
+ );
389
+
390
+ await interaction.showModal(modal);
391
+ return true;
392
+ }
393
+
394
+ // ── MODAL SUBMISSIONS ──
395
+ if (interaction.isModalSubmit() && interaction.customId.startsWith('mass_modal_')) {
396
+ const itemIndex = parseInt(interaction.customId.split('_').pop(), 10);
397
+
398
+ const newTitle = interaction.fields.getTextInputValue('edit_title');
399
+ const newStatusRaw = interaction.fields.getTextInputValue('edit_status').toLowerCase();
400
+ const newDesc = interaction.fields.getTextInputValue('edit_desc');
401
+
402
+ const newStatus = newStatusRaw.includes('uncheck') ? 'unchecked' : 'checked';
403
+
404
+ // Apply edits to memory
405
+ session.files[itemIndex].title = newTitle;
406
+ session.files[itemIndex].status = newStatus;
407
+ session.files[itemIndex].description = newDesc || 'enjoy the drop :))';
408
+
409
+ // Re-render the dashboard
410
+ await interaction.update(generateDashboard(session));
411
+ return true;
412
+ }
413
+
414
+ return false;
415
+ }
416
+
417
+ module.exports = {
418
+ massDropSessions,
419
+ startMassDropSession,
420
+ hasMassSession,
421
+ getMassPrompt,
422
+ handleMassDropMessage,
423
+ generateDashboard,
424
+ handleMassDropInteraction
425
+ };