diff --git a/src/commandDetails/coin/transfer.ts b/src/commandDetails/coin/transfer.ts index c790ecc5..923280b0 100644 --- a/src/commandDetails/coin/transfer.ts +++ b/src/commandDetails/coin/transfer.ts @@ -6,14 +6,10 @@ import { SapphireMessageExecuteType, getUserFromMessage, SapphireMessageResponseWithMetadata, + SapphireAfterReplyType, } from '../../codeyCommand'; -import { - adjustCoinBalanceByUserId, - getCoinBalanceByUserId, - UserCoinEvent, -} from '../../components/coin'; +import { getCoinBalanceByUserId, transferTracker } from '../../components/coin'; import { getCoinEmoji } from '../../components/emojis'; -import { pluralize } from '../../utils/pluralize'; const coinTransferExecuteCommand: SapphireMessageExecuteType = async ( client, @@ -36,7 +32,7 @@ const coinTransferExecuteCommand: SapphireMessageExecuteType = async ( } // Optional argument is reason - const reason = args['reason']; + const reason = args['reason'] ?? ''; // Retrieve sending user const sendingUser = getUserFromMessage(messageFromUser); @@ -53,41 +49,30 @@ const coinTransferExecuteCommand: SapphireMessageExecuteType = async ( `You don't have enough ${getCoinEmoji()} to transfer that amount.`, {}, ); - } else if (amount <= 0) { + } else if (amount < 1) { return new SapphireMessageResponseWithMetadata(`You can't transfer less than 1 coin.`, {}); } - // Adjust the receiver balance with coins transferred - await adjustCoinBalanceByUserId( - receivingUser.id, + const transfer = await transferTracker.startTransfer( + sendingUser, + receivingUser, amount, - UserCoinEvent.AdminCoinAdjust, - (reason ?? ''), - client.user?.id, + reason, + client, + messageFromUser.channelId, ); - // Get new receiver balance - const newReceiverBalance = await getCoinBalanceByUserId(receivingUser.id); - - // Adjust the sender balance with coins transferred - await adjustCoinBalanceByUserId( - sendingUser.id, - (-1 * amount), - UserCoinEvent.AdminCoinAdjust, - (reason ?? ''), - client.user?.id, - ); - - // Get new sender balance - const newSenderBalance = await getCoinBalanceByUserId(sendingUser.id); + return new SapphireMessageResponseWithMetadata(await transfer.getTransferResponse(), { + transferId: transfer.transferId, + }); +}; - return `${receivingUser.username} now has ${newReceiverBalance} Codey ${pluralize( - 'coin', - newReceiverBalance, - )} ${getCoinEmoji()}. ${sendingUser.username} now has ${newSenderBalance} Codey ${pluralize( - 'coin', - newReceiverBalance, - )} ${getCoinEmoji()}.`; +const transferAfterMessageReply: SapphireAfterReplyType = async (result, sentMessage) => { + if (typeof result.metadata.transferId === 'undefined') return; + // Store the message which the game takes place in the game object + transferTracker.runFuncOnTransfer(result.metadata.transferId, (transfer) => { + transfer.transferMessage = sentMessage; + }); }; export const coinTransferCommandDetails: CodeyCommandDetails = { @@ -100,6 +85,7 @@ export const coinTransferCommandDetails: CodeyCommandDetails = { isCommandResponseEphemeral: false, messageWhenExecutingCommand: 'Transferring coins...', + afterMessageReply: transferAfterMessageReply, executeCommand: coinTransferExecuteCommand, options: [ { diff --git a/src/components/coin.ts b/src/components/coin.ts index 98950e35..a29bbf1e 100644 --- a/src/components/coin.ts +++ b/src/components/coin.ts @@ -1,5 +1,10 @@ -import _ from 'lodash'; +import _, { uniqueId } from 'lodash'; import { openDB } from './db'; +import { SapphireClient } from '@sapphire/framework'; +import { ColorResolvable, MessageActionRow, MessageButton, MessageEmbed, User } from 'discord.js'; +import { SapphireMessageResponse, SapphireSentMessageType } from '../codeyCommand'; +import { pluralize } from '../utils/pluralize'; +import { getCoinEmoji, getEmojiByName } from './emojis'; export enum BonusType { Daily = 0, @@ -17,6 +22,8 @@ export enum UserCoinEvent { RpsLoss, RpsDrawAgainstCodey, RpsWin, + CoinTransferReceiver, + CoinTransferSender, } export type Bonus = { @@ -269,3 +276,191 @@ export const applyBonusByUserId = async (userId: string): Promise => { } return false; }; + +export enum TransferSign { + Pending = 0, + Accept = 1, + Decline = 2, +} + +export const getEmojiFromSign = (sign: TransferSign): string => { + switch (sign) { + case TransferSign.Pending: + return '❓'; + case TransferSign.Accept: + return '✅'; + case TransferSign.Decline: + return '❌'; + } +}; + +export enum TransferResult { + Pending, + Rejected, + Confirmed, +} + +type TransferState = { + sender: User; + receiver: User; + result: TransferResult; + amount: number; + reason?: string; +}; + +class TransferTracker { + transfers: Map; // id, transfer + + constructor() { + this.transfers = new Map(); + } + getTransferFromId(id: string): Transfer | undefined { + return this.transfers.get(id); + } + + runFuncOnTransfer(transferId: string, func: (transfer: Transfer) => void): void { + func(this.getTransferFromId(transferId)!); + } + + async startTransfer( + sender: User, + receiver: User, + amount: number, + channelId: string, + client: SapphireClient, + reason?: string, + ): Promise { + const transferId = uniqueId(); + const transferState: TransferState = { + sender: sender, + receiver: receiver, + amount: amount, + reason: reason ?? '', + result: TransferResult.Pending, + }; + const transfer = new Transfer(channelId, client, transferId, transferState); + this.transfers.set(transferId, transfer); + return transfer; + } + + async endTransfer(transferId: string): Promise { + const transfer = this.transfers.get(transferId); + if (!transfer) { + throw new Error(`No transfer with transfer ID ${transferId} found`); + } + + if (transfer.state.result === TransferResult.Pending) return; + await transfer.handleTransaction(); + } +} + +export const transferTracker = new TransferTracker(); + +export class Transfer { + channelId: string; + client: SapphireClient; + state: TransferState; + transferId: string; + transferMessage!: SapphireSentMessageType; + + constructor( + channelId: string, + client: SapphireClient, + transferId: string, + transferState: TransferState, + ) { + this.channelId = channelId; + this.state = transferState; + this.client = client; + this.transferId = transferId; + } + + // called if state is (believed to be) no longer pending. Transfers coins and updates balances if transfer is confirmed + async handleTransaction(): Promise { + if (this.state.result === TransferResult.Confirmed) { + // Adjust the receiver balance with coins transferred + await adjustCoinBalanceByUserId( + this.state.receiver.id, + this.state.amount, + UserCoinEvent.CoinTransferReceiver, + (this.state.reason ?? ''), + this.client.user?.id, + ); + + // Adjust the sender balance with coins transferred + await adjustCoinBalanceByUserId( + this.state.sender.id, + (-1 * this.state.amount), + UserCoinEvent.CoinTransferSender, + (this.state.reason ?? ''), + this.client.user?.id, + ); + } + } + + public getEmbedColor(): ColorResolvable { + switch (this.state.result) { + case TransferResult.Confirmed: + return 'GREEN'; + case TransferResult.Rejected: + return 'RED'; + default: + return 'YELLOW'; + } + } + + public async getStatusAsString(): Promise { + switch (this.state.result) { + case TransferResult.Confirmed: + const newReceiverBalance = await getCoinBalanceByUserId(this.state.receiver.id); + const newSenderBalance = await getCoinBalanceByUserId(this.state.sender.id); + return `${this.state.receiver.username} accepted the transfer. ${ + this.state.receiver.username + } now has ${newReceiverBalance} Codey ${pluralize( + 'coin', + newReceiverBalance, + )} ${getCoinEmoji()}. ${ + this.state.sender.username + } now has ${newSenderBalance} Codey ${pluralize( + 'coin', + newReceiverBalance, + )} ${getCoinEmoji()}.`; + case TransferResult.Rejected: + return `This transfer was rejected by ${this.state.receiver.username}.`; + case TransferResult.Pending: + return 'Please choose whether you would like to accept this transfer.'; + default: + return `Something went wrong! ${getEmojiByName('codey_sad')}`; + } + } + + public async getTransferResponse(): Promise { + const embed = new MessageEmbed() + .setColor(this.getEmbedColor()) + .setTitle('Coin Transfer') + .setDescription( + ` +Amount: ${this.state.amount} ${getCoinEmoji()} +Sender: ${this.state.sender.username} + +${await this.getStatusAsString()} +`, + ); + // Buttons + const row = new MessageActionRow().addComponents( + new MessageButton() + .setCustomId(`transfer-check-${this.transferId}`) + .setLabel('Accept') + .setStyle('SUCCESS'), + new MessageButton() + .setCustomId(`transfer-x-${this.transferId}`) + .setLabel('Reject') + .setStyle('DANGER'), + ); + + return { + embeds: [embed], + components: this.state.result === TransferResult.Pending ? [row] : [], + }; + } +} diff --git a/src/interaction-handlers/games/rps.ts b/src/interaction-handlers/games/rps.ts index 7d86f533..34e24653 100644 --- a/src/interaction-handlers/games/rps.ts +++ b/src/interaction-handlers/games/rps.ts @@ -4,9 +4,10 @@ import { Maybe, PieceContext, } from '@sapphire/framework'; -import { ButtonInteraction, CommandInteraction, Message, MessagePayload } from 'discord.js'; +import { ButtonInteraction } from 'discord.js'; import { getEmojiByName } from '../../components/emojis'; import { getCodeyRpsSign, RpsGameSign, rpsGameTracker } from '../../components/games/rps'; +import { updateMessageEmbed } from '../../utils/embeds'; export class RpsHandler extends InteractionHandler { public constructor(ctx: PieceContext, options: InteractionHandler.Options) { @@ -64,11 +65,7 @@ export class RpsHandler extends InteractionHandler { game.state.player2Sign = getCodeyRpsSign(); game.setStatus(undefined); } - if (game.gameMessage instanceof Message) { - game.gameMessage.edit(game.getGameResponse()); - } else if (game.gameMessage instanceof CommandInteraction) { - game.gameMessage.editReply(game.getGameResponse()); - } + updateMessageEmbed(game.gameMessage, game.getGameResponse()); }); rpsGameTracker.endGame(result.gameId); } diff --git a/src/interaction-handlers/transfer.ts b/src/interaction-handlers/transfer.ts new file mode 100644 index 00000000..cf6e17cd --- /dev/null +++ b/src/interaction-handlers/transfer.ts @@ -0,0 +1,80 @@ +import { + InteractionHandler, + InteractionHandlerTypes, + Maybe, + PieceContext, +} from '@sapphire/framework'; +import { ButtonInteraction } from 'discord.js'; +import { getEmojiByName } from '../components/emojis'; +import { TransferSign, TransferResult, transferTracker } from '../components/coin'; +import { updateMessageEmbed } from '../utils/embeds'; + +export class TransferHandler extends InteractionHandler { + public constructor(ctx: PieceContext, options: InteractionHandler.Options) { + super(ctx, { + ...options, + interactionHandlerType: InteractionHandlerTypes.Button, + }); + } + // Get the game info and the interaction type + public override parse(interaction: ButtonInteraction): Maybe<{ + transferId: string; + sign: TransferSign; + }> { + // interaction.customId should be in the form "transfer-{check|x}-{transfer id} as in src/components/coin.ts" + if (!interaction.customId.startsWith('transfer')) return this.none(); + const parsedCustomId = interaction.customId.split('-'); + const sign = parsedCustomId[1]; + const transferId = parsedCustomId[2]; + + let transferSign: TransferSign; + switch (sign) { + case 'check': + transferSign = TransferSign.Accept; + break; + case 'x': + transferSign = TransferSign.Decline; + break; + default: + transferSign = TransferSign.Pending; + break; + } + return this.some({ + transferId: transferId, + sign: transferSign, + }); + } + public async run( + interaction: ButtonInteraction, + result: { transferId: string; sign: TransferSign }, + ): Promise { + const transfer = transferTracker.getTransferFromId(result.transferId); + if (!transfer) { + throw new Error('Transfer with given id does not exist'); + } + // only receiver can confirm the transfer + if (interaction.user.id !== transfer.state.receiver.id) { + return await interaction.reply({ + content: `This isn't your transfer! ${getEmojiByName('codey_angry')}`, + ephemeral: true, // other users do not see this message + }); + } + transferTracker.runFuncOnTransfer(result.transferId, async (transfer) => { + // set the result of the transfer + switch (result.sign) { + case TransferSign.Accept: + transfer.state.result = TransferResult.Confirmed; + break; + case TransferSign.Decline: + transfer.state.result = TransferResult.Rejected; + break; + default: + transfer.state.result = TransferResult.Pending; + } + // update the balances of the sender/receiver as per the transfer result + await transferTracker.endTransfer(result.transferId); + const message = await transfer.getTransferResponse(); + updateMessageEmbed(transfer.transferMessage, message); + }); + } +} diff --git a/src/utils/embeds.ts b/src/utils/embeds.ts index 05e4ddc5..09250a81 100644 --- a/src/utils/embeds.ts +++ b/src/utils/embeds.ts @@ -1,4 +1,13 @@ -import { Client, Message, MessageEmbed, TextChannel, User } from 'discord.js'; +import { + Client, + CommandInteraction, + Message, + MessageEmbed, + MessagePayload, + TextChannel, + User, +} from 'discord.js'; +import { SapphireMessageResponse } from '../codeyCommand'; import { vars } from '../config'; const NOTIF_CHANNEL_ID: string = vars.NOTIF_CHANNEL_ID; @@ -35,3 +44,17 @@ export const sendKickEmbed = async ( } await (client.channels.cache.get(NOTIF_CHANNEL_ID) as TextChannel).send({ embeds: [kickEmbed] }); }; + +/** + * Update a message embed + */ +export const updateMessageEmbed = ( + embedMessage: Message | CommandInteraction, + newMessage: SapphireMessageResponse, +): void => { + if (embedMessage instanceof Message) { + embedMessage.edit(newMessage); + } else if (embedMessage instanceof CommandInteraction) { + embedMessage.editReply(newMessage); + } +};