wsb-bot / src /systems /drops.js
APRK01
Premium Redesign: System Modules and Surgical Layout
5fb7488
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<userId, session>
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_<assetId>
*/
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,
};