Implement currency converter module using data from Frankfurter.dev

This commit is contained in:
Erik C. Thauvin 2025-05-16 01:27:00 -07:00
parent 38cf75f8c9
commit 4f55685eff
Signed by: erik
GPG key ID: 776702A6A2DA330E
7 changed files with 98 additions and 109 deletions

View file

@ -12,9 +12,9 @@
<ID>LongParameterList:EntryLink.kt$EntryLink$( // Link's comments val comments: MutableList&lt;EntryComment&gt; = mutableListOf(), // Tags/categories val tags: MutableList&lt;SyndCategory&gt; = 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 )</ID>
<ID>MagicNumber:Comment.kt$Comment$3</ID>
<ID>MagicNumber:CryptoPrices.kt$CryptoPrices$10</ID>
<ID>MagicNumber:CurrencyConverter.kt$CurrencyConverter$11</ID>
<ID>MagicNumber:CurrencyConverter.kt$CurrencyConverter.Companion$3</ID>
<ID>MagicNumber:CurrencyConverter.kt$CurrencyConverter.Companion$4</ID>
<ID>MagicNumber:CurrencyConverter2.kt$CurrencyConverter2$11</ID>
<ID>MagicNumber:CurrencyConverter2.kt$CurrencyConverter2.Companion$3</ID>
<ID>MagicNumber:CurrencyConverter2.kt$CurrencyConverter2.Companion$4</ID>
<ID>MagicNumber:Cycle.kt$Cycle$10</ID>
<ID>MagicNumber:Cycle.kt$Cycle$1000L</ID>
<ID>MagicNumber:Ignore.kt$Ignore$8</ID>
@ -47,8 +47,7 @@
<ID>NestedBlockDepth:Addons.kt$Addons$fun add(command: AbstractCommand): Boolean</ID>
<ID>NestedBlockDepth:Addons.kt$Addons$fun add(module: AbstractModule): Boolean</ID>
<ID>NestedBlockDepth:Comment.kt$Comment$override fun commandResponse(channel: String, args: String, event: GenericMessageEvent)</ID>
<ID>NestedBlockDepth:CurrencyConverter.kt$CurrencyConverter.Companion$@JvmStatic @Throws(ModuleException::class) fun loadSymbols(apiKey: String?)</ID>
<ID>NestedBlockDepth:CurrencyConverter.kt$CurrencyConverter.Companion$@JvmStatic fun convertCurrency(apiKey: String?, query: String): Message</ID>
<ID>NestedBlockDepth:CurrencyConverter2.kt$CurrencyConverter2.Companion$@JvmStatic fun convertCurrency(query: String): Message</ID>
<ID>NestedBlockDepth:EntryLink.kt$EntryLink$private fun setTags(tags: List&lt;String?&gt;)</ID>
<ID>NestedBlockDepth:FeedsManager.kt$FeedsManager.Companion$@JvmStatic @Throws(IOException::class, FeedException::class) fun loadFeed(entries: Entries, currentFile: String = CURRENT_XML): String</ID>
<ID>NestedBlockDepth:FeedsManager.kt$FeedsManager.Companion$@JvmStatic fun saveFeed(entries: Entries, currentFile: String = CURRENT_XML)</ID>
@ -87,7 +86,6 @@
<ID>UtilityClassWithPublicConstructor:LocalProperties.kt$LocalProperties</ID>
<ID>WildcardImport:AddonsTest.kt$import net.thauvin.erik.mobibot.modules.*</ID>
<ID>WildcardImport:CryptoPricesTest.kt$import assertk.assertions.*</ID>
<ID>WildcardImport:CurrencyConverterTest.kt$import assertk.assertions.*</ID>
<ID>WildcardImport:EntryLinkTest.kt$import assertk.assertions.*</ID>
<ID>WildcardImport:FeedMgrTest.kt$import assertk.assertions.*</ID>
<ID>WildcardImport:FeedReaderTest.kt$import assertk.assertions.*</ID>

View file

@ -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

View file

@ -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())

View file

@ -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/"

View file

@ -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<String, String> = 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<String, String> = 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:")

View file

@ -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)
}

View file

@ -75,7 +75,7 @@
<div><code>mobibot: cryto btc</code></div>
<div><code>mobibot: cryto eth eur</code></div>
</li>
<li>Converting between currencies
<li>Converting between currencies using <a href="https://frankfurter.dev/">Frankfurter</a>
<div><code>mobibot: currency 17.54 USD to EUR</code></div>
</li>
<li>Performing Google searches
@ -86,7 +86,7 @@
<div><code>mobibot: chatgpt explain quantum computing in simple terms</code></div>
<div><code>mobibot: gemini what are all the colors in a rainbow?</code></div>
</li>
<li>Displaying weather information
<li>Displaying weather information from <a href="https://openweathermap.org/">OpenWeatherMap</a>
<div><code>mobibot: weather san francisco</code></div>
<div><code>mobibot: weather 94123 </code></div>
<div><code>mobibot: weather tokyo, jp</code></div>