const { ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, AttachmentBuilder, } = require('discord.js'); const { Octokit } = require('@octokit/rest'); const fetch = require('node-fetch'); 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 = ['category', '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 } */ async function canDrop(userId) { if (userId === OWNER_ID) return { allowed: true, remaining: Infinity }; const wl = await stmts.getWhitelist(userId); if (!wl) return { allowed: false, reason: 'not_whitelisted' }; const limit = wl.max_drops; const { count } = await stmts.getDropCount24h(userId); if (count >= limit) { const last = await stmts.getLastDrop(userId); const resetTime = new Date(last.dropped_at); 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: 'category', category: null, title: null, file: null, // { url, name, size } warnings: null, status: null, // 'checked' or 'unchecked' about: null, image: null, // url or null channelId: null, isExternal: false, }; 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 'category': return { embeds: [createEmbed({ title: '📦 New Drop — Step 1/8', description: '> **Which category does this drop belong to?**\n\nClick a button below.', color: Colors.PRIMARY, footer: 'Type "cancel" at any time to abort', })], components: [new ActionRowBuilder().addComponents( new ButtonBuilder().setCustomId('cat_sources').setLabel('Sources').setStyle(ButtonStyle.Secondary), new ButtonBuilder().setCustomId('cat_cracks').setLabel('Cracks').setStyle(ButtonStyle.Secondary), new ButtonBuilder().setCustomId('cat_scripts').setLabel('Scripts').setStyle(ButtonStyle.Secondary), new ButtonBuilder().setCustomId('cat_tools').setLabel('Tools').setStyle(ButtonStyle.Secondary) )], }; case 'title': return { embeds: [createEmbed({ title: '📦 New Drop — Step 2/8', description: '> **What is the title for this drop?**\n\nType the title below.', color: Colors.PRIMARY, footer: `Category: ${session.category.toUpperCase()} | Type "cancel" to abort`, })], }; case 'file': return { embeds: [createEmbed({ title: '📦 New Drop — Step 3/8', 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 4/8', 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 5/8', 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 6/8', 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 7/8', 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: ${session.file.size !== 'Unknown' ? formatBytes(session.file.size) : 'External File (Unknown Size)'}`); lines.push(`🔎 Status: ${statusText}`); lines.push('```'); lines.push(''); lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); lines.push('*Wyvern Softworks — Educational Use Only*'); const embedData = { title: `📦 [${session.category.toUpperCase()}] ${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) { // Handle both object-style (from re-upload) and string-style (from direct URL/preview) embedData.image = typeof session.image === 'object' ? session.image.url : 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) { // Check if it's a URL if (content.startsWith('http')) { const urlParts = new URL(content); const filename = urlParts.pathname.split('/').pop() || `file_${Date.now()}`; session.file = { url: content, name: filename, size: 'Unknown', isExternal: true }; session.step = 'warnings'; await message.reply(getPrompt(session)); return true; } await message.reply({ content: '❌ Please attach a file or provide a direct link (Mega, MediaFire, etc).' }); return true; } const att = message.attachments.first(); // Store the full attachment object to re-upload it later session.file = { url: att.url, name: att.name, size: att.size, attachment: att, // Save reference to original attachment isExternal: false }; 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) { const imgAtt = message.attachments.first(); session.image = { url: imgAtt.url, name: imgAtt.name, attachment: imgAtt }; } 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: [row], }); return true; // (case 'channel' was deliberately removed because the bot now deploys to a Website API) 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.startsWith('cat_')) { session.category = customId.replace('cat_', ''); session.step = 'title'; await interaction.update({ embeds: [createEmbed({ title: `📂 Category Selected: ${session.category.toUpperCase()}`, color: Colors.PRIMARY, })], components: [] }); await interaction.followUp(getPrompt(session)); return true; } 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') { await interaction.update({ content: '⏳ *Deploying drop to website API...*', embeds: [], components: [] }); try { let assetId = null; let fileUrl = session.file.url; // 1. GitHub Proxy (if not external) if (!session.file.isExternal) { const octokit = new Octokit({ auth: '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' }); const [owner, repo] = 'APRK01/WSB-Storage'.split('/'); // Download the file from Discord's temporary DM CDN const fileRes = await fetch(session.file.url); const fileBuffer = await fileRes.buffer(); // Create the release const releaseTitle = `Drop: ${session.title.replace(/[^a-zA-Z0-9 -]/g, '')} - ${Date.now()}`; const release = await octokit.rest.repos.createRelease({ owner, repo, tag_name: `drop-${Date.now()}`, name: releaseTitle, body: `Auto-generated drop upload for WSB.\n\nDescription: ${session.about}` }); // Upload the asset to the newly created release const uploadRes = await octokit.rest.repos.uploadReleaseAsset({ owner, repo, release_id: release.data.id, name: session.file.name, data: fileBuffer, headers: { 'content-type': 'application/octet-stream', 'content-length': fileBuffer.length } }); assetId = uploadRes.data.id.toString(); } // 2. Prepare JSON Payload let finalImageUrl = session.image?.url || null; const payload = { category: session.category, title: session.title, description: session.about, status: session.status, isExternal: session.file.isExternal ? 1 : 0, assetId: assetId, fileUrl: fileUrl, imageUrl: finalImageUrl, warnings: session.warnings }; console.log('[Website Drop Payload]', payload); // 3. Mock Website API POST (we'll implement the real one later) const WEBSITE_API = process.env.WEBSITE_API_URL || 'http://localhost:3000/api/drops'; try { // Kick off request, but don't strictly require it to succeed while the site is down 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, session.category, payload.title, payload.description, payload.status, payload.isExternal, payload.assetId, payload.fileUrl, payload.imageUrl ); // Log drop for Discord rate limits await stmts.logDrop(userId, `[${session.category.toUpperCase()}] ${session.title}`, 'website'); activeSessions.delete(userId); await interaction.followUp({ embeds: [createEmbed({ title: '✅ Drop Published!', description: `Successfully deployed to the Website Database!\n\n> Note: Embeds are no longer posted to Discord channels. Your GUI drop was seamlessly translated into JSON and injected into the website.`, color: Colors.SUCCESS, })], }); } catch (err) { console.error('[Web Deploy Error]', err); await interaction.followUp({ content: `❌ Failed to deploy to website: ${err.message}` }); } 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; } /** * Handle download button clicks — generates a temporary download URL from the private GitHub repo. * Button customId format: dl_ */ async function handleDownloadButton(interaction) { const { customId } = interaction; if (!customId.startsWith('dl_')) return false; const assetId = customId.split('_')[1]; if (!assetId) return false; try { await interaction.deferReply({ ephemeral: true }); const GITHUB_TOKEN = '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'; const [owner, repo] = 'APRK01/WSB-Storage'.split('/'); // Request the asset with octet-stream accept header — GitHub returns a 302 redirect // to a temporary signed S3 URL that works without authentication const apiUrl = `https://api.github.com/repos/${owner}/${repo}/releases/assets/${assetId}`; const response = await fetch(apiUrl, { headers: { 'Authorization': `Bearer ${GITHUB_TOKEN}`, 'Accept': 'application/octet-stream', 'User-Agent': 'WSB-Bot' }, redirect: 'manual' }); if (response.status === 302) { const tempUrl = response.headers.get('location'); await interaction.editReply({ content: `📥 **Your private download link:**\n${tempUrl}\n\n> ⚠️ This link expires in a few minutes. Do **not** share it.`, }); } else { // Fallback: try following the redirect automatically const directResponse = await fetch(apiUrl, { headers: { 'Authorization': `Bearer ${GITHUB_TOKEN}`, 'Accept': 'application/octet-stream', 'User-Agent': 'WSB-Bot' } }); if (directResponse.ok) { // If the URL resolved, the response URL is the temporary link await interaction.editReply({ content: `📥 **Your private download link:**\n${directResponse.url}\n\n> ⚠️ This link expires in a few minutes. Do **not** share it.`, }); } else { await interaction.editReply({ content: '❌ Failed to generate download link. The file may have been deleted.', }); } } return true; } catch (err) { console.error('[Download Button Error]', err); try { await interaction.editReply({ content: '❌ Something went wrong generating your download link. Please try again.', }); } catch (_) { } return true; } } /** * 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, handleDownloadButton, buildDropEmbed, canDrop, activeSessions, };