Implement currency converter module using data from Frankfurter.dev
This commit is contained in:
parent
38cf75f8c9
commit
4f55685eff
7 changed files with 98 additions and 109 deletions
|
@ -12,9 +12,9 @@
|
|||
<ID>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 )</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<String?>)</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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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/"
|
||||
|
|
|
@ -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:")
|
|
@ -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)
|
||||
}
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue