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>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:Comment.kt$Comment$3</ID>
<ID>MagicNumber:CryptoPrices.kt$CryptoPrices$10</ID> <ID>MagicNumber:CryptoPrices.kt$CryptoPrices$10</ID>
<ID>MagicNumber:CurrencyConverter.kt$CurrencyConverter$11</ID> <ID>MagicNumber:CurrencyConverter2.kt$CurrencyConverter2$11</ID>
<ID>MagicNumber:CurrencyConverter.kt$CurrencyConverter.Companion$3</ID> <ID>MagicNumber:CurrencyConverter2.kt$CurrencyConverter2.Companion$3</ID>
<ID>MagicNumber:CurrencyConverter.kt$CurrencyConverter.Companion$4</ID> <ID>MagicNumber:CurrencyConverter2.kt$CurrencyConverter2.Companion$4</ID>
<ID>MagicNumber:Cycle.kt$Cycle$10</ID> <ID>MagicNumber:Cycle.kt$Cycle$10</ID>
<ID>MagicNumber:Cycle.kt$Cycle$1000L</ID> <ID>MagicNumber:Cycle.kt$Cycle$1000L</ID>
<ID>MagicNumber:Ignore.kt$Ignore$8</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(command: AbstractCommand): Boolean</ID>
<ID>NestedBlockDepth:Addons.kt$Addons$fun add(module: AbstractModule): 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: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:CurrencyConverter2.kt$CurrencyConverter2.Companion$@JvmStatic fun convertCurrency(query: String): Message</ID>
<ID>NestedBlockDepth:CurrencyConverter.kt$CurrencyConverter.Companion$@JvmStatic fun convertCurrency(apiKey: String?, query: String): Message</ID>
<ID>NestedBlockDepth:EntryLink.kt$EntryLink$private fun setTags(tags: List&lt;String?&gt;)</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 @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> <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>UtilityClassWithPublicConstructor:LocalProperties.kt$LocalProperties</ID>
<ID>WildcardImport:AddonsTest.kt$import net.thauvin.erik.mobibot.modules.*</ID> <ID>WildcardImport:AddonsTest.kt$import net.thauvin.erik.mobibot.modules.*</ID>
<ID>WildcardImport:CryptoPricesTest.kt$import assertk.assertions.*</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:EntryLinkTest.kt$import assertk.assertions.*</ID>
<ID>WildcardImport:FeedMgrTest.kt$import assertk.assertions.*</ID> <ID>WildcardImport:FeedMgrTest.kt$import assertk.assertions.*</ID>
<ID>WildcardImport:FeedReaderTest.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 # Automatically post links to Mastodon
#mastodon-auto-post=true #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/ # Create custom search engine at: https://programmablesearchengine.google.com/
# and get API key from: https://console.cloud.google.com/apis # 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(Calc())
addons.add(ChatGpt2()) addons.add(ChatGpt2())
addons.add(CryptoPrices()) addons.add(CryptoPrices())
addons.add(CurrencyConverter()) addons.add(CurrencyConverter2())
addons.add(Dice()) addons.add(Dice())
addons.add(Gemini2()) addons.add(Gemini2())
addons.add(GoogleSearch()) addons.add(GoogleSearch())

View file

