diff --git a/buttons/_global/sessionClose.js b/buttons/_global/sessionClose.js new file mode 100644 index 0000000..1c9ea1e --- /dev/null +++ b/buttons/_global/sessionClose.js @@ -0,0 +1,11 @@ +const { + closeInteractionSession +} = require("../../utils/interactionSession"); + +module.exports = { + name: "sessionClose", + + async execute(interaction) { + return closeInteractionSession(interaction); + } +}; diff --git a/buttons/crumbSaucer/bankruptcy.js b/buttons/crumbSaucer/bankruptcy.js new file mode 100644 index 0000000..b2746f4 --- /dev/null +++ b/buttons/crumbSaucer/bankruptcy.js @@ -0,0 +1,91 @@ +const { + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + MessageFlags +} = require("discord.js"); + +const { + declareBankruptcy +} = require("../../utils/bankruptcy"); + +const { + requireInteractionSession +} = require("../../utils/interactionSession"); + +module.exports = { + name: "crumbSaucerBankruptcy", + + async execute(interaction) { + const session = await requireInteractionSession(interaction); + + if (!session) { + return; + } + + const result = await declareBankruptcy(interaction); + + if (!result.ok) { + if (result.reason === "COOLDOWN") { + return interaction.reply({ + content: + `Bankruptcy assistance is unavailable until .`, + flags: MessageFlags.Ephemeral + }); + } + + if (result.reason === "BALANCE_NOT_ZERO") { + return interaction.reply({ + content: + `Bankruptcy can only be declared at exactly **0 Cookies**. Your current balance is **${result.balance.toLocaleString()}**.`, + flags: MessageFlags.Ephemeral + }); + } + + return interaction.reply({ + content: "Bankruptcy could not be declared.", + flags: MessageFlags.Ephemeral + }); + } + + const embed = new EmbedBuilder() + .setTitle("🍪 Bankruptcy Assistance") + .setDescription( + [ + `${interaction.user} declared bankruptcy and received a starter kit.`, + "", + `**+${result.amount.toLocaleString()} Cookies**`, + "", + "Spend them wisely. Or at least memorably." + ].join("\n") + ) + .addFields({ + name: "Next Eligibility", + value: `` + }) + .setFooter({ + text: `crumbSaucer:${interaction.user.id}` + }); + + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId("crumbSaucerBack") + .setLabel("Back to Crumb Saucer") + .setEmoji("◀️") + .setStyle(ButtonStyle.Secondary), + + new ButtonBuilder() + .setCustomId("sessionClose") + .setLabel("Close") + .setEmoji("✖️") + .setStyle(ButtonStyle.Secondary) + ); + + return interaction.update({ + embeds: [embed], + components: [row] + }); + } +}; diff --git a/commands/applications/bankruptcy.js b/commands/applications/bankruptcy.js new file mode 100644 index 0000000..13082fe --- /dev/null +++ b/commands/applications/bankruptcy.js @@ -0,0 +1,64 @@ +const { + SlashCommandBuilder, + EmbedBuilder, + MessageFlags +} = require("discord.js"); + +const { + declareBankruptcy +} = require("../../utils/bankruptcy"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("bankruptcy") + .setDescription( + "Declare bankruptcy at exactly 0 Cookies and receive a starter kit." + ), + + async execute(interaction) { + const result = await declareBankruptcy(interaction); + + if (!result.ok) { + if (result.reason === "COOLDOWN") { + return interaction.reply({ + content: + `Bankruptcy assistance is unavailable until .`, + flags: MessageFlags.Ephemeral + }); + } + + if (result.reason === "BALANCE_NOT_ZERO") { + return interaction.reply({ + content: + `You may only declare bankruptcy at exactly **0 Cookies**. Your current balance is **${result.balance.toLocaleString()}**.`, + flags: MessageFlags.Ephemeral + }); + } + + return interaction.reply({ + content: "Bankruptcy could not be declared.", + flags: MessageFlags.Ephemeral + }); + } + + const embed = new EmbedBuilder() + .setTitle("🍪 Bankruptcy Declared") + .setDescription( + [ + `${interaction.user} has been issued a fresh starter kit.`, + "", + `**+${result.amount.toLocaleString()} Cookies**`, + "", + "The Cookie economy believes in second chances." + ].join("\n") + ) + .addFields({ + name: "Next Eligibility", + value: `` + }); + + return interaction.reply({ + embeds: [embed] + }); + } +}; diff --git a/commands/applications/profile.js b/commands/applications/profile.js index 02d4ce4..ca6d41a 100644 --- a/commands/applications/profile.js +++ b/commands/applications/profile.js @@ -1,63 +1,46 @@ -const { - SlashCommandBuilder -} = require( - "discord.js" -); - +const { SlashCommandBuilder } = require("discord.js"); const { buildProfileOverview, buildProfileButtons -} = require( - "../../utils/profileSystem" -); +} = require("../../utils/profileSystem"); +const { + createInteractionSession +} = require("../../utils/interactionSession"); module.exports = { - data: - new SlashCommandBuilder() - .setName( - "profile" - ) - .setDescription( - "View a player profile" - ) - .addUserOption( - option => - option - .setName( - "user" - ) - .setDescription( - "Player to view" - ) - ), + data: new SlashCommandBuilder() + .setName("profile") + .setDescription("View a player profile") + .addUserOption(option => + option + .setName("user") + .setDescription("Player to view") + ), - async execute( - interaction - ) { - const user = - interaction.options.getUser( - "user" - ) ?? - interaction.user; + async execute(interaction) { + const user = interaction.options.getUser("user") ?? interaction.user; - const sessionOwnerId = - interaction.user.id; + const embed = await buildProfileOverview( + interaction.guildId, + user, + interaction.user.id + ); - const embed = - await buildProfileOverview( - interaction.guildId, - user, - sessionOwnerId - ); - - return interaction.reply({ + await interaction.reply({ embeds: [embed], - components: - buildProfileButtons( - "overview", - 0, - 1 - ) + components: buildProfileButtons() }); + + const message = await interaction.fetchReply(); + + await createInteractionSession( + interaction, + { + messageId: message.id, + ownerId: interaction.user.id, + kind: "profile", + targetId: user.id + } + ); } }; diff --git a/events/InteractionCreate.js b/events/InteractionCreate.js index c26b4c6..8718179 100644 --- a/events/InteractionCreate.js +++ b/events/InteractionCreate.js @@ -1,275 +1,179 @@ -const { - Events, - MessageFlags -} = require("discord.js"); - -require("dotenv").config(); - - -module.exports = { - name: Events.InteractionCreate, - once: false, - - async execute(interaction) { - if (interaction.isAutocomplete()) { - return autocompleteHandler(interaction); - } - - if (interaction.isChatInputCommand()) { - return commandHandler(interaction); - } - - if (interaction.isButton()) { - return buttonHandler(interaction); - } - - if ( - interaction.isMentionableSelectMenu() || - interaction.isUserSelectMenu() - ) { - return selectMenuHandler(interaction); - } - - if (interaction.isModalSubmit()) { - return modalHandler(interaction); - } - } -}; - - -async function autocompleteHandler(interaction) { - const command = - interaction.client.commands.get( - interaction.commandName - ); - - - if (!command) { - return console.error( - `No command matching ${interaction.commandName} was found.` - ); - } - - - if ( - typeof command.autocomplete !== - "function" - ) { - return; - } - - - try { - await command.autocomplete( - interaction - ); - - } catch (error) { - console.error( - `Autocomplete error for /${interaction.commandName}:`, - error - ); - - - /* - * Autocomplete cannot use normal interaction.reply(). - * If something failed, send an empty set so Discord - * stops waiting. - */ - try { - await interaction.respond([]); - } catch { - // Interaction may already be acknowledged or expired. - } - } -} - - -async function commandHandler(interaction) { - const command = - interaction.client.commands.get( - interaction.commandName - ); - - - if (!command) { - return console.error( - `No command matching ${interaction.commandName} was found.` - ); - } - - - try { - await command.execute( - interaction - ); - - } catch (error) { - console.error(error); - - if ( - interaction.replied || - interaction.deferred - ) { - await interaction.followUp({ - content: - "There was an error while executing this command!", - flags: - MessageFlags.Ephemeral - }); - - } else { - await interaction.reply({ - content: - "There was an error while executing this command!", - flags: - MessageFlags.Ephemeral - }); - } - } -} - - -async function buttonHandler(interaction) { - const button = - interaction.client.buttons.get( - interaction.customId - ); - - - if (!button) { - return console.error( - `No button registered matching ${interaction.customId}.` - ); - } - - - try { - await button.execute( - interaction - ); - - } catch (error) { - console.error(error); - - if ( - interaction.replied || - interaction.deferred - ) { - await interaction.followUp({ - content: - "There was an error while executing this button!", - flags: - MessageFlags.Ephemeral - }); - - } else { - await interaction.reply({ - content: - "There was an error while executing this button!", - flags: - MessageFlags.Ephemeral - }); - } - } -} - - -async function selectMenuHandler(interaction) { - const selectMenu = - interaction.client.selectMenus.get( - interaction.customId - ); - - - if (!selectMenu) { - return console.error( - `No Select Menu registered matching ${interaction.customId}.` - ); - } - - - try { - await selectMenu.execute( - interaction - ); - - } catch (error) { - console.error(error); - - if ( - interaction.replied || - interaction.deferred - ) { - await interaction.followUp({ - content: - "There was an error while executing this select menu!", - flags: - MessageFlags.Ephemeral - }); - - } else { - await interaction.reply({ - content: - "There was an error while executing this select menu!", - flags: - MessageFlags.Ephemeral - }); - } - } -} - - -async function modalHandler(interaction) { - const customId = - interaction.customId - .split(":")[0]; - - - const modal = - interaction.client - .modals - .get( - customId - ); - - - if (!modal) { - return console.error( - `No Modal registered matching ${interaction.customId}.` - ); - } - - - try { - await modal.execute( - interaction - ); - - } catch (error) { - console.error(error); - - - if ( - interaction.replied || - interaction.deferred - ) { - await interaction.followUp({ - content: - "There was an error while executing this modal!", - - flags: - MessageFlags.Ephemeral - }); - - } else { - await interaction.reply({ - content: - "There was an error while executing this modal!", - - flags: - MessageFlags.Ephemeral - }); - } - } -} \ No newline at end of file +const { + Events, + MessageFlags +} = require("discord.js"); + +const { + guardSessionInteraction +} = require("../utils/interactionSession"); + +require("dotenv").config(); + +module.exports = { + name: Events.InteractionCreate, + once: false, + + async execute(interaction) { + if (interaction.isAutocomplete()) { + return autocompleteHandler(interaction); + } + + if (interaction.isChatInputCommand()) { + return commandHandler(interaction); + } + + if (interaction.isButton()) { + return buttonHandler(interaction); + } + + if ( + interaction.isMentionableSelectMenu() || + interaction.isUserSelectMenu() + ) { + return selectMenuHandler(interaction); + } + + if (interaction.isModalSubmit()) { + return modalHandler(interaction); + } + } +}; + +async function autocompleteHandler(interaction) { + const command = interaction.client.commands.get( + interaction.commandName + ); + + if (!command) { + return console.error( + `No command matching ${interaction.commandName} was found.` + ); + } + + if (typeof command.autocomplete !== "function") { + return; + } + + try { + await command.autocomplete(interaction); + } catch (error) { + console.error( + `Autocomplete error for /${interaction.commandName}:`, + error + ); + + try { + await interaction.respond([]); + } catch {} + } +} + +async function commandHandler(interaction) { + const command = interaction.client.commands.get( + interaction.commandName + ); + + if (!command) { + return console.error( + `No command matching ${interaction.commandName} was found.` + ); + } + + try { + await command.execute(interaction); + } catch (error) { + console.error(error); + await sendInteractionError( + interaction, + "There was an error while executing this command!" + ); + } +} + +async function buttonHandler(interaction) { + const allowed = await guardSessionInteraction(interaction); + + if (!allowed) { + return; + } + + const button = interaction.client.buttons.get( + interaction.customId + ); + + if (!button) { + return console.error( + `No button registered matching ${interaction.customId}.` + ); + } + + try { + await button.execute(interaction); + } catch (error) { + console.error(error); + await sendInteractionError( + interaction, + "There was an error while executing this button!" + ); + } +} + +async function selectMenuHandler(interaction) { + const selectMenu = interaction.client.selectMenus.get( + interaction.customId + ); + + if (!selectMenu) { + return console.error( + `No Select Menu registered matching ${interaction.customId}.` + ); + } + + try { + await selectMenu.execute(interaction); + } catch (error) { + console.error(error); + await sendInteractionError( + interaction, + "There was an error while executing this select menu!" + ); + } +} + +async function modalHandler(interaction) { + const customId = interaction.customId.split(":")[0]; + const modal = interaction.client.modals.get(customId); + + if (!modal) { + return console.error( + `No Modal registered matching ${interaction.customId}.` + ); + } + + try { + await modal.execute(interaction); + } catch (error) { + console.error(error); + await sendInteractionError( + interaction, + "There was an error while executing this modal!" + ); + } +} + +async function sendInteractionError(interaction, content) { + const payload = { + content, + flags: MessageFlags.Ephemeral + }; + + try { + if (interaction.replied || interaction.deferred) { + await interaction.followUp(payload); + } else { + await interaction.reply(payload); + } + } catch (error) { + console.error("Failed to send interaction error response:", error); + } +} diff --git a/index.js b/index.js index 0d4b5c0..587341b 100644 --- a/index.js +++ b/index.js @@ -5,156 +5,82 @@ const { Partials } = require("discord.js"); -const { - MongoClient -} = require("mongodb"); - +const { MongoClient } = require("mongodb"); const fs = require("node:fs"); require("dotenv").config(); +const mClient = new MongoClient( + process.env.M_URI +); -/* - * MONGODB - */ +exports.mClient = mClient; -const mClient = - new MongoClient( - process.env.M_URI - ); +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildPresences, + GatewayIntentBits.GuildMessageReactions, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.MessageContent + ], + partials: [ + Partials.Channel, + Partials.Message, + Partials.User, + Partials.GuildMember, + Partials.Reaction + ] +}); -exports.mClient = - mClient; +exports.client = client; - -/* - * DISCORD CLIENT - */ - -const client = - new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMembers, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.GuildPresences, - GatewayIntentBits.GuildMessageReactions, - GatewayIntentBits.GuildVoiceStates, - GatewayIntentBits.DirectMessages, - GatewayIntentBits.MessageContent - ], - - partials: [ - Partials.Channel, - Partials.Message, - Partials.User, - Partials.GuildMember, - Partials.Reaction - ] - }); - -exports.client = - client; - - -/* - * COLLECTIONS - */ - -client.commands = - new Collection(); - -client.legacyCommands = - new Collection(); - -client.aliases = - new Collection(); - -client.buttons = - new Collection(); - -client.selectMenus = - new Collection(); - -client.modals = - new Collection(); - - -/* - * START APPLICATION - */ +client.commands = new Collection(); +client.legacyCommands = new Collection(); +client.aliases = new Collection(); +client.buttons = new Collection(); +client.selectMenus = new Collection(); +client.modals = new Collection(); async function start() { try { - /* - * Connect Mongo first. - */ - await mClient.connect(); - - console.log( - "MongoDB connected" - ); - - - /* - * Initialize database indexes before handlers are - * exposed to Discord interactions. - * - * Requiring these after mClient is exported avoids - * circular-import initialization problems because the - * shared utilities import mClient from index.js. - */ + console.log("MongoDB connected"); const { ensureCookieIndexes - } = require( - "./utils/cookieEconomy" - ); - + } = require("./utils/cookieEconomy"); const { ensureProfileSystem - } = require( - "./utils/profileSystem" - ); + } = require("./utils/profileSystem"); + const { + ensureInteractionSessionIndexes + } = require("./utils/interactionSession"); + const { + ensureBankruptcyIndexes + } = require("./utils/bankruptcy"); await Promise.all([ ensureCookieIndexes(), - ensureProfileSystem() + ensureProfileSystem(), + ensureInteractionSessionIndexes(), + ensureBankruptcyIndexes() ]); - console.log( - "Database indexes initialized" - ); + console.log("Database indexes initialized"); + const handlerFiles = fs.readdirSync("./handlers"); - /* - * Load handlers. - */ - - const handlerFiles = - fs.readdirSync( - "./handlers" - ); - - for ( - const handler - of handlerFiles - ) { - require( - `./handlers/${handler}` - )(client); + for (const handler of handlerFiles) { + require(`./handlers/${handler}`)(client); } - - /* - * Login Discord. - */ - await client.login( process.env.D_TOKEN ); - } catch (error) { console.error( "Failed to start ShaiBot:", diff --git a/utils/bankruptcy.js b/utils/bankruptcy.js new file mode 100644 index 0000000..97b90fe --- /dev/null +++ b/utils/bankruptcy.js @@ -0,0 +1,188 @@ +const { getCollections, getCookieBalance, getUnixTime } = require("./cookieEconomy"); +const { trackProfileEvent } = require("./profileSystem"); + +const BANKRUPTCY_COLLECTION = "items_bankruptcy"; +const BANKRUPTCY_GRANT = 1000; +const BANKRUPTCY_COOLDOWN = 1 * 60 * 60; + +function getBankruptcyCollection() { + return getCollections().db.collection(BANKRUPTCY_COLLECTION); +} + +async function ensureBankruptcyIndexes() { + const bankruptcy = getBankruptcyCollection(); + + await bankruptcy.createIndex( + { userId: 1, guildId: 1 }, + { unique: true } + ); +} + +async function getBankruptcyStatus(userId, guildId) { + const balance = await getCookieBalance(userId); + const document = await getBankruptcyCollection().findOne({ + userId, + guildId + }); + + const now = getUnixTime(); + const nextAvailableAt = Number(document?.nextAvailableAt ?? 0); + + return { + balance, + eligible: balance === 0 && nextAvailableAt <= now, + nextAvailableAt, + claims: Number(document?.claims ?? 0) + }; +} + +async function declareBankruptcy(interaction, userId = interaction.user.id) { + const guildId = interaction.guildId; + + if (!guildId) { + return { + ok: false, + reason: "GUILD_ONLY" + }; + } + + const { cookies } = getCollections(); + const bankruptcy = getBankruptcyCollection(); + const now = getUnixTime(); + + const previous = await bankruptcy.findOne({ + userId, + guildId + }); + + const nextAvailableAt = Number(previous?.nextAvailableAt ?? 0); + + if (nextAvailableAt > now) { + return { + ok: false, + reason: "COOLDOWN", + nextAvailableAt + }; + } + + let claimLock; + + try { + claimLock = await bankruptcy.findOneAndUpdate( + { + userId, + guildId, + $or: [ + { nextAvailableAt: { $exists: false } }, + { nextAvailableAt: { $lte: now } } + ] + }, + { + $set: { + nextAvailableAt: now + BANKRUPTCY_COOLDOWN, + lastClaimedAt: now + }, + $inc: { + claims: 1 + }, + $setOnInsert: { + userId, + guildId + } + }, + { + upsert: true, + returnDocument: "after" + } + ); + } catch (error) { + if (error?.code !== 11000) { + throw error; + } + } + + if (!claimLock) { + const status = await getBankruptcyStatus(userId, guildId); + + return { + ok: false, + reason: "COOLDOWN", + nextAvailableAt: status.nextAvailableAt + }; + } + + let credit; + + try { + credit = await cookies.updateOne( + { + userId, + $or: [ + { cookies: 0 }, + { cookies: { $exists: false } } + ] + }, + { + $inc: { + cookies: BANKRUPTCY_GRANT + }, + $setOnInsert: { + userId + } + }, + { + upsert: true + } + ); + } catch (error) { + if (error?.code !== 11000) { + throw error; + } + + credit = { modifiedCount: 0, upsertedCount: 0 }; + } + + if (credit.modifiedCount !== 1 && credit.upsertedCount !== 1) { + await bankruptcy.updateOne( + { userId, guildId }, + { + $set: { + nextAvailableAt, + lastClaimedAt: previous?.lastClaimedAt ?? null, + claims: Number(previous?.claims ?? 0) + } + } + ); + + return { + ok: false, + reason: "BALANCE_NOT_ZERO", + balance: await getCookieBalance(userId) + }; + } + + await trackProfileEvent( + interaction, + userId, + { + type: "cookie_gain", + amount: BANKRUPTCY_GRANT + } + ); + + return { + ok: true, + amount: BANKRUPTCY_GRANT, + balance: BANKRUPTCY_GRANT, + nextAvailableAt: now + BANKRUPTCY_COOLDOWN, + claims: Number(claimLock.claims ?? 1) + }; +} + +module.exports = { + BANKRUPTCY_GRANT, + BANKRUPTCY_COOLDOWN, + ensureBankruptcyIndexes, + getBankruptcyStatus, + declareBankruptcy +}; diff --git a/utils/cookieHeist.js b/utils/cookieHeist.js index 1617570..e512bbe 100644 --- a/utils/cookieHeist.js +++ b/utils/cookieHeist.js @@ -27,30 +27,30 @@ const HEIST_METHODS = { baseChance: 0.65, minStealPercent: 0.01, maxStealPercent: 0.03, - maxStealAmount: 1500, + maxStealAmount: 5000, minPenaltyPercent: 0.01, maxPenaltyPercent: 0.04, - maxPenaltyAmount: 750 + maxPenaltyAmount: 1500 }, Hack: { baseChance: 0.45, minStealPercent: 0.03, maxStealPercent: 0.06, - maxStealAmount: 3500, + maxStealAmount: 12500, minPenaltyPercent: 0.03, maxPenaltyPercent: 0.07, - maxPenaltyAmount: 2000 + maxPenaltyAmount: 4000 }, Smash: { baseChance: 0.25, minStealPercent: 0.06, maxStealPercent: 0.12, - maxStealAmount: 7500, + maxStealAmount: 25000, minPenaltyPercent: 0.06, maxPenaltyPercent: 0.12, - maxPenaltyAmount: 5000 + maxPenaltyAmount: 10000 } }; diff --git a/utils/crumbCollect.js b/utils/crumbCollect.js index 41d8e0f..154a2c0 100644 --- a/utils/crumbCollect.js +++ b/utils/crumbCollect.js @@ -26,6 +26,7 @@ const { const COLLECT_COOLDOWN = 60 * 60; +const COLLECT_MAX = 1000; /* * IMPORTANT: @@ -223,7 +224,7 @@ async function collectCookiesFromSaucer( const amount = Math.floor( Math.random() * - 1000 + COLLECT_MAX ) + 1; diff --git a/utils/crumbSaucer.js b/utils/crumbSaucer.js index d4c45f5..056efd5 100644 --- a/utils/crumbSaucer.js +++ b/utils/crumbSaucer.js @@ -6,101 +6,56 @@ const { } = require("discord.js"); const { - getCookieBalance, - temporaryEphemeralReply + getCookieBalance } = require("./cookieEconomy"); +const { + createInteractionSession, + requireInteractionSession +} = require("./interactionSession"); -/* - * ============================================================ - * MAIN MENU - * ============================================================ - */ +async function showCrumbSaucer(interaction) { + if (interaction.isButton()) { + const session = await requireInteractionSession(interaction); -async function showCrumbSaucer( - interaction -) { - /* - * Button navigation must preserve ownership. - */ - - if ( - interaction.isButton() - ) { - const ownerId = - getCrumbSaucerSessionOwner( - interaction - ); - - - if ( - ownerId !== - interaction.user.id - ) { - return temporaryEphemeralReply( - interaction, - { - content: - "This isn't your Crumb Saucer session." - } - ); + if (!session) { + return; } - } + const balance = await getCookieBalance(interaction.user.id); + const embed = buildMainMenuEmbed(interaction.user, balance); - const balance = - await getCookieBalance( - interaction.user.id - ); - - - const embed = - buildMainMenuEmbed( - interaction.user, - balance - ); - - - if ( - interaction.isButton() - ) { return interaction.update({ - embeds: [ - embed - ], - - components: - buildMainMenuButtons() + embeds: [embed], + components: buildMainMenuButtons() }); } + const balance = await getCookieBalance(interaction.user.id); + const embed = buildMainMenuEmbed(interaction.user, balance); - return interaction.reply({ - embeds: [ - embed - ], - - components: - buildMainMenuButtons() + await interaction.reply({ + embeds: [embed], + components: buildMainMenuButtons() }); + + const message = await interaction.fetchReply(); + + await createInteractionSession( + interaction, + { + messageId: message.id, + ownerId: interaction.user.id, + kind: "crumbSaucer" + } + ); + + return message; } - -/* - * ============================================================ - * MAIN MENU EMBED - * ============================================================ - */ - -function buildMainMenuEmbed( - user, - balance -) { +function buildMainMenuEmbed(user, balance) { return new EmbedBuilder() - .setTitle( - "🎰 The Crumb Saucer" - ) - + .setTitle("🎰 The Crumb Saucer") .setDescription( [ `Welcome, ${user}!`, @@ -121,158 +76,78 @@ function buildMainMenuEmbed( "Bet on Red, Black, Odd, Even, or Zero.", "", "### 🏦 Vault Heist", - "Attempt to breach ShaiBot's Cookie Vault." + "Attempt to breach ShaiBot's Cookie Vault.", + "", + "### 🛟 Bankruptcy", + "At exactly 0 Cookies, claim a 1,000 Cookie starter kit." ].join("\n") ) - .addFields({ - name: - "Your Vault", - - value: - `**${balance.toLocaleString()} 🍪**` + name: "Your Vault", + value: `**${balance.toLocaleString()} 🍪**` }) - .setFooter({ - text: - `crumbSaucer:${user.id}` + text: `crumbSaucer:${user.id}` }); } - -/* - * ============================================================ - * MAIN MENU BUTTONS - * ============================================================ - */ - function buildMainMenuButtons() { return [ new ActionRowBuilder() .addComponents( new ButtonBuilder() - .setCustomId( - "crumbSaucerSlots" - ) - .setLabel( - "Slots" - ) - .setEmoji( - "🎰" - ) - .setStyle( - ButtonStyle.Primary - ), + .setCustomId("crumbSaucerSlots") + .setLabel("Slots") + .setEmoji("🎰") + .setStyle(ButtonStyle.Primary), new ButtonBuilder() - .setCustomId( - "crumbSaucerBlackjack" - ) - .setLabel( - "Blackjack" - ) - .setEmoji( - "🃏" - ) - .setStyle( - ButtonStyle.Success - ), + .setCustomId("crumbSaucerBlackjack") + .setLabel("Blackjack") + .setEmoji("🃏") + .setStyle(ButtonStyle.Success), new ButtonBuilder() - .setCustomId( - "crumbSaucerRoulette" - ) - .setLabel( - "Roulette" - ) - .setEmoji( - "🎡" - ) - .setStyle( - ButtonStyle.Primary - ), + .setCustomId("crumbSaucerRoulette") + .setLabel("Roulette") + .setEmoji("🎡") + .setStyle(ButtonStyle.Primary), new ButtonBuilder() - .setCustomId( - "crumbSaucerHeist" - ) - .setLabel( - "Vault Heist" - ) - .setEmoji( - "🏦" - ) - .setStyle( - ButtonStyle.Danger - ) + .setCustomId("crumbSaucerHeist") + .setLabel("Vault Heist") + .setEmoji("🏦") + .setStyle(ButtonStyle.Danger) ), new ActionRowBuilder() .addComponents( new ButtonBuilder() - .setCustomId( - "crumbSaucerCollectMenu" - ) - .setLabel( - "Cookie Bakery" - ) - .setEmoji( - "🍪" - ) - .setStyle( - ButtonStyle.Success - ), - - /* - * Do NOT use global "abort" here. - * - * The dedicated handler validates - * Crumb Saucer ownership first. - */ + .setCustomId("crumbSaucerCollectMenu") + .setLabel("Cookie Bakery") + .setEmoji("🍪") + .setStyle(ButtonStyle.Success), new ButtonBuilder() - .setCustomId( - "crumbSaucerClose" - ) - .setLabel( - "Close" - ) - .setEmoji( - "✖️" - ) - .setStyle( - ButtonStyle.Secondary - ) + .setCustomId("crumbSaucerBankruptcy") + .setLabel("Bankruptcy") + .setEmoji("🛟") + .setStyle(ButtonStyle.Secondary), + + new ButtonBuilder() + .setCustomId("sessionClose") + .setLabel("Close") + .setEmoji("✖️") + .setStyle(ButtonStyle.Secondary) ) ]; } +function getCrumbSaucerSessionOwner(interaction) { + const footer = interaction.message?.embeds?.[0]?.footer?.text; + const parts = footer?.split(":") ?? []; -/* - * ============================================================ - * SESSION OWNER - * ============================================================ - */ - -function getCrumbSaucerSessionOwner( - interaction -) { - const footer = - interaction.message - ?.embeds?.[0] - ?.footer - ?.text; - - - const parts = - footer?.split(":") - ?? - []; - - - switch ( - parts[0] - ) { + switch (parts[0]) { case "crumbSaucer": case "crumbSaucerCollect": case "crumbSaucerSlots": @@ -283,35 +158,19 @@ function getCrumbSaucerSessionOwner( case "crumbRouletteBet": case "crumbRouletteResult": case "cookieHeist": - return parts[1]; - - /* - * Active Blackjack: - * - * crumbBlackjack:: - */ - case "crumbBlackjack": - return parts[2]; - default: - return null; } } - -/* - * ============================================================ - * EXPORTS - * ============================================================ - */ - module.exports = { showCrumbSaucer, + buildMainMenuEmbed, + buildMainMenuButtons, getCrumbSaucerSessionOwner -}; \ No newline at end of file +}; diff --git a/utils/interactionSession.js b/utils/interactionSession.js new file mode 100644 index 0000000..df75d98 --- /dev/null +++ b/utils/interactionSession.js @@ -0,0 +1,284 @@ +const { EmbedBuilder, MessageFlags } = require("discord.js"); +const { mClient } = require("../index"); + +const DATABASE_NAME = process.env.M_DB; +const SESSION_COLLECTION = "items_sessions"; +const DEFAULT_SESSION_LIFETIME_MS = 24 * 60 * 60 * 1000; + +function getSessionCollection() { + if (!DATABASE_NAME) { + throw new Error("M_DB is not configured in .env"); + } + + return mClient.db(DATABASE_NAME).collection(SESSION_COLLECTION); +} + +async function ensureInteractionSessionIndexes() { + const sessions = getSessionCollection(); + + await sessions.createIndex( + { messageId: 1 }, + { unique: true } + ); + + await sessions.createIndex( + { expiresAt: 1 }, + { expireAfterSeconds: 0 } + ); +} + +async function createInteractionSession( + interaction, + { + messageId = null, + ownerId = interaction.user.id, + kind = "menu", + targetId = null, + metadata = {}, + lifetimeMs = DEFAULT_SESSION_LIFETIME_MS + } = {} +) { + const resolvedMessageId = + messageId ?? interaction.message?.id; + + if (!resolvedMessageId) { + throw new Error("SESSION_MESSAGE_ID_REQUIRED"); + } + + const sessions = getSessionCollection(); + const now = new Date(); + const expiresAt = new Date(now.getTime() + lifetimeMs); + + await sessions.updateOne( + { messageId: resolvedMessageId }, + { + $set: { + messageId: resolvedMessageId, + ownerId, + guildId: interaction.guildId ?? null, + channelId: interaction.channelId ?? null, + kind, + targetId, + metadata, + active: true, + updatedAt: now, + expiresAt + }, + $setOnInsert: { + createdAt: now + } + }, + { upsert: true } + ); + + return sessions.findOne({ messageId: resolvedMessageId }); +} + +async function registerReplySession(interaction, options = {}) { + const message = await interaction.fetchReply(); + + return createInteractionSession( + interaction, + { + ...options, + messageId: message.id + } + ); +} + +async function getInteractionSession(interaction) { + const messageId = interaction.message?.id; + + if (!messageId) { + return null; + } + + return getSessionCollection().findOne({ + messageId, + active: true, + expiresAt: { $gt: new Date() } + }); +} + +function inferLegacyOwnerId(interaction) { + const footer = interaction.message?.embeds?.[0]?.footer?.text; + const parts = footer?.split(":") ?? []; + + if (!parts.length) { + return null; + } + + switch (parts[0]) { + case "profile": + case "crumbSaucer": + case "crumbSaucerCollect": + case "crumbSaucerSlots": + case "crumbSaucerSlotsResult": + case "crumbSaucerBlackjack": + case "crumbBlackjackResult": + case "crumbRouletteMenu": + case "crumbRouletteBet": + case "crumbRouletteResult": + case "cookieHeist": + return parts[1] ?? null; + + case "crumbBlackjack": + return parts[2] ?? null; + + default: + return null; + } +} + +async function replySessionError( + interaction, + content = "This isn't your session." +) { + const payload = { + content, + flags: MessageFlags.Ephemeral + }; + + if (interaction.replied || interaction.deferred) { + return interaction.followUp(payload); + } + + return interaction.reply(payload); +} + +async function requireInteractionSession( + interaction, + { + kinds = null, + allowLegacy = true + } = {} +) { + const session = await getInteractionSession(interaction); + + if (session) { + if (session.ownerId !== interaction.user.id) { + await replySessionError(interaction); + return null; + } + + if ( + Array.isArray(kinds) && + kinds.length > 0 && + !kinds.includes(session.kind) + ) { + await replySessionError( + interaction, + "This control belongs to a different session." + ); + return null; + } + + return session; + } + + if (allowLegacy) { + const ownerId = inferLegacyOwnerId(interaction); + + if (ownerId) { + if (ownerId !== interaction.user.id) { + await replySessionError(interaction); + return null; + } + + return { + messageId: interaction.message?.id ?? null, + ownerId, + guildId: interaction.guildId ?? null, + channelId: interaction.channelId ?? null, + kind: "legacy", + targetId: null, + metadata: {}, + active: true, + legacy: true + }; + } + } + + return { + messageId: interaction.message?.id ?? null, + ownerId: interaction.user.id, + kind: "unmanaged", + metadata: {}, + active: true, + unmanaged: true + }; +} + +async function guardSessionInteraction(interaction) { + if (!interaction.isButton()) { + return true; + } + + const session = await getInteractionSession(interaction); + + if (session) { + if (session.ownerId === interaction.user.id) { + return true; + } + + await replySessionError(interaction); + return false; + } + + const ownerId = inferLegacyOwnerId(interaction); + + if (!ownerId || ownerId === interaction.user.id) { + return true; + } + + await replySessionError(interaction); + return false; +} + +async function closeInteractionSession(interaction) { + const session = await requireInteractionSession(interaction); + + if (!session) { + return false; + } + + if (session.messageId && !session.unmanaged) { + await getSessionCollection().updateOne( + { messageId: session.messageId }, + { + $set: { + active: false, + closedAt: new Date(), + updatedAt: new Date() + } + } + ); + } + + const embed = new EmbedBuilder() + .setTitle("Session Closed") + .setDescription("Closing menu. . ."); + + await interaction.update({ + embeds: [embed], + components: [] + }); + + setTimeout(() => { + interaction.deleteReply().catch(() => {}); + }, 1200); + + return true; +} + +module.exports = { + ensureInteractionSessionIndexes, + createInteractionSession, + registerReplySession, + getInteractionSession, + requireInteractionSession, + guardSessionInteraction, + closeInteractionSession, + replySessionError, + inferLegacyOwnerId +}; diff --git a/utils/profileSystem.js b/utils/profileSystem.js index 2bccbd9..ea3fea0 100644 --- a/utils/profileSystem.js +++ b/utils/profileSystem.js @@ -1610,9 +1610,7 @@ function buildProfileButtons() { ), new ButtonBuilder() - .setCustomId( - "profileClose" - ) + .setCustomId("sessionClose") .setLabel( "Close" )