From 4f55685eff0b649821d819a7df4675a0fd647bc5 Mon Sep 17 00:00:00 2001 From: "Erik C. Thauvin" Date: Fri, 16 May 2025 01:27:00 -0700 Subject: [PATCH] Implement currency converter module using data from Frankfurter.dev --- config/detekt/baseline.xml | 10 +- properties/mobibot.properties | 5 - .../net/thauvin/erik/mobibot/Mobibot.kt | 2 +- .../net/thauvin/erik/mobibot/ReleaseInfo.kt | 4 +- ...encyConverter.kt => CurrencyConverter2.kt} | 128 ++++++++---------- ...erterTest.kt => CurrencyConverter2Test.kt} | 54 ++++---- website/index.html | 4 +- 7 files changed, 98 insertions(+), 109 deletions(-) rename src/main/kotlin/net/thauvin/erik/mobibot/modules/{CurrencyConverter.kt => CurrencyConverter2.kt} (65%) rename src/test/kotlin/net/thauvin/erik/mobibot/modules/{CurrencyConverterTest.kt => CurrencyConverter2Test.kt} (72%) diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml index 27368e9..8b0ec6b 100644 --- a/config/detekt/baseline.xml +++ b/config/detekt/baseline.xml @@ -12,9 +12,9 @@ LongParameterList:EntryLink.kt$EntryLink$( // Link's comments val comments: MutableList<EntryComment> = mutableListOf(), // Tags/categories val tags: MutableList<SyndCategory> = mutableListOf(), // Channel var channel: String, // Creation date var date: Date = Calendar.getInstance().time, // Link's URL var link: String, // Author's login var login: String = "", // Author's nickname var nick: String, // Link's title var title: String ) MagicNumber:Comment.kt$Comment$3 MagicNumber:CryptoPrices.kt$CryptoPrices$10 - MagicNumber:CurrencyConverter.kt$CurrencyConverter$11 - MagicNumber:CurrencyConverter.kt$CurrencyConverter.Companion$3 - MagicNumber:CurrencyConverter.kt$CurrencyConverter.Companion$4 + MagicNumber:CurrencyConverter2.kt$CurrencyConverter2$11 + MagicNumber:CurrencyConverter2.kt$CurrencyConverter2.Companion$3 + MagicNumber:CurrencyConverter2.kt$CurrencyConverter2.Companion$4 MagicNumber:Cycle.kt$Cycle$10 MagicNumber:Cycle.kt$Cycle$1000L MagicNumber:Ignore.kt$Ignore$8 @@ -47,8 +47,7 @@ NestedBlockDepth:Addons.kt$Addons$fun add(command: AbstractCommand): Boolean NestedBlockDepth:Addons.kt$Addons$fun add(module: AbstractModule): Boolean NestedBlockDepth:Comment.kt$Comment$override fun commandResponse(channel: String, args: String, event: GenericMessageEvent) - NestedBlockDepth:CurrencyConverter.kt$CurrencyConverter.Companion$@JvmStatic @Throws(ModuleException::class) fun loadSymbols(apiKey: String?) - NestedBlockDepth:CurrencyConverter.kt$CurrencyConverter.Companion$@JvmStatic fun convertCurrency(apiKey: String?, query: String): Message + NestedBlockDepth:CurrencyConverter2.kt$CurrencyConverter2.Companion$@JvmStatic fun convertCurrency(query: String): Message NestedBlockDepth:EntryLink.kt$EntryLink$private fun setTags(tags: List<String?>) NestedBlockDepth:FeedsManager.kt$FeedsManager.Companion$@JvmStatic @Throws(IOException::class, FeedException::class) fun loadFeed(entries: Entries, currentFile: String = CURRENT_XML): String NestedBlockDepth:FeedsManager.kt$FeedsManager.Companion$@JvmStatic fun saveFeed(entries: Entries, currentFile: String = CURRENT_XML) @@ -87,7 +86,6 @@ UtilityClassWithPublicConstructor:LocalProperties.kt$LocalProperties WildcardImport:AddonsTest.kt$import net.thauvin.erik.mobibot.modules.* WildcardImport:CryptoPricesTest.kt$import assertk.assertions.* - WildcardImport:CurrencyConverterTest.kt$import assertk.assertions.* WildcardImport:EntryLinkTest.kt$import assertk.assertions.* WildcardImport:FeedMgrTest.kt$import assertk.assertions.* WildcardImport:FeedReaderTest.kt$import assertk.assertions.* diff --git a/properties/mobibot.properties b/properties/mobibot.properties index 90e4ab1..05dfadb 100644 --- a/properties/mobibot.properties +++ b/properties/mobibot.properties @@ -47,11 +47,6 @@ disabled-modules=mastodon # Automatically post links to Mastodon #mastodon-auto-post=true -# -# Get Exchange Rate API key from: https://www.exchangerate-api.com/ -# -#exchangerate-api-key= - # # Create custom search engine at: https://programmablesearchengine.google.com/ # and get API key from: https://console.cloud.google.com/apis diff --git a/src/main/kotlin/net/thauvin/erik/mobibot/Mobibot.kt b/src/main/kotlin/net/thauvin/erik/mobibot/Mobibot.kt index 39e2c35..fba4d38 100644 --- a/src/main/kotlin/net/thauvin/erik/mobibot/Mobibot.kt +++ b/src/main/kotlin/net/thauvin/erik/mobibot/Mobibot.kt @@ -256,7 +256,7 @@ class Mobibot(nickname: String, val channel: String, logsDirPath: String, p: Pro addons.add(Calc()) addons.add(ChatGpt2()) addons.add(CryptoPrices()) - addons.add(CurrencyConverter()) + addons.add(CurrencyConverter2()) addons.add(Dice()) addons.add(Gemini2()) addons.add(GoogleSearch()) diff --git a/src/main/kotlin/net/thauvin/erik/mobibot/ReleaseInfo.kt b/src/main/kotlin/net/thauvin/erik/mobibot/ReleaseInfo.kt index 423ab6e..bbe3d51 100644 --- a/src/main/kotlin/net/thauvin/erik/mobibot/ReleaseInfo.kt +++ b/src/main/kotlin/net/thauvin/erik/mobibot/ReleaseInfo.kt @@ -14,12 +14,12 @@ import java.time.ZoneId */ object ReleaseInfo { const val PROJECT = "mobibot" - const val VERSION = "0.8.0-rc+20250509223055" + const val VERSION = "0.8.0-rc+20250516012000" @JvmField @Suppress("MagicNumber") val BUILD_DATE: LocalDateTime = LocalDateTime.ofInstant( - Instant.ofEpochMilli(1746855055425L), ZoneId.systemDefault() + Instant.ofEpochMilli(1747383600399L), ZoneId.systemDefault() ) const val WEBSITE = "https://mobitopia.org/mobibot/" diff --git a/src/main/kotlin/net/thauvin/erik/mobibot/modules/CurrencyConverter.kt b/src/main/kotlin/net/thauvin/erik/mobibot/modules/CurrencyConverter2.kt similarity index 65% rename from src/main/kotlin/net/thauvin/erik/mobibot/modules/CurrencyConverter.kt rename to src/main/kotlin/net/thauvin/erik/mobibot/modules/CurrencyConverter2.kt index 5fdad95..7769bdd 100644 --- a/src/main/kotlin/net/thauvin/erik/mobibot/modules/CurrencyConverter.kt +++ b/src/main/kotlin/net/thauvin/erik/mobibot/modules/CurrencyConverter2.kt @@ -51,70 +51,62 @@ import java.util.* /** * Converts between currencies. */ -class CurrencyConverter : AbstractModule() { +class CurrencyConverter2 : AbstractModule() { override val name = "CurrencyConverter" companion object { - /** - * The API Key property. - */ - const val API_KEY_PROP = "exchangerate-api-key" - // Currency command private const val CURRENCY_CMD = "currency" + // Currency codes + private val CURRENCY_CODES: TreeMap = TreeMap() + // Currency codes keyword private const val CODES_KEYWORD = "codes" // Decimal format private val DECIMAL_FORMAT = DecimalFormat("0.00#") - // Empty symbols table. - private const val EMPTY_SYMBOLS_TABLE = "Sorry, but the currency table is empty." + // Empty codes table. + private const val EMPTY_CODES_TABLE = "Sorry, but the currencies codes table is empty." // Logger - private val LOGGER: Logger = LoggerFactory.getLogger(CurrencyConverter::class.java) - - // Currency symbols - private val SYMBOLS: TreeMap = TreeMap() - - /** - * No API key error message. - */ - const val ERROR_MESSAGE_NO_API_KEY = "No Exchange Rate API key specified." - + private val LOGGER: Logger = LoggerFactory.getLogger(CurrencyConverter2::class.java) /** * Converts from a currency to another. */ @JvmStatic - fun convertCurrency(apiKey: String?, query: String): Message { - if (apiKey.isNullOrEmpty()) { - throw ModuleException("${CURRENCY_CMD}($query)", ERROR_MESSAGE_NO_API_KEY) - } - + fun convertCurrency(query: String): Message { val cmds = query.split(" ") return if (cmds.size == 4) { if (cmds[3] == cmds[1] || "0" == cmds[0]) { PublicMessage("You're kidding, right?") } else { - val to = cmds[1].uppercase() - val from = cmds[3].uppercase() - if (SYMBOLS.contains(to) && SYMBOLS.contains(from)) { + val from = cmds[1].uppercase() + val to = cmds[3].uppercase() + if (CURRENCY_CODES.contains(to) && CURRENCY_CODES.contains(from)) { try { val amt = cmds[0].replace(",", "") - val url = URL("https://v6.exchangerate-api.com/v6/$apiKey/pair/$to/$from/$amt") + val url = URL("https://api.frankfurter.dev/v1/latest?base=$from&symbols=$to") val body = url.reader().body - val json = JSONObject(body) - - if (json.getString("result") == "success") { - val result = DECIMAL_FORMAT.format(json.getDouble("conversion_result")) - PublicMessage( - "${cmds[0]} ${SYMBOLS[to]} = $result ${SYMBOLS[from]}" - ) - } else { - ErrorMessage("Sorry, an error occurred while converting the currencies.") + if (LOGGER.isTraceEnabled) { + LOGGER.trace(body) } + val json = JSONObject(body) + val rates = json.getJSONObject("rates") + val rate = rates.getDouble(to) + val result = DECIMAL_FORMAT.format(amt.toDouble() * rate) + + + PublicMessage( + "${cmds[0]} ${CURRENCY_CODES[from]} = $result ${CURRENCY_CODES[to]}" + ) + } catch (nfe: NumberFormatException) { + if (LOGGER.isWarnEnabled) { + LOGGER.warn("IO error while converting currencies: ${nfe.message}", nfe) + } + ErrorMessage("Sorry, an error occurred while converting the currencies.") } catch (ioe: IOException) { if (LOGGER.isWarnEnabled) { LOGGER.warn("IO error while converting currencies: ${ioe.message}", ioe) @@ -122,7 +114,9 @@ class CurrencyConverter : AbstractModule() { ErrorMessage("Sorry, an IO error occurred while converting the currencies.") } } else { - ErrorMessage("Sounds like monopoly money to me!") + ErrorMessage( + "Sounds like monopoly money to me! Try looking up the supported currency codes." + ) } } } else { @@ -131,44 +125,40 @@ class CurrencyConverter : AbstractModule() { } /** - * Loads the currency ISO symbols. + * Loads the currency ISO codes. */ @JvmStatic @Throws(ModuleException::class) - fun loadSymbols(apiKey: String?) { - if (!apiKey.isNullOrEmpty()) { - try { - val url = URL("https://v6.exchangerate-api.com/v6/$apiKey/codes") - val json = JSONObject(url.reader().body) - if (json.getString("result") == "success") { - val codes = json.getJSONArray("supported_codes") - for (i in 0 until codes.length()) { - val code = codes.getJSONArray(i) - SYMBOLS[code.getString(0)] = code.getString(1) - } - } - } catch (e: IOException) { - throw ModuleException( - "loadCodes(): IOE", - "An IO error has occurred while retrieving the currencies.", - e - ) + fun loadCurrencyCodes() { + try { + val url = URL("https://api.frankfurter.dev/v1/currencies") + val body = url.reader().body + val json = JSONObject(body) + if (LOGGER.isTraceEnabled) { + LOGGER.trace(body) } + json.keySet().forEach { key -> + CURRENCY_CODES[key] = json.getString(key) + } + } catch (e: IOException) { + throw ModuleException( + "loadCurrencyCodes(): IOE", + "An IO error has occurred while retrieving the currency codes.", + e + ) } } } init { commands.add(CURRENCY_CMD) - initProperties(API_KEY_PROP) - loadSymbols(properties[ChatGpt2.API_KEY_PROP]) } // Reload currency codes - private fun reload(apiKey: String?) { - if (!apiKey.isNullOrEmpty() && SYMBOLS.isEmpty()) { + private fun reload() { + if (CURRENCY_CODES.isEmpty()) { try { - loadSymbols(apiKey) + loadCurrencyCodes() } catch (e: ModuleException) { if (LOGGER.isWarnEnabled) LOGGER.warn(e.debugMessage, e) } @@ -179,15 +169,15 @@ class CurrencyConverter : AbstractModule() { * Converts the specified currencies. */ override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) { - reload(properties[API_KEY_PROP]) + reload() when { - SYMBOLS.isEmpty() -> { - event.respond(EMPTY_SYMBOLS_TABLE) + CURRENCY_CODES.isEmpty() -> { + event.respond(EMPTY_CODES_TABLE) } args.matches("\\d+([,\\d]+)?(\\.\\d+)? [a-zA-Z]{3}+ (to|in) [a-zA-Z]{3}+".toRegex()) -> { try { - val msg = convertCurrency(properties[API_KEY_PROP], args) + val msg = convertCurrency(args) if (msg.isError) { helpResponse(event) } else { @@ -201,7 +191,7 @@ class CurrencyConverter : AbstractModule() { args.contains(CODES_KEYWORD) -> { event.sendMessage("The supported currency codes are:") - event.sendList(SYMBOLS.keys.toList(), 11, isIndent = true) + event.sendList(CURRENCY_CODES.keys.toList(), 11, isIndent = true) } else -> { @@ -211,10 +201,10 @@ class CurrencyConverter : AbstractModule() { } override fun helpResponse(event: GenericMessageEvent): Boolean { - reload(properties[API_KEY_PROP]) + reload() - if (SYMBOLS.isEmpty()) { - event.sendMessage(EMPTY_SYMBOLS_TABLE) + if (CURRENCY_CODES.isEmpty()) { + event.sendMessage(EMPTY_CODES_TABLE) } else { val nick = event.bot().nick event.sendMessage("To convert from one currency to another:") diff --git a/src/test/kotlin/net/thauvin/erik/mobibot/modules/CurrencyConverterTest.kt b/src/test/kotlin/net/thauvin/erik/mobibot/modules/CurrencyConverter2Test.kt similarity index 72% rename from src/test/kotlin/net/thauvin/erik/mobibot/modules/CurrencyConverterTest.kt rename to src/test/kotlin/net/thauvin/erik/mobibot/modules/CurrencyConverter2Test.kt index 7ee2b4d..e4756ca 100644 --- a/src/test/kotlin/net/thauvin/erik/mobibot/modules/CurrencyConverterTest.kt +++ b/src/test/kotlin/net/thauvin/erik/mobibot/modules/CurrencyConverter2Test.kt @@ -32,10 +32,12 @@ package net.thauvin.erik.mobibot.modules import assertk.all import assertk.assertThat -import assertk.assertions.* -import net.thauvin.erik.mobibot.LocalProperties -import net.thauvin.erik.mobibot.modules.CurrencyConverter.Companion.convertCurrency -import net.thauvin.erik.mobibot.modules.CurrencyConverter.Companion.loadSymbols +import assertk.assertions.contains +import assertk.assertions.isInstanceOf +import assertk.assertions.matches +import assertk.assertions.prop +import net.thauvin.erik.mobibot.modules.CurrencyConverter2.Companion.convertCurrency +import net.thauvin.erik.mobibot.modules.CurrencyConverter2.Companion.loadCurrencyCodes import net.thauvin.erik.mobibot.msg.ErrorMessage import net.thauvin.erik.mobibot.msg.Message import net.thauvin.erik.mobibot.msg.PublicMessage @@ -46,10 +48,9 @@ import org.mockito.Mockito import org.pircbotx.hooks.types.GenericMessageEvent import kotlin.test.Test -class CurrencyConverterTest : LocalProperties() { +class CurrencyConverter2Test { init { - val apiKey = getProperty(CurrencyConverter.API_KEY_PROP) - loadSymbols(apiKey) + loadCurrencyCodes() } @Nested @@ -57,41 +58,37 @@ class CurrencyConverterTest : LocalProperties() { inner class CommandResponseTests { @Test fun `USD to CAD`() { - val currencyConverter = CurrencyConverter() + val currencyConverter = CurrencyConverter2() val event = Mockito.mock(GenericMessageEvent::class.java) val captor = ArgumentCaptor.forClass(String::class.java) - currencyConverter.properties.put( - CurrencyConverter.API_KEY_PROP, getProperty(CurrencyConverter.API_KEY_PROP) - ) currencyConverter.commandResponse("channel", "currency", "1 USD to CAD", event) Mockito.verify(event, Mockito.atLeastOnce()).respond(captor.capture()) - assertThat(captor.value).matches("1 United States Dollar = \\d+\\.\\d{2,3} Canadian Dollar".toRegex()) + assertThat(captor.value) + .matches("1 United States Dollar = \\d+\\.\\d{2,3} Canadian Dollar".toRegex()) } @Test - fun `API Key is not specified`() { - val currencyConverter = CurrencyConverter() + fun `USD to GBP`() { + val currencyConverter = CurrencyConverter2() val event = Mockito.mock(GenericMessageEvent::class.java) val captor = ArgumentCaptor.forClass(String::class.java) - currencyConverter.commandResponse("channel", "currency", "1 USD to CAD", event) + currencyConverter.commandResponse("channel", "currency", "1 usd to gbp", event) Mockito.verify(event, Mockito.atLeastOnce()).respond(captor.capture()) - assertThat(captor.value).isEqualTo(CurrencyConverter.ERROR_MESSAGE_NO_API_KEY) + assertThat(captor.value).matches("1 United States Dollar = \\d+\\.\\d{2,3} British Pound".toRegex()) } } @Nested @DisplayName("Currency Converter Tests") inner class CurrencyConverterTests { - private val apiKey = getProperty(CurrencyConverter.API_KEY_PROP) - @Test fun `Convert CAD to USD`() { assertThat( - convertCurrency(apiKey, "100,000.00 CAD to USD").msg, + convertCurrency("100,000.00 CAD to USD").msg, "convertCurrency(100,000.00 GBP to USD)" ).matches("100,000.00 Canadian Dollar = \\d+\\.\\d{2,3} United States Dollar".toRegex()) } @@ -99,7 +96,7 @@ class CurrencyConverterTest : LocalProperties() { @Test fun `Convert USD to EUR`() { assertThat( - convertCurrency(apiKey, "100 USD to EUR").msg, + convertCurrency("100 USD to EUR").msg, "convertCurrency(100 USD to EUR)" ).matches("100 United States Dollar = \\d{2,3}\\.\\d{2,3} Euro".toRegex()) } @@ -107,14 +104,23 @@ class CurrencyConverterTest : LocalProperties() { @Test fun `Convert USD to GBP`() { assertThat( - convertCurrency(apiKey, "1 USD to GBP").msg, + convertCurrency("1 USD to GBP").msg, "convertCurrency(1 USD to BGP)" - ).matches("1 United States Dollar = 0\\.\\d{2,3} Pound Sterling".toRegex()) + ).matches("1 United States Dollar = 0\\.\\d{2,3} British Pound".toRegex()) + } + + @Test + fun `Convert USD to Invalid Currency`() { + assertThat(convertCurrency("100 USD to FOO"), "convertCurrency(100 USD to FOO)").all { + prop(Message::msg) + .contains("Sounds like monopoly money to me! Try looking up the supported currency codes.") + isInstanceOf(ErrorMessage::class.java) + } } @Test fun `Convert USD to USD`() { - assertThat(convertCurrency(apiKey, "100 USD to USD"), "convertCurrency(100 USD to USD)").all { + assertThat(convertCurrency("100 USD to USD"), "convertCurrency(100 USD to USD)").all { prop(Message::msg).contains("You're kidding, right?") isInstanceOf(PublicMessage::class.java) } @@ -122,7 +128,7 @@ class CurrencyConverterTest : LocalProperties() { @Test fun `Invalid Query should throw exception`() { - assertThat(convertCurrency(apiKey, "100 USD"), "convertCurrency(100 USD)").all { + assertThat(convertCurrency("100 USD"), "convertCurrency(100 USD)").all { prop(Message::msg).contains("Invalid query.") isInstanceOf(ErrorMessage::class.java) } diff --git a/website/index.html b/website/index.html index a2d52b3..a559804 100644 --- a/website/index.html +++ b/website/index.html @@ -75,7 +75,7 @@
mobibot: cryto btc
mobibot: cryto eth eur
-
  • Converting between currencies +
  • Converting between currencies using Frankfurter
    mobibot: currency 17.54 USD to EUR
  • Performing Google searches @@ -86,7 +86,7 @@
    mobibot: chatgpt explain quantum computing in simple terms
    mobibot: gemini what are all the colors in a rainbow?
  • -
  • Displaying weather information +
  • Displaying weather information from OpenWeatherMap
    mobibot: weather san francisco
    mobibot: weather 94123
    mobibot: weather tokyo, jp