Moved to exchangerate.host API for currency conversion

This commit is contained in:
Erik C. Thauvin 2022-07-10 13:53:32 -07:00
parent a33b8354b8
commit 070d29c7dd
7 changed files with 77 additions and 121 deletions

2
.idea/misc.xml generated
View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="17" project-jdk-type="JavaSDK" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="18" project-jdk-type="JavaSDK" />
</project>

View file

@ -1,9 +1,11 @@
# mobibot
[![License (3-Clause BSD)](https://img.shields.io/badge/license-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=ethauvin_mobibot&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=ethauvin_mobibot)
[![Known Vulnerabilities](https://snyk.io/test/github/ethauvin/mobibot/badge.svg?targetFile=build.gradle)](https://snyk.io/test/github/ethauvin/mobibot?targetFile=build.gradle) [![GitHub CI](https://github.com/ethauvin/mobibot/actions/workflows/gradle.yml/badge.svg)](https://github.com/ethauvin/mobibot/actions/workflows/gradle.yml) [![CircleCI](https://circleci.com/gh/ethauvin/mobibot/tree/master.svg?style=shield)](https://circleci.com/gh/ethauvin/mobibot/tree/master)
Some very basic instructions:
```
```sh
{ clone with git or download the ZIP }
git clone https://github.com/ethauvin/mobibot.git

View file

@ -5,10 +5,10 @@ plugins {
id 'io.gitlab.arturbosch.detekt' version '1.20.0'
id 'java'
id 'net.thauvin.erik.gradle.semver' version '1.0.4'
id 'org.jetbrains.kotlin.jvm' version '1.6.21'
id 'org.jetbrains.kotlin.kapt' version '1.6.21'
id 'org.jetbrains.kotlinx.kover' version '0.5.0'
id 'org.sonarqube' version '3.3'
id 'org.jetbrains.kotlin.jvm' version '1.7.10'
id 'org.jetbrains.kotlin.kapt' version '1.7.10'
id 'org.jetbrains.kotlinx.kover' version '0.5.1'
id 'org.sonarqube' version '3.4.0.2513'
id 'pmd'
}
@ -27,8 +27,8 @@ def isNonStable = { String version ->
mainClassName = packageName + '.Mobibot'
ext.versions = [
log4j: '2.17.2',
pmd : '6.44.0',
log4j: '2.18.0',
pmd : '6.47.0',
]
repositories {
@ -59,7 +59,7 @@ dependencies {
// Kotlin
implementation platform('org.jetbrains.kotlin:kotlin-bom')
implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.3'
// Logging
implementation 'org.slf4j:slf4j-api:1.7.36'
@ -68,11 +68,11 @@ dependencies {
implementation "org.apache.logging.log4j:log4j-slf4j-impl:$versions.log4j"
implementation 'com.rometools:rome:1.18.0'
implementation 'com.squareup.okhttp3:okhttp:4.9.3'
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'net.aksingh:owm-japis:2.5.3.0'
implementation 'net.objecthunter:exp4j:0.4.8'
implementation 'org.json:json:20220320'
implementation 'org.jsoup:jsoup:1.14.3'
implementation 'org.jsoup:jsoup:1.15.2'
implementation 'org.twitter4j:twitter4j-core:4.0.7'
implementation 'net.thauvin.erik:cryptoprice:0.9.0'
@ -81,7 +81,7 @@ dependencies {
testImplementation 'com.willowtreeapps.assertk:assertk-jvm:0.25'
// testImplementation 'org.mockito.kotlin:mockito-kotlin:4.0.0'
// testImplementation "org.mockito:mockito-core:4.0.0"
testImplementation 'org.testng:testng:7.5'
testImplementation 'org.testng:testng:7.6.1'
}
test {

View file

@ -13,13 +13,10 @@
<ID>LongParameterList:Twitter.kt$Twitter.Companion$( consumerKey: String?, consumerSecret: String?, token: String?, tokenSecret: String?, handle: String?, message: String, isDm: Boolean )</ID>
<ID>MagicNumber:Comment.kt$Comment$3</ID>
<ID>MagicNumber:CurrencyConverter.kt$CurrencyConverter$11</ID>
<ID>MagicNumber:CurrencyConverter.kt$CurrencyConverter$3</ID>
<ID>MagicNumber:CurrencyConverter.kt$CurrencyConverter.Companion$3</ID>
<ID>MagicNumber:CurrencyConverter.kt$CurrencyConverter.Companion$4</ID>
<ID>MagicNumber:CurrencyConverter.kt$CurrencyConverter.Companion$8</ID>
<ID>MagicNumber:Cycle.kt$Cycle$10</ID>
<ID>MagicNumber:Cycle.kt$Cycle$1000L</ID>
<ID>MagicNumber:Dice.kt$Dice$6</ID>
<ID>MagicNumber:Ignore.kt$Ignore$8</ID>
<ID>MagicNumber:Info.kt$Info.Companion$30</ID>
<ID>MagicNumber:Info.kt$Info.Companion$365</ID>
@ -47,6 +44,7 @@
<ID>NestedBlockDepth:Addons.kt$Addons$ fun add(module: AbstractModule)</ID>
<ID>NestedBlockDepth:Comment.kt$Comment$override fun commandResponse(channel: String, args: String, event: GenericMessageEvent)</ID>
<ID>NestedBlockDepth:CurrencyConverter.kt$CurrencyConverter.Companion$ @JvmStatic fun convertCurrency(query: String): Message</ID>
<ID>NestedBlockDepth:CurrencyConverter.kt$CurrencyConverter.Companion$@JvmStatic @Throws(ModuleException::class) fun loadSymbols()</ID>
<ID>NestedBlockDepth:EntryLink.kt$EntryLink$ private fun setTags(tags: List&lt;String?&gt;)</ID>
<ID>NestedBlockDepth:FeedsMgr.kt$FeedsMgr.Companion$ @JvmStatic @Throws(IOException::class, FeedException::class) fun loadFeed(entries: Entries, currentFile: String = currentXml): String</ID>
<ID>NestedBlockDepth:FeedsMgr.kt$FeedsMgr.Companion$ @JvmStatic fun saveFeed(entries: Entries, currentFile: String = currentXml)</ID>
@ -73,6 +71,5 @@
<ID>TooGenericExceptionCaught:StockQuote.kt$StockQuote.Companion$e: NullPointerException</ID>
<ID>TooGenericExceptionCaught:Weather2.kt$Weather2.Companion$e: NullPointerException</ID>
<ID>TooManyFunctions:Tell.kt$Tell : AbstractCommand</ID>
<ID>UnusedPrivateMember:Dice.kt$Dice$private val sides = 6</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -31,27 +31,22 @@
*/
package net.thauvin.erik.mobibot.modules
import net.thauvin.erik.mobibot.Utils.bold
import net.thauvin.erik.mobibot.Utils.bot
import net.thauvin.erik.mobibot.Utils.helpCmdSyntax
import net.thauvin.erik.mobibot.Utils.helpFormat
import net.thauvin.erik.mobibot.Utils.reader
import net.thauvin.erik.mobibot.Utils.sendList
import net.thauvin.erik.mobibot.Utils.sendMessage
import net.thauvin.erik.mobibot.Utils.today
import net.thauvin.erik.mobibot.msg.ErrorMessage
import net.thauvin.erik.mobibot.msg.Message
import net.thauvin.erik.mobibot.msg.PublicMessage
import org.jdom2.JDOMException
import org.jdom2.input.SAXBuilder
import org.json.JSONObject
import org.pircbotx.hooks.types.GenericMessageEvent
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.io.IOException
import java.net.URL
import java.text.NumberFormat
import java.util.Currency
import java.util.Locale
import javax.xml.XMLConstants
/**
* The CurrencyConverter module.
@ -64,7 +59,7 @@ class CurrencyConverter : ThreadedModule() {
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
synchronized(this) {
if (pubDate != today()) {
EXCHANGE_RATES.clear()
SYMBOLS.clear()
}
}
super.commandResponse(channel, cmd, args, event)
@ -74,52 +69,55 @@ class CurrencyConverter : ThreadedModule() {
* Converts the specified currencies.
*/
override fun run(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
if (EXCHANGE_RATES.isEmpty()) {
if (SYMBOLS.isEmpty()) {
try {
loadRates()
loadSymbols()
} catch (e: ModuleException) {
if (logger.isWarnEnabled) logger.warn(e.debugMessage, e)
}
}
if (EXCHANGE_RATES.isEmpty()) {
event.respond(EMPTY_RATE_TABLE)
if (SYMBOLS.isEmpty()) {
event.respond(EMPTY_SYMBOLS_TABLE)
} else if (args.matches("\\d+([,\\d]+)?(\\.\\d+)? [a-zA-Z]{3}+ to [a-zA-Z]{3}+".toRegex())) {
val msg = convertCurrency(args)
event.respond(msg.msg)
if (msg.isError) {
helpResponse(event)
}
} else if (args.contains(CURRENCY_RATES_KEYWORD)) {
event.sendMessage("The reference rates for ${pubDate.bold()} are:")
event.sendList(currencyRates(), 3, " ", isIndent = true)
} else if (args.contains(CURRENCY_SYMBOLS_KEYWORD)) {
event.sendMessage("The supported currency symbols are: ")
event.sendList(ArrayList(SYMBOLS.keys.sorted()), 11, isIndent = true)
} else {
helpResponse(event)
}
}
override fun helpResponse(event: GenericMessageEvent): Boolean {
if (EXCHANGE_RATES.isEmpty()) {
if (SYMBOLS.isEmpty()) {
try {
loadRates()
loadSymbols()
} catch (e: ModuleException) {
if (logger.isWarnEnabled) logger.warn(e.debugMessage, e)
}
}
if (EXCHANGE_RATES.isEmpty()) {
event.sendMessage(EMPTY_RATE_TABLE)
if (SYMBOLS.isEmpty()) {
event.sendMessage(EMPTY_SYMBOLS_TABLE)
} else {
val nick = event.bot().nick
event.sendMessage("To convert from one currency to another:")
event.sendMessage(helpFormat(helpCmdSyntax("%c $CURRENCY_CMD 100 USD to EUR", nick, isPrivateMsgEnabled)))
event.sendMessage("For a listing of current reference rates:")
event.sendMessage(
helpFormat(
helpCmdSyntax("%c $CURRENCY_CMD $CURRENCY_RATES_KEYWORD", nick, isPrivateMsgEnabled)
helpCmdSyntax("%c $CURRENCY_CMD 50,000 GBP to BTC", nick, isPrivateMsgEnabled)
)
)
event.sendMessage("To list the supported currency symbols: ")
event.sendMessage(
helpFormat(
helpCmdSyntax("%c $CURRENCY_CMD $CURRENCY_SYMBOLS_KEYWORD", nick, isPrivateMsgEnabled)
)
)
event.sendMessage("The supported currencies are: ")
event.sendList(ArrayList(EXCHANGE_RATES.keys), 11, isIndent = true)
}
return true
}
@ -128,27 +126,18 @@ class CurrencyConverter : ThreadedModule() {
// Currency command
private const val CURRENCY_CMD = "currency"
// Rates keyword
private const val CURRENCY_RATES_KEYWORD = "rates"
// Currency synbols keywords
private const val CURRENCY_SYMBOLS_KEYWORD = "symbols"
// Empty rate table.
private const val EMPTY_RATE_TABLE = "Sorry, but the exchange rate table is empty."
// Empty symbols table.
private const val EMPTY_SYMBOLS_TABLE = "Sorry, but the currency symbols table is empty."
// Exchange rates
private val EXCHANGE_RATES: MutableMap<String, String> = mutableMapOf()
// Exchange rates table URL
private const val EXCHANGE_TABLE_URL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml"
// Currency Symbols
private val SYMBOLS: MutableMap<String, String> = mutableMapOf()
// Last exchange rates table publication date
private var pubDate = ""
private fun Double.formatCurrency(currency: String): String =
NumberFormat.getCurrencyInstance(Locale.getDefault(Locale.Category.FORMAT)).let {
it.currency = Currency.getInstance(currency)
it.format(this)
}
/**
* Converts from a currency to another.
*/
@ -161,17 +150,21 @@ class CurrencyConverter : ThreadedModule() {
} else {
val to = cmds[1].uppercase()
val from = cmds[3].uppercase()
val toRate = EXCHANGE_RATES[to]
val fromRate = EXCHANGE_RATES[from]
if (!toRate.isNullOrBlank() && !fromRate.isNullOrBlank()) {
if (SYMBOLS.contains(to) && SYMBOLS.contains(from)) {
try {
val amt = cmds[0].replace(",", "").toDouble()
PublicMessage(
amt.formatCurrency(to) + " = "
+ (amt * toRate.toDouble() / fromRate.toDouble()).formatCurrency(from)
)
} catch (e: NumberFormatException) {
ErrorMessage("Let's try with some real numbers next time, okay?")
val amt = cmds[0].replace(",", "")
val url = URL("https://api.exchangerate.host/convert?from=$to&to=$from&amount=$amt")
val json = JSONObject(url.reader())
if (json.getBoolean("success")) {
PublicMessage(
"${cmds[0]} ${SYMBOLS[to]} = ${json.get("result")} ${SYMBOLS[from]}"
)
} else {
ErrorMessage("Sorry, an error occurred while converting the currencies.")
}
} catch (ignore: IOException) {
ErrorMessage("Sorry, an IO error occurred while converting the currencies.")
}
} else {
ErrorMessage("Sounds like monopoly money to me!")
@ -180,48 +173,23 @@ class CurrencyConverter : ThreadedModule() {
} else ErrorMessage("Invalid query. Let's try again.")
}
@JvmStatic
fun currencyRates(): List<String> {
val rates = buildList {
for ((key, value) in EXCHANGE_RATES.toSortedMap()) {
add("$key: ${value.padStart(8)}")
}
}
return rates
}
@JvmStatic
@Throws(ModuleException::class)
fun loadRates() {
if (EXCHANGE_RATES.isEmpty()) {
fun loadSymbols() {
if (SYMBOLS.isEmpty()) {
try {
val builder = SAXBuilder()
// See https://rules.sonarsourcecom/java/tag/owasp/RSPEC-2755
builder.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "")
builder.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "")
builder.ignoringElementContentWhitespace = true
val doc = builder.build(URL(EXCHANGE_TABLE_URL))
val root = doc.rootElement
val ns = root.getNamespace("")
val cubeRoot = root.getChild("Cube", ns)
val cubeTime = cubeRoot.getChild("Cube", ns)
pubDate = cubeTime.getAttribute("time").value
val cubes = cubeTime.children
for (cube in cubes) {
EXCHANGE_RATES[cube.getAttribute("currency").value] = cube.getAttribute("rate").value
val url = URL("https://api.exchangerate.host/symbols")
val json = JSONObject(url.reader())
if (json.getBoolean("success")) {
val symbols = json.getJSONObject("symbols")
for (key in symbols.keys()) {
SYMBOLS[key] = symbols.getJSONObject(key).getString("description")
}
}
EXCHANGE_RATES["EUR"] = "1"
} catch (e: JDOMException) {
throw ModuleException(
"loadRates(): JDOM",
"An JDOM parsing error has occurred while parsing the exchange rates table.",
e
)
} catch (e: IOException) {
throw ModuleException(
"loadRates(): IOE",
"An IO error has occurred while parsing the exchange rates table.",
"loadSymbols(): IOE",
"An IO error has occurred while retrieving the currency symbols table.",
e
)
}

View file

@ -33,16 +33,12 @@ package net.thauvin.erik.mobibot.modules
import assertk.all
import assertk.assertThat
import assertk.assertions.any
import assertk.assertions.contains
import assertk.assertions.isGreaterThan
import assertk.assertions.isInstanceOf
import assertk.assertions.matches
import assertk.assertions.prop
import assertk.assertions.size
import net.thauvin.erik.mobibot.modules.CurrencyConverter.Companion.convertCurrency
import net.thauvin.erik.mobibot.modules.CurrencyConverter.Companion.currencyRates
import net.thauvin.erik.mobibot.modules.CurrencyConverter.Companion.loadRates
import net.thauvin.erik.mobibot.modules.CurrencyConverter.Companion.loadSymbols
import net.thauvin.erik.mobibot.msg.ErrorMessage
import net.thauvin.erik.mobibot.msg.Message
import net.thauvin.erik.mobibot.msg.PublicMessage
@ -56,7 +52,7 @@ class CurrencyConverterTest {
@BeforeClass
@Throws(ModuleException::class)
fun before() {
loadRates()
loadSymbols()
}
@Test
@ -64,7 +60,11 @@ class CurrencyConverterTest {
assertThat(
convertCurrency("100 USD to EUR").msg,
"100 USD to EUR"
).matches("\\$100\\.00 = €\\d{2,3}\\.\\d{2}".toRegex())
).matches("100 United States Dollar = \\d{2,3}\\.\\d+ Euro".toRegex())
assertThat(
convertCurrency("100,000.00 GBP to BTC").msg,
"100 USD to EUR"
).matches("100,000.00 British Pound Sterling = \\d{1,2}\\.\\d+ Bitcoin".toRegex())
assertThat(convertCurrency("100 USD to USD"), "100 USD to USD").all {
prop(Message::msg).contains("You're kidding, right?")
isInstanceOf(PublicMessage::class.java)
@ -74,15 +74,4 @@ class CurrencyConverterTest {
isInstanceOf(ErrorMessage::class.java)
}
}
@Test
fun testCurrencyRates() {
val rates = currencyRates()
assertThat(rates).all {
size().isGreaterThan(30)
any { it.matches("[A-Z]{3}: +[\\d.]+".toRegex()) }
contains("EUR: 1")
}
}
}

View file

@ -1,9 +1,9 @@
#Generated by the Semver Plugin for Gradle
#Tue Apr 19 17:22:30 PDT 2022
version.buildmeta=221
#Sun Jul 10 14:02:30 PDT 2022
version.buildmeta=257
version.major=0
version.minor=8
version.patch=0
version.prerelease=rc
version.project=mobibot
version.semver=0.8.0-rc+221
version.semver=0.8.0-rc+257