diff --git a/src/app.js b/src/app.js index c0625b6b5a..775e661944 100644 --- a/src/app.js +++ b/src/app.js @@ -12,6 +12,7 @@ import { logger, startupLog, shutdownLog } from './utils/logger.js'; import { checkBirthdays } from './services/birthdayService.js'; import { checkGiveaways } from './services/giveawayService.js'; import { loadCommands, registerCommands as registerSlashCommands } from './handlers/commandLoader.js'; +import { registerSessionReactions } from './handlers/events/sessionReactions.js'; class TitanBot extends Client { constructor() { @@ -79,6 +80,14 @@ class TitanBot extends Client { startupLog('Loading handlers...'); await this.loadHandlers(); startupLog('Handlers loaded'); + + // Register session reaction listeners (if available) + try { + registerSessionReactions(this); + startupLog('Session reaction handler registered'); + } catch (err) { + logger.warn('Failed to register session reaction handler:', err && err.message ? err.message : err); + } startupLog('Logging into Discord...'); await this.login(this.config.bot.token); @@ -347,7 +356,7 @@ class TitanBot extends Client { logger.info('✅ Graceful shutdown complete'); shutdownLog('Bot stopped successfully.'); - process.exit(0); + process.exit(0); } catch (error) { logger.error('Error during graceful shutdown:', error); process.exit(1); @@ -381,6 +390,3 @@ try { } export default TitanBot; - - - diff --git a/src/commands/Economy/giveall.js b/src/commands/Economy/giveall.js new file mode 100644 index 0000000000..32f6e053aa --- /dev/null +++ b/src/commands/Economy/giveall.js @@ -0,0 +1,183 @@ +import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; +import { getEconomyData, addMoney, removeMoney, setEconomyData } from '../../utils/economy.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { MessageTemplates } from '../../utils/messageTemplates.js'; +import EconomyService from '../../services/economyService.js'; + +export default { + data: new SlashCommandBuilder() + .setName('giveall') + .setDescription('Give money to all server members (Owner/Admin only)') + .addIntegerOption(option => + option + .setName('amount') + .setDescription('Amount to give to each member') + .setRequired(true) + .setMinValue(0) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), + + execute: withErrorHandling(async (interaction, config, client) => { + const deferred = await InteractionHelper.safeDefer(interaction, true); + if (!deferred) return; + + const executorId = interaction.user.id; + const amount = interaction.options.getInteger("amount"); + const guildId = interaction.guildId; + + // Check if user is a bot owner + const ownerIds = config.commands.owners || [1022692130512191518]; + const isOwner = ownerIds.includes(interaction.user.id); + const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.Administrator); + + if (!isOwner && !isAdmin) { + throw createError( + "Permission denied", + ErrorTypes.VALIDATION, + "Only bot owners and server administrators can use this command.", + { userId: executorId, guildId } + ); + } + + if (amount <= 0) { + throw createError( + "Invalid amount", + ErrorTypes.VALIDATION, + "Amount must be greater than zero.", + { amount } + ); + } + + logger.debug(`[ECONOMY] GiveAll command initiated`, { + executorId, + amount, + guildId + }); + + try { + // Fetch all members + const guild = interaction.guild; + if (!guild) { + throw createError( + "Guild not found", + ErrorTypes.VALIDATION, + "Could not find the server.", + { guildId } + ); + } + + await InteractionHelper.safeEditReply(interaction, { + content: `⏳ Processing giveaway to **${guild.memberCount}** members...` + }); + + // Fetch all members from the guild + const members = await guild.members.fetch({ limit: 0 }); + const memberIds = members + .filter(m => !m.user.bot) // Exclude bots + .map(m => m.user.id); + + logger.info(`[ECONOMY] Starting money distribution`, { + executorId, + memberCount: memberIds.length, + amountPerMember: amount, + guildId + }); + + let successCount = 0; + let failureCount = 0; + const errors = []; + + // Process each member in batches to avoid rate limiting + const batchSize = 10; + for (let i = 0; i < memberIds.length; i += batchSize) { + const batch = memberIds.slice(i, i + batchSize); + + await Promise.all( + batch.map(async (memberId) => { + try { + // Get member's current economy data + const memberData = await getEconomyData(client, guildId, memberId); + + if (!memberData) { + failureCount++; + return; + } + + // Add money to member's wallet + const newWallet = memberData.wallet + amount; + memberData.wallet = newWallet; + + // Update the economy data + await setEconomyData(client, guildId, memberId, memberData); + + successCount++; + } catch (err) { + failureCount++; + errors.push(`${memberId}: ${err.message}`); + logger.warn(`Failed to give money to ${memberId}`, { error: err.message }); + } + }) + ); + } + + const embed = MessageTemplates.SUCCESS.DATA_UPDATED( + "giveall", + `Successfully distributed **$${amount.toLocaleString()}** to **${successCount}** members!` + ) + .addFields( + { + name: "✅ Successful", + value: `${successCount} members`, + inline: true, + }, + { + name: "❌ Failed", + value: `${failureCount} members`, + inline: true, + }, + { + name: "💵 Amount Per Member", + value: `$${amount.toLocaleString()}`, + inline: true, + }, + { + name: "👥 Total Members Processed", + value: `${memberIds.length}`, + inline: true, + } + ) + .setFooter({ + text: `Executed by ${interaction.user.username}`, + iconURL: interaction.user.displayAvatarURL(), + }); + + if (errors.length > 0 && errors.length <= 5) { + embed.addFields({ + name: "⚠️ Error Details", + value: errors.join("\n").substring(0, 1024) + }); + } + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + + logger.info(`[ECONOMY] GiveAll completed`, { + executorId, + successCount, + failureCount, + totalAmount: successCount * amount, + guildId + }); + + } catch (err) { + logger.error(`[ECONOMY] GiveAll command failed`, { + error: err.message, + executorId, + guildId + }); + throw err; + } + }, { command: 'giveall' }) +}; diff --git a/src/commands/Session/startup.js b/src/commands/Session/startup.js new file mode 100644 index 0000000000..f8cce8d137 --- /dev/null +++ b/src/commands/Session/startup.js @@ -0,0 +1,101 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { createEmbed } from '../../utils/embeds.js'; + +/** + * /startup required_reactions: int + * Checks staff role from guild config, enforces allowed channels from guild config, + * posts the startup embed, pings everyone and stores session state/pending sessions on the client. + */ +export default { + slashOnly: true, + data: new SlashCommandBuilder() + .setName('startup') + .setDescription('Start a roleplay session') + .addIntegerOption((opt) => + opt + .setName('required_reactions') + .setDescription('Number of reactions needed to start the session') + .setRequired(true) + .setMinValue(1), + ), + + category: 'Session', + + async execute(interaction, guildConfig = {}, client) { + const required = interaction.options.getInteger('required_reactions'); + + // Resolve staff role ID from guild config: config.roles.staff_team + const staffRoleId = String(guildConfig?.roles?.staff_team || ''); + const memberRoles = interaction.member?.roles?.cache; + + if (!staffRoleId || !(memberRoles && memberRoles.has && memberRoles.has(staffRoleId))) { + await interaction.reply({ content: 'Only staff team members can use this command.', ephemeral: true }); + return; + } + + // Enforce allowed channels from guild config: channels.session_commands_channel_id / _2 + const channels = guildConfig?.channels || {}; + const allowed1 = String(channels.session_commands_channel_id || ''); + const allowed2 = String(channels.session_commands_channel_id_2 || ''); + + if (![String(interaction.channelId), String(allowed1), String(allowed2)].includes(String(interaction.channelId))) { + await interaction.reply({ + content: `This command can only be used in <#${allowed1}> or <#${allowed2}>.`, + ephemeral: true, + }); + return; + } + + // Build embed from guild embed config (keys: embeds.startup) + const ecfg = (guildConfig?.embeds && guildConfig.embeds.startup) || {}; + const title = ecfg.title || '_Greenville Roleplay Legacy_ - ___Session Startup___'; + // replace placeholders {user} and {required} if present + let description = ecfg.description || ''; + description = description.replace(/\{user\}/g, interaction.user?.toString() || '') + .replace(/\{required\}/g, String(required)); + const embed = createEmbed({ + title, + description, + color: 'success', + }); + if (ecfg.image_url) embed.setImage(ecfg.image_url); + embed.setFooter({ text: guildConfig?.bot?.footer_text || '', iconURL: guildConfig?.bot?.footer_icon || '' }); + + // Reply to invoker, then post the announcement (ping everyone) + await interaction.reply({ content: 'Startup initiated.', ephemeral: true }); + + const channel = interaction.channel; + const announcement = await channel.send({ + content: '@everyone', + embeds: [embed], + allowedMentions: { parse: ['everyone'] }, + }); + + // Add reaction (custom emoji string works: <:name:id>) + const targetEmoji = '<:pinkcheckmark:1502780778449342494>'; // same emoji used in your python + try { + await announcement.react(targetEmoji); + } catch (err) { + // Reaction might fail if emoji not available; ignore silently + // If you have a logger, you can log this error + } + + // Ensure client-side stores exist + if (!client.sessionStates) client.sessionStates = new Map(); + if (!client.pendingSessions) client.pendingSessions = new Map(); + + // Save session state and pending session entry + client.sessionStates.set(String(channel.id), { + messageId: announcement.id, + time: Math.floor(Date.now() / 1000), + completed: true, + reactors: new Set(), + }); + + client.pendingSessions.set(String(announcement.id), { + type: 'startup', + required, + user: interaction.user?.toString() || interaction.user?.tag || 'unknown', + }); + }, +}; diff --git a/src/config/bot.js b/src/config/bot.js index 36e588cd42..154620c70e 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -25,9 +25,9 @@ export const botConfig = { activities: [ { // Text users will see (example: "Playing /help | Titan Bot"). - name: "Made with ❤️", + name: "Welcome to Greenville Roleplay Legacy (GVRL). We are a premier roleplay community committed to delivering a high-quality, realistic experience. Our community emphasizes professionalism, structure, and immersion.", // Activity type number (0 = Playing). - type: 0, + type: 4, }, ], }, @@ -38,10 +38,10 @@ export const botConfig = { commands: { // Bot owner user IDs (comma-separated in OWNER_IDS env var). // Owners can access owner/admin-level bot commands. - owners: process.env.OWNER_IDS?.split(",") || [], + owners: process.env.OWNER_IDS?.split(",") || [], // Default wait time between command uses (in seconds). - defaultCooldown: 3, + defaultCooldown: 30, // If true, old commands are removed before re-registering. deleteCommands: false, @@ -63,9 +63,9 @@ export const botConfig = { // Embed colors by application status. statusColors: { - pending: "#FFA500", - approved: "#00FF00", - denied: "#FF0000", + pending: "#FFB700", + approved: "#57F287", + denied: "#ED4245", }, // How long users must wait before submitting another application (hours). @@ -78,7 +78,7 @@ export const botConfig = { deleteApprovedAfter: 30, // Role IDs allowed to manage applications. - managerRoles: [], // Will be populated from environment or database + managerRoles: [1502772079953711275], // Will be populated from environment or database }, // ========================= @@ -88,18 +88,18 @@ export const botConfig = { embeds: { colors: { // Main brand colors. - primary: "#336699", - secondary: "#2F3136", + primary: "#8D021F", + secondary: "#DC143C", // Standard status colors for success/error/warning/info messages. success: "#57F287", error: "#ED4245", - warning: "#FEE75C", - info: "#3498DB", + warning: "#FFB700", + info: "#5865F2", // Neutral utility colors. light: "#FFFFFF", - dark: "#202225", + dark: "#2C2F33", gray: "#99AAB5", // Discord-style palette shortcuts. @@ -116,22 +116,22 @@ export const botConfig = { ended: "#ED4245", }, ticket: { - open: "#57F287", - claimed: "#FAA61A", + open: "#5865F2", + claimed: "#FFB700", closed: "#ED4245", - pending: "#99AAB5", + pending: "#FEE75C", }, - economy: "#F1C40F", - birthday: "#E91E63", - moderation: "#9B59B6", + economy: "#FFB700", + birthday: "#EB459E", + moderation: "#ED4245", // Ticket priority color mapping. priority: { - none: "#95A5A6", - low: "#3498db", - medium: "#2ecc71", - high: "#f1c40f", - urgent: "#e74c3c", + none: "#99AAB5", + low: "#57F287", + medium: "#FFB700", + high: "#ED4245", + urgent: "#EB459E", }, }, footer: { @@ -164,13 +164,13 @@ export const botConfig = { }, // Starting balance for new users. - startingBalance: 0, + startingBalance: 5000, // Maximum bank amount before upgrades (if upgrades are used). - baseBankCapacity: 100000, + baseBankCapacity: 900000, // Daily reward amount. - dailyAmount: 100, + dailyAmount: 1000, // Work command random payout range. workMin: 10, @@ -188,6 +188,24 @@ export const botConfig = { robFailJailTime: 3600000, }, + // ========================= + // GIVEALL SETTINGS + // ========================= + giveall: { + // Batch size for processing members (helps avoid rate limiting). + batchSize: 10, + + // Role IDs allowed to use the /giveall command (if empty, only admins/owners can use). + allowedRoles: [1502786282793861180], + + // If true, the command will process and give money even if some members fail. + continueOnError: true, + + // Maximum amount allowed per person in a single giveall. + // Set to 0 for unlimited. + maxAmountPerMember: 0, + }, + // ========================= // SHOP SETTINGS // ========================= @@ -201,10 +219,10 @@ export const botConfig = { // ========================= tickets: { // Category ID where new tickets are created (null = no forced category). - defaultCategory: null, + defaultCategory: 1474582957334204607, // Role IDs allowed to manage/support tickets. - supportRoles: [], + supportRoles: [1474582829248544779], // Priority options users/staff can assign. priorities: { @@ -242,7 +260,7 @@ export const botConfig = { archiveCategory: null, // Channel ID where ticket logs are sent. - logChannel: null, + logChannel: 1502684126841409620, }, // ========================= @@ -264,7 +282,7 @@ export const botConfig = { maximumDuration: 2592000000, // Role IDs allowed to host giveaways. - allowedRoles: [], + allowedRoles: [1502772079953711275], // Role IDs that bypass giveaway restrictions. bypassRoles: [], @@ -281,7 +299,7 @@ export const botConfig = { announcementChannel: null, // Timezone used to calculate birthday dates. - timezone: "UTC", + timezone: "CST", }, // ========================= @@ -346,10 +364,10 @@ export const botConfig = { maxAuditMetadataBytes: 4096, // Maximum number of audit entries kept in memory. maxInMemoryAuditEntries: 1000, - // If true, log every verification action. - logAllVerifications: true, - // If true, preserve verification audit history. - keepAuditTrail: true, + // If true, log every verification action. + logAllVerifications: true, + // If true, preserve verification audit history. + keepAuditTrail: true, }, // ========================= @@ -365,7 +383,7 @@ export const botConfig = { defaultGoodbyeMessage: "{user} has left the server. We now have {memberCount} members.", // Channel ID for welcome messages. - defaultWelcomeChannel: null, + defaultWelcomeChannel: 1474582967107190906, // Channel ID for goodbye messages. defaultGoodbyeChannel: null, }, @@ -543,7 +561,3 @@ export function getRandomColor() { } export default botConfig; - - - - diff --git a/src/handlers/events/sessionReactions.js b/src/handlers/events/sessionReactions.js new file mode 100644 index 0000000000..3497119379 --- /dev/null +++ b/src/handlers/events/sessionReactions.js @@ -0,0 +1,95 @@ +/** + * registerSessionReactions(client) + * - Listens for messageReactionAdd and messageReactionRemove + * - When required non-bot reactions on a pending startup announcement are reached, + * posts the setup embed and clears pendingSessions entry. + * + * Put this file at: src/handlers/events/sessionReactions.js + * and call registerSessionReactions(client) after your client is created and client.config is loaded. + */ + +export function registerSessionReactions(client) { + const targetEmojiStr = '<:pinkcheckmark:1502780778449342494>'; + + client.on('messageReactionAdd', async (reaction, user) => { + if (!client.pendingSessions) return; + if (user?.bot) return; + + try { + if (reaction.partial) await reaction.fetch(); + } catch { + return; + } + + const messageId = String(reaction.message?.id); + if (!client.pendingSessions.has(messageId)) return; + + const data = client.pendingSessions.get(messageId); + if (!data || data.type !== 'startup') return; + + // Only handle target emoji + if (String(reaction.emoji?.toString?.()) !== targetEmojiStr) return; + + // Fetch users for the reaction and filter bots + let users; + try { + users = await reaction.users.fetch(); + } catch { + users = new Map(); + } + const humanCount = Array.from(users.values()).filter((u) => !u.bot).length; + + // If we haven't reached required reactions yet, update sessionStates and bail + if (humanCount < Number(data.required)) { + if (client.sessionStates) { + for (const [, state] of client.sessionStates) { + if (String(state.messageId) === messageId) { + if (!state.reactors) state.reactors = new Set(); + state.reactors.add(user.id); + break; + } + } + } + return; + } + + // Enough reactions reached — post the setup embed and clean up pendingSessions + const channel = reaction.message.channel || (await client.channels.fetch(reaction.message.channelId).catch(() => null)); + if (!channel) return; + + const ecfg = (client.config && client.config.embeds && client.config.embeds.setup) || {}; + const embed = { + title: ecfg.title || '_Greenville Roleplay Legacy_ - ___Roleplay Preparation:___', + description: (ecfg.description || '').replace(/\{user\}/g, data.user), + color: 0xadcf8b, + }; + if (ecfg.image_url) embed.image = { url: ecfg.image_url }; + if (client.config?.bot?.footer_text || client.config?.bot?.footer_icon) { + embed.footer = { + text: client.config.bot.footer_text || '', + icon_url: client.config.bot.footer_icon || '', + }; + } + + await channel.send({ embeds: [embed] }).catch(() => null); + client.pendingSessions.delete(messageId); + }); + + client.on('messageReactionRemove', async (reaction, user) => { + if (!client.sessionStates) return; + if (!reaction) return; + + try { + if (reaction.partial) await reaction.fetch(); + } catch { + return; + } + + for (const [, state] of client.sessionStates) { + if (String(state.messageId) === String(reaction.message?.id) && String(reaction.emoji?.toString?.()) === targetEmojiStr) { + if (state.reactors && state.reactors.delete) state.reactors.delete(user?.id); + break; + } + } + }); +}