wsb-bot / src /systems /drops.js
APRK01
feat: add support for custom whitelist drop limits
6bab161
raw
history blame
13.1 kB
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<userId, session>
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,
};