336 lines
13 KiB
TypeScript
336 lines
13 KiB
TypeScript
import {
|
||
ButtonInteraction,
|
||
ModalSubmitInteraction,
|
||
StringSelectMenuInteraction,
|
||
type Interaction,
|
||
EmbedBuilder,
|
||
ActionRowBuilder,
|
||
ButtonBuilder,
|
||
ButtonStyle,
|
||
StringSelectMenuBuilder,
|
||
ModalBuilder,
|
||
TextInputBuilder,
|
||
TextInputStyle,
|
||
ThreadChannel,
|
||
TextChannel,
|
||
} from "discord.js";
|
||
import { TradeService } from "./trade.service";
|
||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
|
||
|
||
const EMBED_COLOR = 0xFFD700; // Gold
|
||
|
||
export async function handleTradeInteraction(interaction: Interaction) {
|
||
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return;
|
||
|
||
const { customId } = interaction;
|
||
const threadId = interaction.channelId;
|
||
|
||
if (!threadId) return;
|
||
|
||
try {
|
||
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);
|
||
}
|
||
} catch (error: any) {
|
||
const errorEmbed = createErrorEmbed(error.message);
|
||
if (interaction.replied || interaction.deferred) {
|
||
await interaction.followUp({ embeds: [errorEmbed], ephemeral: true });
|
||
} else {
|
||
await interaction.reply({ embeds: [errorEmbed], ephemeral: true });
|
||
}
|
||
}
|
||
}
|
||
|
||
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 = 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);
|
||
|
||
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 Error("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 => ({
|
||
label: `${entry.item.name} (${entry.quantity})`,
|
||
value: entry.item.id.toString(),
|
||
description: `Rarity: ${entry.item.rarity}`
|
||
}));
|
||
|
||
const select = new StringSelectMenuBuilder()
|
||
.setCustomId('trade_select_item')
|
||
.setPlaceholder('Select an item to add')
|
||
.addOptions(options);
|
||
|
||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
|
||
|
||
await interaction.reply({ content: "Select an item to add:", components: [row], 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 Error("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 select = new StringSelectMenuBuilder()
|
||
.setCustomId('trade_remove_item_select')
|
||
.setPlaceholder('Select an item to remove')
|
||
.addOptions(options);
|
||
|
||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
|
||
|
||
await interaction.reply({ content: "Select an item to remove:", components: [row], 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 = new EmbedBuilder()
|
||
.setTitle("✅ Trade Completed")
|
||
.setColor("Green")
|
||
.addFields(
|
||
{ name: session.userA.username, value: formatOffer(session.userA), inline: true },
|
||
{ name: session.userB.username, value: formatOffer(session.userB), inline: true }
|
||
)
|
||
.setTimestamp();
|
||
|
||
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 embed = new EmbedBuilder()
|
||
.setTitle("🤝 Trading Session")
|
||
.setColor(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),
|
||
);
|
||
|
||
await updateDashboardMessage(interaction, { embeds: [embed], components: [row] });
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function formatOffer(participant: any) {
|
||
let text = "";
|
||
if (participant.offer.money > 0n) {
|
||
text += `💰 ${participant.offer.money} 🪙\n`;
|
||
}
|
||
if (participant.offer.items.length > 0) {
|
||
text += participant.offer.items.map((i: any) => `- ${i.name} (x${i.quantity})`).join("\n");
|
||
}
|
||
if (text === "") text = "*Empty Offer*";
|
||
return text;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|