@ -14,12 +14,12 @@ import java.time.ZoneId
*/ */
object ReleaseInfo { object ReleaseInfo {
const val PROJECT = "mobibot" const val PROJECT = "mobibot"
const val VERSION = "0.8.0-rc+20250509223055" const val VERSION = "0.8.0-rc+20250516012000"
@JvmField @JvmField
@Suppress("MagicNumber") @Suppress("MagicNumber")
val BUILD_DATE: LocalDateTime = LocalDateTime.ofInstant( val BUILD_DATE: LocalDateTime = LocalDateTime.ofInstant(
Instant.ofEpochMilli(1746855055425L), ZoneId.systemDefault() Instant.ofEpochMilli(1747383600399L), ZoneId.systemDefault()
) )
const val WEBSITE = "https://mobitopia.org/mobibot/" const val WEBSITE = "https://mobitopia.org/mobibot/"

View file

@ -51,70 +51,62 @@ import java.util.*
/** /**
* Converts between currencies. * Converts between currencies.
*/ */
class CurrencyConverter : AbstractModule() { class CurrencyConverter2 : AbstractModule() {
override val name = "CurrencyConverter" override val name = "CurrencyConverter"
companion object { companion object {
/**
* The API Key property.
*/
const val API_KEY_PROP = "exchangerate-api-key"
// Currency command // Currency command
private const val CURRENCY_CMD = "currency" private const val CURRENCY_CMD = "currency"
// Currency codes
private val CURRENCY_CODES: TreeMap<String, String> = TreeMap()
// Currency codes keyword // Currency codes keyword
private const val CODES_KEYWORD = "codes" private const val CODES_KEYWORD = "codes"
// Decimal format // Decimal format
private val DECIMAL_FORMAT = DecimalFormat("0.00#") private val DECIMAL_FORMAT = DecimalFormat("0.00#")
// Empty symbols table. // Empty codes table.
private const val EMPTY_SYMBOLS_TABLE = "Sorry, but the currency table is empty." private const val EMPTY_CODES_TABLE = "Sorry, but the currencies codes table is empty."
// Logger // Logger
private val LOGGER: Logger = LoggerFactory.getLogger(CurrencyConverter::class.java) private val LOGGER: Logger = LoggerFactory.getLogger(CurrencyConverter2::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."
/** /**
* Converts from a currency to another. * Converts from a currency to another.
*/ */
@JvmStatic @JvmStatic
fun convertCurrency(apiKey: String?, query: String): Message { fun convertCurrency(query: String): Message {
if (apiKey.isNullOrEmpty()) {
throw ModuleException("${CURRENCY_CMD}($query)", ERROR_MESSAGE_NO_API_KEY)
}
val cmds = query.split(" ") val cmds = query.split(" ")
return if (cmds.size == 4) { return if (cmds.size == 4) {
if (cmds[3] == cmds[1] || "0" == cmds[0]) { if (cmds[3] == cmds[1] || "0" == cmds[0]) {
PublicMessage("You're kidding, right?") PublicMessage("You're kidding, right?")
} else { } else {
val to = cmds[1].uppercase() val from = cmds[1].uppercase()
val from = cmds[3].uppercase() val to = cmds[3].uppercase()
if (SYMBOLS.contains(to) && SYMBOLS.contains(from)) { if (CURRENCY_CODES.contains(to) && CURRENCY_CODES.contains(from)) {
try { try {
val amt = cmds[0].replace(",", "") 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 body = url.reader().body
val json = JSONObject(body) if (LOGGER.isTraceEnabled) {
LOGGER.trace(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.")
} }
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) { } catch (ioe: IOException) {
if (LOGGER.isWarnEnabled) { if (LOGGER.isWarnEnabled) {
LOGGER.warn("IO error while converting currencies: ${ioe.message}", ioe) 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.") ErrorMessage("Sorry, an IO error occurred while converting the currencies.")
} }
} else { } else {
ErrorMessage("Sounds like monopoly money to me!") ErrorMessage(
"Sounds like monopoly money to me! Try looking up the supported currency codes."
)
} }
} }
} else { } else {
@ -131,44 +125,40 @@ class CurrencyConverter : AbstractModule() {
} }
/** /**
* Loads the currency ISO symbols. * Loads the currency ISO codes.
*/ */
@JvmStatic @JvmStatic
@Throws(ModuleException::class) @Throws(ModuleException::class)
fun loadSymbols(apiKey: String?) { fun loadCurrencyCodes() {
if (!apiKey.isNullOrEmpty()) { try {
try { val url = URL("https://api.frankfurter.dev/v1/currencies")
val url = URL("https://v6.exchangerate-api.com/v6/$apiKey/codes") val body = url.reader().body
val json = JSONObject(url.reader().body) val json = JSONObject(body)
if (json.getString("result") == "success") { if (LOGGER.isTraceEnabled) {
val codes = json.getJSONArray("supported_codes") LOGGER.trace(body)
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
)
} }
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 { init {
commands.add(CURRENCY_CMD) commands.add(CURRENCY_CMD)
initProperties(API_KEY_PROP)
loadSymbols(properties[ChatGpt2.API_KEY_PROP])
} }
// Reload currency codes // Reload currency codes
private fun reload(apiKey: String?) { private fun reload() {
if (!apiKey.isNullOrEmpty() && SYMBOLS.isEmpty()) { if (CURRENCY_CODES.isEmpty()) {
try { try {
loadSymbols(apiKey) loadCurrencyCodes()
} catch (e: ModuleException) { } catch (e: ModuleException) {
if (LOGGER.isWarnEnabled) LOGGER.warn(e.debugMessage, e) if (LOGGER.isWarnEnabled) LOGGER.warn(e.debugMessage, e)
} }
@ -179,15 +169,15 @@ class CurrencyConverter : AbstractModule() {
* Converts the specified currencies. * Converts the specified currencies.
*/ */
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) { override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
reload(properties[API_KEY_PROP]) reload()
when { when {
SYMBOLS.isEmpty() -> { CURRENCY_CODES.isEmpty() -> {
event.respond(EMPTY_SYMBOLS_TABLE) event.respond(EMPTY_CODES_TABLE)
} }
args.matches("\\d+([,\\d]+)?(\\.\\d+)? [a-zA-Z]{3}+ (to|in) [a-zA-Z]{3}+".toRegex()) -> { args.matches("\\d+([,\\d]+)?(\\.\\d+)? [a-zA-Z]{3}+ (to|in) [a-zA-Z]{3}+".toRegex()) -> {
try { try {
val msg = convertCurrency(properties[API_KEY_PROP], args) val msg = convertCurrency(args)
if (msg.isError) { if (msg.isError) {
helpResponse(event) helpResponse(event)
} else { } else {
@ -201,7 +191,7 @@ class CurrencyConverter : AbstractModule() {
args.contains(CODES_KEYWORD) -> { args.contains(CODES_KEYWORD) -> {
event.sendMessage("The supported currency codes are:") 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 -> { else -> {
@ -211,10 +201,10 @@ class CurrencyConverter : AbstractModule() {
} }
override fun helpResponse(event: GenericMessageEvent): Boolean { override fun helpResponse(event: GenericMessageEvent): Boolean {
reload(properties[API_KEY_PROP]) reload()
if (SYMBOLS.isEmpty()) { if (CURRENCY_CODES.isEmpty()) {
event.sendMessage(EMPTY_SYMBOLS_TABLE) event.sendMessage(EMPTY_CODES_TABLE)
} else { } else {
val nick = event.bot().nick val nick = event.bot().nick
event.sendMessage("To convert from one currency to another:") 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.all
import assertk.assertThat import assertk.assertThat
import assertk.assertions.* import assertk.assertions.contains
import net.thauvin.erik.mobibot.LocalProperties import assertk.assertions.isInstanceOf
import net.thauvin.erik.mobibot.modules.CurrencyConverter.Companion.convertCurrency import assertk.assertions.matches
import net.thauvin.erik.mobibot.modules.CurrencyConverter.Companion.loadSymbols 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.ErrorMessage
import net.thauvin.erik.mobibot.msg.Message import net.thauvin.erik.mobibot.msg.Message
import net.thauvin.erik.mobibot.msg.PublicMessage import net.thauvin.erik.mobibot.msg.PublicMessage
@ -46,10 +48,9 @@ import org.mockito.Mockito
import org.pircbotx.hooks.types.GenericMessageEvent import org.pircbotx.hooks.types.GenericMessageEvent
import kotlin.test.Test import kotlin.test.Test
class CurrencyConverterTest : LocalProperties() { class CurrencyConverter2Test {
init { init {
val apiKey = getProperty(CurrencyConverter.API_KEY_PROP) loadCurrencyCodes()
loadSymbols(apiKey)
} }
@Nested @Nested
@ -57,41 +58,37 @@ class CurrencyConverterTest : LocalProperties() {
inner class CommandResponseTests { inner class CommandResponseTests {
@Test @Test
fun `USD to CAD`() { fun `USD to CAD`() {
val currencyConverter = CurrencyConverter() val currencyConverter = CurrencyConverter2()
val event = Mockito.mock(GenericMessageEvent::class.java) val event = Mockito.mock(GenericMessageEvent::class.java)
val captor = ArgumentCaptor.forClass(String::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) currencyConverter.commandResponse("channel", "currency", "1 USD to CAD", event)
Mockito.verify(event, Mockito.atLeastOnce()).respond(captor.capture()) 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 @Test
fun `API Key is not specified`() { fun `USD to GBP`() {
val currencyConverter = CurrencyConverter() val currencyConverter = CurrencyConverter2()
val event = Mockito.mock(GenericMessageEvent::class.java) val event = Mockito.mock(GenericMessageEvent::class.java)
val captor = ArgumentCaptor.forClass(String::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()) 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 @Nested
@DisplayName("Currency Converter Tests") @DisplayName("Currency Converter Tests")
inner class CurrencyConverterTests { inner class CurrencyConverterTests {
private val apiKey = getProperty(CurrencyConverter.API_KEY_PROP)
@Test @Test
fun `Convert CAD to USD`() { fun `Convert CAD to USD`() {
assertThat( 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)" "convertCurrency(100,000.00 GBP to USD)"
).matches("100,000.00 Canadian Dollar = \\d+\\.\\d{2,3} United States Dollar".toRegex()) ).matches("100,000.00 Canadian Dollar = \\d+\\.\\d{2,3} United States Dollar".toRegex())
} }
@ -99,7 +96,7 @@ class CurrencyConverterTest : LocalProperties() {
@Test @Test
fun `Convert USD to EUR`() { fun `Convert USD to EUR`() {
assertThat( assertThat(
convertCurrency(apiKey, "100 USD to EUR").msg, convertCurrency("100 USD to EUR").msg,
"convertCurrency(100 USD to EUR)" "convertCurrency(100 USD to EUR)"
).matches("100 United States Dollar = \\d{2,3}\\.\\d{2,3} Euro".toRegex()) ).matches("100 United States Dollar = \\d{2,3}\\.\\d{2,3} Euro".toRegex())
} }
@ -107,14 +104,23 @@ class CurrencyConverterTest : LocalProperties() {
@Test @Test
fun `Convert USD to GBP`() { fun `Convert USD to GBP`() {
assertThat( assertThat(
convertCurrency(apiKey, "1 USD to GBP").msg, convertCurrency("1 USD to GBP").msg,
"convertCurrency(1 USD to BGP)" "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 @Test
fun `Convert USD to USD`() { 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?") prop(Message::msg).contains("You're kidding, right?")
isInstanceOf(PublicMessage::class.java) isInstanceOf(PublicMessage::class.java)
} }
@ -122,7 +128,7 @@ class CurrencyConverterTest : LocalProperties() {
@Test @Test
fun `Invalid Query should throw exception`() { 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.") prop(Message::msg).contains("Invalid query.")
isInstanceOf(ErrorMessage::class.java) isInstanceOf(ErrorMessage::class.java)
} }

View file

@ -75,7 +75,7 @@
<div><code>mobibot: cryto btc</code></div> <div><code>mobibot: cryto btc</code></div>
<div><code>mobibot: cryto eth eur</code></div> <div><code>mobibot: cryto eth eur</code></div>
</li> </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> <div><code>mobibot: currency 17.54 USD to EUR</code></div>
</li> </li>
<li>Performing Google searches <li>Performing Google searches
@ -86,7 +86,7 @@
<div><code>mobibot: chatgpt explain quantum computing in simple terms</code></div> <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> <div><code>mobibot: gemini what are all the colors in a rainbow?</code></div>
</li> </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 san francisco</code></div>
<div><code>mobibot: weather 94123 </code></div> <div><code>mobibot: weather 94123 </code></div>
<div><code>mobibot: weather tokyo, jp</code></div> <div><code>mobibot: weather tokyo, jp</code></div>