const { SlashCommandBuilder, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require("discord.js"); const { Card, User, Character, TradeHistory } = require("../models"); const { UserUtils, CardUtils, DbUtils, GeneralUtils } = require("../util"); const { TradeStore } = require("../stores"); const tradeExpiry = 920000; //When an active trade expires automatically const tradeTimeout = 120000; //Time until user can start a new trade const db = DbUtils.getDb(); module.exports = { data: new SlashCommandBuilder() .setName("trade") .setDescription("Start trading with a User") .addSubcommand((subcommand) => subcommand .setName("start") .setDescription("Start a trade with a User") .addUserOption((option) => option .setName("user") .setDescription("User to trade with") .setRequired(true) )) .addSubcommand((subcommand) => subcommand .setName("view") .setDescription("View active trade") ) .addSubcommand((subcommand) => subcommand .setName("add") .setDescription("Add a card to the trade") .addStringOption((option) => option .setName("card") .setDescription("Card to add") .setRequired(true) .setAutocomplete(true) ) ), permissionLevel: 0, async execute(interaction) { await interaction.deferReply(); let user1 = await UserUtils.getUserByDiscordId(interaction.member.id); let trade = await TradeStore.getTradeByUser(user1.id); switch (interaction.options.getSubcommand()) { case "start": let user2 = await UserUtils.getUserByDiscordId(interaction.options.getUser("user").id); if (!user2) { await interaction.editReply({ content: "You can't trade with an unregistered user!" }); return; } //Attach usernames for convenience user2.name = interaction.options.getUser("user").username; user1.name = interaction.member.user.username; this.startTrade(interaction, user1, user2); break; case "view": this.viewTrade(interaction, trade); break; case "add": if (!trade) { await interaction.editReply({ content: "You don't have an active trade", ephemeral: true }); return; } let card = await Card.findOne({ where: { identifier: interaction.options.getString("card") , userId: user1.id } , include: [ { model: Character }, { model: User} ]} ); this.addCardToTrade(interaction, trade, card); break; } }, async startTrade(interaction, user1, user2) { //user1 is the user who started the trade, user2 is the user who is being traded with if (!user2) { await interaction.editReply({ content: "This User is not registered yet!" }); return; } if (user2.discordId === interaction.member.id) { await interaction.editReply({ content: "You can't trade with yourself!" }); return; } let user1Trade = await TradeStore.getTradeByUser(user1.id); if (user1Trade) { if (user1Trade.state <= 1) { await interaction.editReply({ content: "You are already in a Trade!" }); return; } //If the initiating user still has a finished or cancelled trade in store, he's on a timeout if (user1Trade.state >= 2 && user1Trade.user1.id === user1.id) { await interaction.editReply({ content: `You need to wait ${tradeTimeout / 1000 / 60 } minutes before staring a new trade` }); return; } } let user2Trade = await TradeStore.getTradeByUser(user2.id); if (user2Trade) { await interaction.editReply({ content: `This User is ${(user2Trade.state <= 1 ) ? "already in a trade" : "on cooldown!" }` }); return; } let trade = new TradeStore.Trade(CardUtils.generateIdentifier(), user1, user2); await TradeStore.addTrade(trade); this.viewTrade(interaction, trade); }, async addCardToTrade(interaction, trade, card) { //find card by identifier if owned by user if (!card) { await interaction.editReply({ content: "You don't own this card", ephemeral: true }); return; } if (trade.user1.id === card.userId) { trade.user1Cards.push(card); } if (trade.user2.id === card.userId) { trade.user2Cards.push(card); } await interaction.editReply({ content: `User ${interaction.member.user.username} added ${card.identifier} to the trade` }); await this.viewTrade(interaction, trade); }, async viewTrade(interaction, trade) { if (!trade) { await interaction.editReply({ content: "No active Trade" }); return; } let user1Cards = ""; let user2Cards = ""; // for each of user1's cards in the trade for (card of trade.user1Cards) { let cardStr = CardUtils.getShortString(card); user1Cards += `\n${cardStr}`; } // for each of user2's cards in the trade for (card of trade.user2Cards) { let cardStr = CardUtils.getShortString(card); user2Cards += cardStr; } let color = 0xff000c; let tradeLocked = trade.user1Locked && trade.user2Locked; let tradeAccepted = trade.user1accepted && trade.user2accepted; if (trade.state === TradeStore.States.LOCKED) { //color orange color = 0xffa500; } if (trade.state === TradeStore.States.ACCEPTED) { //color green color = 0x00ff00; } if (trade.state == TradeStore.States.CANCELLED || trade.state == TradeStore.States.EXPIRED) { //color red color = 0xff0000; } const embed = new EmbedBuilder() .setTitle(`Trade [${trade.id}] ${trade.user1.name} with ${trade.user2.name}`) .setDescription("DUMMY DESCRIPTION") .addFields( { name: `${trade.user1.name}'s cards ${trade.user1Locked ? '🔒 Locked' : '🔓'}`, value: user1Cards || "No cards" }, { name: `${trade.user2.name}'s cards ${trade.user2Locked ? '🔒 Locked' : '🔓'}`, value: user2Cards || "No cards" } ) .setColor(color) .setFooter({ text: `TRADE`, iconURL: 'https://cdn.discordapp.com/attachments/856904078754971658/1017431187234508820/fp.png' }); let row = new ActionRowBuilder(); row = this.addComponentsToRow(row, trade); let reply; if (trade.embed) { reply = await trade.embed.edit({ embeds: [embed], components: [row] }); return; } else { reply = await interaction.editReply({ embeds: [embed], components: [row] }); await this.attachCollectors(trade, reply, interaction); } trade.embed = reply; }, addComponentsToRow(row, trade) { //Anything before State.ACCEPTED can still be cancelled if (trade.state < TradeStore.States.ACCEPTED) { row.addComponents( new ButtonBuilder() .setCustomId(`cancel-trade-${trade.id}`) .setLabel('Cancel') .setStyle(ButtonStyle.Danger) ); } //Anything before State.LOCKED can still be locked if (trade.state < TradeStore.States.LOCKED) { row.addComponents( new ButtonBuilder() .setCustomId(`lock-trade-${trade.id}`) .setLabel('Lock') .setStyle(ButtonStyle.Success) .setEmoji('🔒') ); } let acceptCount = 0; if (trade.user1accepted) { acceptCount++; } if (trade.user2accepted) { acceptCount++; } //Only trades in state locked can be accepted if (trade.state === TradeStore.States.LOCKED) { row.addComponents( new ButtonBuilder() .setCustomId(`accept-trade-${trade.id}`) .setLabel(`Accept (${acceptCount}/2)`) .setStyle(ButtonStyle.Success) .setEmoji('✅') ); } //if trade is accepted, add a button to finalize it if (trade.state === TradeStore.States.ACCEPTED) { row.addComponents( new ButtonBuilder() .setCustomId(`trade-completed-${trade.id}`) .setLabel('Completed') .setStyle(ButtonStyle.Success) .setDisabled(true) ); } //if trade is cancelled, add a button to finalize it if (trade.state === TradeStore.States.CANCELLED) { row.addComponents( new ButtonBuilder() .setCustomId(`trade-cancelled-${trade.id}`) .setLabel('Cancelled') .setStyle(ButtonStyle.Danger) .setDisabled(true) ); } //if trade is expired, add a button to finalize it if (trade.state === TradeStore.States.EXPIRED) { row.addComponents( new ButtonBuilder() .setCustomId(`trade-expired-${trade.id}`) .setLabel(`Expired after ${tradeExpiry / 1000 / 60} minutes`) .setStyle(ButtonStyle.Danger) .setDisabled(true) ); } return row; }, async attachCollectors(trade, reply, interaction) { //button collector const filter = (button) => button.user.id === trade.user1.discordId || button.user.id === trade.user2.discordId; const collector = reply.createMessageComponentCollector({ filter, time: tradeExpiry }); collector.on('collect', async (button) => { //if interaction member is neither user1 nor user2, ignore if (button.user.id !== trade.user1.discordId && button.user.id !== trade.user2.discordId) { return; } if (button.customId === `cancel-trade-${trade.id}`) { button.deferUpdate(); collector.stop("cancel"); } if (button.customId === `lock-trade-${trade.id}`) { //if interaction member is user1 if (button.user.id === trade.user1.discordId) { trade.user1Locked = true; } //if interaction member is user2 if (button.user.id === trade.user2.discordId) { trade.user2Locked = true; } if (trade.user1Locked && trade.user2Locked) { trade.state = TradeStore.States.LOCKED; } await button.deferUpdate(); await this.viewTrade(interaction, trade); } if (button.customId === `accept-trade-${trade.id}`) { //if interaction member is user1 if (button.user.id === trade.user1.discordId) { trade.user1accepted = true; } //if interaction member is user2 if (button.user.id === trade.user2.discordId) { trade.user2accepted = true; } if (trade.user1accepted && trade.user2accepted) { collector.stop("accepted"); trade.state = TradeStore.States.ACCEPTED; await this.transactCards(trade, interaction); } await button.deferUpdate(); await this.viewTrade(interaction, trade); } }); collector.on('end', async (collected, reason) => { console.log(`Collected ${collected.size} items, reason: ${reason}`); if (reason === "time") { trade.state = TradeStore.States.EXPIRED; } if (reason === "cancel") { trade.state = TradeStore.States.CANCELLED; } if (reason === "accepted") { trade.state = TradeStore.States.ACCEPTED; } await this.viewTrade(interaction, trade); await this.endTrade(trade); }); }, async transactCards(trade, interaction) { try { const result = await db.sequelize.transaction(async (trx) => { let historyData = { userAId: trade.user1.id, userBId: trade.user2.id, userATraded: [], userBTraded: [] } //check and update user1 cards for (let i = 0; i < trade.user1Cards.length; i++) { const card = trade.user1Cards[i]; let result = await Card.update( { userId: trade.user2.id }, { where: { id: card.id, userId: trade.user1.id } }, { transaction: trx } ) if (result[0] == 0) { await interaction.channel.send(`Card ${card.id} is not owned by ${trade.user1.discordId}! That's not supposed to happen, transaction rolled back!`); trx.rollback(); throw new Error(`Card ${card.id} is not owned by ${trade.user1.discordId}!`); } historyData.userATraded.push(card.id); } //check and update user1 cards for (let i = 0; i < trade.user2Cards.length; i++) { const card = trade.user2Cards[i]; let result = await Card.update( { userId: trade.user1.id }, { where: { id: card.id, userId: trade.user2.id } }, { transaction: trx } ) if (!result) { await interaction.channel.send(`Card ${card.id} is not owned by ${trade.user2.discordId}! That's not supposed to happen, transaction rolled back!`); trx.rollback(); throw new Error(`Card ${card.id} is not owned by ${trade.user2.discordId}!`); } historyData.userBTraded.push(card.id); } let history = await TradeHistory.create(historyData); }); } catch (error) { let logID = await GeneralUtils.generateLogID(); console.log(`[${logID}] ${error} : ${error.stack}`); await interaction.channel.send(`Transaction failed! Please contact an admin with log ID ${logID}!`); } }, async endTrade(trade) { setTimeout(async () => { await TradeStore.removeTrade(trade); }, tradeTimeout); } }