forked from syntaxbullet/AuroraBot-discord
refactor: initial moves
This commit is contained in:
260
bot/modules/admin/item_wizard.test.ts
Normal file
260
bot/modules/admin/item_wizard.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { describe, test, expect, spyOn, beforeEach, mock } from "bun:test";
|
||||
import { handleItemWizardInteraction, renderWizard } from "./item_wizard";
|
||||
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
|
||||
|
||||
// Mock Setup
|
||||
const valuesMock = mock((_args: any) => Promise.resolve());
|
||||
const insertMock = mock(() => ({ values: valuesMock }));
|
||||
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: {
|
||||
insert: insertMock
|
||||
}
|
||||
}));
|
||||
|
||||
mock.module("@db/schema", () => ({
|
||||
items: "items_schema"
|
||||
}));
|
||||
|
||||
describe("ItemWizard", () => {
|
||||
const userId = "test-user-123";
|
||||
|
||||
beforeEach(() => {
|
||||
valuesMock.mockClear();
|
||||
insertMock.mockClear();
|
||||
// Since draftSession is internal, we can't easily clear it.
|
||||
// We will use unique user IDs or rely on overwrite behavior.
|
||||
});
|
||||
|
||||
// Helper to create base interaction
|
||||
const createBaseInteraction = (id: string, customId: string) => ({
|
||||
user: { id },
|
||||
customId,
|
||||
deferUpdate: mock(() => Promise.resolve()),
|
||||
editReply: mock(() => Promise.resolve()),
|
||||
update: mock(() => Promise.resolve()),
|
||||
showModal: mock(() => Promise.resolve()),
|
||||
followUp: mock(() => Promise.resolve()),
|
||||
reply: mock(() => Promise.resolve()),
|
||||
});
|
||||
|
||||
test("renderWizard should return initial state for new user", () => {
|
||||
const result = renderWizard(`new-${Date.now()}`);
|
||||
expect(result.embeds).toHaveLength(1);
|
||||
expect(result.embeds[0]?.data.title).toContain("New Item");
|
||||
expect(result.components).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should handle details modal submit", async () => {
|
||||
const uid = `user-details-${Date.now()}`;
|
||||
renderWizard(uid); // Init session
|
||||
|
||||
const interaction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_details"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => {
|
||||
if (key === "name") return "Updated Name";
|
||||
if (key === "desc") return "Updated Desc";
|
||||
if (key === "rarity") return "Legendary";
|
||||
return "";
|
||||
}
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
|
||||
await handleItemWizardInteraction(interaction);
|
||||
|
||||
expect(interaction.deferUpdate).toHaveBeenCalled();
|
||||
const result = renderWizard(uid);
|
||||
expect(result.embeds[0]?.data.title).toContain("Updated Name");
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should handle economy modal submit", async () => {
|
||||
const uid = `user-economy-${Date.now()}`;
|
||||
renderWizard(uid);
|
||||
|
||||
const interaction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_economy"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => (key === "price" ? "500" : "")
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
|
||||
await handleItemWizardInteraction(interaction);
|
||||
|
||||
const result = renderWizard(uid);
|
||||
const economyField = result.embeds[0]?.data.fields?.find(f => f.name === "Economy");
|
||||
expect(economyField?.value).toContain("500 🪙");
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should handle visuals modal submit", async () => {
|
||||
const uid = `user-visuals-${Date.now()}`;
|
||||
renderWizard(uid);
|
||||
|
||||
const interaction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_visuals"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => {
|
||||
if (key === "icon") return "http://icon.com";
|
||||
if (key === "image") return "http://image.com";
|
||||
return "";
|
||||
}
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
|
||||
await handleItemWizardInteraction(interaction);
|
||||
|
||||
const result = renderWizard(uid);
|
||||
expect(result.embeds[0]?.data.thumbnail?.url).toBe("http://icon.com");
|
||||
expect(result.embeds[0]?.data.image?.url).toBe("http://image.com");
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should flow through adding an effect", async () => {
|
||||
const uid = `user-effect-${Date.now()}`;
|
||||
renderWizard(uid);
|
||||
|
||||
// 1. Start Add Effect
|
||||
const startInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_addeffect_start"),
|
||||
isButton: () => true,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => false,
|
||||
isMessageComponent: () => true,
|
||||
} as unknown as ButtonInteraction;
|
||||
await handleItemWizardInteraction(startInteraction);
|
||||
expect(startInteraction.update).toHaveBeenCalled(); // Should show select menu
|
||||
|
||||
// 2. Select Effect Type
|
||||
const selectInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_select_effect_type"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => true,
|
||||
isModalSubmit: () => false,
|
||||
isMessageComponent: () => true,
|
||||
values: ["ADD_XP"]
|
||||
} as unknown as StringSelectMenuInteraction;
|
||||
await handleItemWizardInteraction(selectInteraction);
|
||||
expect(selectInteraction.showModal).toHaveBeenCalled(); // Should show config modal
|
||||
|
||||
// 3. Submit Effect Config Modal
|
||||
const modalInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_effect"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => (key === "amount" ? "1000" : "")
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
await handleItemWizardInteraction(modalInteraction);
|
||||
|
||||
// Verify Effect Added
|
||||
const result = renderWizard(uid);
|
||||
const effectsField = result.embeds[0]?.data.fields?.find(f => f.name === "Usage Effects");
|
||||
expect(effectsField?.value).toContain("ADD_XP");
|
||||
expect(effectsField?.value).toContain("1000");
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should save item to database", async () => {
|
||||
const uid = `user-save-${Date.now()}`;
|
||||
renderWizard(uid);
|
||||
|
||||
// Set name first so we can check it
|
||||
const nameInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_details"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => {
|
||||
if (key === "name") return "Saved Item";
|
||||
if (key === "desc") return "Desc";
|
||||
if (key === "rarity") return "Common";
|
||||
return "";
|
||||
}
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
await handleItemWizardInteraction(nameInteraction);
|
||||
|
||||
// Save
|
||||
const saveInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_save"),
|
||||
isButton: () => true,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => false,
|
||||
isMessageComponent: () => true,
|
||||
} as unknown as ButtonInteraction;
|
||||
|
||||
await handleItemWizardInteraction(saveInteraction);
|
||||
|
||||
expect(valuesMock).toHaveBeenCalled();
|
||||
const calls = valuesMock.mock.calls as any[];
|
||||
if (calls.length > 0) {
|
||||
const callArgs = calls[0][0];
|
||||
expect(callArgs).toMatchObject({
|
||||
name: "Saved Item",
|
||||
description: "Desc",
|
||||
rarity: "Common",
|
||||
// Add other fields as needed
|
||||
});
|
||||
}
|
||||
|
||||
expect(saveInteraction.editReply).toHaveBeenCalledWith(expect.objectContaining({
|
||||
content: expect.stringContaining("successfully")
|
||||
}));
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should cancel and clear session", async () => {
|
||||
const uid = `user-cancel-${Date.now()}`;
|
||||
renderWizard(uid);
|
||||
|
||||
const interaction = {
|
||||
...createBaseInteraction(uid, "createitem_cancel"),
|
||||
isButton: () => true, // Technically any component
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => false,
|
||||
isMessageComponent: () => true,
|
||||
} as unknown as ButtonInteraction;
|
||||
|
||||
await handleItemWizardInteraction(interaction);
|
||||
|
||||
expect(interaction.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
content: expect.stringContaining("cancelled")
|
||||
}));
|
||||
|
||||
// Verify session is gone by checking if renderWizard returns default New Item
|
||||
// Let's modify it first
|
||||
const modInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_details"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => (key === "name" ? "Modified" : "x")
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
await handleItemWizardInteraction(modInteraction);
|
||||
|
||||
// Now Cancel
|
||||
await handleItemWizardInteraction(interaction);
|
||||
|
||||
// New render should be "New Item" not "Modified"
|
||||
const result = renderWizard(uid);
|
||||
expect(result.embeds[0]?.data.title).toContain("New Item");
|
||||
expect(result.embeds[0]?.data.title).not.toContain("Modified");
|
||||
});
|
||||
});
|
||||
243
bot/modules/admin/item_wizard.ts
Normal file
243
bot/modules/admin/item_wizard.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { type Interaction } from "discord.js";
|
||||
import { items } from "@db/schema";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import type { ItemUsageData, ItemEffect } from "@shared/lib/types";
|
||||
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
|
||||
import type { DraftItem } from "./item_wizard.types";
|
||||
import { ItemType, EffectType } from "@shared/lib/constants";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
|
||||
// --- State ---
|
||||
const draftSession = new Map<string, DraftItem>();
|
||||
|
||||
|
||||
|
||||
// --- Render ---
|
||||
export const renderWizard = (userId: string, isDraft = true) => {
|
||||
let draft = draftSession.get(userId);
|
||||
|
||||
// Initialize if new
|
||||
if (!draft) {
|
||||
draft = {
|
||||
name: "New Item",
|
||||
description: "No description",
|
||||
rarity: "Common",
|
||||
type: ItemType.MATERIAL,
|
||||
price: null,
|
||||
iconUrl: "",
|
||||
imageUrl: "",
|
||||
usageData: { consume: true, effects: [] } // Default Consume to true for now
|
||||
};
|
||||
draftSession.set(userId, draft);
|
||||
}
|
||||
|
||||
const { embeds, components } = getItemWizardEmbed(draft);
|
||||
return { embeds, components };
|
||||
};
|
||||
|
||||
// --- Handler ---
|
||||
export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
// Only handle createitem interactions
|
||||
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return;
|
||||
if (!interaction.customId.startsWith("createitem_")) return;
|
||||
|
||||
const userId = interaction.user.id;
|
||||
let draft = draftSession.get(userId);
|
||||
|
||||
// Special case for Cancel - doesn't need draft checks usually, but we want to clear it
|
||||
if (interaction.customId === "createitem_cancel") {
|
||||
draftSession.delete(userId);
|
||||
if (interaction.isMessageComponent()) {
|
||||
await interaction.update({ content: "❌ Item creation cancelled.", embeds: [], components: [] });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize draft if missing for other actions (edge case: bot restart)
|
||||
if (!draft) {
|
||||
if (interaction.isMessageComponent()) {
|
||||
// Create one implicitly to prevent crashes, or warn user
|
||||
if (interaction.customId === "createitem_start") {
|
||||
// Allow start
|
||||
} else {
|
||||
await interaction.reply({ content: "⚠️ Session expired. Please run `/createitem` again.", ephemeral: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-get draft (guaranteed now if we handled the start/restart)
|
||||
// Actually renderWizard initializes it, so if we call that we are safe.
|
||||
// But for Modals we need it.
|
||||
|
||||
if (!draft) {
|
||||
// Just init it
|
||||
renderWizard(userId);
|
||||
draft = draftSession.get(userId)!;
|
||||
}
|
||||
|
||||
|
||||
// --- Routing ---
|
||||
|
||||
// 1. Details Modal
|
||||
if (interaction.customId === "createitem_details") {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = getDetailsModal(draft);
|
||||
await interaction.showModal(modal);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Economy Modal
|
||||
if (interaction.customId === "createitem_economy") {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = getEconomyModal(draft);
|
||||
await interaction.showModal(modal);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Visuals Modal
|
||||
if (interaction.customId === "createitem_visuals") {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = getVisualsModal(draft);
|
||||
await interaction.showModal(modal);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Type Toggle (Start Select Menu)
|
||||
if (interaction.customId === "createitem_type_toggle") {
|
||||
if (!interaction.isButton()) return;
|
||||
const { components } = getItemTypeSelection();
|
||||
await interaction.update({ components }); // Temporary view
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === "createitem_select_type") {
|
||||
if (!interaction.isStringSelectMenu()) return;
|
||||
const selected = interaction.values[0];
|
||||
if (selected) {
|
||||
draft.type = selected;
|
||||
}
|
||||
// Re-render
|
||||
const payload = renderWizard(userId);
|
||||
await interaction.update(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Add Effect Flow
|
||||
if (interaction.customId === "createitem_addeffect_start") {
|
||||
if (!interaction.isButton()) return;
|
||||
const { components } = getEffectTypeSelection();
|
||||
await interaction.update({ components });
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === "createitem_select_effect_type") {
|
||||
if (!interaction.isStringSelectMenu()) return;
|
||||
const effectType = interaction.values[0];
|
||||
if (!effectType) return;
|
||||
draft.pendingEffectType = effectType;
|
||||
|
||||
// Immediately show modal for data collection
|
||||
// Note: You can't showModal from an update? You CAN showModal from a component interaction (SelectMenu).
|
||||
// But we shouldn't update the message AND show modal. We must pick one.
|
||||
// We will show modal. The message remains in "Select Effect" state until modal submit re-renders it.
|
||||
|
||||
const modal = getEffectConfigModal(effectType);
|
||||
await interaction.showModal(modal);
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle Consume
|
||||
if (interaction.customId === "createitem_toggle_consume") {
|
||||
if (!interaction.isButton()) return;
|
||||
draft.usageData.consume = !draft.usageData.consume;
|
||||
const payload = renderWizard(userId);
|
||||
await interaction.update(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. Handle Modal Submits
|
||||
if (interaction.isModalSubmit()) {
|
||||
if (interaction.customId === "createitem_modal_details") {
|
||||
draft.name = interaction.fields.getTextInputValue("name");
|
||||
draft.description = interaction.fields.getTextInputValue("desc");
|
||||
draft.rarity = interaction.fields.getTextInputValue("rarity");
|
||||
}
|
||||
else if (interaction.customId === "createitem_modal_economy") {
|
||||
const price = parseInt(interaction.fields.getTextInputValue("price"));
|
||||
draft.price = isNaN(price) || price === 0 ? null : price;
|
||||
}
|
||||
else if (interaction.customId === "createitem_modal_visuals") {
|
||||
draft.iconUrl = interaction.fields.getTextInputValue("icon");
|
||||
draft.imageUrl = interaction.fields.getTextInputValue("image");
|
||||
}
|
||||
else if (interaction.customId === "createitem_modal_effect") {
|
||||
const type = draft.pendingEffectType;
|
||||
if (type) {
|
||||
let effect: ItemEffect | null = null;
|
||||
|
||||
if (type === EffectType.ADD_XP || type === EffectType.ADD_BALANCE) {
|
||||
const amount = parseInt(interaction.fields.getTextInputValue("amount"));
|
||||
if (!isNaN(amount)) effect = { type: type as any, amount };
|
||||
}
|
||||
else if (type === EffectType.REPLY_MESSAGE) {
|
||||
effect = { type: EffectType.REPLY_MESSAGE, message: interaction.fields.getTextInputValue("message") };
|
||||
}
|
||||
else if (type === EffectType.XP_BOOST) {
|
||||
const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier"));
|
||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
||||
if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: EffectType.XP_BOOST, multiplier, durationSeconds: duration };
|
||||
}
|
||||
else if (type === EffectType.TEMP_ROLE) {
|
||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
||||
if (roleId && !isNaN(duration)) effect = { type: EffectType.TEMP_ROLE, roleId: roleId, durationSeconds: duration };
|
||||
}
|
||||
else if (type === EffectType.COLOR_ROLE) {
|
||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
||||
if (roleId) effect = { type: EffectType.COLOR_ROLE, roleId: roleId };
|
||||
}
|
||||
|
||||
if (effect) {
|
||||
draft.usageData.effects.push(effect);
|
||||
}
|
||||
draft.pendingEffectType = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-render
|
||||
const payload = renderWizard(userId);
|
||||
await interaction.deferUpdate();
|
||||
await interaction.editReply(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
// 7. Save
|
||||
if (interaction.customId === "createitem_save") {
|
||||
if (!interaction.isButton()) return;
|
||||
|
||||
await interaction.deferUpdate(); // Prepare to save
|
||||
|
||||
try {
|
||||
await DrizzleClient.insert(items).values({
|
||||
name: draft.name,
|
||||
description: draft.description,
|
||||
type: draft.type,
|
||||
rarity: draft.rarity,
|
||||
price: draft.price ? BigInt(draft.price) : null,
|
||||
iconUrl: draft.iconUrl,
|
||||
imageUrl: draft.imageUrl,
|
||||
usageData: draft.usageData
|
||||
});
|
||||
|
||||
draftSession.delete(userId);
|
||||
await interaction.editReply({ content: `✅ **${draft.name}** has been created successfully!`, embeds: [], components: [] });
|
||||
} catch (error: any) {
|
||||
console.error("Failed to create item:", error);
|
||||
// Restore state
|
||||
await interaction.followUp({ content: `❌ Failed to save item: ${error.message}`, ephemeral: true });
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
14
bot/modules/admin/item_wizard.types.ts
Normal file
14
bot/modules/admin/item_wizard.types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ItemUsageData } from "@shared/lib/types";
|
||||
|
||||
export interface DraftItem {
|
||||
name: string;
|
||||
description: string;
|
||||
rarity: string;
|
||||
type: string;
|
||||
price: number | null;
|
||||
iconUrl: string;
|
||||
imageUrl: string;
|
||||
usageData: ItemUsageData;
|
||||
// Temporary state for effect adding flow
|
||||
pendingEffectType?: string;
|
||||
}
|
||||
135
bot/modules/admin/item_wizard.view.ts
Normal file
135
bot/modules/admin/item_wizard.view.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
ModalBuilder,
|
||||
StringSelectMenuBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle,
|
||||
type MessageActionRowComponentBuilder
|
||||
} from "discord.js";
|
||||
import { createBaseEmbed } from "@lib/embeds";
|
||||
import type { DraftItem } from "./item_wizard.types";
|
||||
import { ItemType } from "@shared/lib/constants";
|
||||
|
||||
const getItemTypeOptions = () => [
|
||||
{ label: "Material", value: ItemType.MATERIAL, description: "Used for crafting or trading" },
|
||||
{ label: "Consumable", value: ItemType.CONSUMABLE, description: "Can be used to gain effects" },
|
||||
{ label: "Equipment", value: ItemType.EQUIPMENT, description: "Can be equipped (Not yet implemented)" },
|
||||
{ label: "Quest Item", value: ItemType.QUEST, description: "Required for quests" },
|
||||
];
|
||||
|
||||
const getEffectTypeOptions = () => [
|
||||
{ label: "Add XP", value: "ADD_XP", description: "Gives XP to the user" },
|
||||
{ label: "Add Balance", value: "ADD_BALANCE", description: "Gives currency to the user" },
|
||||
{ label: "Reply Message", value: "REPLY_MESSAGE", description: "Bot replies with a message" },
|
||||
{ label: "XP Boost", value: "XP_BOOST", description: "Temporarily boosts XP gain" },
|
||||
{ label: "Temp Role", value: "TEMP_ROLE", description: "Gives a temporary role" },
|
||||
{ label: "Color Role", value: "COLOR_ROLE", description: "Equips a permanent color role (swaps)" },
|
||||
];
|
||||
|
||||
export const getItemWizardEmbed = (draft: DraftItem) => {
|
||||
const embed = createBaseEmbed(`🛠️ Item Creator: ${draft.name}`, undefined, "Blue")
|
||||
.addFields(
|
||||
{ name: "General", value: `**Type:** ${draft.type}\n**Rarity:** ${draft.rarity}\n**Desc:** ${draft.description}`, inline: true },
|
||||
{ name: "Economy", value: `**Price:** ${draft.price ? `${draft.price} 🪙` : "Not for sale"}`, inline: true },
|
||||
{ name: "Visuals", value: `**Icon:** ${draft.iconUrl ? "✅ Set" : "❌"}\n**Image:** ${draft.imageUrl ? "✅ Set" : "❌"}`, inline: true },
|
||||
{ name: "Usage", value: `**Consume:** ${draft.usageData.consume ? "✅ Yes" : "❌ No"}`, inline: true },
|
||||
);
|
||||
|
||||
// Effects Display
|
||||
if (draft.usageData.effects.length > 0) {
|
||||
const effecto = draft.usageData.effects.map((e, i) => `${i + 1}. **${e.type}**: ${JSON.stringify(e)}`).join("\n");
|
||||
embed.addFields({ name: "Usage Effects", value: effecto.substring(0, 1024) });
|
||||
} else {
|
||||
embed.addFields({ name: "Usage Effects", value: "None" });
|
||||
}
|
||||
|
||||
if (draft.imageUrl) embed.setImage(draft.imageUrl);
|
||||
if (draft.iconUrl) embed.setThumbnail(draft.iconUrl);
|
||||
|
||||
// Components
|
||||
const row1 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder().setCustomId("createitem_details").setLabel("Edit Details").setStyle(ButtonStyle.Secondary).setEmoji("📝"),
|
||||
new ButtonBuilder().setCustomId("createitem_economy").setLabel("Edit Economy").setStyle(ButtonStyle.Secondary).setEmoji("💰"),
|
||||
new ButtonBuilder().setCustomId("createitem_visuals").setLabel("Edit Visuals").setStyle(ButtonStyle.Secondary).setEmoji("🖼️"),
|
||||
new ButtonBuilder().setCustomId("createitem_type_toggle").setLabel("Change Type").setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
||||
);
|
||||
|
||||
const row2 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder().setCustomId("createitem_addeffect_start").setLabel("Add Effect").setStyle(ButtonStyle.Primary).setEmoji("✨"),
|
||||
new ButtonBuilder().setCustomId("createitem_toggle_consume").setLabel(`Consume: ${draft.usageData.consume ? "ON" : "OFF"}`).setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
||||
new ButtonBuilder().setCustomId("createitem_save").setLabel("Save Item").setStyle(ButtonStyle.Success).setEmoji("💾"),
|
||||
new ButtonBuilder().setCustomId("createitem_cancel").setLabel("Cancel").setStyle(ButtonStyle.Danger).setEmoji("✖️")
|
||||
);
|
||||
|
||||
return { embeds: [embed], components: [row1, row2] };
|
||||
};
|
||||
|
||||
export const getItemTypeSelection = () => {
|
||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||
new StringSelectMenuBuilder().setCustomId("createitem_select_type").setPlaceholder("Select Item Type").addOptions(getItemTypeOptions())
|
||||
);
|
||||
return { components: [row] };
|
||||
};
|
||||
|
||||
export const getEffectTypeSelection = () => {
|
||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||
new StringSelectMenuBuilder().setCustomId("createitem_select_effect_type").setPlaceholder("Select Effect Type").addOptions(getEffectTypeOptions())
|
||||
);
|
||||
return { components: [row] };
|
||||
};
|
||||
|
||||
export const getDetailsModal = (current: DraftItem) => {
|
||||
const modal = new ModalBuilder().setCustomId("createitem_modal_details").setTitle("Edit Details");
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("name").setLabel("Name").setValue(current.name).setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("desc").setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("Common, Rare, Legendary...").setRequired(true))
|
||||
);
|
||||
return modal;
|
||||
};
|
||||
|
||||
export const getEconomyModal = (current: DraftItem) => {
|
||||
const modal = new ModalBuilder().setCustomId("createitem_modal_economy").setTitle("Edit Economy");
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("price").setLabel("Price (0 for not for sale)").setValue(current.price?.toString() || "0").setStyle(TextInputStyle.Short).setRequired(true))
|
||||
);
|
||||
return modal;
|
||||
};
|
||||
|
||||
export const getVisualsModal = (current: DraftItem) => {
|
||||
const modal = new ModalBuilder().setCustomId("createitem_modal_visuals").setTitle("Edit Visuals");
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("icon").setLabel("Icon URL (Emoji or Link)").setValue(current.iconUrl).setStyle(TextInputStyle.Short).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("image").setLabel("Image URL").setValue(current.imageUrl).setStyle(TextInputStyle.Short).setRequired(false))
|
||||
);
|
||||
return modal;
|
||||
};
|
||||
|
||||
export const getEffectConfigModal = (effectType: string) => {
|
||||
let modal = new ModalBuilder().setCustomId("createitem_modal_effect").setTitle(`Config ${effectType}`);
|
||||
|
||||
if (effectType === "ADD_XP" || effectType === "ADD_BALANCE") {
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("amount").setLabel("Amount").setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder("100")));
|
||||
} else if (effectType === "REPLY_MESSAGE") {
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("message").setLabel("Message").setStyle(TextInputStyle.Paragraph).setRequired(true)));
|
||||
} else if (effectType === "XP_BOOST") {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("multiplier").setLabel("Multiplier (e.g. 1.5)").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||
);
|
||||
} else if (effectType === "TEMP_ROLE") {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||
);
|
||||
} else if (effectType === "COLOR_ROLE") {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true))
|
||||
);
|
||||
}
|
||||
return modal;
|
||||
};
|
||||
33
bot/modules/admin/update.types.ts
Normal file
33
bot/modules/admin/update.types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
|
||||
export interface RestartContext {
|
||||
channelId: string;
|
||||
userId: string;
|
||||
timestamp: number;
|
||||
runMigrations: boolean;
|
||||
installDependencies: boolean;
|
||||
previousCommit: string;
|
||||
newCommit: string;
|
||||
}
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
needsRootInstall: boolean;
|
||||
needsWebInstall: boolean;
|
||||
needsMigrations: boolean;
|
||||
changedFiles: string[];
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
hasUpdates: boolean;
|
||||
branch: string;
|
||||
currentCommit: string;
|
||||
latestCommit: string;
|
||||
commitCount: number;
|
||||
commits: CommitInfo[];
|
||||
}
|
||||
|
||||
export interface CommitInfo {
|
||||
hash: string;
|
||||
message: string;
|
||||
author: string;
|
||||
}
|
||||
274
bot/modules/admin/update.view.ts
Normal file
274
bot/modules/admin/update.view.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js";
|
||||
import { createInfoEmbed, createSuccessEmbed, createWarningEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
import type { UpdateInfo, UpdateCheckResult } from "./update.types";
|
||||
|
||||
// Constants for UI
|
||||
const LOG_TRUNCATE_LENGTH = 800;
|
||||
const OUTPUT_TRUNCATE_LENGTH = 400;
|
||||
|
||||
function truncate(text: string, maxLength: number): string {
|
||||
if (!text) return "";
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
|
||||
}
|
||||
|
||||
// ============ Pre-Update Embeds ============
|
||||
|
||||
export function getCheckingEmbed() {
|
||||
return createInfoEmbed("🔍 Fetching latest changes from remote...", "Checking for Updates");
|
||||
}
|
||||
|
||||
export function getNoUpdatesEmbed(currentCommit: string) {
|
||||
return createSuccessEmbed(
|
||||
`You're running the latest version.\n\n**Current:** \`${currentCommit}\``,
|
||||
"✅ Already Up to Date"
|
||||
);
|
||||
}
|
||||
|
||||
export function getUpdatesAvailableMessage(
|
||||
updateInfo: UpdateInfo,
|
||||
requirements: UpdateCheckResult,
|
||||
changeCategories: Record<string, number>,
|
||||
force: boolean
|
||||
) {
|
||||
const { branch, currentCommit, latestCommit, commitCount, commits } = updateInfo;
|
||||
const { needsRootInstall, needsWebInstall, needsMigrations } = requirements;
|
||||
|
||||
// Build commit list (max 5)
|
||||
const commitList = commits
|
||||
.slice(0, 5)
|
||||
.map(c => `\`${c.hash}\` ${truncate(c.message, 50)}`)
|
||||
.join("\n");
|
||||
|
||||
const moreCommits = commitCount > 5 ? `\n*...and ${commitCount - 5} more*` : "";
|
||||
|
||||
// Build change categories
|
||||
const categoryList = Object.entries(changeCategories)
|
||||
.map(([cat, count]) => `• ${cat}: ${count} file${count > 1 ? "s" : ""}`)
|
||||
.join("\n");
|
||||
|
||||
// Build requirements list
|
||||
const reqs: string[] = [];
|
||||
if (needsRootInstall) reqs.push("📦 Install root dependencies");
|
||||
if (needsWebInstall) reqs.push("🌐 Install web dependencies");
|
||||
if (needsMigrations) reqs.push("🗃️ Run database migrations");
|
||||
if (reqs.length === 0) reqs.push("⚡ Quick update (no extra steps)");
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("📥 Updates Available")
|
||||
.setColor(force ? 0xFF6B6B : 0x5865F2)
|
||||
.addFields(
|
||||
{
|
||||
name: "Version",
|
||||
value: `\`${currentCommit}\` → \`${latestCommit}\``,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: "Branch",
|
||||
value: `\`${branch}\``,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: "Commits",
|
||||
value: `${commitCount} new commit${commitCount > 1 ? "s" : ""}`,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: "Recent Changes",
|
||||
value: commitList + moreCommits || "No commits",
|
||||
inline: false
|
||||
},
|
||||
{
|
||||
name: "Files Changed",
|
||||
value: categoryList || "Unknown",
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: "Update Actions",
|
||||
value: reqs.join("\n"),
|
||||
inline: true
|
||||
}
|
||||
)
|
||||
.setFooter({ text: force ? "⚠️ Force mode enabled" : "This will restart the bot" })
|
||||
.setTimestamp();
|
||||
|
||||
const confirmButton = new ButtonBuilder()
|
||||
.setCustomId("confirm_update")
|
||||
.setLabel(force ? "Force Update" : "Update Now")
|
||||
.setEmoji(force ? "⚠️" : "🚀")
|
||||
.setStyle(force ? ButtonStyle.Danger : ButtonStyle.Success);
|
||||
|
||||
const cancelButton = new ButtonBuilder()
|
||||
.setCustomId("cancel_update")
|
||||
.setLabel("Cancel")
|
||||
.setStyle(ButtonStyle.Secondary);
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(confirmButton, cancelButton);
|
||||
|
||||
return { embeds: [embed], components: [row] };
|
||||
}
|
||||
|
||||
// ============ Update Progress Embeds ============
|
||||
|
||||
export function getPreparingEmbed() {
|
||||
return createInfoEmbed(
|
||||
"🔒 Saving rollback point...\n📥 Preparing to download updates...",
|
||||
"⏳ Preparing Update"
|
||||
);
|
||||
}
|
||||
|
||||
export function getUpdatingEmbed(requirements: UpdateCheckResult) {
|
||||
const steps: string[] = ["✅ Rollback point saved"];
|
||||
|
||||
steps.push("📥 Downloading updates...");
|
||||
|
||||
if (requirements.needsRootInstall || requirements.needsWebInstall) {
|
||||
steps.push("📦 Dependencies will be installed after restart");
|
||||
}
|
||||
if (requirements.needsMigrations) {
|
||||
steps.push("🗃️ Migrations will run after restart");
|
||||
}
|
||||
|
||||
steps.push("\n🔄 **Restarting now...**");
|
||||
|
||||
return createWarningEmbed(steps.join("\n"), "🚀 Updating");
|
||||
}
|
||||
|
||||
export function getCancelledEmbed() {
|
||||
return createInfoEmbed("Update cancelled. No changes were made.", "❌ Cancelled");
|
||||
}
|
||||
|
||||
export function getTimeoutEmbed() {
|
||||
return createWarningEmbed(
|
||||
"No response received within 30 seconds.\nRun `/update` again when ready.",
|
||||
"⏰ Timed Out"
|
||||
);
|
||||
}
|
||||
|
||||
export function getErrorEmbed(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return createErrorEmbed(
|
||||
`The update could not be completed:\n\`\`\`\n${truncate(message, 500)}\n\`\`\``,
|
||||
"❌ Update Failed"
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Post-Restart Embeds ============
|
||||
|
||||
export interface PostRestartResult {
|
||||
installSuccess: boolean;
|
||||
installOutput: string;
|
||||
migrationSuccess: boolean;
|
||||
migrationOutput: string;
|
||||
ranInstall: boolean;
|
||||
ranMigrations: boolean;
|
||||
previousCommit?: string;
|
||||
newCommit?: string;
|
||||
}
|
||||
|
||||
export function getPostRestartEmbed(result: PostRestartResult, hasRollback: boolean) {
|
||||
const isSuccess = result.installSuccess && result.migrationSuccess;
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(isSuccess ? "✅ Update Complete" : "⚠️ Update Completed with Issues")
|
||||
.setColor(isSuccess ? 0x57F287 : 0xFEE75C)
|
||||
.setTimestamp();
|
||||
|
||||
// Version info
|
||||
if (result.previousCommit && result.newCommit) {
|
||||
embed.addFields({
|
||||
name: "Version",
|
||||
value: `\`${result.previousCommit}\` → \`${result.newCommit}\``,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Results summary
|
||||
const results: string[] = [];
|
||||
|
||||
if (result.ranInstall) {
|
||||
results.push(result.installSuccess
|
||||
? "✅ Dependencies installed"
|
||||
: "❌ Dependency installation failed"
|
||||
);
|
||||
}
|
||||
|
||||
if (result.ranMigrations) {
|
||||
results.push(result.migrationSuccess
|
||||
? "✅ Migrations applied"
|
||||
: "❌ Migration failed"
|
||||
);
|
||||
}
|
||||
|
||||
if (results.length > 0) {
|
||||
embed.addFields({
|
||||
name: "Actions Performed",
|
||||
value: results.join("\n"),
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Output details (collapsed if too long)
|
||||
if (result.installOutput && !result.installSuccess) {
|
||||
embed.addFields({
|
||||
name: "Install Output",
|
||||
value: `\`\`\`\n${truncate(result.installOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
if (result.migrationOutput && !result.migrationSuccess) {
|
||||
embed.addFields({
|
||||
name: "Migration Output",
|
||||
value: `\`\`\`\n${truncate(result.migrationOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Footer with rollback hint
|
||||
if (!isSuccess && hasRollback) {
|
||||
embed.setFooter({ text: "💡 Use /update rollback to revert if needed" });
|
||||
}
|
||||
|
||||
// Build components
|
||||
const components: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||
|
||||
if (!isSuccess && hasRollback) {
|
||||
const rollbackButton = new ButtonBuilder()
|
||||
.setCustomId("rollback_update")
|
||||
.setLabel("Rollback")
|
||||
.setEmoji("↩️")
|
||||
.setStyle(ButtonStyle.Danger);
|
||||
|
||||
components.push(new ActionRowBuilder<ButtonBuilder>().addComponents(rollbackButton));
|
||||
}
|
||||
|
||||
return { embeds: [embed], components };
|
||||
}
|
||||
|
||||
export function getInstallingDependenciesEmbed() {
|
||||
return createInfoEmbed(
|
||||
"📦 Installing dependencies for root and web projects...\nThis may take a moment.",
|
||||
"⏳ Installing Dependencies"
|
||||
);
|
||||
}
|
||||
|
||||
export function getRunningMigrationsEmbed() {
|
||||
return createInfoEmbed(
|
||||
"🗃️ Applying database migrations...",
|
||||
"⏳ Running Migrations"
|
||||
);
|
||||
}
|
||||
|
||||
export function getRollbackSuccessEmbed(commit: string) {
|
||||
return createSuccessEmbed(
|
||||
`Successfully rolled back to commit \`${commit}\`.\nThe bot will restart now.`,
|
||||
"↩️ Rollback Complete"
|
||||
);
|
||||
}
|
||||
|
||||
export function getRollbackFailedEmbed(error: string) {
|
||||
return createErrorEmbed(
|
||||
`Could not rollback:\n\`\`\`\n${error}\n\`\`\``,
|
||||
"❌ Rollback Failed"
|
||||
);
|
||||
}
|
||||
35
bot/modules/economy/lootdrop.interaction.ts
Normal file
35
bot/modules/economy/lootdrop.interaction.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ButtonInteraction } from "discord.js";
|
||||
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { getLootdropClaimedMessage } from "./lootdrop.view";
|
||||
|
||||
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
|
||||
if (interaction.customId === "lootdrop_claim") {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
const result = await lootdropService.tryClaim(interaction.message.id, interaction.user.id, interaction.user.username);
|
||||
|
||||
if (!result.success) {
|
||||
throw new UserError(result.error || "Failed to claim.");
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
content: `🎉 You successfully claimed **${result.amount} ${result.currency}**!`
|
||||
});
|
||||
|
||||
const { content, files, components } = await getLootdropClaimedMessage(
|
||||
interaction.user.id,
|
||||
interaction.user.username,
|
||||
interaction.user.displayAvatarURL({ extension: "png" }),
|
||||
result.amount || 0,
|
||||
result.currency || "Coins"
|
||||
);
|
||||
|
||||
await interaction.message.edit({
|
||||
content,
|
||||
embeds: [],
|
||||
files,
|
||||
components
|
||||
});
|
||||
}
|
||||
}
|
||||
43
bot/modules/economy/lootdrop.view.ts
Normal file
43
bot/modules/economy/lootdrop.view.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
||||
import { generateLootdropCard, generateClaimedLootdropCard } from "@/graphics/lootdrop";
|
||||
|
||||
export async function getLootdropMessage(reward: number, currency: string) {
|
||||
const cardBuffer = await generateLootdropCard(reward, currency);
|
||||
const attachment = new AttachmentBuilder(cardBuffer, { name: "lootdrop.png" });
|
||||
|
||||
const claimButton = new ButtonBuilder()
|
||||
.setCustomId("lootdrop_claim")
|
||||
.setLabel("CLAIM REWARD")
|
||||
.setStyle(ButtonStyle.Secondary) // Changed to Secondary to fit the darker theme better? Or keep Success? Let's try Secondary with custom emoji
|
||||
.setEmoji("🌠");
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>()
|
||||
.addComponents(claimButton);
|
||||
|
||||
return {
|
||||
content: "",
|
||||
files: [attachment],
|
||||
components: [row]
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLootdropClaimedMessage(userId: string, username: string, avatarUrl: string, amount: number, currency: string) {
|
||||
const cardBuffer = await generateClaimedLootdropCard(amount, currency, username, avatarUrl);
|
||||
const attachment = new AttachmentBuilder(cardBuffer, { name: "lootdrop_claimed.png" });
|
||||
|
||||
const newRow = new ActionRowBuilder<ButtonBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId("lootdrop_claim_disabled")
|
||||
.setLabel("CLAIMED")
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji("✅")
|
||||
.setDisabled(true)
|
||||
);
|
||||
|
||||
return {
|
||||
content: ``, // Remove content as the image says it all
|
||||
files: [attachment],
|
||||
components: [newRow]
|
||||
};
|
||||
}
|
||||
34
bot/modules/economy/shop.interaction.ts
Normal file
34
bot/modules/economy/shop.interaction.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ButtonInteraction, MessageFlags } from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { UserError } from "@/lib/errors";
|
||||
|
||||
export async function handleShopInteraction(interaction: ButtonInteraction) {
|
||||
if (!interaction.customId.startsWith("shop_buy_")) return;
|
||||
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const itemId = parseInt(interaction.customId.replace("shop_buy_", ""));
|
||||
if (isNaN(itemId)) {
|
||||
throw new UserError("Invalid Item ID.");
|
||||
}
|
||||
|
||||
const item = await inventoryService.getItem(itemId);
|
||||
if (!item || !item.price) {
|
||||
throw new UserError("Item not found or not for sale.");
|
||||
}
|
||||
|
||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
if (!user) {
|
||||
throw new UserError("User profiles could not be loaded. Please try again later.");
|
||||
}
|
||||
|
||||
// Double check balance here too, although service handles it, we want a nice message
|
||||
if ((user.balance ?? 0n) < item.price) {
|
||||
throw new UserError(`You need ${item.price} 🪙 to buy this item. You have ${user.balance?.toString() ?? "0"} 🪙.`);
|
||||
}
|
||||
|
||||
await inventoryService.buyItem(user.id.toString(), item.id, 1n);
|
||||
|
||||
await interaction.editReply({ content: `✅ **Success!** You bought **${item.name}** for ${item.price} 🪙.` });
|
||||
}
|
||||
20
bot/modules/economy/shop.view.ts
Normal file
20
bot/modules/economy/shop.view.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
||||
import { createBaseEmbed } from "@/lib/embeds";
|
||||
|
||||
export function getShopListingMessage(item: { id: number; name: string; description: string | null; formattedPrice: string; iconUrl: string | null; imageUrl: string | null; price: number | bigint }) {
|
||||
const embed = createBaseEmbed(`Shop: ${item.name}`, item.description || "No description available.", "Green")
|
||||
.addFields({ name: "Price", value: item.formattedPrice, inline: true })
|
||||
.setThumbnail(item.iconUrl || null)
|
||||
.setImage(item.imageUrl || null)
|
||||
.setFooter({ text: "Click the button below to purchase instantly." });
|
||||
|
||||
const buyButton = new ButtonBuilder()
|
||||
.setCustomId(`shop_buy_${item.id}`)
|
||||
.setLabel(`Buy for ${item.price} 🪙`)
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setEmoji("🛒");
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton);
|
||||
|
||||
return { embeds: [embed], components: [row] };
|
||||
}
|
||||
79
bot/modules/feedback/feedback.interaction.ts
Normal file
79
bot/modules/feedback/feedback.interaction.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { Interaction } from "discord.js";
|
||||
import { TextChannel, MessageFlags } from "discord.js";
|
||||
import { config } from "@/lib/config";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
|
||||
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
|
||||
import { UserError } from "@/lib/errors";
|
||||
|
||||
export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
||||
// Handle select menu for choosing feedback type
|
||||
if (interaction.isStringSelectMenu() && interaction.customId === "feedback_select_type") {
|
||||
const feedbackType = interaction.values[0] as FeedbackType;
|
||||
|
||||
if (!feedbackType) {
|
||||
throw new UserError("Invalid feedback type selected.");
|
||||
}
|
||||
|
||||
const modal = getFeedbackModal(feedbackType);
|
||||
await interaction.showModal(modal);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle modal submission
|
||||
if (interaction.isModalSubmit() && interaction.customId.startsWith(FEEDBACK_CUSTOM_IDS.MODAL)) {
|
||||
// Extract feedback type from customId (format: feedback_modal_FEATURE_REQUEST)
|
||||
const parts = interaction.customId.split("_");
|
||||
const feedbackType = parts.slice(2).join("_") as FeedbackType;
|
||||
|
||||
console.log(`Processing feedback modal. CustomId: ${interaction.customId}, Extracted type: ${feedbackType}`);
|
||||
|
||||
if (!feedbackType || !["FEATURE_REQUEST", "BUG_REPORT", "GENERAL"].includes(feedbackType)) {
|
||||
console.error(`Invalid feedback type extracted: ${feedbackType} from customId: ${interaction.customId}`);
|
||||
throw new UserError("An error occurred processing your feedback. Please try again.");
|
||||
}
|
||||
|
||||
if (!config.feedbackChannelId) {
|
||||
throw new UserError("Feedback channel is not configured. Please contact an administrator.");
|
||||
}
|
||||
|
||||
// Parse modal inputs
|
||||
const title = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.TITLE_FIELD);
|
||||
const description = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.DESCRIPTION_FIELD);
|
||||
|
||||
// Build feedback data
|
||||
const feedbackData: FeedbackData = {
|
||||
type: feedbackType,
|
||||
title,
|
||||
description,
|
||||
userId: interaction.user.id,
|
||||
username: interaction.user.username,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
// Get feedback channel
|
||||
const channel = await AuroraClient.channels.fetch(config.feedbackChannelId).catch(() => null) as TextChannel | null;
|
||||
|
||||
if (!channel) {
|
||||
throw new UserError("Feedback channel not found. Please contact an administrator.");
|
||||
}
|
||||
|
||||
// Build and send beautiful message
|
||||
const containers = buildFeedbackMessage(feedbackData);
|
||||
|
||||
const feedbackMessage = await channel.send({
|
||||
components: containers as any,
|
||||
flags: MessageFlags.IsComponentsV2
|
||||
});
|
||||
|
||||
// Add reaction votes
|
||||
await feedbackMessage.react("👍");
|
||||
await feedbackMessage.react("👎");
|
||||
|
||||
// Confirm to user
|
||||
await interaction.reply({
|
||||
content: "✨ **Feedback Submitted**\nYour feedback has been submitted successfully! Thank you for helping improve Aurora.",
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
};
|
||||
23
bot/modules/feedback/feedback.types.ts
Normal file
23
bot/modules/feedback/feedback.types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export type FeedbackType = "FEATURE_REQUEST" | "BUG_REPORT" | "GENERAL";
|
||||
|
||||
export interface FeedbackData {
|
||||
type: FeedbackType;
|
||||
title: string;
|
||||
description: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export const FEEDBACK_TYPE_LABELS: Record<FeedbackType, string> = {
|
||||
FEATURE_REQUEST: "💡 Feature Request",
|
||||
BUG_REPORT: "🐛 Bug Report",
|
||||
GENERAL: "💬 General Feedback"
|
||||
};
|
||||
|
||||
export const FEEDBACK_CUSTOM_IDS = {
|
||||
MODAL: "feedback_modal",
|
||||
TYPE_FIELD: "feedback_type",
|
||||
TITLE_FIELD: "feedback_title",
|
||||
DESCRIPTION_FIELD: "feedback_description"
|
||||
} as const;
|
||||
123
bot/modules/feedback/feedback.view.ts
Normal file
123
bot/modules/feedback/feedback.view.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
ModalBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle,
|
||||
ActionRowBuilder,
|
||||
StringSelectMenuBuilder,
|
||||
ActionRowBuilder as MessageActionRowBuilder,
|
||||
ContainerBuilder,
|
||||
TextDisplayBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle
|
||||
} from "discord.js";
|
||||
import { FEEDBACK_TYPE_LABELS, FEEDBACK_CUSTOM_IDS, type FeedbackData, type FeedbackType } from "./feedback.types";
|
||||
|
||||
export function getFeedbackTypeMenu() {
|
||||
const select = new StringSelectMenuBuilder()
|
||||
.setCustomId("feedback_select_type")
|
||||
.setPlaceholder("Choose feedback type")
|
||||
.addOptions([
|
||||
{
|
||||
label: "💡 Feature Request",
|
||||
description: "Suggest a new feature or improvement",
|
||||
value: "FEATURE_REQUEST"
|
||||
},
|
||||
{
|
||||
label: "🐛 Bug Report",
|
||||
description: "Report a bug or issue",
|
||||
value: "BUG_REPORT"
|
||||
},
|
||||
{
|
||||
label: "💬 General Feedback",
|
||||
description: "Share your thoughts or suggestions",
|
||||
value: "GENERAL"
|
||||
}
|
||||
]);
|
||||
|
||||
const row = new MessageActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
|
||||
return { components: [row] };
|
||||
}
|
||||
|
||||
export function getFeedbackModal(feedbackType: FeedbackType) {
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId(`${FEEDBACK_CUSTOM_IDS.MODAL}_${feedbackType}`)
|
||||
.setTitle(FEEDBACK_TYPE_LABELS[feedbackType]);
|
||||
|
||||
// Title Input
|
||||
const titleInput = new TextInputBuilder()
|
||||
.setCustomId(FEEDBACK_CUSTOM_IDS.TITLE_FIELD)
|
||||
.setLabel("Title")
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setPlaceholder("Brief summary of your feedback")
|
||||
.setRequired(true)
|
||||
.setMaxLength(100);
|
||||
|
||||
const titleRow = new ActionRowBuilder<TextInputBuilder>().addComponents(titleInput);
|
||||
|
||||
// Description Input
|
||||
const descriptionInput = new TextInputBuilder()
|
||||
.setCustomId(FEEDBACK_CUSTOM_IDS.DESCRIPTION_FIELD)
|
||||
.setLabel("Description")
|
||||
.setStyle(TextInputStyle.Paragraph)
|
||||
.setPlaceholder("Provide detailed information about your feedback")
|
||||
.setRequired(true)
|
||||
.setMaxLength(1000);
|
||||
|
||||
const descriptionRow = new ActionRowBuilder<TextInputBuilder>().addComponents(descriptionInput);
|
||||
|
||||
modal.addComponents(titleRow, descriptionRow);
|
||||
|
||||
return modal;
|
||||
}
|
||||
|
||||
export function buildFeedbackMessage(feedback: FeedbackData) {
|
||||
// Define colors/themes for each feedback type
|
||||
const themes = {
|
||||
FEATURE_REQUEST: {
|
||||
icon: "💡",
|
||||
color: "Blue",
|
||||
title: "FEATURE REQUEST",
|
||||
description: "A new starlight suggestion has been received"
|
||||
},
|
||||
BUG_REPORT: {
|
||||
icon: "🐛",
|
||||
color: "Red",
|
||||
title: "BUG REPORT",
|
||||
description: "A cosmic anomaly has been detected"
|
||||
},
|
||||
GENERAL: {
|
||||
icon: "💬",
|
||||
color: "Gray",
|
||||
title: "GENERAL FEEDBACK",
|
||||
description: "A message from the cosmos"
|
||||
}
|
||||
};
|
||||
|
||||
const theme = themes[feedback.type];
|
||||
|
||||
if (!theme) {
|
||||
console.error(`Unknown feedback type: ${feedback.type}`);
|
||||
throw new Error(`Invalid feedback type: ${feedback.type}`);
|
||||
}
|
||||
|
||||
const timestamp = Math.floor(feedback.timestamp.getTime() / 1000);
|
||||
|
||||
// Header Container
|
||||
const headerContainer = new ContainerBuilder()
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`# ${theme.icon} ${theme.title}`),
|
||||
new TextDisplayBuilder().setContent(`*${theme.description}*`)
|
||||
);
|
||||
|
||||
// Content Container
|
||||
const contentContainer = new ContainerBuilder()
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`## ${feedback.title}`),
|
||||
new TextDisplayBuilder().setContent(`> ${feedback.description.split('\n').join('\n> ')}`),
|
||||
new TextDisplayBuilder().setContent(
|
||||
`**Submitted by:** <@${feedback.userId}>\n**Time:** <t:${timestamp}:F> (<t:${timestamp}:R>)`
|
||||
)
|
||||
);
|
||||
|
||||
return [headerContainer, contentContainer];
|
||||
}
|
||||
137
bot/modules/inventory/effects/handlers.ts
Normal file
137
bot/modules/inventory/effects/handlers.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { userTimers } from "@db/schema";
|
||||
import type { EffectHandler } from "./types";
|
||||
import type { LootTableItem } from "@shared/lib/types";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { inventory, items } from "@db/schema";
|
||||
import { TimerType, TransactionType, LootType } from "@shared/lib/constants";
|
||||
|
||||
|
||||
// Helper to extract duration in seconds
|
||||
const getDuration = (effect: any): number => {
|
||||
if (effect.durationHours) return effect.durationHours * 3600;
|
||||
if (effect.durationMinutes) return effect.durationMinutes * 60;
|
||||
return effect.durationSeconds || 60; // Default to 60s if nothing provided
|
||||
};
|
||||
|
||||
export const handleAddXp: EffectHandler = async (userId, effect, txFn) => {
|
||||
await levelingService.addXp(userId, BigInt(effect.amount), txFn);
|
||||
return `Gained ${effect.amount} XP`;
|
||||
};
|
||||
|
||||
export const handleAddBalance: EffectHandler = async (userId, effect, txFn) => {
|
||||
await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn);
|
||||
return `Gained ${effect.amount} 🪙`;
|
||||
};
|
||||
|
||||
export const handleReplyMessage: EffectHandler = async (_userId, effect, _txFn) => {
|
||||
return effect.message;
|
||||
};
|
||||
|
||||
export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
|
||||
const boostDuration = getDuration(effect);
|
||||
const expiresAt = new Date(Date.now() + boostDuration * 1000);
|
||||
await txFn.insert(userTimers).values({
|
||||
userId: BigInt(userId),
|
||||
type: TimerType.EFFECT,
|
||||
key: 'xp_boost',
|
||||
expiresAt: expiresAt,
|
||||
metadata: { multiplier: effect.multiplier }
|
||||
}).onConflictDoUpdate({
|
||||
target: [userTimers.userId, userTimers.type, userTimers.key],
|
||||
set: { expiresAt: expiresAt, metadata: { multiplier: effect.multiplier } }
|
||||
});
|
||||
return `XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`;
|
||||
};
|
||||
|
||||
export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
|
||||
const roleDuration = getDuration(effect);
|
||||
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
|
||||
await txFn.insert(userTimers).values({
|
||||
userId: BigInt(userId),
|
||||
type: TimerType.ACCESS,
|
||||
key: `role_${effect.roleId}`,
|
||||
expiresAt: roleExpiresAt,
|
||||
metadata: { roleId: effect.roleId }
|
||||
}).onConflictDoUpdate({
|
||||
target: [userTimers.userId, userTimers.type, userTimers.key],
|
||||
set: { expiresAt: roleExpiresAt }
|
||||
});
|
||||
// Actual role assignment happens in the Command layer
|
||||
return `Temporary Role granted for ${Math.floor(roleDuration / 60)}m`;
|
||||
};
|
||||
|
||||
export const handleColorRole: EffectHandler = async (_userId, _effect, _txFn) => {
|
||||
return "Color Role Equipped";
|
||||
};
|
||||
|
||||
export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
||||
const pool = effect.pool as LootTableItem[];
|
||||
if (!pool || pool.length === 0) return "The box is empty...";
|
||||
|
||||
const totalWeight = pool.reduce((sum, item) => sum + item.weight, 0);
|
||||
let random = Math.random() * totalWeight;
|
||||
|
||||
let winner: LootTableItem | null = null;
|
||||
for (const item of pool) {
|
||||
if (random < item.weight) {
|
||||
winner = item;
|
||||
break;
|
||||
}
|
||||
random -= item.weight;
|
||||
}
|
||||
|
||||
if (!winner) return "The box is empty..."; // Should not happen
|
||||
|
||||
// Process Winner
|
||||
if (winner.type === LootType.NOTHING) {
|
||||
return winner.message || "You found nothing inside.";
|
||||
}
|
||||
|
||||
if (winner.type === LootType.CURRENCY) {
|
||||
let amount = winner.amount || 0;
|
||||
if (winner.minAmount && winner.maxAmount) {
|
||||
amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount;
|
||||
}
|
||||
if (amount > 0) {
|
||||
await economyService.modifyUserBalance(userId, BigInt(amount), TransactionType.LOOTBOX, 'Lootbox Reward', null, txFn);
|
||||
return winner.message || `You found ${amount} 🪙!`;
|
||||
}
|
||||
}
|
||||
|
||||
if (winner.type === LootType.XP) {
|
||||
let amount = winner.amount || 0;
|
||||
if (winner.minAmount && winner.maxAmount) {
|
||||
amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount;
|
||||
}
|
||||
if (amount > 0) {
|
||||
await levelingService.addXp(userId, BigInt(amount), txFn);
|
||||
return winner.message || `You gained ${amount} XP!`;
|
||||
}
|
||||
}
|
||||
|
||||
if (winner.type === LootType.ITEM) {
|
||||
if (winner.itemId) {
|
||||
const quantity = BigInt(winner.amount || 1);
|
||||
|
||||
await inventoryService.addItem(userId, winner.itemId, quantity, txFn);
|
||||
|
||||
// Try to fetch item name for the message
|
||||
try {
|
||||
const item = await txFn.query.items.findFirst({
|
||||
where: (items: any, { eq }: any) => eq(items.id, winner.itemId!)
|
||||
});
|
||||
if (item) {
|
||||
return winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch item name for lootbox message", e);
|
||||
}
|
||||
|
||||
return winner.message || `You found an item! (ID: ${winner.itemId})`;
|
||||
}
|
||||
}
|
||||
|
||||
return "You found nothing suitable inside.";
|
||||
};
|
||||
20
bot/modules/inventory/effects/registry.ts
Normal file
20
bot/modules/inventory/effects/registry.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
handleAddXp,
|
||||
handleAddBalance,
|
||||
handleReplyMessage,
|
||||
handleXpBoost,
|
||||
handleTempRole,
|
||||
handleColorRole,
|
||||
handleLootbox
|
||||
} from "./handlers";
|
||||
import type { EffectHandler } from "./types";
|
||||
|
||||
export const effectHandlers: Record<string, EffectHandler> = {
|
||||
'ADD_XP': handleAddXp,
|
||||
'ADD_BALANCE': handleAddBalance,
|
||||
'REPLY_MESSAGE': handleReplyMessage,
|
||||
'XP_BOOST': handleXpBoost,
|
||||
'TEMP_ROLE': handleTempRole,
|
||||
'COLOR_ROLE': handleColorRole,
|
||||
'LOOTBOX': handleLootbox
|
||||
};
|
||||
4
bot/modules/inventory/effects/types.ts
Normal file
4
bot/modules/inventory/effects/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
|
||||
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<string>;
|
||||
54
bot/modules/inventory/inventory.view.ts
Normal file
54
bot/modules/inventory/inventory.view.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { EmbedBuilder } from "discord.js";
|
||||
import type { ItemUsageData } from "@shared/lib/types";
|
||||
import { EffectType } from "@shared/lib/constants";
|
||||
|
||||
/**
|
||||
* Inventory entry with item details
|
||||
*/
|
||||
interface InventoryEntry {
|
||||
quantity: bigint | null;
|
||||
item: {
|
||||
id: number;
|
||||
name: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an embed displaying a user's inventory
|
||||
*/
|
||||
export function getInventoryEmbed(items: InventoryEntry[], username: string): EmbedBuilder {
|
||||
const description = items.map(entry => {
|
||||
return `**${entry.item.name}** x${entry.quantity}`;
|
||||
}).join("\n");
|
||||
|
||||
return new EmbedBuilder()
|
||||
.setTitle(`📦 ${username}'s Inventory`)
|
||||
.setDescription(description)
|
||||
.setColor(0x3498db); // Blue
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an embed showing the results of using an item
|
||||
*/
|
||||
export function getItemUseResultEmbed(results: string[], item?: { name: string, iconUrl: string | null, usageData: any }): EmbedBuilder {
|
||||
const description = results.map(r => `• ${r}`).join("\n");
|
||||
|
||||
// Check if it was a lootbox
|
||||
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX);
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setDescription(description)
|
||||
.setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise
|
||||
|
||||
if (isLootbox && item) {
|
||||
embed.setTitle(`🎁 ${item.name} Opened!`);
|
||||
if (item.iconUrl) {
|
||||
embed.setThumbnail(item.iconUrl);
|
||||
}
|
||||
} else {
|
||||
embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!");
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
71
bot/modules/leveling/leveling.view.ts
Normal file
71
bot/modules/leveling/leveling.view.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { EmbedBuilder } from "discord.js";
|
||||
|
||||
/**
|
||||
* User data for leaderboard display
|
||||
*/
|
||||
interface LeaderboardUser {
|
||||
username: string;
|
||||
level: number | null;
|
||||
xp: bigint | null;
|
||||
balance: bigint | null;
|
||||
netWorth?: bigint | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate medal emoji for a ranking position
|
||||
*/
|
||||
function getMedalEmoji(index: number): string {
|
||||
if (index === 0) return "🥇";
|
||||
if (index === 1) return "🥈";
|
||||
if (index === 2) return "🥉";
|
||||
return `${index + 1}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a single leaderboard entry based on type
|
||||
*/
|
||||
function formatLeaderEntry(user: LeaderboardUser, index: number, type: 'xp' | 'balance' | 'networth'): string {
|
||||
const medal = getMedalEmoji(index);
|
||||
let value = '';
|
||||
|
||||
switch (type) {
|
||||
case 'xp':
|
||||
value = `Lvl ${user.level ?? 1} (${user.xp ?? 0n} XP)`;
|
||||
break;
|
||||
case 'balance':
|
||||
value = `${user.balance ?? 0n} 🪙`;
|
||||
break;
|
||||
case 'networth':
|
||||
value = `${user.netWorth ?? 0n} 🪙 (Net Worth)`;
|
||||
break;
|
||||
}
|
||||
|
||||
return `${medal} **${user.username}** — ${value}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a leaderboard embed for either XP, Balance or Net Worth rankings
|
||||
*/
|
||||
export function getLeaderboardEmbed(leaders: LeaderboardUser[], type: 'xp' | 'balance' | 'networth'): EmbedBuilder {
|
||||
const description = leaders.map((user, index) =>
|
||||
formatLeaderEntry(user, index, type)
|
||||
).join("\n");
|
||||
|
||||
let title = '';
|
||||
switch (type) {
|
||||
case 'xp':
|
||||
title = "🏆 XP Leaderboard";
|
||||
break;
|
||||
case 'balance':
|
||||
title = "💰 Richest Players";
|
||||
break;
|
||||
case 'networth':
|
||||
title = "💎 Net Worth Leaderboard";
|
||||
break;
|
||||
}
|
||||
|
||||
return new EmbedBuilder()
|
||||
.setTitle(title)
|
||||
.setDescription(description)
|
||||
.setColor(0xFFD700); // Gold
|
||||
}
|
||||
46
bot/modules/moderation/moderation.types.ts
Normal file
46
bot/modules/moderation/moderation.types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { CaseType } from "@shared/lib/constants";
|
||||
|
||||
export { CaseType };
|
||||
|
||||
export interface CreateCaseOptions {
|
||||
type: CaseType;
|
||||
userId: string;
|
||||
username: string;
|
||||
moderatorId: string;
|
||||
moderatorName: string;
|
||||
reason: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ClearCaseOptions {
|
||||
caseId: string;
|
||||
clearedBy: string;
|
||||
clearedByName: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ModerationCase {
|
||||
id: bigint;
|
||||
caseId: string;
|
||||
type: string;
|
||||
userId: bigint;
|
||||
username: string;
|
||||
moderatorId: bigint;
|
||||
moderatorName: string;
|
||||
reason: string;
|
||||
metadata: unknown;
|
||||
active: boolean;
|
||||
createdAt: Date;
|
||||
resolvedAt: Date | null;
|
||||
resolvedBy: bigint | null;
|
||||
resolvedReason: string | null;
|
||||
}
|
||||
|
||||
export interface SearchCasesFilter {
|
||||
userId?: string;
|
||||
moderatorId?: string;
|
||||
type?: CaseType;
|
||||
active?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
241
bot/modules/moderation/moderation.view.ts
Normal file
241
bot/modules/moderation/moderation.view.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { EmbedBuilder, Colors, time, TimestampStyles } from "discord.js";
|
||||
import type { ModerationCase } from "./moderation.types";
|
||||
|
||||
/**
|
||||
* Get color based on case type
|
||||
*/
|
||||
function getCaseColor(type: string): number {
|
||||
switch (type) {
|
||||
case 'warn': return Colors.Yellow;
|
||||
case 'timeout': return Colors.Orange;
|
||||
case 'kick': return Colors.Red;
|
||||
case 'ban': return Colors.DarkRed;
|
||||
case 'note': return Colors.Blue;
|
||||
case 'prune': return Colors.Grey;
|
||||
default: return Colors.Grey;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get emoji based on case type
|
||||
*/
|
||||
function getCaseEmoji(type: string): string {
|
||||
switch (type) {
|
||||
case 'warn': return '⚠️';
|
||||
case 'timeout': return '🔇';
|
||||
case 'kick': return '👢';
|
||||
case 'ban': return '🔨';
|
||||
case 'note': return '📝';
|
||||
case 'prune': return '🧹';
|
||||
default: return '📋';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a single case
|
||||
*/
|
||||
export function getCaseEmbed(moderationCase: ModerationCase): EmbedBuilder {
|
||||
const emoji = getCaseEmoji(moderationCase.type);
|
||||
const color = getCaseColor(moderationCase.type);
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`${emoji} Case ${moderationCase.caseId}`)
|
||||
.setColor(color)
|
||||
.addFields(
|
||||
{ name: 'Type', value: moderationCase.type.toUpperCase(), inline: true },
|
||||
{ name: 'Status', value: moderationCase.active ? '🟢 Active' : '⚫ Resolved', inline: true },
|
||||
{ name: '\u200B', value: '\u200B', inline: true },
|
||||
{ name: 'User', value: `${moderationCase.username} (${moderationCase.userId})`, inline: false },
|
||||
{ name: 'Moderator', value: moderationCase.moderatorName, inline: true },
|
||||
{ name: 'Date', value: time(moderationCase.createdAt, TimestampStyles.ShortDateTime), inline: true }
|
||||
)
|
||||
.addFields({ name: 'Reason', value: moderationCase.reason })
|
||||
.setTimestamp(moderationCase.createdAt);
|
||||
|
||||
// Add resolution info if resolved
|
||||
if (!moderationCase.active && moderationCase.resolvedAt) {
|
||||
embed.addFields(
|
||||
{ name: '\u200B', value: '**Resolution**' },
|
||||
{ name: 'Resolved At', value: time(moderationCase.resolvedAt, TimestampStyles.ShortDateTime), inline: true }
|
||||
);
|
||||
|
||||
if (moderationCase.resolvedReason) {
|
||||
embed.addFields({ name: 'Resolution Reason', value: moderationCase.resolvedReason });
|
||||
}
|
||||
}
|
||||
|
||||
// Add metadata if present
|
||||
if (moderationCase.metadata && Object.keys(moderationCase.metadata).length > 0) {
|
||||
const metadataStr = JSON.stringify(moderationCase.metadata, null, 2);
|
||||
if (metadataStr.length < 1024) {
|
||||
embed.addFields({ name: 'Additional Info', value: `\`\`\`json\n${metadataStr}\n\`\`\`` });
|
||||
}
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a list of cases
|
||||
*/
|
||||
export function getCasesListEmbed(
|
||||
cases: ModerationCase[],
|
||||
title: string,
|
||||
description?: string
|
||||
): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(title)
|
||||
.setColor(Colors.Blue)
|
||||
.setTimestamp();
|
||||
|
||||
if (description) {
|
||||
embed.setDescription(description);
|
||||
}
|
||||
|
||||
if (cases.length === 0) {
|
||||
embed.setDescription('No cases found.');
|
||||
return embed;
|
||||
}
|
||||
|
||||
// Group by type for better display
|
||||
const casesByType: Record<string, ModerationCase[]> = {};
|
||||
for (const c of cases) {
|
||||
if (!casesByType[c.type]) {
|
||||
casesByType[c.type] = [];
|
||||
}
|
||||
casesByType[c.type]!.push(c);
|
||||
}
|
||||
|
||||
// Add fields for each type
|
||||
for (const [type, typeCases] of Object.entries(casesByType)) {
|
||||
const emoji = getCaseEmoji(type);
|
||||
const caseList = typeCases.slice(0, 5).map(c => {
|
||||
const status = c.active ? '🟢' : '⚫';
|
||||
const date = time(c.createdAt, TimestampStyles.ShortDate);
|
||||
return `${status} **${c.caseId}** - ${c.reason.substring(0, 50)}${c.reason.length > 50 ? '...' : ''} (${date})`;
|
||||
}).join('\n');
|
||||
|
||||
embed.addFields({
|
||||
name: `${emoji} ${type.toUpperCase()} (${typeCases.length})`,
|
||||
value: caseList || 'None',
|
||||
inline: false
|
||||
});
|
||||
|
||||
if (typeCases.length > 5) {
|
||||
embed.addFields({
|
||||
name: '\u200B',
|
||||
value: `_...and ${typeCases.length - 5} more_`,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display user's active warnings
|
||||
*/
|
||||
export function getWarningsEmbed(warnings: ModerationCase[], username: string): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`⚠️ Active Warnings for ${username}`)
|
||||
.setColor(Colors.Yellow)
|
||||
.setTimestamp();
|
||||
|
||||
if (warnings.length === 0) {
|
||||
embed.setDescription('No active warnings.');
|
||||
return embed;
|
||||
}
|
||||
|
||||
embed.setDescription(`**Total Active Warnings:** ${warnings.length}`);
|
||||
|
||||
for (const warning of warnings.slice(0, 10)) {
|
||||
const date = time(warning.createdAt, TimestampStyles.ShortDateTime);
|
||||
embed.addFields({
|
||||
name: `${warning.caseId} - ${date}`,
|
||||
value: `**Moderator:** ${warning.moderatorName}\n**Reason:** ${warning.reason}`,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
if (warnings.length > 10) {
|
||||
embed.addFields({
|
||||
name: '\u200B',
|
||||
value: `_...and ${warnings.length - 10} more warnings. Use \`/cases\` to view all._`,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Success message after warning a user
|
||||
*/
|
||||
export function getWarnSuccessEmbed(caseId: string, username: string, reason: string): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle('✅ Warning Issued')
|
||||
.setDescription(`**${username}** has been warned.`)
|
||||
.addFields(
|
||||
{ name: 'Case ID', value: caseId, inline: true },
|
||||
{ name: 'Reason', value: reason, inline: false }
|
||||
)
|
||||
.setColor(Colors.Green)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Success message after adding a note
|
||||
*/
|
||||
export function getNoteSuccessEmbed(caseId: string, username: string): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle('✅ Note Added')
|
||||
.setDescription(`Staff note added for **${username}**.`)
|
||||
.addFields({ name: 'Case ID', value: caseId, inline: true })
|
||||
.setColor(Colors.Green)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Success message after clearing a warning
|
||||
*/
|
||||
export function getClearSuccessEmbed(caseId: string): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle('✅ Warning Cleared')
|
||||
.setDescription(`Case **${caseId}** has been resolved.`)
|
||||
.setColor(Colors.Green)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Error embed for moderation operations
|
||||
*/
|
||||
export function getModerationErrorEmbed(message: string): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle('❌ Error')
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Red)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Warning embed to send to user via DM
|
||||
*/
|
||||
export function getUserWarningEmbed(
|
||||
serverName: string,
|
||||
reason: string,
|
||||
caseId: string,
|
||||
warningCount: number
|
||||
): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle('⚠️ You have received a warning')
|
||||
.setDescription(`You have been warned in **${serverName}**.`)
|
||||
.addFields(
|
||||
{ name: 'Reason', value: reason, inline: false },
|
||||
{ name: 'Case ID', value: caseId, inline: true },
|
||||
{ name: 'Total Warnings', value: warningCount.toString(), inline: true }
|
||||
)
|
||||
.setColor(Colors.Yellow)
|
||||
.setTimestamp()
|
||||
.setFooter({ text: 'Please review the server rules to avoid further action.' });
|
||||
}
|
||||
18
bot/modules/moderation/prune.types.ts
Normal file
18
bot/modules/moderation/prune.types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface PruneOptions {
|
||||
amount?: number;
|
||||
userId?: string;
|
||||
all?: boolean;
|
||||
}
|
||||
|
||||
export interface PruneResult {
|
||||
deletedCount: number;
|
||||
requestedCount: number;
|
||||
filtered: boolean;
|
||||
username?: string;
|
||||
skippedOld?: number;
|
||||
}
|
||||
|
||||
export interface PruneProgress {
|
||||
current: number;
|
||||
total: number;
|
||||
}
|
||||
115
bot/modules/moderation/prune.view.ts
Normal file
115
bot/modules/moderation/prune.view.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, Colors } from "discord.js";
|
||||
import type { PruneResult, PruneProgress } from "./prune.types";
|
||||
|
||||
/**
|
||||
* Creates a confirmation message for prune operations
|
||||
*/
|
||||
export function getConfirmationMessage(
|
||||
amount: number | 'all',
|
||||
estimatedCount?: number
|
||||
): { embeds: EmbedBuilder[], components: ActionRowBuilder<ButtonBuilder>[] } {
|
||||
const isAll = amount === 'all';
|
||||
const messageCount = isAll ? estimatedCount : amount;
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("⚠️ Confirm Deletion")
|
||||
.setDescription(
|
||||
isAll
|
||||
? `You are about to delete **ALL messages** in this channel.\n\n` +
|
||||
`Estimated messages: **~${estimatedCount || 'Unknown'}**\n` +
|
||||
`This action **cannot be undone**.`
|
||||
: `You are about to delete **${amount} messages**.\n\n` +
|
||||
`This action **cannot be undone**.`
|
||||
)
|
||||
.setColor(Colors.Orange)
|
||||
.setTimestamp();
|
||||
|
||||
const confirmButton = new ButtonBuilder()
|
||||
.setCustomId("confirm_prune")
|
||||
.setLabel("Confirm")
|
||||
.setStyle(ButtonStyle.Danger);
|
||||
|
||||
const cancelButton = new ButtonBuilder()
|
||||
.setCustomId("cancel_prune")
|
||||
.setLabel("Cancel")
|
||||
.setStyle(ButtonStyle.Secondary);
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>()
|
||||
.addComponents(confirmButton, cancelButton);
|
||||
|
||||
return { embeds: [embed], components: [row] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a progress embed for ongoing deletions
|
||||
*/
|
||||
export function getProgressEmbed(progress: PruneProgress): EmbedBuilder {
|
||||
const percentage = Math.round((progress.current / progress.total) * 100);
|
||||
|
||||
return new EmbedBuilder()
|
||||
.setTitle("🔄 Deleting Messages")
|
||||
.setDescription(
|
||||
`Progress: **${progress.current}/${progress.total}** (${percentage}%)\n\n` +
|
||||
`Please wait...`
|
||||
)
|
||||
.setColor(Colors.Blue)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a success embed after deletion
|
||||
*/
|
||||
export function getSuccessEmbed(result: PruneResult): EmbedBuilder {
|
||||
let description = `Successfully deleted **${result.deletedCount} messages**.`;
|
||||
|
||||
if (result.filtered && result.username) {
|
||||
description = `Successfully deleted **${result.deletedCount} messages** from **${result.username}**.`;
|
||||
}
|
||||
|
||||
if (result.skippedOld && result.skippedOld > 0) {
|
||||
description += `\n\n⚠️ **${result.skippedOld} messages** were older than 14 days and could not be deleted.`;
|
||||
}
|
||||
|
||||
if (result.deletedCount < result.requestedCount && !result.skippedOld) {
|
||||
description += `\n\nℹ️ Only **${result.deletedCount}** messages were available to delete.`;
|
||||
}
|
||||
|
||||
return new EmbedBuilder()
|
||||
.setTitle("✅ Messages Deleted")
|
||||
.setDescription(description)
|
||||
.setColor(Colors.Green)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an error embed
|
||||
*/
|
||||
export function getPruneErrorEmbed(message: string): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle("❌ Prune Failed")
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Red)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a warning embed
|
||||
*/
|
||||
export function getPruneWarningEmbed(message: string): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle("⚠️ Warning")
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Yellow)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a cancelled embed
|
||||
*/
|
||||
export function getCancelledEmbed(): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle("🚫 Deletion Cancelled")
|
||||
.setDescription("Message deletion has been cancelled.")
|
||||
.setColor(Colors.Grey)
|
||||
.setTimestamp();
|
||||
}
|
||||
54
bot/modules/quest/quest.view.ts
Normal file
54
bot/modules/quest/quest.view.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { EmbedBuilder } from "discord.js";
|
||||
|
||||
/**
|
||||
* Quest entry with quest details and progress
|
||||
*/
|
||||
interface QuestEntry {
|
||||
progress: number | null;
|
||||
completedAt: Date | null;
|
||||
quest: {
|
||||
name: string;
|
||||
description: string | null;
|
||||
rewards: any;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats quest rewards object into a human-readable string
|
||||
*/
|
||||
function formatQuestRewards(rewards: { xp?: number, balance?: number }): string {
|
||||
const rewardStr: string[] = [];
|
||||
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
|
||||
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
|
||||
return rewardStr.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the quest status display string
|
||||
*/
|
||||
function getQuestStatus(completedAt: Date | null): string {
|
||||
return completedAt ? "✅ Completed" : "📝 In Progress";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an embed displaying a user's quest log
|
||||
*/
|
||||
export function getQuestListEmbed(userQuests: QuestEntry[]): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("📜 Quest Log")
|
||||
.setColor(0x3498db); // Blue
|
||||
|
||||
userQuests.forEach(entry => {
|
||||
const status = getQuestStatus(entry.completedAt);
|
||||
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
||||
const rewardsText = formatQuestRewards(rewards);
|
||||
|
||||
embed.addFields({
|
||||
name: `${entry.quest.name} (${status})`,
|
||||
value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${entry.progress}%`,
|
||||
inline: false
|
||||
});
|
||||
});
|
||||
|
||||
return embed;
|
||||
}
|
||||
21
bot/modules/system/scheduler.ts
Normal file
21
bot/modules/system/scheduler.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { temporaryRoleService } from "@shared/modules/system/temp-role.service";
|
||||
|
||||
export const schedulerService = {
|
||||
start: () => {
|
||||
console.log("🕒 Scheduler started: Maintenance loops initialized.");
|
||||
|
||||
// 1. Temporary Role Revocation (every 60s)
|
||||
setInterval(() => {
|
||||
temporaryRoleService.processExpiredRoles();
|
||||
}, 60 * 1000);
|
||||
|
||||
// 2. Terminal Update Loop (every 60s)
|
||||
const { terminalService } = require("@/modules/terminal/terminal.service");
|
||||
setInterval(() => {
|
||||
terminalService.update();
|
||||
}, 60 * 1000);
|
||||
|
||||
// Run an initial check on start
|
||||
temporaryRoleService.processExpiredRoles();
|
||||
}
|
||||
};
|
||||
253
bot/modules/trade/trade.interaction.ts
Normal file
253
bot/modules/trade/trade.interaction.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
type Interaction,
|
||||
ButtonInteraction,
|
||||
ModalSubmitInteraction,
|
||||
StringSelectMenuInteraction,
|
||||
ThreadChannel,
|
||||
TextChannel,
|
||||
EmbedBuilder
|
||||
} from "discord.js";
|
||||
import { tradeService } from "@shared/modules/trade/trade.service";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@lib/errors";
|
||||
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";
|
||||
|
||||
|
||||
|
||||
export async function handleTradeInteraction(interaction: Interaction) {
|
||||
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return;
|
||||
|
||||
const { customId } = interaction;
|
||||
const threadId = interaction.channelId;
|
||||
|
||||
if (!threadId) return;
|
||||
|
||||
if (customId === 'trade_cancel') {
|
||||
await handleCancel(interaction, threadId);
|
||||
} else if (customId === 'trade_lock') {
|
||||
await handleLock(interaction, threadId);
|
||||
} else if (customId === 'trade_confirm') {
|
||||
// Confirm logic is handled implicitly by both locking or explicitly if needed.
|
||||
// For now, locking both triggers execution, so no separate confirm handler is actively used
|
||||
// unless we re-introduce a specific button. keeping basic handler stub if needed.
|
||||
} else if (customId === 'trade_add_money') {
|
||||
await handleAddMoneyClick(interaction);
|
||||
} else if (customId === 'trade_money_modal') {
|
||||
await handleMoneySubmit(interaction as ModalSubmitInteraction, threadId);
|
||||
} else if (customId === 'trade_add_item') {
|
||||
await handleAddItemClick(interaction as ButtonInteraction, threadId);
|
||||
} else if (customId === 'trade_select_item') {
|
||||
await handleItemSelect(interaction as StringSelectMenuInteraction, threadId);
|
||||
} else if (customId === 'trade_remove_item') {
|
||||
await handleRemoveItemClick(interaction as ButtonInteraction, threadId);
|
||||
} else if (customId === 'trade_remove_item_select') {
|
||||
await handleRemoveItemSelect(interaction as StringSelectMenuInteraction, threadId);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
|
||||
const session = tradeService.getSession(threadId);
|
||||
const user = interaction.user;
|
||||
|
||||
tradeService.endSession(threadId);
|
||||
|
||||
await interaction.deferUpdate();
|
||||
|
||||
if (interaction.channel?.isThread()) {
|
||||
const embed = createInfoEmbed(`Trade cancelled by ${user.username}.`, "Trade Cancelled");
|
||||
await scheduleThreadCleanup(interaction.channel, "This thread will be deleted in 5 seconds.", 5000, embed);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLock(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
|
||||
await interaction.deferUpdate();
|
||||
const isLocked = tradeService.toggleLock(threadId, interaction.user.id);
|
||||
await updateTradeDashboard(interaction, threadId);
|
||||
|
||||
// Check if trade executed (both locked)
|
||||
const session = tradeService.getSession(threadId);
|
||||
if (session && session.state === 'COMPLETED') {
|
||||
// Trade executed during updateTradeDashboard
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.followUp({ content: isLocked ? "🔒 You locked your offer." : "<22> You unlocked your offer.", ephemeral: true });
|
||||
}
|
||||
|
||||
async function handleAddMoneyClick(interaction: Interaction) {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = getTradeMoneyModal();
|
||||
await interaction.showModal(modal);
|
||||
}
|
||||
|
||||
async function handleMoneySubmit(interaction: ModalSubmitInteraction, threadId: string) {
|
||||
const amountStr = interaction.fields.getTextInputValue('amount');
|
||||
const amount = BigInt(amountStr);
|
||||
|
||||
if (amount < 0n) throw new UserError("Amount must be positive");
|
||||
|
||||
tradeService.updateMoney(threadId, interaction.user.id, amount);
|
||||
await interaction.deferUpdate(); // Acknowledge modal
|
||||
await updateTradeDashboard(interaction, threadId);
|
||||
}
|
||||
|
||||
async function handleAddItemClick(interaction: ButtonInteraction, threadId: string) {
|
||||
const inventory = await inventoryService.getInventory(interaction.user.id);
|
||||
|
||||
if (inventory.length === 0) {
|
||||
await interaction.reply({ embeds: [createWarningEmbed("Your inventory is empty.")], ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Slice top 25 for select menu
|
||||
const options = inventory.slice(0, 25).map((entry: any) => ({
|
||||
label: `${entry.item.name} (${entry.quantity})`,
|
||||
value: entry.item.id.toString(),
|
||||
description: `Rarity: ${entry.item.rarity} `
|
||||
}));
|
||||
|
||||
const { components } = getItemSelectMenu(options, 'trade_select_item', 'Select an item to add');
|
||||
await interaction.reply({ content: "Select an item to add:", components, ephemeral: true });
|
||||
}
|
||||
|
||||
async function handleItemSelect(interaction: StringSelectMenuInteraction, threadId: string) {
|
||||
const value = interaction.values[0];
|
||||
if (!value) return;
|
||||
const itemId = parseInt(value);
|
||||
|
||||
// Assuming implementation implies adding 1 item for now
|
||||
const item = await inventoryService.getItem(itemId);
|
||||
if (!item) throw new UserError("Item not found");
|
||||
|
||||
tradeService.addItem(threadId, interaction.user.id, { id: item.id, name: item.name }, 1n);
|
||||
|
||||
await interaction.update({ content: `Added ${item.name} x1`, components: [] });
|
||||
await updateTradeDashboard(interaction, threadId);
|
||||
}
|
||||
|
||||
async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: string) {
|
||||
const session = tradeService.getSession(threadId);
|
||||
if (!session) return;
|
||||
|
||||
const participant = session.userA.id === interaction.user.id ? session.userA : session.userB;
|
||||
|
||||
if (participant.offer.items.length === 0) {
|
||||
await interaction.reply({ embeds: [createWarningEmbed("No items in offer to remove.")], ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const options = participant.offer.items.slice(0, 25).map(i => ({
|
||||
label: `${i.name} (${i.quantity})`,
|
||||
value: i.id.toString(),
|
||||
}));
|
||||
|
||||
const { components } = getItemSelectMenu(options, 'trade_remove_item_select', 'Select an item to remove');
|
||||
await interaction.reply({ content: "Select an item to remove:", components, ephemeral: true });
|
||||
}
|
||||
|
||||
async function handleRemoveItemSelect(interaction: StringSelectMenuInteraction, threadId: string) {
|
||||
const value = interaction.values[0];
|
||||
if (!value) return;
|
||||
const itemId = parseInt(value);
|
||||
tradeService.removeItem(threadId, interaction.user.id, itemId);
|
||||
|
||||
await interaction.update({ content: `Removed item.`, components: [] });
|
||||
await updateTradeDashboard(interaction, threadId);
|
||||
}
|
||||
|
||||
|
||||
// --- DASHBOARD UPDATER ---
|
||||
|
||||
export async function updateTradeDashboard(interaction: Interaction, threadId: string) {
|
||||
const session = tradeService.getSession(threadId);
|
||||
if (!session) return;
|
||||
|
||||
// Check Auto-Execute (If both locked)
|
||||
if (session.userA.locked && session.userB.locked) {
|
||||
// Execute Trade
|
||||
try {
|
||||
await tradeService.executeTrade(threadId);
|
||||
const embed = getTradeCompletedEmbed(session);
|
||||
await updateDashboardMessage(interaction, { embeds: [embed], components: [] });
|
||||
|
||||
// Notify and Schedule Cleanup
|
||||
if (interaction.channel?.isThread()) {
|
||||
const successEmbed = createSuccessEmbed("Trade executed successfully. Items and funds have been transferred.", "Trade Complete");
|
||||
await scheduleThreadCleanup(
|
||||
interaction.channel,
|
||||
`🎉 Trade successful! < @${session.userA.id}> <@${session.userB.id}>\nThis thread will be deleted in 10 seconds.`,
|
||||
10000,
|
||||
successEmbed
|
||||
);
|
||||
}
|
||||
return;
|
||||
} catch (e: any) {
|
||||
console.error("Trade Execution Error:", e);
|
||||
const errorEmbed = createErrorEmbed(e.message, "Trade Failed");
|
||||
|
||||
if (interaction.channel?.isThread()) {
|
||||
await scheduleThreadCleanup(
|
||||
interaction.channel,
|
||||
"❌ Trade failed due to an error. This thread will be deleted in 10 seconds.",
|
||||
10000,
|
||||
errorEmbed
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Build Status Embed
|
||||
const { embeds, components } = getTradeDashboard(session);
|
||||
await updateDashboardMessage(interaction, { embeds, components });
|
||||
}
|
||||
|
||||
async function updateDashboardMessage(interaction: Interaction, payload: any) {
|
||||
if (interaction.isButton() && interaction.message) {
|
||||
// If interaction came from the dashboard itself, we can edit directly
|
||||
try {
|
||||
await interaction.message.edit(payload);
|
||||
} catch (e) {
|
||||
console.error("Failed to edit message directly", e);
|
||||
}
|
||||
} else {
|
||||
// Find dashboard in channel
|
||||
const channel = interaction.channel as ThreadChannel;
|
||||
if (channel && channel.isThread()) {
|
||||
try {
|
||||
const messages = await channel.messages.fetch({ limit: 10 });
|
||||
const dashboardFn = messages.find(m => m.embeds[0]?.title === "🤝 Trading Session");
|
||||
if (dashboardFn) {
|
||||
await dashboardFn.edit(payload);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch/edit dashboard", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function scheduleThreadCleanup(channel: ThreadChannel | TextChannel, message: string, delayMs: number = 10000, embed?: EmbedBuilder) {
|
||||
try {
|
||||
const payload: any = { content: message };
|
||||
if (embed) payload.embeds = [embed];
|
||||
|
||||
await channel.send(payload);
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
if (channel.isThread()) {
|
||||
console.log(`Deleting thread: ${channel.id} `);
|
||||
await channel.delete("Trade Session Ended");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to delete thread", e);
|
||||
}
|
||||
}, delayMs);
|
||||
} catch (e) {
|
||||
console.error("Failed to send cleanup notification", e);
|
||||
}
|
||||
}
|
||||
28
bot/modules/trade/trade.types.ts
Normal file
28
bot/modules/trade/trade.types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
export interface TradeItem {
|
||||
id: number;
|
||||
name: string; // Cache name for UI display
|
||||
quantity: bigint;
|
||||
}
|
||||
|
||||
export interface TradeOffer {
|
||||
money: bigint;
|
||||
items: TradeItem[]; // easier to iterate for UI than Map
|
||||
}
|
||||
|
||||
export interface TradeParticipant {
|
||||
id: string;
|
||||
username: string;
|
||||
locked: boolean;
|
||||
offer: TradeOffer;
|
||||
}
|
||||
|
||||
export type TradeState = 'NEGOTIATING' | 'COMPLETED' | 'CANCELLED';
|
||||
|
||||
export interface TradeSession {
|
||||
threadId: string;
|
||||
userA: TradeParticipant;
|
||||
userB: TradeParticipant;
|
||||
state: TradeState;
|
||||
lastInteraction: number;
|
||||
}
|
||||
85
bot/modules/trade/trade.view.ts
Normal file
85
bot/modules/trade/trade.view.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ModalBuilder, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle } from "discord.js";
|
||||
import { createBaseEmbed } from "@lib/embeds";
|
||||
import type { TradeSession, TradeParticipant } from "./trade.types";
|
||||
|
||||
const EMBED_COLOR = 0xFFD700; // Gold
|
||||
|
||||
function formatOffer(participant: TradeParticipant) {
|
||||
let text = "";
|
||||
if (participant.offer.money > 0n) {
|
||||
text += `💰 ${participant.offer.money} 🪙\n`;
|
||||
}
|
||||
if (participant.offer.items.length > 0) {
|
||||
text += participant.offer.items.map((i) => `- ${i.name} (x${i.quantity})`).join("\n");
|
||||
}
|
||||
if (text === "") text = "*Empty Offer*";
|
||||
return text;
|
||||
}
|
||||
|
||||
export function getTradeDashboard(session: TradeSession) {
|
||||
const embed = createBaseEmbed("🤝 Trading Session", undefined, EMBED_COLOR)
|
||||
.addFields(
|
||||
{
|
||||
name: `${session.userA.username} ${session.userA.locked ? '✅ (Ready)' : '✏️ (Editing)'}`,
|
||||
value: formatOffer(session.userA),
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: `${session.userB.username} ${session.userB.locked ? '✅ (Ready)' : '✏️ (Editing)'}`,
|
||||
value: formatOffer(session.userB),
|
||||
inline: true
|
||||
}
|
||||
)
|
||||
.setFooter({ text: "Both parties must click Lock to confirm trade." });
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder().setCustomId('trade_add_item').setLabel('Add Item').setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId('trade_add_money').setLabel('Add Money').setStyle(ButtonStyle.Success),
|
||||
new ButtonBuilder().setCustomId('trade_remove_item').setLabel('Remove Item').setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId('trade_lock').setLabel('Lock / Unlock').setStyle(ButtonStyle.Primary),
|
||||
new ButtonBuilder().setCustomId('trade_cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger),
|
||||
);
|
||||
|
||||
return { embeds: [embed], components: [row] };
|
||||
}
|
||||
|
||||
export function getTradeCompletedEmbed(session: TradeSession) {
|
||||
const embed = createBaseEmbed("✅ Trade Completed", undefined, "Green")
|
||||
.addFields(
|
||||
{ name: session.userA.username, value: formatOffer(session.userA), inline: true },
|
||||
{ name: session.userB.username, value: formatOffer(session.userB), inline: true }
|
||||
)
|
||||
.setTimestamp();
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
export function getTradeMoneyModal() {
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId('trade_money_modal')
|
||||
.setTitle('Add Money');
|
||||
|
||||
const input = new TextInputBuilder()
|
||||
.setCustomId('amount')
|
||||
.setLabel("Amount to trade")
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setPlaceholder("100")
|
||||
.setRequired(true);
|
||||
|
||||
const row = new ActionRowBuilder<TextInputBuilder>().addComponents(input);
|
||||
modal.addComponents(row);
|
||||
|
||||
return modal;
|
||||
}
|
||||
|
||||
export function getItemSelectMenu(items: { label: string, value: string, description?: string }[], customId: string, placeholder: string) {
|
||||
const select = new StringSelectMenuBuilder()
|
||||
.setCustomId(customId)
|
||||
.setPlaceholder(placeholder)
|
||||
.addOptions(items);
|
||||
|
||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
|
||||
|
||||
return { components: [row] };
|
||||
}
|
||||
93
bot/modules/user/enrollment.interaction.ts
Normal file
93
bot/modules/user/enrollment.interaction.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { ButtonInteraction, MessageFlags } from "discord.js";
|
||||
import { config } from "@/lib/config";
|
||||
import { getEnrollmentSuccessMessage } from "./enrollment.view";
|
||||
import { classService } from "@shared/modules/class/class.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||
|
||||
export async function handleEnrollmentInteraction(interaction: ButtonInteraction) {
|
||||
if (!interaction.inCachedGuild()) {
|
||||
throw new UserError("This action can only be performed in a server.");
|
||||
}
|
||||
|
||||
const { studentRole, visitorRole } = config;
|
||||
|
||||
if (!studentRole || !visitorRole) {
|
||||
throw new UserError("No student or visitor role configured for enrollment.");
|
||||
}
|
||||
|
||||
// 1. Ensure user exists in DB and check current enrollment status
|
||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
if (!user) {
|
||||
throw new UserError("User profiles could not be loaded. Please try again later.");
|
||||
}
|
||||
|
||||
// Check DB enrollment
|
||||
if (user.class) {
|
||||
throw new UserError("You are already enrolled in a class.");
|
||||
}
|
||||
|
||||
const member = interaction.member;
|
||||
|
||||
// Check Discord role enrollment (Double safety)
|
||||
if (member.roles.cache.has(studentRole)) {
|
||||
throw new UserError("You already have the student role.");
|
||||
}
|
||||
|
||||
// 2. Get available classes
|
||||
const allClasses = await classService.getAllClasses();
|
||||
const validClasses = allClasses.filter((c: any) => c.roleId);
|
||||
|
||||
if (validClasses.length === 0) {
|
||||
throw new UserError("No classes with specified roles found in database.");
|
||||
}
|
||||
|
||||
// 3. Pick random class
|
||||
const selectedClass = validClasses[Math.floor(Math.random() * validClasses.length)]!;
|
||||
const classRoleId = selectedClass.roleId!;
|
||||
|
||||
// Check if the role exists in the guild
|
||||
const classRole = interaction.guild.roles.cache.get(classRoleId);
|
||||
if (!classRole) {
|
||||
throw new UserError(`The configured role ID \`${classRoleId}\` for class **${selectedClass.name}** does not exist in this server.`);
|
||||
}
|
||||
|
||||
// 4. Perform Enrollment Actions
|
||||
await member.roles.remove(visitorRole);
|
||||
await member.roles.add(studentRole);
|
||||
await member.roles.add(classRole);
|
||||
|
||||
// Persist to DB
|
||||
await classService.assignClass(user.id.toString(), selectedClass.id);
|
||||
|
||||
await interaction.reply({
|
||||
...getEnrollmentSuccessMessage(classRole.name),
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
|
||||
// 5. Send Welcome Message (if configured)
|
||||
if (config.welcomeChannelId) {
|
||||
const welcomeChannel = interaction.guild.channels.cache.get(config.welcomeChannelId);
|
||||
if (welcomeChannel && welcomeChannel.isTextBased()) {
|
||||
const rawMessage = config.welcomeMessage || "Welcome to Aurora, {user}! You have been enrolled as a **{class}**.";
|
||||
|
||||
const processedMessage = rawMessage
|
||||
.replace(/{user}/g, member.toString())
|
||||
.replace(/{username}/g, member.user.username)
|
||||
.replace(/{class}/g, selectedClass.name)
|
||||
.replace(/{guild}/g, interaction.guild.name);
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(processedMessage);
|
||||
} catch {
|
||||
payload = processedMessage;
|
||||
}
|
||||
|
||||
// Fire and forget webhook
|
||||
sendWebhookMessage(welcomeChannel, payload, interaction.client.user, "New Student Enrollment")
|
||||
.catch((err: any) => console.error("Failed to send welcome message:", err));
|
||||
}
|
||||
}
|
||||
}
|
||||
12
bot/modules/user/enrollment.view.ts
Normal file
12
bot/modules/user/enrollment.view.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
|
||||
export function getEnrollmentErrorEmbed(message: string, title: string = "Enrollment Failed") {
|
||||
const embed = createErrorEmbed(message, title);
|
||||
return { embeds: [embed] };
|
||||
}
|
||||
|
||||
export function getEnrollmentSuccessMessage(roleName: string) {
|
||||
return {
|
||||
content: `🎉 You have been successfully enrolled! You received the **${roleName}** role.`
|
||||
};
|
||||
}
|
||||
97
bot/modules/user/user.timers.ts
Normal file
97
bot/modules/user/user.timers.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { userTimers } from "@db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { TimerType } from "@shared/lib/constants";
|
||||
|
||||
export { TimerType };
|
||||
|
||||
export const userTimerService = {
|
||||
/**
|
||||
* Set a timer for a user.
|
||||
* Upserts the timer (updates expiration if exists).
|
||||
*/
|
||||
setTimer: async (userId: string, type: TimerType, key: string, durationMs: number, metadata: any = {}, tx?: any) => {
|
||||
const execute = async (txFn: any) => {
|
||||
const expiresAt = new Date(Date.now() + durationMs);
|
||||
|
||||
await txFn.insert(userTimers)
|
||||
.values({
|
||||
userId: BigInt(userId),
|
||||
type,
|
||||
key,
|
||||
expiresAt,
|
||||
metadata,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [userTimers.userId, userTimers.type, userTimers.key],
|
||||
set: { expiresAt, metadata }, // Update metadata too on re-set
|
||||
});
|
||||
|
||||
return expiresAt;
|
||||
};
|
||||
|
||||
if (tx) {
|
||||
return await execute(tx);
|
||||
} else {
|
||||
return await DrizzleClient.transaction(async (t: any) => {
|
||||
return await execute(t);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a timer is active (not expired).
|
||||
* Returns true if ACTIVE.
|
||||
*/
|
||||
checkTimer: async (userId: string, type: TimerType, key: string, tx?: any): Promise<boolean> => {
|
||||
const uniqueTx = tx || DrizzleClient; // Optimization: Read-only doesn't strictly need transaction wrapper overhead if single query
|
||||
|
||||
const timer = await uniqueTx.query.userTimers.findFirst({
|
||||
where: and(
|
||||
eq(userTimers.userId, BigInt(userId)),
|
||||
eq(userTimers.type, type),
|
||||
eq(userTimers.key, key)
|
||||
),
|
||||
});
|
||||
|
||||
if (!timer) return false;
|
||||
return timer.expiresAt > new Date();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get timer details including metadata and expiry.
|
||||
*/
|
||||
getTimer: async (userId: string, type: TimerType, key: string, tx?: any) => {
|
||||
const uniqueTx = tx || DrizzleClient;
|
||||
|
||||
return await uniqueTx.query.userTimers.findFirst({
|
||||
where: and(
|
||||
eq(userTimers.userId, BigInt(userId)),
|
||||
eq(userTimers.type, type),
|
||||
eq(userTimers.key, key)
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Manually clear a timer.
|
||||
*/
|
||||
clearTimer: async (userId: string, type: TimerType, key: string, tx?: any) => {
|
||||
const execute = async (txFn: any) => {
|
||||
await txFn.delete(userTimers)
|
||||
.where(and(
|
||||
eq(userTimers.userId, BigInt(userId)),
|
||||
eq(userTimers.type, type),
|
||||
eq(userTimers.key, key)
|
||||
));
|
||||
};
|
||||
|
||||
if (tx) {
|
||||
return await execute(tx);
|
||||
} else {
|
||||
return await DrizzleClient.transaction(async (t: any) => {
|
||||
return await execute(t);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user