wsb-bot / src /systems /massdrop.js
APRK01
Premium Redesign: System Modules and Surgical Layout
5fb7488
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
};