const { ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, } = require('discord.js'); const { createEmbed } = require('../utils/embeds'); const { Colors } = require('../config'); const { stmts } = require('../database'); const OWNER_ID = process.env.OWNER_ID; // Active drop sessions: Map const activeSessions = new Map(); const STEPS = ['title', 'file', 'warnings', 'status', 'about', 'image', 'preview']; /** * Check if a user can drop (owner = unlimited, whitelisted = custom limit). * Returns { allowed: boolean, remaining?: number, resetIn?: string, limit?: number } */ function canDrop(userId) { if (userId === OWNER_ID) return { allowed: true, remaining: Infinity }; const wl = stmts.getWhitelist.get(userId); if (!wl) return { allowed: false, reason: 'not_whitelisted' }; const limit = wl.max_drops; const { count } = stmts.getDropCount24h.get(userId); if (count >= limit) { const last = stmts.getLastDrop.get(userId); const resetTime = new Date(last.dropped_at + 'Z'); resetTime.setHours(resetTime.getHours() + 24); const diff = resetTime - new Date(); const hours = Math.floor(diff / 3600000); const mins = Math.floor((diff % 3600000) / 60000); return { allowed: false, reason: 'rate_limited', resetIn: `${hours}h ${mins}m`, limit }; } return { allowed: true, remaining: limit - count, limit }; } /** * Start a new drop session for the owner. */ function startDropSession(userId) { const session = { step: 'title', title: null, file: null, // { url, name, size } warnings: null, status: null, // 'checked' or 'unchecked' about: null, image: null, // url or null channelId: null, }; activeSessions.set(userId, session); return session; } /** * Check if a user has an active drop session. */ function hasSession(userId) { return activeSessions.has(userId); } /** * Get the current prompt message for the active step. */ function getPrompt(session) { switch (session.step) { case 'title': return { embeds: [createEmbed({ title: '📦 New Drop — Step 1/6', description: '> **What is the title for this drop?**\n\nType the title below.', color: Colors.PRIMARY, footer: 'Type "cancel" at any time to abort', })], }; case 'file': return { embeds: [createEmbed({ title: '📦 New Drop — Step 2/6', description: '> **Upload the file for this drop.**\n\nAttach the file to your next message.', color: Colors.PRIMARY, footer: `Title: ${session.title}`, })], }; case 'warnings': return { embeds: [createEmbed({ title: '📦 New Drop — Step 3/6', description: '> **Any warnings for this drop?**\n\nType warnings below, or type `none` to skip.', color: Colors.WARNING, footer: `Title: ${session.title}`, })], }; case 'status': return { embeds: [createEmbed({ title: '📦 New Drop — Step 4/6', description: '> **Is this source checked or unchecked?**\n\nClick a button below.', color: Colors.PRIMARY, footer: `Title: ${session.title}`, })], components: [new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('drop_checked') .setLabel('✅ Checked') .setStyle(ButtonStyle.Success), new ButtonBuilder() .setCustomId('drop_unchecked') .setLabel('⚠️ Unchecked') .setStyle(ButtonStyle.Danger), )], }; case 'about': return { embeds: [createEmbed({ title: '📦 New Drop — Step 5/6', description: '> **Describe this source.**\n\nWrite a short description about what this is.', color: Colors.PRIMARY, footer: `Title: ${session.title}`, })], }; case 'image': return { embeds: [createEmbed({ title: '📦 New Drop — Step 6/6', description: '> **Reference image?**\n\nUpload an image or type `none` to skip.', color: Colors.PRIMARY, footer: `Title: ${session.title}`, })], }; default: return null; } } /** * Build the final preview embed for the drop. */ function buildDropEmbed(session) { const statusIcon = session.status === 'checked' ? '✅' : '⚠️'; const statusText = session.status === 'checked' ? 'Verified / Checked' : 'Unchecked — Use at your own risk'; const lines = [ `${statusIcon} **${statusText}**`, '', '> **About**', `> ${session.about}`, '', ]; if (session.warnings && session.warnings.toLowerCase() !== 'none') { lines.push('⚠️ **Warnings**'); lines.push(`> ${session.warnings}`); lines.push(''); } lines.push('```'); lines.push(`📁 File: ${session.file.name}`); lines.push(`📊 Size: ${formatBytes(session.file.size)}`); lines.push(`🔎 Status: ${statusText}`); lines.push('```'); lines.push(''); lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); lines.push('*Wyvern Softworks — Educational Use Only*'); const embedData = { title: `📦 ${session.title}`, description: lines.join('\n'), color: session.status === 'checked' ? Colors.SUCCESS : Colors.WARNING, footer: 'Wyvern Softworks • ' + new Date().toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric' }), }; if (session.image) { embedData.image = session.image; } return createEmbed(embedData); } /** * Build the download button row for a drop. */ function buildDownloadButton(session) { return new ActionRowBuilder().addComponents( new ButtonBuilder() .setLabel('📥 Download') .setStyle(ButtonStyle.Link) .setURL(session.file.url), ); } /** * Handle a message in an active drop session. * Returns true if handled, false if not. */ async function handleDropMessage(message) { const userId = message.author.id; const session = activeSessions.get(userId); if (!session) return false; const content = message.content.trim(); // Cancel if (content.toLowerCase() === 'cancel') { activeSessions.delete(userId); await message.reply({ embeds: [createEmbed({ title: '❌ Drop Cancelled', color: Colors.ACCENT })] }); return true; } switch (session.step) { case 'title': session.title = content; session.step = 'file'; await message.reply(getPrompt(session)); return true; case 'file': if (message.attachments.size === 0) { await message.reply({ content: '❌ Please attach a file.' }); return true; } const att = message.attachments.first(); session.file = { url: att.url, name: att.name, size: att.size }; session.step = 'warnings'; await message.reply(getPrompt(session)); return true; case 'warnings': session.warnings = content.toLowerCase() === 'none' ? null : content; session.step = 'status'; await message.reply(getPrompt(session)); return true; case 'about': session.about = content; session.step = 'image'; await message.reply(getPrompt(session)); return true; case 'image': if (content.toLowerCase() === 'none') { session.image = null; } else if (message.attachments.size > 0) { session.image = message.attachments.first().url; } else { session.image = null; } // Show preview session.step = 'preview'; const previewEmbed = buildDropEmbed(session); const row = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('drop_approve') .setLabel('✅ Approve & Post') .setStyle(ButtonStyle.Success), new ButtonBuilder() .setCustomId('drop_cancel') .setLabel('❌ Cancel') .setStyle(ButtonStyle.Danger), ); await message.reply({ content: '**📋 Preview — This is how the drop will look:**', embeds: [previewEmbed], components: [buildDownloadButton(session), row], }); return true; case 'channel': // User provides channel ID to post in const channelId = content.replace(/[<#>]/g, ''); session.channelId = channelId; try { const guild = message.client.guilds.cache.first(); const channel = await guild.channels.fetch(channelId); if (!channel) throw new Error('Channel not found'); const dropEmbed = buildDropEmbed(session); const downloadRow = buildDownloadButton(session); await channel.send({ embeds: [dropEmbed], components: [downloadRow], }); // Log the drop for rate limiting stmts.logDrop.run(userId, session.title, channelId); activeSessions.delete(userId); await message.reply({ embeds: [createEmbed({ title: '✅ Drop Posted!', description: `Successfully posted to <#${channelId}>`, color: Colors.SUCCESS, })], }); } catch (err) { await message.reply({ content: `❌ Failed to post: ${err.message}\nPlease send a valid channel ID.` }); } return true; default: return false; } } /** * Handle drop button interactions (status select, approve/cancel). */ async function handleDropButton(interaction) { const userId = interaction.user.id; const session = activeSessions.get(userId); if (!session) return false; const { customId } = interaction; if (customId === 'drop_checked' || customId === 'drop_unchecked') { session.status = customId === 'drop_checked' ? 'checked' : 'unchecked'; session.step = 'about'; await interaction.update({ embeds: [createEmbed({ title: `${customId === 'drop_checked' ? '✅' : '⚠️'} Status: ${session.status}`, color: customId === 'drop_checked' ? Colors.SUCCESS : Colors.WARNING, })], components: [] }); await interaction.followUp(getPrompt(session)); return true; } if (customId === 'drop_approve') { session.step = 'channel'; await interaction.update({ embeds: [interaction.message.embeds[0]], components: [], }); await interaction.followUp({ embeds: [createEmbed({ title: '📤 Where to post?', description: '> Send the **channel ID** where this drop should be posted.\n\nYou can right-click a channel → Copy Channel ID.', color: Colors.INFO, })], }); return true; } if (customId === 'drop_cancel') { activeSessions.delete(userId); await interaction.update({ embeds: [createEmbed({ title: '❌ Drop Cancelled', color: Colors.ACCENT })], components: [], }); return true; } return false; } /** * Format bytes to human readable string. */ function formatBytes(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } module.exports = { startDropSession, hasSession, getPrompt, handleDropMessage, handleDropButton, buildDropEmbed, canDrop, };