const { ActionRowBuilder, ButtonBuilder, ButtonStyle, ModalBuilder, TextInputBuilder, TextInputStyle, StringSelectMenuBuilder, StringSelectMenuOptionBuilder } = require('discord.js'); const { createEmbed, errorEmbed } = require('../utils/embeds'); const { Colors } = require('../config'); const { Octokit } = require('@octokit/rest'); const fetch = require('node-fetch'); const { stmts } = require('../database'); const { buildDropEmbed } = require('./drops'); // Holds active mass drop sessions // Map structure: userId => { step, config: { status, about }, files: [] } const massDropSessions = new Map(); /** * Initializes a new mass drop session for the user * Starts at the 'config' step where they set the default About text. */ function startMassDropSession(userId) { const session = { step: 'config_status', config: { status: 'unchecked', about: 'enjoy the drop :))' }, files: [] // Array of { title, type, url, description, status, name } }; massDropSessions.set(userId, session); return session; } /** * Checks if user is in an active mass drop session */ function hasMassSession(userId) { return massDropSessions.has(userId); } /** * Generates the UI prompt based on the current step of the mass drop session */ function getMassPrompt(session) { switch (session.step) { case 'config_status': return { embeds: [createEmbed({ title: '📦 Mass Drop Initialization', description: 'Let\'s set up the defaults for this batch of drops.\n\nFirst, what should the **Default Verification Status** be for all files?', color: Colors.PRIMARY })], components: [ new ActionRowBuilder().addComponents( new ButtonBuilder().setCustomId('mass_checked').setLabel('Verified / Checked').setStyle(ButtonStyle.Success).setEmoji('✅'), new ButtonBuilder().setCustomId('mass_unchecked').setLabel('Unchecked / Unknown').setStyle(ButtonStyle.Secondary).setEmoji('❓'), new ButtonBuilder().setCustomId('mass_cancel').setLabel('Cancel Mass Drop').setStyle(ButtonStyle.Danger) ) ] }; case 'config_about': return { embeds: [createEmbed({ title: '📝 Set Default Description', 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 :))")*`, color: Colors.PRIMARY })], components: [ new ActionRowBuilder().addComponents( new ButtonBuilder().setCustomId('mass_skip_about').setLabel('Use Default ("enjoy the drop :))")').setStyle(ButtonStyle.Secondary), new ButtonBuilder().setCustomId('mass_cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger) ) ] }; case 'listening': return { embeds: [createEmbed({ title: '📥 Listening for Drops', 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**.`, color: Colors.SUCCESS })], components: [ new ActionRowBuilder().addComponents( new ButtonBuilder().setCustomId('mass_finish').setLabel('Finish Uploading').setStyle(ButtonStyle.Primary).setEmoji('📦'), new ButtonBuilder().setCustomId('mass_cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger) ) ] }; // The dashboard step is handled dynamically during interactions } } /** * Generates the unified Mass Drop Dashboard summarizing all files, * along with the dropdown menu to edit individual items. */ function generateDashboard(session) { if (session.files.length === 0) { return { embeds: [createEmbed({ title: '❌ Empty Batch', description: 'You did not upload any files. The mass drop session has been cancelled.', color: Colors.WARNING })], components: [] }; } let descriptionObj = '### 📦 Pending Drops\n\n'; session.files.forEach((file, index) => { descriptionObj += `**${index + 1}.** [${file.status === 'checked' ? '✅' : '❓'}] **${file.title}**\n`; }); 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.`; const embed = createEmbed({ title: '🎛️ Mass Drop Dashboard', description: descriptionObj, color: Colors.PRIMARY }); // Create the dropdown menu for editing const options = session.files.map((file, index) => { return new StringSelectMenuOptionBuilder() .setLabel(`${index + 1}. ${file.title.substring(0, 50)}`) .setDescription(`Status: ${file.status}`) .setValue(`mass_edit_${index}`) // Emoji optional: .setEmoji(file.status === 'checked' ? '✅' : '❓') }); // Discord limits dropdowns to 25 options per exact menu. // If they drop >25, we'll slice it for the UI to prevent a crash, // though the core engine still processes all 50+. const safeOptions = options.slice(0, 25); const components = []; if (safeOptions.length > 0) { components.push( new ActionRowBuilder().addComponents( new StringSelectMenuBuilder() .setCustomId('mass_select_edit') .setPlaceholder('✏️ Select a drop to edit text...') .addOptions(safeOptions) ) ); components.push( new ActionRowBuilder().addComponents( new StringSelectMenuBuilder() .setCustomId('mass_select_image') .setPlaceholder('🖼️ Select a drop to attach an image...') .addOptions(safeOptions) ) ); } components.push( new ActionRowBuilder().addComponents( new ButtonBuilder().setCustomId('mass_deploy').setLabel(`Deploy ${session.files.length} Drops`).setStyle(ButtonStyle.Success).setEmoji('🚀'), new ButtonBuilder().setCustomId('mass_cancel').setLabel('Cancel All').setStyle(ButtonStyle.Danger) ) ); return { embeds: [embed], components }; } /** * Handles incoming messages while a mass drop session is active */ async function handleMassDropMessage(message) { const userId = message.author.id; const session = massDropSessions.get(userId); if (!session) return false; // We only care about text messages if we're in the config_about step. // Otherwise, we only care about attachments in the listening step. if (session.step === 'config_about') { const content = message.content.trim(); if (content) { session.config.about = content; session.step = 'listening'; await message.reply(getMassPrompt(session)); } else { // Ignored, must be text return true; } } else if (session.step === 'listening') { // Collect any attachments dropped if (message.attachments.size > 0) { // First, see if there's a main file and an image file in this single message let mainFile = null; let imageFile = null; message.attachments.forEach(attachment => { const type = attachment.contentType || ''; if (type.startsWith('image/')) { if (!imageFile) imageFile = attachment; } else { if (!mainFile) mainFile = attachment; } }); // If there's NO main file, but there IS an image, treat the image AS the main file if (!mainFile && imageFile) { mainFile = imageFile; imageFile = null; } if (mainFile) { session.files.push({ title: mainFile.name, name: mainFile.name, url: mainFile.url, size: mainFile.size, description: session.config.about, status: session.config.status, isExternal: false, imageUrl: imageFile ? imageFile.url : null }); } // Brief confirmation logic await message.react('✅').catch(() => { }); } else { // If they just type text while listening, we might want to allow external links const content = message.content.trim(); if (content.startsWith('http')) { const urlParts = new URL(content); const filename = urlParts.pathname.split('/').pop() || `file_${Date.now()}`; session.files.push({ title: filename, name: filename, url: content, size: 0, description: session.config.about, status: session.config.status, isExternal: true }); await message.react('🔗').catch(() => { }); } } } else if (session.step === 'waiting_image') { // Stop waiting if they click cancel (handled in interactions) or upload an image if (message.attachments.size > 0) { const imageAttachment = message.attachments.find(a => (a.contentType || '').startsWith('image/')); if (imageAttachment) { const targetIndex = session.pendingImageIndex; if (targetIndex !== undefined && session.files[targetIndex]) { session.files[targetIndex].imageUrl = imageAttachment.url; } // Return to dashboard session.step = 'dashboard'; session.pendingImageIndex = null; await message.react('🖼️').catch(() => { }); await message.reply(generateDashboard(session)); } else { await message.reply({ content: '⚠️ Please attach a valid image file, or click Cancel on the dashboard to abort.', ephemeral: true }); } } } else if (session.step === 'deploying') { const processingMsg = await message.reply({ content: `⏳ *Deploying **${session.files.length}** drops to website API...*\n> This may take a while depending on file sizes.` }); try { const octokit = new Octokit({ auth: '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' }); const [owner, repo] = 'APRK01/WSB-Storage'.split('/'); let successCount = 0; let failCount = 0; const WEBSITE_API = process.env.WEBSITE_API_URL || 'http://localhost:3000/api/drops'; for (const fileConf of session.files) { try { let assetId = null; let fileUrl = fileConf.url; // 1. GitHub Proxy (if not external) if (!fileConf.isExternal) { const fileRes = await fetch(fileConf.url); const fileBuffer = await fileRes.buffer(); const releaseTitle = `Drop: ${fileConf.title.replace(/[^a-zA-Z0-9 -]/g, '')} - ${Date.now()}`; const release = await octokit.rest.repos.createRelease({ owner, repo, tag_name: `drop-${Date.now()}-${Math.floor(Math.random() * 1000)}`, name: releaseTitle, body: `Auto-generated drop upload for WSB.\n\nDescription: ${fileConf.description}` }); const uploadRes = await octokit.rest.repos.uploadReleaseAsset({ owner, repo, release_id: release.data.id, name: fileConf.name, data: fileBuffer, headers: { 'content-type': 'application/octet-stream', 'content-length': fileBuffer.length } }); assetId = uploadRes.data.id.toString(); } // 2. Prepare JSON Payload const payload = { title: fileConf.title, description: fileConf.description, status: fileConf.status, isExternal: fileConf.isExternal ? 1 : 0, assetId: assetId, fileUrl: fileUrl, imageUrl: fileConf.imageUrl || null }; console.log(`[Mass Drop -> Web payload]`, payload); // 3. Mock Website API POST try { await fetch(WEBSITE_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).catch(() => {}); } catch(e) {} // 4. Save to Supabase Web Tracking await stmts.addWebDrop( userId, 'sources', // Defaulting to sources for mass drop for now, or use from config payload.title, payload.description, payload.status, payload.isExternal, payload.assetId, payload.fileUrl, payload.imageUrl ); successCount++; await stmts.logDrop(userId, fileConf.title, 'website-mass'); } catch (pushErr) { console.error(`[Mass Drop Deploy Error] ${fileConf.title}:`, pushErr); failCount++; } } activeSessions = require('./drops').activeSessions; activeSessions?.delete(userId); // Safety catch massDropSessions.delete(userId); await processingMsg.edit({ content: `✅ **Mass Web Deployment Complete!**\n> Successfully deployed: **${successCount}** files to the Website Database.\n> Failed: **${failCount}** files.` }); } catch (err) { await message.reply({ content: `❌ Failed deployment loop: ${err.message}` }); } } return true; // We consumed the message } /** * Handle all interactive components (buttons, select menus, modals) * related to the Mass Drop system. */ async function handleMassDropInteraction(interaction) { const userId = interaction.user.id; const session = massDropSessions.get(userId); if (!session) return false; // ── BUTTONS ── if (interaction.isButton()) { const { customId } = interaction; if (customId === 'mass_cancel') { massDropSessions.delete(userId); await interaction.update({ embeds: [createEmbed({ title: '❌ Mass Drop Cancelled', color: Colors.ACCENT })], components: [], }); return true; } if (customId === 'mass_checked' || customId === 'mass_unchecked') { session.config.status = customId === 'mass_checked' ? 'checked' : 'unchecked'; session.step = 'config_about'; await interaction.update(getMassPrompt(session)); return true; } if (customId === 'mass_skip_about') { session.step = 'listening'; await interaction.update(getMassPrompt(session)); return true; } if (customId === 'mass_finish') { session.step = 'dashboard'; await interaction.update(generateDashboard(session)); return true; } if (customId === 'mass_cancel_image') { session.step = 'dashboard'; session.pendingImageIndex = null; await interaction.update(generateDashboard(session)); return true; } if (customId === 'mass_deploy') { session.step = 'deploying'; await interaction.update({ embeds: [createEmbed({ title: '🚀 Ready to Deploy', description: `> Type **confirm** to deploy these **${session.files.length}** drops to the Website Database.`, color: Colors.INFO })], components: [] }); return true; } } // ── DROPDOWN MENUS ── if (interaction.isStringSelectMenu() && interaction.customId === 'mass_select_edit') { const selectedValue = interaction.values[0]; // e.g. mass_edit_0 const itemIndex = parseInt(selectedValue.split('_').pop(), 10); const file = session.files[itemIndex]; // Pop up a modal for the user to edit Title/Description const modal = new ModalBuilder() .setCustomId(`mass_modal_${itemIndex}`) .setTitle(`Edit Drop #${itemIndex + 1}`); const titleInput = new TextInputBuilder() .setCustomId('edit_title') .setLabel('Drop Title') .setStyle(TextInputStyle.Short) .setRequired(true) .setValue(file.title); const descInput = new TextInputBuilder() .setCustomId('edit_desc') .setLabel('Description / About') .setStyle(TextInputStyle.Paragraph) .setRequired(false) .setValue(file.description); // Status Toggle (Since modals don't support checkboxes yet, // we use a short text input that accepts yes/no, verified/unverified, etc) const statusInput = new TextInputBuilder() .setCustomId('edit_status') .setLabel('Status (checked/unchecked)') .setStyle(TextInputStyle.Short) .setRequired(true) .setValue(file.status); modal.addComponents( new ActionRowBuilder().addComponents(titleInput), new ActionRowBuilder().addComponents(statusInput), new ActionRowBuilder().addComponents(descInput) ); await interaction.showModal(modal); return true; } if (interaction.isStringSelectMenu() && interaction.customId === 'mass_select_image') { const selectedValue = interaction.values[0]; // e.g. mass_edit_0 const itemIndex = parseInt(selectedValue.split('_').pop(), 10); const file = session.files[itemIndex]; // Put session into waiting state for this specific index session.step = 'waiting_image'; session.pendingImageIndex = itemIndex; await interaction.update({ embeds: [createEmbed({ title: '🖼️ Attach Image', description: `> Drop the image file here to attach it to **${file.title}**.\n\n*Dashboard will automatically reload once received.*`, color: Colors.INFO })], components: [ new ActionRowBuilder().addComponents( new ButtonBuilder().setCustomId('mass_cancel_image').setLabel('Cancel Attachment').setStyle(ButtonStyle.Secondary) ) ] }); return true; } // ── MODAL SUBMISSIONS ── if (interaction.isModalSubmit() && interaction.customId.startsWith('mass_modal_')) { const itemIndex = parseInt(interaction.customId.split('_').pop(), 10); const newTitle = interaction.fields.getTextInputValue('edit_title'); const newStatusRaw = interaction.fields.getTextInputValue('edit_status').toLowerCase(); const newDesc = interaction.fields.getTextInputValue('edit_desc'); const newStatus = newStatusRaw.includes('uncheck') ? 'unchecked' : 'checked'; // Apply edits to memory session.files[itemIndex].title = newTitle; session.files[itemIndex].status = newStatus; session.files[itemIndex].description = newDesc || 'enjoy the drop :))'; // Re-render the dashboard await interaction.update(generateDashboard(session)); return true; } return false; } module.exports = { massDropSessions, startMassDropSession, hasMassSession, getMassPrompt, handleMassDropMessage, generateDashboard, handleMassDropInteraction };