updated profile and centralized functions

This commit is contained in:
DeSqBlocki
2026-08-22 23:33:20 +02:00
parent 08d1a8c5cd
commit 030b4b8883
18 changed files with 3747 additions and 1202 deletions
+33 -36
View File
@@ -1,54 +1,51 @@
const {
requireProfileOwner,
buildAchievementsEmbed,
buildProfileButtons,
getProfileContextFromFooter,
requireProfileOwner
} = require(
"../../utils/profileSystem"
);
buildProfilePaginationButtons
} = require("../../utils/profileSystem");
module.exports = {
name:
"profileAchievements",
name: "profileAchievements",
async execute(
interaction
) {
const context =
await requireProfileOwner(
interaction
);
async execute(interaction) {
const session = await requireProfileOwner(
interaction
);
if (!context) {
if (!session) {
return;
}
const user =
await interaction.client.users.fetch(
context.profileUserId
);
const user = await interaction.client.users.fetch(
session.profileUserId
);
const embed =
await buildAchievementsEmbed(
interaction.guildId,
user,
context.sessionOwnerId,
0
);
const result = await buildAchievementsEmbed(
interaction.guildId,
user,
0,
session.sessionOwnerId
);
const pageContext =
getProfileContextFromFooter(
embed.data.footer?.text
const components = [
...buildProfileButtons()
];
if (result.pageCount > 1) {
components.push(
buildProfilePaginationButtons(
"profileAchievementsLeft",
"profileAchievementsRight",
result.page,
result.pageCount
)
);
}
return interaction.update({
embeds: [embed],
components:
buildProfileButtons(
"achievements",
pageContext?.page ?? 0,
pageContext?.pageCount ?? 1
)
embeds: [result.embed],
components
});
}
};
+35 -36
View File
@@ -1,54 +1,53 @@
// buttons/profile/achievementsLeft.js
const {
requireProfileOwner,
buildAchievementsEmbed,
buildProfileButtons,
getProfileContextFromFooter,
requireProfileOwner
} = require(
"../../utils/profileSystem"
);
buildProfilePaginationButtons
} = require("../../utils/profileSystem");
module.exports = {
name:
"profileAchievementsLeft",
name: "profileAchievementsLeft",
async execute(
interaction
) {
const context =
await requireProfileOwner(
interaction
);
async execute(interaction) {
const session = await requireProfileOwner(
interaction
);
if (!context) {
if (!session) {
return;
}
const user =
await interaction.client.users.fetch(
context.profileUserId
);
const user = await interaction.client.users.fetch(
session.profileUserId
);
const embed =
await buildAchievementsEmbed(
interaction.guildId,
user,
context.sessionOwnerId,
context.page - 1
);
const result = await buildAchievementsEmbed(
interaction.guildId,
user,
session.page - 1,
session.sessionOwnerId
);
const pageContext =
getProfileContextFromFooter(
embed.data.footer?.text
const components = [
...buildProfileButtons()
];
if (result.pageCount > 1) {
components.push(
buildProfilePaginationButtons(
"profileAchievementsLeft",
"profileAchievementsRight",
result.page,
result.pageCount
)
);
}
return interaction.update({
embeds: [embed],
components:
buildProfileButtons(
"achievements",
pageContext?.page ?? 0,
pageContext?.pageCount ?? 1
)
embeds: [result.embed],
components
});
}
};
+35 -36
View File
@@ -1,54 +1,53 @@
// buttons/profile/achievementsRight.js
const {
requireProfileOwner,
buildAchievementsEmbed,
buildProfileButtons,
getProfileContextFromFooter,
requireProfileOwner
} = require(
"../../utils/profileSystem"
);
buildProfilePaginationButtons
} = require("../../utils/profileSystem");
module.exports = {
name:
"profileAchievementsRight",
name: "profileAchievementsRight",
async execute(
interaction
) {
const context =
await requireProfileOwner(
interaction
);
async execute(interaction) {
const session = await requireProfileOwner(
interaction
);
if (!context) {
if (!session) {
return;
}
const user =
await interaction.client.users.fetch(
context.profileUserId
);
const user = await interaction.client.users.fetch(
session.profileUserId
);
const embed =
await buildAchievementsEmbed(
interaction.guildId,
user,
context.sessionOwnerId,
context.page + 1
);
const result = await buildAchievementsEmbed(
interaction.guildId,
user,
session.page + 1,
session.sessionOwnerId
);
const pageContext =
getProfileContextFromFooter(
embed.data.footer?.text
const components = [
...buildProfileButtons()
];
if (result.pageCount > 1) {
components.push(
buildProfilePaginationButtons(
"profileAchievementsLeft",
"profileAchievementsRight",
result.page,
result.pageCount
)
);
}
return interaction.update({
embeds: [embed],
components:
buildProfileButtons(
"achievements",
pageContext?.page ?? 0,
pageContext?.pageCount ?? 1
)
embeds: [result.embed],
components
});
}
};
+33 -36
View File
@@ -1,54 +1,51 @@
const {
requireProfileOwner,
buildBadgesEmbed,
buildProfileButtons,
getProfileContextFromFooter,
requireProfileOwner
} = require(
"../../utils/profileSystem"
);
buildProfilePaginationButtons
} = require("../../utils/profileSystem");
module.exports = {
name:
"profileBadges",
name: "profileBadges",
async execute(
interaction
) {
const context =
await requireProfileOwner(
interaction
);
async execute(interaction) {
const session = await requireProfileOwner(
interaction
);
if (!context) {
if (!session) {
return;
}
const user =
await interaction.client.users.fetch(
context.profileUserId
);
const user = await interaction.client.users.fetch(
session.profileUserId
);
const embed =
await buildBadgesEmbed(
interaction.guildId,
user,
context.sessionOwnerId,
0
);
const result = await buildBadgesEmbed(
interaction.guildId,
user,
0,
session.sessionOwnerId
);
const pageContext =
getProfileContextFromFooter(
embed.data.footer?.text
const components = [
...buildProfileButtons()
];
if (result.pageCount > 1) {
components.push(
buildProfilePaginationButtons(
"profileBadgesLeft",
"profileBadgesRight",
result.page,
result.pageCount
)
);
}
return interaction.update({
embeds: [embed],
components:
buildProfileButtons(
"badges",
pageContext?.page ?? 0,
pageContext?.pageCount ?? 1
)
embeds: [result.embed],
components
});
}
};
+35 -36
View File
@@ -1,54 +1,53 @@
// buttons/profile/badgesLeft.js
const {
requireProfileOwner,
buildBadgesEmbed,
buildProfileButtons,
getProfileContextFromFooter,
requireProfileOwner
} = require(
"../../utils/profileSystem"
);
buildProfilePaginationButtons
} = require("../../utils/profileSystem");
module.exports = {
name:
"profileBadgesLeft",
name: "profileBadgesLeft",
async execute(
interaction
) {
const context =
await requireProfileOwner(
interaction
);
async execute(interaction) {
const session = await requireProfileOwner(
interaction
);
if (!context) {
if (!session) {
return;
}
const user =
await interaction.client.users.fetch(
context.profileUserId
);
const user = await interaction.client.users.fetch(
session.profileUserId
);
const embed =
await buildBadgesEmbed(
interaction.guildId,
user,
context.sessionOwnerId,
context.page - 1
);
const result = await buildBadgesEmbed(
interaction.guildId,
user,
session.page - 1,
session.sessionOwnerId
);
const pageContext =
getProfileContextFromFooter(
embed.data.footer?.text
const components = [
...buildProfileButtons()
];
if (result.pageCount > 1) {
components.push(
buildProfilePaginationButtons(
"profileBadgesLeft",
"profileBadgesRight",
result.page,
result.pageCount
)
);
}
return interaction.update({
embeds: [embed],
components:
buildProfileButtons(
"badges",
pageContext?.page ?? 0,
pageContext?.pageCount ?? 1
)
embeds: [result.embed],
components
});
}
};
+35 -36
View File
@@ -1,54 +1,53 @@
// buttons/profile/badgesRight.js
const {
requireProfileOwner,
buildBadgesEmbed,
buildProfileButtons,
getProfileContextFromFooter,
requireProfileOwner
} = require(
"../../utils/profileSystem"
);
buildProfilePaginationButtons
} = require("../../utils/profileSystem");
module.exports = {
name:
"profileBadgesRight",
name: "profileBadgesRight",
async execute(
interaction
) {
const context =
await requireProfileOwner(
interaction
);
async execute(interaction) {
const session = await requireProfileOwner(
interaction
);
if (!context) {
if (!session) {
return;
}
const user =
await interaction.client.users.fetch(
context.profileUserId
);
const user = await interaction.client.users.fetch(
session.profileUserId
);
const embed =
await buildBadgesEmbed(
interaction.guildId,
user,
context.sessionOwnerId,
context.page + 1
);
const result = await buildBadgesEmbed(
interaction.guildId,
user,
session.page + 1,
session.sessionOwnerId
);
const pageContext =
getProfileContextFromFooter(
embed.data.footer?.text
const components = [
...buildProfileButtons()
];
if (result.pageCount > 1) {
components.push(
buildProfilePaginationButtons(
"profileBadgesLeft",
"profileBadgesRight",
result.page,
result.pageCount
)
);
}
return interaction.update({
embeds: [embed],
components:
buildProfileButtons(
"badges",
pageContext?.page ?? 0,
pageContext?.pageCount ?? 1
)
embeds: [result.embed],
components
});
}
};
+19 -32
View File
@@ -1,47 +1,34 @@
const {
buildStatsEmbed,
buildProfileButtons,
requireProfileOwner
} = require(
"../../utils/profileSystem"
);
requireProfileOwner,
buildProfileStatsEmbed,
buildProfileButtons
} = require("../../utils/profileSystem");
module.exports = {
name:
"profileStats",
name: "profileStats",
async execute(
interaction
) {
const context =
await requireProfileOwner(
interaction
);
async execute(interaction) {
const session = await requireProfileOwner(
interaction
);
if (!context) {
if (!session) {
return;
}
const user =
await interaction.client.users.fetch(
context.profileUserId
);
const user = await interaction.client.users.fetch(
session.profileUserId
);
const embed =
await buildStatsEmbed(
interaction.guildId,
user,
context.sessionOwnerId
);
const embed = await buildProfileStatsEmbed(
interaction.guildId,
user,
session.sessionOwnerId
);
return interaction.update({
embeds: [embed],
components:
buildProfileButtons(
"stats",
0,
1
)
components: buildProfileButtons()
});
}
};
+415
View File
@@ -0,0 +1,415 @@
const {
SlashCommandBuilder,
MessageFlags
} = require(
"discord.js"
);
const {
requireAdminAccess
} = require(
"../../utils/permissions"
);
const {
reconcileProfileAchievements,
auditProfileSystem
} = require(
"../../utils/profileSystem"
);
/*
* ============================================================
* COMMAND
* ============================================================
*/
module.exports = {
data:
new SlashCommandBuilder()
.setName(
"profileadmin"
)
.setDescription(
"Profile and achievement administration."
)
/*
* =================================================
* RECONCILE
* =================================================
*/
.addSubcommand(
subcommand =>
subcommand
.setName(
"reconcile"
)
.setDescription(
"Re-evaluate stored statistics for achievements."
)
.addUserOption(
option =>
option
.setName(
"user"
)
.setDescription(
"Only reconcile this user's profile."
)
.setRequired(
false
)
)
.addBooleanOption(
option =>
option
.setName(
"dry-run"
)
.setDescription(
"Show what would change without unlocking anything."
)
.setRequired(
false
)
)
)
/*
* =================================================
* AUDIT
* =================================================
*/
.addSubcommand(
subcommand =>
subcommand
.setName(
"audit"
)
.setDescription(
"Audit the profile and achievement system."
)
),
/*
* ========================================================
* EXECUTE
* ========================================================
*/
async execute(
interaction
) {
/*
* Runtime authorization.
*
* This intentionally replaces:
*
* .setDefaultMemberPermissions(
* PermissionFlagsBits.Administrator
* )
*
* so BOT_AUTHOR_ID can also invoke the command.
*/
if (
!await requireAdminAccess(
interaction
)
) {
return;
}
const subcommand =
interaction.options
.getSubcommand();
switch (
subcommand
) {
case "reconcile":
return runReconcile(
interaction
);
case "audit":
return runAudit(
interaction
);
default:
return interaction.reply({
content:
"Unknown profile administration action.",
flags:
MessageFlags.Ephemeral
});
}
}
};
/*
* ============================================================
* RECONCILE
* ============================================================
*/
async function runReconcile(
interaction
) {
const target =
interaction.options.getUser(
"user"
);
const dryRun =
interaction.options.getBoolean(
"dry-run"
)
??
false;
/*
* This can touch multiple Mongo documents,
* so acknowledge the interaction first.
*/
await interaction.deferReply({
flags:
MessageFlags.Ephemeral
});
try {
const result =
await reconcileProfileAchievements(
interaction.guildId,
{
userId:
target?.id
??
null,
dryRun
}
);
const lines = [
dryRun
? "### 🔎 Profile Reconciliation — Dry Run"
: "### ✅ Profile Reconciliation Complete",
"",
target
? `Target: ${target}`
: "Target: **Entire Guild**",
"",
`Profiles checked: **${result.profilesChecked ?? 0}**`,
`Achievement unlocks found: **${result.unlocksFound ?? 0}**`
];
if (
!dryRun
) {
lines.push(
`Achievements unlocked: **${result.unlocksApplied ?? 0}**`
);
}
if (
result.skippedEventOnly !==
undefined
) {
lines.push(
`Event-only checks skipped: **${result.skippedEventOnly}**`
);
}
if (
Array.isArray(
result.details
)
&&
result.details.length >
0
) {
const preview =
result.details
.slice(
0,
15
)
.map(
entry => {
if (
typeof entry ===
"string"
) {
return `• ${entry}`;
}
return (
`• <@${entry.userId}> — ` +
`${entry.achievementName ?? entry.achievementId}`
);
}
);
lines.push(
"",
"### Changes",
...preview
);
if (
result.details.length >
preview.length
) {
lines.push(
"",
`*...and ${result.details.length - preview.length} more.*`
);
}
}
return interaction.editReply({
content:
lines.join(
"\n"
)
});
} catch (error) {
console.error(
"[ProfileAdmin] Reconciliation failed:",
error
);
return interaction.editReply({
content:
"Profile reconciliation failed. Check the bot logs for details."
});
}
}
/*
* ============================================================
* AUDIT
* ============================================================
*/
async function runAudit(
interaction
) {
await interaction.deferReply({
flags:
MessageFlags.Ephemeral
});
try {
const result =
await auditProfileSystem(
interaction.guildId
);
const lines = [
"### 🧾 Profile System Audit",
"",
`Profile documents: **${result.profileDocuments ?? 0}**`,
`Achievement documents: **${result.achievementDocuments ?? 0}**`,
`Active achievement definitions: **${result.activeAchievements ?? 0}**`,
`Legacy achievement definitions: **${result.legacyAchievements ?? 0}**`,
`Guild badges: **${result.badges ?? 0}**`,
`Claimed badges: **${result.claimedBadges ?? 0}**`,
`Available badges: **${result.availableBadges ?? 0}**`
];
if (
Array.isArray(
result.warnings
)
&&
result.warnings.length >
0
) {
lines.push(
"",
"### ⚠️ Warnings",
...result.warnings.map(
warning =>
`• ${warning}`
)
);
}
else {
lines.push(
"",
"✅ No obvious profile-system issues were found."
);
}
return interaction.editReply({
content:
lines.join(
"\n"
)
});
} catch (error) {
console.error(
"[ProfileAdmin] Audit failed:",
error
);
return interaction.editReply({
content:
"Profile audit failed. Check the bot logs for details."
});
}
}
+61
View File
@@ -0,0 +1,61 @@
const {
SlashCommandBuilder
} = require("discord.js");
const {
buildRecordsEmbed,
getRecordChoices
} = require(
"../../utils/profileRecords"
);
module.exports = {
data:
new SlashCommandBuilder()
.setName(
"records"
)
.setDescription(
"View Shai Cookie and Crumb Saucer records"
)
.addStringOption(
option =>
option
.setName(
"category"
)
.setDescription(
"Record category"
)
.setRequired(
true
)
.addChoices(
...getRecordChoices()
)
),
async execute(
interaction
) {
const category =
interaction.options.getString(
"category",
true
);
const embed =
await buildRecordsEmbed({
guildId:
interaction.guildId,
category,
botUserId:
interaction.client.user.id,
limit: 10
});
return interaction.reply({
embeds: [embed]
});
}
};
+101 -74
View File
@@ -1,12 +1,12 @@
const {
Client,
GatewayIntentBits,
Collection,
Partials
Client,
GatewayIntentBits,
Collection,
Partials
} = require("discord.js");
const {
MongoClient
MongoClient
} = require("mongodb");
const fs = require("node:fs");
@@ -19,12 +19,12 @@ require("dotenv").config();
*/
const mClient =
new MongoClient(
process.env.M_URI
);
new MongoClient(
process.env.M_URI
);
exports.mClient =
mClient;
mClient;
/*
@@ -32,30 +32,29 @@ exports.mClient =
*/
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
]
});
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;
client;
/*
@@ -63,22 +62,22 @@ exports.client =
*/
client.commands =
new Collection();
new Collection();
client.legacyCommands =
new Collection();
new Collection();
client.aliases =
new Collection();
new Collection();
client.buttons =
new Collection();
new Collection();
client.selectMenus =
new Collection();
new Collection();
client.modals =
new Collection();
new Collection();
/*
@@ -86,56 +85,84 @@ client.modals =
*/
async function start() {
try {
try {
/*
* Connect Mongo first.
*/
/*
* Connect Mongo first.
*/
await mClient.connect();
await mClient.connect();
console.log(
"MongoDB connected"
);
console.log(
"MongoDB connected"
);
/*
* Load handlers.
*/
/*
* 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.
*/
const handlerFiles =
fs.readdirSync(
"./handlers"
);
const {
ensureCookieIndexes
} = require(
"./utils/cookieEconomy"
);
const {
ensureProfileSystem
} = require(
"./utils/profileSystem"
);
await Promise.all([
ensureCookieIndexes(),
ensureProfileSystem()
]);
console.log(
"Database indexes initialized"
);
for (
const handler
of handlerFiles
) {
require(
`./handlers/${handler}`
)(client);
}
/*
* Load handlers.
*/
const handlerFiles =
fs.readdirSync(
"./handlers"
);
for (
const handler
of handlerFiles
) {
require(
`./handlers/${handler}`
)(client);
}
/*
* Login Discord.
*/
/*
* Login Discord.
*/
await client.login(
process.env.D_TOKEN
);
await client.login(
process.env.D_TOKEN
);
} catch (error) {
console.error(
"Failed to start ShaiBot:",
error
);
} catch (error) {
console.error(
"Failed to start ShaiBot:",
error
);
process.exit(1);
}
process.exit(1);
}
}
start();
+4 -26
View File
@@ -15,7 +15,8 @@ const {
} = require("./cookieEconomy");
const {
trackProfileEvent
trackProfileEvent,
recordGameOutcome
} = require("./profileSystem");
const HEIST_COOLDOWN =
@@ -255,12 +256,10 @@ async function resolveCookieHeist(
throw error;
}
await trackProfileEvent(
await recordGameOutcome(
interaction,
playerId,
{
type:
"game_result",
game:
"heist",
result:
@@ -280,15 +279,6 @@ async function resolveCookieHeist(
}
);
await trackProfileEvent(
interaction,
playerId,
{
type:
"cookie_gain",
amount
}
);
const [
playerAfter,
@@ -444,12 +434,10 @@ async function resolveCookieHeist(
throw error;
}
await trackProfileEvent(
await recordGameOutcome(
interaction,
playerId,
{
type:
"game_result",
game:
"heist",
result:
@@ -459,16 +447,6 @@ async function resolveCookieHeist(
}
);
await trackProfileEvent(
interaction,
playerId,
{
type:
"cookie_loss",
amount:
penalty
}
);
const [
playerAfter,
+2 -30
View File
@@ -18,7 +18,7 @@ const {
} = require("./cookieEconomy");
const {
trackProfileEvent
recordGameOutcome
} = require("./profileSystem");
const GAME_COLLECTION =
@@ -929,12 +929,10 @@ async function finishBlackjack(
profileResult = "loss";
}
await trackProfileEvent(
await recordGameOutcome(
interaction,
game.userId,
{
type:
"game_result",
game:
"blackjack",
result:
@@ -949,32 +947,6 @@ async function finishBlackjack(
}
);
if (profit > 0) {
await trackProfileEvent(
interaction,
game.userId,
{
type:
"cookie_gain",
amount:
profit
}
);
}
else if (profit < 0) {
await trackProfileEvent(
interaction,
game.userId,
{
type:
"cookie_loss",
amount:
Math.abs(profit)
}
);
}
const description = [
"**Dealer**",
formatCards(
+2 -30
View File
@@ -12,7 +12,7 @@ const {
} = require("./cookieEconomy");
const {
trackProfileEvent
recordGameOutcome
} = require("./profileSystem");
const ROULETTE_BETS = [
@@ -577,12 +577,10 @@ async function playRoulette(
const profit =
payout - bet;
await trackProfileEvent(
await recordGameOutcome(
interaction,
playerId,
{
type:
"game_result",
game:
"roulette",
result:
@@ -602,32 +600,6 @@ async function playRoulette(
}
);
if (profit > 0) {
await trackProfileEvent(
interaction,
playerId,
{
type:
"cookie_gain",
amount:
profit
}
);
}
else if (profit < 0) {
await trackProfileEvent(
interaction,
playerId,
{
type:
"cookie_loss",
amount:
Math.abs(profit)
}
);
}
const resultEmbed =
new EmbedBuilder()
.setTitle(
+2 -30
View File
@@ -13,7 +13,7 @@ const {
} = require("./cookieEconomy");
const {
trackProfileEvent
recordGameOutcome
} = require("./profileSystem");
const SLOT_BETS = [
@@ -308,12 +308,10 @@ async function playSlots(
const profit =
payout - bet;
await trackProfileEvent(
await recordGameOutcome(
interaction,
playerId,
{
type:
"game_result",
game:
"slots",
result:
@@ -331,32 +329,6 @@ async function playSlots(
}
);
if (profit > 0) {
await trackProfileEvent(
interaction,
playerId,
{
type:
"cookie_gain",
amount:
profit
}
);
}
else if (profit < 0) {
await trackProfileEvent(
interaction,
playerId,
{
type:
"cookie_loss",
amount:
Math.abs(profit)
}
);
}
const resultEmbed =
new EmbedBuilder()
.setTitle(
+216
View File
@@ -0,0 +1,216 @@
const {
PermissionFlagsBits,
MessageFlags
} = require(
"discord.js"
);
/*
* ============================================================
* CONFIG
* ============================================================
*
* .env:
*
* BOT_AUTHOR_ID=123456789012345678
*
* Do not hard-code the author ID into commands.
*/
function getBotAuthorId() {
return (
process.env.BOT_AUTHOR_ID
??
null
);
}
/*
* ============================================================
* BOT AUTHOR
* ============================================================
*/
function isBotAuthor(
userId
) {
const authorId =
getBotAuthorId();
if (
!authorId
||
!userId
) {
return false;
}
return (
String(
userId
)
===
String(
authorId
)
);
}
/*
* ============================================================
* GUILD ADMINISTRATOR
* ============================================================
*/
function isAdministrator(
interaction
) {
if (
!interaction
?.memberPermissions
) {
return false;
}
return interaction
.memberPermissions
.has(
PermissionFlagsBits.Administrator
);
}
/*
* ============================================================
* ADMIN COMMAND ACCESS
* ============================================================
*
* Access is granted when:
*
* - the user is the configured bot author
* OR
* - the user has Discord Administrator
*
* Bot author is checked first so the owner can use admin
* utilities even without Discord Administrator in the guild.
*/
function canUseAdminCommands(
interaction
) {
if (
!interaction
?.user
?.id
) {
return false;
}
return (
isBotAuthor(
interaction.user.id
)
||
isAdministrator(
interaction
)
);
}
/*
* ============================================================
* STANDARD ACCESS DENIAL
* ============================================================
*
* Administrative access failures are intentionally ephemeral.
*/
async function denyAdminAccess(
interaction,
message =
"You don't have permission to use this command."
) {
const payload = {
content:
message,
flags:
MessageFlags.Ephemeral
};
if (
interaction.replied
||
interaction.deferred
) {
return interaction.followUp(
payload
);
}
return interaction.reply(
payload
);
}
/*
* ============================================================
* REQUIRE ADMIN ACCESS
* ============================================================
*
* Convenience helper:
*
* if (!await requireAdminAccess(interaction)) {
* return;
* }
*/
async function requireAdminAccess(
interaction
) {
if (
canUseAdminCommands(
interaction
)
) {
return true;
}
await denyAdminAccess(
interaction
);
return false;
}
/*
* ============================================================
* EXPORTS
* ============================================================
*/
module.exports = {
getBotAuthorId,
isBotAuthor,
isAdministrator,
canUseAdminCommands,
requireAdminAccess,
denyAdminAccess
};
+403
View File
@@ -0,0 +1,403 @@
const {
getProfileCollections,
getProfileStats,
readStatValue
} = require("./profileSystem");
const {
getUnixTime
} = require("./cookieEconomy");
function getActiveDefinitions(
achievementConfig
) {
return achievementConfig
.find({
active: {
$ne: false
}
})
.toArray();
}
async function reconcileUserAchievements({
guildId,
userId,
dryRun = false
}) {
const {
achievements,
achievementConfig
} = getProfileCollections();
const [
statsDocument,
definitions,
achievementDocument
] = await Promise.all([
getProfileStats(
userId,
guildId
),
getActiveDefinitions(
achievementConfig
),
achievements.findOne({
userId,
guildId
})
]);
const unlocked = {
...(achievementDocument?.unlocked ?? {})
};
const additions = {};
let skippedEventOnly = 0;
const normalDefinitions =
definitions.filter(
definition =>
definition.measure !==
"achievementPercentage"
);
for (const definition of normalDefinitions) {
if (unlocked[definition.achievementId]) {
continue;
}
if (!definition.stat) {
skippedEventOnly++;
continue;
}
const currentValue =
readStatValue(
statsDocument,
definition
);
if (
currentValue <
Number(definition.target ?? 0)
) {
continue;
}
const timestamp =
getUnixTime();
additions[
definition.achievementId
] = timestamp;
unlocked[
definition.achievementId
] = timestamp;
}
const collectibleDefinitions =
definitions.filter(
definition =>
definition.measure !==
"achievementPercentage"
);
const unlockedCollectible =
collectibleDefinitions.filter(
definition =>
Boolean(
unlocked[
definition.achievementId
]
)
).length;
const percentage =
collectibleDefinitions.length > 0
? unlockedCollectible /
collectibleDefinitions.length
: 0;
for (
const definition
of definitions.filter(
item =>
item.measure ===
"achievementPercentage"
)
) {
if (
unlocked[
definition.achievementId
]
) {
continue;
}
if (
percentage <
Number(definition.target ?? 0)
) {
continue;
}
additions[
definition.achievementId
] = getUnixTime();
}
const additionEntries =
Object.entries(additions);
if (
!dryRun &&
additionEntries.length > 0
) {
const set = {};
for (
const [achievementId, timestamp]
of additionEntries
) {
set[
`unlocked.${achievementId}`
] = timestamp;
}
await achievements.updateOne(
{
userId,
guildId
},
{
$setOnInsert: {
userId,
guildId
},
$set: set
},
{
upsert: true
}
);
}
return {
userId,
checked:
definitions.length,
unlocked:
additionEntries.length,
skippedEventOnly,
dryRun,
achievementIds:
additionEntries.map(
([achievementId]) =>
achievementId
)
};
}
async function reconcileAllAchievements({
guildId,
dryRun = false
}) {
const {
stats
} = getProfileCollections();
const userIds =
await stats.distinct(
"userId",
{
guildId
}
);
const summary = {
users:
userIds.length,
unlocked:
0,
skippedEventOnly:
0,
results: []
};
for (const userId of userIds) {
const result =
await reconcileUserAchievements({
guildId,
userId,
dryRun
});
summary.unlocked +=
result.unlocked;
summary.skippedEventOnly +=
result.skippedEventOnly;
summary.results.push(
result
);
}
return summary;
}
async function auditProfileData(
guildId
) {
const {
stats,
achievements,
achievementConfig,
badges
} = getProfileCollections();
const [
statDocuments,
achievementDocuments,
activeAchievements,
badgeDocuments
] = await Promise.all([
stats.find({
guildId
}).toArray(),
achievements.find({
guildId
}).toArray(),
achievementConfig.countDocuments({
active: {
$ne: false
}
}),
badges.find({
guildId
}).toArray()
]);
const issues = [];
for (const document of statDocuments) {
if (!document.userId) {
issues.push(
"Stats document missing userId."
);
continue;
}
const total =
document.games?.total ?? {};
const played =
Number(total.played ?? 0);
const resolved =
Number(total.won ?? 0) +
Number(total.lost ?? 0) +
Number(total.pushed ?? 0);
if (played !== resolved) {
issues.push(
`${document.userId}: games.total.played (${played}) does not match W/L/P total (${resolved}).`
);
}
for (
const game
of [
"slots",
"blackjack",
"roulette",
"heist"
]
) {
const value =
document.games?.[game] ?? {};
const gamePlayed =
Number(value.played ?? 0);
const gameResolved =
Number(value.won ?? 0) +
Number(value.lost ?? 0) +
Number(value.pushed ?? 0);
if (
gamePlayed !==
gameResolved
) {
issues.push(
`${document.userId}: games.${game}.played (${gamePlayed}) does not match W/L/P total (${gameResolved}).`
);
}
}
const nonNegativeFields = [
document.economy?.cookiesObtainedTotal,
document.economy?.cookiesLostTotal,
document.economy?.cookiesGivenTotal,
document.economy?.stolenTotal,
document.economy?.stolenFromMeTotal,
document.economy?.vaultStolenTotal
];
if (
nonNegativeFields.some(
value =>
Number(value ?? 0) < 0
)
) {
issues.push(
`${document.userId}: one or more economy counters are negative.`
);
}
}
const statUsers =
new Set(
statDocuments.map(
document =>
document.userId
)
);
const orphanAchievementDocs =
achievementDocuments.filter(
document =>
!statUsers.has(
document.userId
)
).length;
const claimedBadges =
badgeDocuments.filter(
badge =>
Boolean(badge.ownerId)
).length;
return {
statDocuments:
statDocuments.length,
achievementDocuments:
achievementDocuments.length,
activeAchievements,
badgeDocuments:
badgeDocuments.length,
claimedBadges,
orphanAchievementDocs,
issueCount:
issues.length,
issues
};
}
module.exports = {
reconcileUserAchievements,
reconcileAllAchievements,
auditProfileData
};
+225
View File
@@ -0,0 +1,225 @@
const {
EmbedBuilder
} = require("discord.js");
const {
getProfileCollections
} = require("./profileSystem");
const {
getCollections
} = require("./cookieEconomy");
const RECORDS = {
balance: {
label:
"🍪 Current Balance",
source:
"cookies",
field:
"cookies",
format:
value =>
`${Number(value ?? 0).toLocaleString()} 🍪`
},
obtained: {
label:
"🍪 Lifetime Cookies Obtained",
field:
"economy.cookiesObtainedTotal",
format:
value =>
`${Number(value ?? 0).toLocaleString()} 🍪`
},
given: {
label:
"🎁 Cookies Given",
field:
"economy.cookiesGivenTotal",
format:
value =>
`${Number(value ?? 0).toLocaleString()} 🍪`
},
stolen: {
label:
"🥷 Cookies Stolen",
field:
"economy.stolenTotal",
format:
value =>
`${Number(value ?? 0).toLocaleString()} 🍪`
},
vault: {
label:
"🏦 Bot Vault Stolen",
field:
"economy.vaultStolenTotal",
format:
value =>
`${Number(value ?? 0).toLocaleString()} 🍪`
},
wins: {
label:
"🏆 Games Won",
field:
"games.total.won",
format:
value =>
Number(value ?? 0).toLocaleString()
},
played: {
label:
"🎮 Games Played",
field:
"games.total.played",
format:
value =>
Number(value ?? 0).toLocaleString()
},
streak: {
label:
"🔥 Best Win Streak",
field:
"streaks.bestWin",
format:
value =>
Number(value ?? 0).toLocaleString()
},
lost: {
label:
"💸 Lifetime Cookies Lost",
field:
"economy.cookiesLostTotal",
format:
value =>
`${Number(value ?? 0).toLocaleString()} 🍪`
}
};
function getNestedValue(
document,
path
) {
return path
.split(".")
.reduce(
(value, key) =>
value?.[key],
document
);
}
async function buildRecordsEmbed({
guildId,
category,
botUserId,
limit = 10
}) {
const definition =
RECORDS[category] ??
RECORDS.balance;
let rows;
if (
definition.source ===
"cookies"
) {
const {
cookies
} = getCollections();
rows =
await cookies
.find({
userId: {
$ne: botUserId
},
cookies: {
$gt: 0
}
})
.sort({
cookies: -1
})
.limit(limit)
.toArray();
}
else {
const {
stats
} = getProfileCollections();
rows =
await stats
.find({
guildId,
userId: {
$ne: botUserId
},
[definition.field]: {
$gt: 0
}
})
.sort({
[definition.field]: -1
})
.limit(limit)
.toArray();
}
const medals = [
"🥇",
"🥈",
"🥉"
];
const description =
rows.length > 0
? rows.map(
(row, index) => {
const value =
getNestedValue(
row,
definition.field
);
const rank =
medals[index] ??
`**${index + 1}.**`;
return `${rank} <@${row.userId}> — **${definition.format(value)}**`;
}
).join("\n")
: "No qualifying records yet.";
return new EmbedBuilder()
.setTitle(
`📚 Records — ${definition.label}`
)
.setDescription(
description
)
.setFooter({
text:
"Records are derived from persisted profile statistics."
})
.setTimestamp();
}
function getRecordChoices() {
return Object.entries(RECORDS)
.map(
([value, definition]) => ({
name:
definition.label,
value
})
);
}
module.exports = {
buildRecordsEmbed,
getRecordChoices
};
+2082 -755
View File
File diff suppressed because it is too large Load Diff