added bankruptcy

This commit is contained in:
DeSqBlocki
2026-08-23 00:09:13 +02:00
parent 030b4b8883
commit 9c867b1adb
12 changed files with 985 additions and 676 deletions
+11
View File
@@ -0,0 +1,11 @@
const {
closeInteractionSession
} = require("../../utils/interactionSession");
module.exports = {
name: "sessionClose",
async execute(interaction) {
return closeInteractionSession(interaction);
}
};
+91
View File
@@ -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 <t:${result.nextAvailableAt}:R>.`,
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: `<t:${result.nextAvailableAt}:R>`
})
.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]
});
}
};
+64
View File
@@ -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 <t:${result.nextAvailableAt}:R>.`,
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: `<t:${result.nextAvailableAt}:R>`
});
return interaction.reply({
embeds: [embed]
});
}
};
+29 -46
View File
@@ -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 =>
data: new SlashCommandBuilder()
.setName("profile")
.setDescription("View a player profile")
.addUserOption(option =>
option
.setName(
"user"
)
.setDescription(
"Player to view"
)
.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(
const embed = await buildProfileOverview(
interaction.guildId,
user,
sessionOwnerId
interaction.user.id
);
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
}
);
}
};
+56 -152
View File
@@ -3,8 +3,11 @@ const {
MessageFlags
} = require("discord.js");
require("dotenv").config();
const {
guardSessionInteraction
} = require("../utils/interactionSession");
require("dotenv").config();
module.exports = {
name: Events.InteractionCreate,
@@ -36,203 +39,110 @@ module.exports = {
}
};
async function autocompleteHandler(interaction) {
const command =
interaction.client.commands.get(
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"
) {
if (typeof command.autocomplete !== "function") {
return;
}
try {
await command.autocomplete(
interaction
);
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.
}
} catch {}
}
}
async function commandHandler(interaction) {
const command =
interaction.client.commands.get(
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
);
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
});
}
await sendInteractionError(
interaction,
"There was an error while executing this command!"
);
}
}
async function buttonHandler(interaction) {
const button =
interaction.client.buttons.get(
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
);
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
});
}
await sendInteractionError(
interaction,
"There was an error while executing this button!"
);
}
}
async function selectMenuHandler(interaction) {
const selectMenu =
interaction.client.selectMenus.get(
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
);
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
});
}
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
);
const customId = interaction.customId.split(":")[0];
const modal = interaction.client.modals.get(customId);
if (!modal) {
return console.error(
@@ -240,36 +150,30 @@ async function modalHandler(interaction) {
);
}
try {
await modal.execute(
interaction
);
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
});
}
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);
}
}
+29 -103
View File
@@ -5,34 +5,18 @@ const {
Partials
} = require("discord.js");
const {
MongoClient
} = require("mongodb");
const { MongoClient } = require("mongodb");
const fs = require("node:fs");
require("dotenv").config();
/*
* MONGODB
*/
const mClient =
new MongoClient(
const mClient = new MongoClient(
process.env.M_URI
);
);
exports.mClient =
mClient;
exports.mClient = mClient;
/*
* DISCORD CLIENT
*/
const client =
new Client({
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
@@ -43,7 +27,6 @@ const client =
GatewayIntentBits.DirectMessages,
GatewayIntentBits.MessageContent
],
partials: [
Partials.Channel,
Partials.Message,
@@ -51,110 +34,53 @@ const client =
Partials.GuildMember,
Partials.Reaction
]
});
});
exports.client =
client;
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:",
+188
View File
@@ -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
};
+6 -6
View File
@@ -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
}
};
+2 -1
View File
@@ -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;
+78 -219
View File
@@ -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 (!session) {
return;
}
if (
interaction.isButton()
) {
const ownerId =
getCrumbSaucerSessionOwner(
interaction
);
const balance = await getCookieBalance(interaction.user.id);
const embed = buildMainMenuEmbed(interaction.user, balance);
return interaction.update({
embeds: [embed],
components: buildMainMenuButtons()
});
}
if (
ownerId !==
interaction.user.id
) {
return temporaryEphemeralReply(
const balance = await getCookieBalance(interaction.user.id);
const embed = buildMainMenuEmbed(interaction.user, balance);
await interaction.reply({
embeds: [embed],
components: buildMainMenuButtons()
});
const message = await interaction.fetchReply();
await createInteractionSession(
interaction,
{
content:
"This isn't your Crumb Saucer session."
messageId: message.id,
ownerId: interaction.user.id,
kind: "crumbSaucer"
}
);
}
}
const balance =
await getCookieBalance(
interaction.user.id
);
const embed =
buildMainMenuEmbed(
interaction.user,
balance
);
if (
interaction.isButton()
) {
return interaction.update({
embeds: [
embed
],
components:
buildMainMenuButtons()
});
}
return interaction.reply({
embeds: [
embed
],
components:
buildMainMenuButtons()
});
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:<gameId>:<userId>
*/
case "crumbBlackjack":
return parts[2];
default:
return null;
}
}
/*
* ============================================================
* EXPORTS
* ============================================================
*/
module.exports = {
showCrumbSaucer,
buildMainMenuEmbed,
buildMainMenuButtons,
getCrumbSaucerSessionOwner
};
+284
View File
@@ -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
};
+1 -3
View File
@@ -1610,9 +1610,7 @@ function buildProfileButtons() {
),
new ButtonBuilder()
.setCustomId(
"profileClose"
)
.setCustomId("sessionClose")
.setLabel(
"Close"
)