From 778e0637582a210ba4d9f431b5e631856eb98c7c Mon Sep 17 00:00:00 2001 From: HeadTriXz Date: Fri, 17 Nov 2023 08:24:24 +0100 Subject: [PATCH] feat: implement command to convert currencies --- .../chatinput/convert/currency/index.ts | 244 ++++++++++++++++++ .../commands/chatinput/convert/index.ts | 29 +++ .../chatinput/convert/currency/index.test.ts | 207 +++++++++++++++ 3 files changed, 480 insertions(+) create mode 100644 apps/barry/src/modules/general/commands/chatinput/convert/currency/index.ts create mode 100644 apps/barry/src/modules/general/commands/chatinput/convert/index.ts create mode 100644 apps/barry/tests/modules/general/commands/chatinput/convert/currency/index.test.ts diff --git a/apps/barry/src/modules/general/commands/chatinput/convert/currency/index.ts b/apps/barry/src/modules/general/commands/chatinput/convert/currency/index.ts new file mode 100644 index 0000000..ceefbc9 --- /dev/null +++ b/apps/barry/src/modules/general/commands/chatinput/convert/currency/index.ts @@ -0,0 +1,244 @@ +import { type APIApplicationCommandOptionChoice, MessageFlags } from "@discordjs/core"; +import { type ApplicationCommandInteraction, SlashCommand, SlashCommandOptionBuilder } from "@barry/core"; +import type GeneralModule from "../../../../index.js"; + +import { fetch } from "undici"; +import config from "../../../../../../config.js"; + +/** + * Represents the options for the "/convert currency" command. + */ +export interface CurrencyOptions { + /** + * The amount to convert. + */ + amount: number; + + /** + * The currency to convert from. + */ + from: string; + + /** + * The currency to convert to. + */ + to: string; +} + +/** + * Represents the result of the API request. + */ +export interface CurrencyResult { + /** + * The amount that was converted. + */ + amount: number; + + /** + * The currency that was converted from. + */ + base: string; + + /** + * The date the data was last updated. + */ + date: string; + + /** + * The rates for each currency. + */ + rates: Record; +} + +/** + * Represents the names of each currency. + */ +const currencyNames: Record = { + "AUD": "Australian Dollar", + "BGN": "Bulgarian Lev", + "BRL": "Brazilian Real", + "CAD": "Canadian Dollar", + "CHF": "Swiss Franc", + "CNY": "Chinese Renminbi Yuan", + "CZK": "Czech Koruna", + "DKK": "Danish Krone", + "EUR": "Euro", + "GBP": "British Pound", + "HKD": "Hong Kong Dollar", + "HUF": "Hungarian Forint", + "IDR": "Indonesian Rupiah", + "ILS": "Israeli New Sheqel", + "INR": "Indian Rupee", + "ISK": "Icelandic Króna", + "JPY": "Japanese Yen", + "KRW": "South Korean Won", + "MXN": "Mexican Peso", + "MYR": "Malaysian Ringgit", + "NOK": "Norwegian Krone", + "NZD": "New Zealand Dollar", + "PHP": "Philippine Peso", + "PLN": "Polish Złoty", + "RON": "Romanian Leu", + "SEK": "Swedish Krona", + "SGD": "Singapore Dollar", + "THB": "Thai Baht", + "TRY": "Turkish Lira", + "USD": "United States Dollar", + "ZAR": "South African Rand" +}; + +/** + * Represents the aliases of each currency. + */ +const currencyAliases: Record = { + "AUD": ["A$", "AU$", "AUS$", "AUS $"], + "BGN": ["ЛВ"], + "BRL": ["R$"], + "CAD": ["C$", "CA$", "CAN$", "CAN $"], + "CHF": ["FR", "SFR", "CHF"], + "CNY": ["¥", "CN¥", "CN ¥"], + "CZK": ["KČ"], + "DKK": ["KR"], + "EUR": ["€"], + "GBP": ["£"], + "HKD": ["HK$", "HKD"], + "HUF": ["FT"], + "IDR": ["RP"], + "ILS": ["₪"], + "INR": ["₹"], + "ISK": ["KR"], + "JPY": ["¥", "JP¥", "JP ¥", "YEN", "圓", "円"], + "KRW": ["₩"], + "MXN": ["MEX$", "MEX $"], + "MYR": ["RM"], + "NOK": ["KR"], + "NZD": ["NZ$", "NZD"], + "PHP": ["₱"], + "PLN": ["ZŁ"], + "RON": ["LEI"], + "SEK": ["KR"], + "SGD": ["S$", "SGD"], + "THB": ["฿"], + "TRY": ["₺"], + "USD": ["$", "US$", "US $"], + "ZAR": ["R"] +}; + +/** + * Represents a slash command for converting currency. + */ +export default class extends SlashCommand { + /** + * Represents a slash command for converting currency. + * + * @param module The module this command belongs to. + */ + constructor(module: GeneralModule) { + super(module, { + name: "currency", + description: "Converts currency from one to another.", + options: { + amount: SlashCommandOptionBuilder.number({ + description: "The amount to convert.", + minimum: 0, + required: true + }), + from: SlashCommandOptionBuilder.string({ + description: "The currency to convert from.", + required: true, + autocomplete: (value) => this.predictCurrency(value) + }), + to: SlashCommandOptionBuilder.string({ + description: "The currency to convert to.", + required: true, + autocomplete: (value) => this.predictCurrency(value) + }) + } + }); + } + + /** + * Sends a message with the result of the conversion. + * + * @param interaction The interaction that triggered this command. + * @param options The options for this command. + */ + async execute(interaction: ApplicationCommandInteraction, options: CurrencyOptions): Promise { + if (!this.isValidCurrency(options.from)) { + return interaction.createMessage({ + content: `${config.emotes.error} \`${options.from}\` is not a valid currency.`, + flags: MessageFlags.Ephemeral + }); + } + + if (!this.isValidCurrency(options.to)) { + return interaction.createMessage({ + content: `${config.emotes.error} \`${options.to}\` is not a valid currency.`, + flags: MessageFlags.Ephemeral + }); + } + + if (options.from === options.to) { + return interaction.createMessage({ + content: `${config.emotes.error} You can't convert from and to the same currency.`, + flags: MessageFlags.Ephemeral + }); + } + + const rate = await this.fetchRate(options.amount, options.from, options.to); + await interaction.createMessage({ + content: `${config.emotes.add} \`${options.amount} ${options.from}\` is \`${rate} ${options.to}\`.` + }); + } + + /** + * Fetches the rate for the given currency. + * + * @param amount The amount to convert. + * @param from The currency to convert from. + * @param to The currency to convert to. + * @returns The rate for the given currency. + */ + async fetchRate(amount: number, from: string, to: string): Promise { + const url = new URL("https://api.frankfurter.app/latest"); + url.searchParams.append("amount", amount.toString()); + url.searchParams.append("from", from); + url.searchParams.append("to", to); + + return fetch(url, { method: "GET" }) + .then((response) => response.json() as Promise) + .then((data) => data.rates[to]); + } + + /** + * Checks if the given currency is valid. + * + * @param currency The currency to check. + * @returns Whether the given currency is valid. + */ + isValidCurrency(currency: string): boolean { + return currency.toUpperCase() in currencyNames; + } + + /** + * Predicts the currency from the given value. + * + * @param currency The currency to predict. + * @returns The predicted currency. + */ + predictCurrency(currency: string): Array> { + const cleaned = currency.toUpperCase().replace(".", ""); + if (cleaned in currencyNames) { + return [{ name: currencyNames[cleaned], value: cleaned }]; + } + + const result = []; + for (const [key, aliases] of Object.entries(currencyAliases)) { + if (aliases.some((a) => a.includes(cleaned)) || currencyNames[key].toUpperCase().includes(cleaned)) { + result.push({ name: currencyNames[key], value: key }); + } + } + + return result.slice(0, 25); + } +} diff --git a/apps/barry/src/modules/general/commands/chatinput/convert/index.ts b/apps/barry/src/modules/general/commands/chatinput/convert/index.ts new file mode 100644 index 0000000..fdd7552 --- /dev/null +++ b/apps/barry/src/modules/general/commands/chatinput/convert/index.ts @@ -0,0 +1,29 @@ +import type GeneralModule from "../../../index.js"; + +import { SlashCommand } from "@barry/core"; +import ConvertCurrencyCommand from "./currency/index.js"; + +/** + * Represents a slash command for converting one unit to another. + */ +export default class extends SlashCommand { + /** + * Represents a slash command for converting one unit to another. + * + * @param module The module this command belongs to. + */ + constructor(module: GeneralModule) { + super(module, { + name: "convert", + description: "Converts one unit to another.", + children: [ConvertCurrencyCommand] + }); + } + + /** + * Executes the '/convert' command. Will throw an error if executed. + */ + execute(): Promise { + throw new Error("Parent commands cannot be executed."); + } +} diff --git a/apps/barry/tests/modules/general/commands/chatinput/convert/currency/index.test.ts b/apps/barry/tests/modules/general/commands/chatinput/convert/currency/index.test.ts new file mode 100644 index 0000000..df85ba4 --- /dev/null +++ b/apps/barry/tests/modules/general/commands/chatinput/convert/currency/index.test.ts @@ -0,0 +1,207 @@ +import { MockAgent, setGlobalDispatcher } from "undici"; + +import { ApplicationCommandInteraction } from "@barry/core"; +import { MessageFlags } from "@discordjs/core"; +import { createMockApplication } from "../../../../../../mocks/application.js"; +import { createMockApplicationCommandInteraction } from "@barry/testing"; + +import ConvertCurrencyCommand from "../../../../../../../src/modules/general/commands/chatinput/convert/currency/index.js"; +import GeneralModule from "../../../../../../../src/modules/general/index.js"; + +describe("/convert currency", () => { + let command: ConvertCurrencyCommand; + let interaction: ApplicationCommandInteraction; + + beforeEach(() => { + const client = createMockApplication(); + const module = new GeneralModule(client); + command = new ConvertCurrencyCommand(module); + + const data = createMockApplicationCommandInteraction(); + interaction = new ApplicationCommandInteraction(data, client, vi.fn()); + interaction.createMessage = vi.fn(); + }); + + describe("execute", () => { + it("should send the converted amount", async () => { + vi.spyOn(command, "isValidCurrency").mockReturnValue(true); + vi.spyOn(command, "fetchRate").mockResolvedValue(0.82); + + await command.execute(interaction, { + amount: 1, + from: "USD", + to: "EUR" + }); + + expect(interaction.createMessage).toHaveBeenCalledOnce(); + expect(interaction.createMessage).toHaveBeenCalledWith({ + content: expect.stringContaining("`1 USD` is `0.82 EUR`.") + }); + }); + + it("should send an error message if the 'from' currency is invalid", async () => { + vi.spyOn(command, "isValidCurrency").mockReturnValue(false); + + await command.execute(interaction, { + amount: 1, + from: "invalid", + to: "USD" + }); + + expect(interaction.createMessage).toHaveBeenCalledOnce(); + expect(interaction.createMessage).toHaveBeenCalledWith({ + content: expect.stringContaining("`invalid` is not a valid currency."), + flags: MessageFlags.Ephemeral + }); + }); + + it("should send an error message if the 'to' currency is invalid", async () => { + vi.spyOn(command, "isValidCurrency") + .mockReturnValueOnce(true) + .mockReturnValue(false); + + await command.execute(interaction, { + amount: 1, + from: "USD", + to: "invalid" + }); + + expect(interaction.createMessage).toHaveBeenCalledOnce(); + expect(interaction.createMessage).toHaveBeenCalledWith({ + content: expect.stringContaining("`invalid` is not a valid currency."), + flags: MessageFlags.Ephemeral + }); + }); + + it("should send an error message if the 'from' and 'to' currencies are the same", async () => { + vi.spyOn(command, "isValidCurrency").mockReturnValue(true); + + await command.execute(interaction, { + amount: 1, + from: "USD", + to: "USD" + }); + + expect(interaction.createMessage).toHaveBeenCalledOnce(); + expect(interaction.createMessage).toHaveBeenCalledWith({ + content: expect.stringContaining("You can't convert from and to the same currency."), + flags: MessageFlags.Ephemeral + }); + }); + }); + + describe("fetchRate", () => { + beforeEach(() => { + const agent = new MockAgent(); + agent + .get("https://api.frankfurter.app") + .intercept({ + path: "/latest", + query: { + amount: 1, + from: "USD", + to: "EUR" + } + }) + .reply(200, { + amount: 1, + base: "USD", + date: "01-01-2023", + rates: { + EUR: 0.82 + } + }); + + setGlobalDispatcher(agent); + }); + + it("should fetch the rate for the given currency", async () => { + const result = await command.fetchRate(1, "USD", "EUR"); + + expect(result).toBe(0.82); + }); + }); + + describe("isValidCurrency", () => { + it("should return true if the currency is valid", () => { + const result = command.isValidCurrency("USD"); + + expect(result).toBe(true); + }); + + it("should return false if the currency is invalid", () => { + const result = command.isValidCurrency("invalid"); + + expect(result).toBe(false); + }); + }); + + describe("predictCurrency", () => { + it("should find the matching currencies for a name", () => { + const result = command.predictCurrency("Euro"); + + expect(result).toEqual([{ + name: "Euro", + value: "EUR" + }]); + }); + + it("should find the matching currencies for a symbol", () => { + const result = command.predictCurrency("€"); + + expect(result).toEqual([{ + name: "Euro", + value: "EUR" + }]); + }); + + it("should find the matching currencies for a code", () => { + const result = command.predictCurrency("EUR"); + + expect(result).toEqual([{ + name: "Euro", + value: "EUR" + }]); + }); + + it("should find the matching currencies for a lowercase name", () => { + const result = command.predictCurrency("euro"); + + expect(result).toEqual([{ + name: "Euro", + value: "EUR" + }]); + }); + + it("should find the matching currencies for an incomplete name", () => { + const result = command.predictCurrency("dollar"); + + expect(result).toEqual([ + { + name: "Australian Dollar", + value: "AUD" + }, + { + name: "Canadian Dollar", + value: "CAD" + }, + { + name: "Hong Kong Dollar", + value: "HKD" + }, + { + name: "New Zealand Dollar", + value: "NZD" + }, + { + name: "Singapore Dollar", + value: "SGD" + }, + { + name: "United States Dollar", + value: "USD" + } + ]); + }); + }); +});