diff --git a/.github/workflows/bld.yml b/.github/workflows/bld.yml
index 37437fd..cf0302b 100644
--- a/.github/workflows/bld.yml
+++ b/.github/workflows/bld.yml
@@ -3,12 +3,12 @@ name: bld-ci
on: [push, pull_request, workflow_dispatch]
env:
- ALPHAVANTAGE_API_KEY: ${{ secrets.ALPHAVANTAGE_API_KEY }}
CHATGPT_API_KEY: ${{ secrets.CHATGPT_API_KEY }}
CI_NAME: "GitHub CI"
COVERAGE_JDK: "21"
COVERAGE_KOTLIN: "2.1.20"
EXCHANGERATE_API_KEY: ${{ secrets.EXCHANGERATE_API_KEY }}
+ FINNHUB_API_KEY: ${{ secrets.FINNHUB_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GOOGLE_CSE_CX: ${{ secrets.GOOGLE_CSE_CX }}
KOTLIN_HOME: /usr/share/kotlinc
diff --git a/.idea/detekt.xml b/.idea/detekt.xml
new file mode 100644
index 0000000..1f9f9ed
--- /dev/null
+++ b/.idea/detekt.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml
index 502be67..27368e9 100644
--- a/config/detekt/baseline.xml
+++ b/config/detekt/baseline.xml
@@ -6,7 +6,7 @@
CyclomaticComplexMethod:Weather2.kt$Weather2.Companion$@JvmStatic @Throws(ModuleException::class) fun getWeather(query: String, apiKey: String?): List<Message>
LongMethod:FeedsManager.kt$FeedsManager.Companion$@JvmStatic fun saveFeed(entries: Entries, currentFile: String = CURRENT_XML)
LongMethod:Mobibot.kt$Mobibot.Companion$@JvmStatic @Throws(Exception::class) fun main(args: Array<String>)
- LongMethod:StockQuote.kt$StockQuote.Companion$@JvmStatic @Throws(ModuleException::class) fun getQuote(symbol: String, apiKey: String?): List<Message>
+ LongMethod:StockQuote2.kt$StockQuote2.Companion$@JvmStatic @Throws(ModuleException::class) fun getQuote(symbol: String, apiKey: String?): List<Message>
LongMethod:Weather2.kt$Weather2.Companion$@JvmStatic @Throws(ModuleException::class) fun getWeather(query: String, apiKey: String?): List<Message>
LongParameterList:Comment.kt$Comment$( channel: String, cmd: String, entry: EntryLink, entryIndex: Int, commentIndex: Int, event: GenericMessageEvent )
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 )
@@ -27,7 +27,8 @@
MagicNumber:Seen.kt$Seen$7
MagicNumber:SocialManager.kt$SocialManager$1000L
MagicNumber:SocialManager.kt$SocialManager$60L
- MagicNumber:StockQuote.kt$StockQuote.Companion$10
+ MagicNumber:StockQuote2.kt$StockQuote2.Companion$10
+ MagicNumber:StockQuote2.kt$StockQuote2.Companion$4
MagicNumber:Tell.kt$Tell$50
MagicNumber:Tell.kt$Tell$7
MagicNumber:Users.kt$Users$8
@@ -55,10 +56,11 @@
NestedBlockDepth:GoogleSearch.kt$GoogleSearch.Companion$@JvmStatic @Throws(ModuleException::class) fun searchGoogle( query: String, apiKey: String?, cseKey: String?, quotaUser: String = ReleaseInfo.PROJECT ): List<Message>
NestedBlockDepth:LinksManager.kt$LinksManager$override fun commandResponse(channel: String, args: String, event: GenericMessageEvent)
NestedBlockDepth:Lookup.kt$Lookup$override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent)
- NestedBlockDepth:Mastodon.kt$Mastodon.Companion$@JvmStatic @Throws(ModuleException::class) fun toot(apiKey: String?, instance: String?, handle: String?, message: String, isDm: Boolean): String
+ NestedBlockDepth:Mastodon.kt$Mastodon.Companion$@JvmStatic @Throws(ModuleException::class) fun toot(accessToken: String?, instance: String?, handle: String?, message: String, isDm: Boolean): String
NestedBlockDepth:Posting.kt$Posting$override fun commandResponse(channel: String, args: String, event: GenericMessageEvent)
NestedBlockDepth:Seen.kt$Seen$override fun commandResponse(channel: String, args: String, event: GenericMessageEvent)
- NestedBlockDepth:StockQuote.kt$StockQuote.Companion$@JvmStatic @Throws(ModuleException::class) fun getQuote(symbol: String, apiKey: String?): List<Message>
+ NestedBlockDepth:StockQuote2.kt$StockQuote2.Companion$@JvmStatic @Throws(ModuleException::class) fun getQuote(symbol: String, apiKey: String?): List<Message>
+ NestedBlockDepth:StockQuote2.kt$StockQuote2.Companion$@JvmStatic @Throws(ModuleException::class) fun lookup(keywords: String, apiKey: String?): List<Message>
NestedBlockDepth:Tell.kt$Tell$fun send(event: GenericUserEvent)
NestedBlockDepth:Utils.kt$Utils$@JvmStatic fun loadSerialData(file: String, default: Any, logger: Logger, description: String): Any
NestedBlockDepth:Utils.kt$Utils$@JvmStatic fun saveSerialData(file: String, data: Any, logger: Logger, description: String)
@@ -70,14 +72,14 @@
ReturnCount:Seen.kt$Seen$override fun commandResponse(channel: String, args: String, event: GenericMessageEvent)
ThrowsCount:GoogleSearch.kt$GoogleSearch.Companion$@JvmStatic @Throws(ModuleException::class) fun searchGoogle( query: String, apiKey: String?, cseKey: String?, quotaUser: String = ReleaseInfo.PROJECT ): List<Message>
ThrowsCount:Joke.kt$Joke.Companion$@JvmStatic @Throws(ModuleException::class) fun randomJoke(): List<Message>
- ThrowsCount:Mastodon.kt$Mastodon.Companion$@JvmStatic @Throws(ModuleException::class) fun toot(apiKey: String?, instance: String?, handle: String?, message: String, isDm: Boolean): String
- ThrowsCount:StockQuote.kt$StockQuote.Companion$@JvmStatic @Throws(ModuleException::class) fun getQuote(symbol: String, apiKey: String?): List<Message>
- ThrowsCount:StockQuote.kt$StockQuote.Companion$@Throws(ModuleException::class) private fun getJsonResponse(response: String, debugMessage: String): JSONObject
+ ThrowsCount:Mastodon.kt$Mastodon.Companion$@JvmStatic @Throws(ModuleException::class) fun toot(accessToken: String?, instance: String?, handle: String?, message: String, isDm: Boolean): String
+ ThrowsCount:StockQuote2.kt$StockQuote2.Companion$@JvmStatic @Throws(ModuleException::class) fun getQuote(symbol: String, apiKey: String?): List<Message>
+ ThrowsCount:StockQuote2.kt$StockQuote2.Companion$@JvmStatic @Throws(ModuleException::class) fun lookup(keywords: String, apiKey: String?): List<Message>
ThrowsCount:Weather2.kt$Weather2.Companion$@JvmStatic @Throws(ModuleException::class) fun getWeather(query: String, apiKey: String?): List<Message>
ThrowsCount:WolframAlpha.kt$WolframAlpha.Companion$@JvmStatic @Throws(ModuleException::class) fun queryWolfram(query: String, units: String = IMPERIAL, appId: String?): String
TooGenericExceptionCaught:ChatGpt2.kt$ChatGpt2.Companion$e: Exception
TooGenericExceptionCaught:Gemini2.kt$Gemini2.Companion$e: Exception
- TooGenericExceptionCaught:StockQuote.kt$StockQuote.Companion$e: NullPointerException
+ TooGenericExceptionCaught:StockQuote2.kt$StockQuote2.Companion$e: NullPointerException
TooGenericExceptionCaught:Weather2.kt$Weather2.Companion$e: NullPointerException
TooManyFunctions:EntryLink.kt$EntryLink : Serializable
TooManyFunctions:Mobibot.kt$Mobibot : ListenerAdapter
@@ -101,7 +103,7 @@
WildcardImport:Mobibot.kt$import org.pircbotx.hooks.events.*
WildcardImport:ModuleExceptionTest.kt$import assertk.assertions.*
WildcardImport:SeenTest.kt$import assertk.assertions.*
- WildcardImport:StockQuoteTest.kt$import assertk.assertions.*
+ WildcardImport:StockQuote2Test.kt$import assertk.assertions.*
WildcardImport:TellMessagesMgrTest.kt$import assertk.assertions.*
WildcardImport:Utils.kt$import java.io.*
WildcardImport:Weather2Test.kt$import assertk.assertions.*
diff --git a/properties/mobibot.properties b/properties/mobibot.properties
index 0520fdd..90e4ab1 100644
--- a/properties/mobibot.properties
+++ b/properties/mobibot.properties
@@ -65,9 +65,9 @@ disabled-modules=mastodon
#owm-api-key=
#
-# Get Alpha Vantage Stock Quote API key from: https://www.alphavantage.co/support/#api-key
+# Get Finnhub (Stock Quote) API key from: https://finnhub.io/
#
-#alphavantage-api-key=
+#finnhub-api-key=
#
# Get Wolfram Alpha AppID from: https://developer.wolframalpha.com/
diff --git a/src/main/kotlin/net/thauvin/erik/mobibot/Mobibot.kt b/src/main/kotlin/net/thauvin/erik/mobibot/Mobibot.kt
index ee726fc..39e2c35 100644
--- a/src/main/kotlin/net/thauvin/erik/mobibot/Mobibot.kt
+++ b/src/main/kotlin/net/thauvin/erik/mobibot/Mobibot.kt
@@ -265,7 +265,7 @@ class Mobibot(nickname: String, val channel: String, logsDirPath: String, p: Pro
addons.add(Lookup())
addons.add(Ping())
addons.add(RockPaperScissors())
- addons.add(StockQuote())
+ addons.add(StockQuote2())
addons.add(War())
addons.add(Weather2())
addons.add(WolframAlpha())
diff --git a/src/main/kotlin/net/thauvin/erik/mobibot/ReleaseInfo.kt b/src/main/kotlin/net/thauvin/erik/mobibot/ReleaseInfo.kt
index 7b347fb..e92c408 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+20250509075545"
+ const val VERSION = "0.8.0-rc+20250509175846"
@JvmField
@Suppress("MagicNumber")
val BUILD_DATE: LocalDateTime = LocalDateTime.ofInstant(
- Instant.ofEpochMilli(1746802545281L), ZoneId.systemDefault()
+ Instant.ofEpochMilli(1746838726462L), ZoneId.systemDefault()
)
const val WEBSITE = "https://mobitopia.org/mobibot/"
diff --git a/src/main/kotlin/net/thauvin/erik/mobibot/modules/StockQuote.kt b/src/main/kotlin/net/thauvin/erik/mobibot/modules/StockQuote.kt
deleted file mode 100644
index d267a30..0000000
--- a/src/main/kotlin/net/thauvin/erik/mobibot/modules/StockQuote.kt
+++ /dev/null
@@ -1,240 +0,0 @@
-/*
- * StockQuote.kt
- *
- * Copyright 2004-2025 Erik C. Thauvin (erik@thauvin.net)
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions are met:
- *
- * Redistributions of source code must retain the above copyright notice, this
- * list of conditions and the following disclaimer.
- *
- * Redistributions in binary form must reproduce the above copyright notice,
- * this list of conditions and the following disclaimer in the documentation
- * and/or other materials provided with the distribution.
- *
- * Neither the name of this project nor the names of its contributors may be
- * used to endorse or promote products derived from this software without
- * specific prior written permission.
- *
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
- * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
- * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
- * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
- * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
- * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
- * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
- * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
- * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
- */
-package net.thauvin.erik.mobibot.modules
-
-import net.thauvin.erik.mobibot.Utils.encodeUrl
-import net.thauvin.erik.mobibot.Utils.helpFormat
-import net.thauvin.erik.mobibot.Utils.reader
-import net.thauvin.erik.mobibot.Utils.sendMessage
-import net.thauvin.erik.mobibot.Utils.unescapeXml
-import net.thauvin.erik.mobibot.msg.ErrorMessage
-import net.thauvin.erik.mobibot.msg.Message
-import net.thauvin.erik.mobibot.msg.NoticeMessage
-import net.thauvin.erik.mobibot.msg.PublicMessage
-import org.json.JSONException
-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
-
-/**
- * Retrieves stock quotes from Alpha Vantage.
- */
-class StockQuote : AbstractModule() {
- private val logger: Logger = LoggerFactory.getLogger(StockQuote::class.java)
-
- override val name = SERVICE_NAME
-
- companion object {
- /**
- * The API property key.
- */
- const val API_KEY_PROP = "alphavantage-api-key"
-
- /**
- * The Invalid Symbol error string.
- */
- const val INVALID_SYMBOL = "Invalid symbol."
-
- /**
- * The service name.
- */
- const val SERVICE_NAME = "StockQuote"
-
- // API URL
- private const val API_URL = "https://www.alphavantage.co/query?function="
-
- // Quote command
- private const val STOCK_CMD = "stock"
-
- @Throws(ModuleException::class)
- private fun getJsonResponse(response: String, debugMessage: String): JSONObject {
- return if (response.isNotBlank()) {
- val json = JSONObject(response)
- try {
- val info = json.getString("Information")
- if (info.isNotEmpty()) {
- throw ModuleException(debugMessage, info.unescapeXml())
- }
- } catch (_: JSONException) {
- // Do nothing
- }
- try {
- var error = json.getString("Note")
- if (error.isNotEmpty()) {
- throw ModuleException(debugMessage, error.unescapeXml())
- }
- error = json.getString("Error Message")
- if (error.isNotEmpty()) {
- throw ModuleException(debugMessage, error.unescapeXml())
- }
- } catch (_: JSONException) {
- // Do nothing
- }
- json
- } else {
- throw ModuleException(debugMessage, "Empty Response.")
- }
- }
-
- /**
- * Retrieves a stock quote.
- */
- @JvmStatic
- @Throws(ModuleException::class)
- fun getQuote(symbol: String, apiKey: String?): List {
- if (apiKey.isNullOrBlank()) {
- throw ModuleException(
- "$SERVICE_NAME is disabled.",
- "$SERVICE_NAME is disabled. The API key is missing."
- )
- }
- val messages = mutableListOf()
- if (symbol.isNotBlank()) {
- val debugMessage = "getQuote($symbol)"
- var response: String
- try {
- with(messages) {
- // Search for symbol/keywords
- response = URL(
- "${API_URL}SYMBOL_SEARCH&keywords=" + symbol.encodeUrl() + "&apikey="
- + apiKey.encodeUrl()
- ).reader().body
- var json = getJsonResponse(response, debugMessage)
- val symbols = json.getJSONArray("bestMatches")
- if (symbols.isEmpty) {
- messages.add(ErrorMessage(INVALID_SYMBOL))
- } else {
- val symbolInfo = symbols.getJSONObject(0)
-
- // Get quote for symbol
- response = URL(
- "${API_URL}GLOBAL_QUOTE&symbol="
- + symbolInfo.getString("1. symbol").encodeUrl() + "&apikey="
- + apiKey.encodeUrl()
- ).reader().body
- json = getJsonResponse(response, debugMessage)
- val quote = json.getJSONObject("Global Quote")
- if (quote.isEmpty) {
- add(ErrorMessage(INVALID_SYMBOL))
- } else {
-
- add(
- PublicMessage(
- "Symbol: " + quote.getString("01. symbol").unescapeXml()
- + " [" + symbolInfo.getString("2. name").unescapeXml() + ']'
- )
- )
-
- val pad = 10
-
- add(
- PublicMessage(
- "Price:".padEnd(pad).prependIndent()
- + quote.getString("05. price").unescapeXml()
- )
- )
- add(
- PublicMessage(
- "Previous:".padEnd(pad).prependIndent()
- + quote.getString("08. previous close").unescapeXml()
- )
- )
-
- val data = arrayOf(
- "Open" to "02. open",
- "High" to "03. high",
- "Low" to "04. low",
- "Volume" to "06. volume",
- "Latest" to "07. latest trading day"
- )
-
- data.forEach {
- add(
- NoticeMessage(
- "${it.first}:".padEnd(pad).prependIndent()
- + quote.getString(it.second).unescapeXml()
- )
- )
- }
-
- add(
- NoticeMessage(
- "Change:".padEnd(pad).prependIndent()
- + quote.getString("09. change").unescapeXml()
- + " [" + quote.getString("10. change percent").unescapeXml() + ']'
- )
- )
- }
- }
- }
- } catch (e: IOException) {
- throw ModuleException("$debugMessage: IOE", "An IO error has occurred retrieving a stock quote.", e)
- } catch (e: NullPointerException) {
- throw ModuleException("$debugMessage: NPE", "An error has occurred retrieving a stock quote.", e)
- }
- } else {
- messages.add(ErrorMessage(INVALID_SYMBOL))
- }
- return messages
- }
- }
-
- init {
- commands.add(STOCK_CMD)
- help.add("To retrieve a stock quote:")
- help.add(helpFormat("%c $STOCK_CMD "))
- initProperties(API_KEY_PROP)
- }
-
- /**
- * Returns the specified stock quote from Alpha Vantage.
- */
- override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
- if (args.isNotBlank()) {
- try {
- val messages = getQuote(args, properties[API_KEY_PROP])
- for (msg in messages) {
- event.sendMessage(channel, msg)
- }
- } catch (e: ModuleException) {
- if (logger.isWarnEnabled) logger.warn(e.debugMessage, e)
- e.message?.let {
- event.respond(it)
- }
- }
- } else {
- helpResponse(event)
- }
- }
-}
diff --git a/src/main/kotlin/net/thauvin/erik/mobibot/modules/StockQuote2.kt b/src/main/kotlin/net/thauvin/erik/mobibot/modules/StockQuote2.kt
new file mode 100644
index 0000000..143c41b
--- /dev/null
+++ b/src/main/kotlin/net/thauvin/erik/mobibot/modules/StockQuote2.kt
@@ -0,0 +1,320 @@
+/*
+ * StockQuote.kt
+ *
+ * Copyright 2004-2025 Erik C. Thauvin (erik@thauvin.net)
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * Neither the name of this project nor the names of its contributors may be
+ * used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package net.thauvin.erik.mobibot.modules
+
+import net.thauvin.erik.mobibot.Utils.bold
+import net.thauvin.erik.mobibot.Utils.encodeUrl
+import net.thauvin.erik.mobibot.Utils.helpFormat
+import net.thauvin.erik.mobibot.Utils.reader
+import net.thauvin.erik.mobibot.Utils.sendMessage
+import net.thauvin.erik.mobibot.Utils.unescapeXml
+import net.thauvin.erik.mobibot.msg.ErrorMessage
+import net.thauvin.erik.mobibot.msg.Message
+import net.thauvin.erik.mobibot.msg.NoticeMessage
+import net.thauvin.erik.mobibot.msg.PublicMessage
+import org.json.JSONException
+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.time.Instant
+import java.time.ZoneId
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+
+/**
+ * Retrieves stock quotes from Finnhub.
+ */
+class StockQuote2 : AbstractModule() {
+ override val name = SERVICE_NAME
+
+ companion object {
+ /**
+ * The API property key.
+ */
+ const val API_KEY_PROP = "finnhub-api-key"
+
+ /**
+ * The Invalid Symbol error string.
+ */
+ const val INVALID_SYMBOL = "Invalid symbol."
+
+ /**
+ * The service name.
+ */
+ const val SERVICE_NAME = "StockQuote"
+
+ // API URL
+ private const val API_URL = "https://finnhub.io/api/v1/"
+
+ // Lookup keyword
+ private const val LOOKUP_KEYWORD = "lookup"
+
+ // Quote command
+ private const val STOCK_CMD = "stock"
+
+ // UTC date/time formatter
+ private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm z")
+
+ /**
+ * The logger.
+ */
+ val logger: Logger = LoggerFactory.getLogger(StockQuote2::class.java)
+
+ @Throws(ModuleException::class)
+ private fun getJsonResponse(response: String, debugMessage: String): JSONObject {
+ return if (response.isNotBlank()) {
+ if (logger.isTraceEnabled) logger.trace(response)
+ val json = JSONObject(response)
+ try {
+ val error = json.getString("error")
+ if (error.isNotEmpty()) {
+ throw ModuleException(debugMessage, error.unescapeXml())
+ }
+ } catch (_: JSONException) {
+ // Do nothing
+ }
+ json
+ } else {
+ throw ModuleException(debugMessage, "Empty Response.")
+ }
+ }
+
+ /**
+ * Retrieves a stock quote.
+ */
+ @JvmStatic
+ @Throws(ModuleException::class)
+ fun getQuote(symbol: String, apiKey: String?): List {
+ if (apiKey.isNullOrBlank()) {
+ throw ModuleException(
+ "$SERVICE_NAME is disabled.",
+ "$SERVICE_NAME is disabled. The API key is missing."
+ )
+ }
+ val messages = mutableListOf()
+ if (symbol.isNotBlank()) {
+ val tickerSymbol = symbol.uppercase()
+ val debugMessage = "getQuote($symbol)"
+ val response: String
+ try {
+ with(messages) {
+ // Get stock quote for symbol
+ response = URL(
+ "${API_URL}quote?symbol=" + tickerSymbol.encodeUrl() + "&token="
+ + apiKey.encodeUrl()
+ ).reader().body
+ val json = getJsonResponse(response, debugMessage)
+ val c = json.getBigDecimal("c")
+ if (c == 0.toBigDecimal()) {
+ add(ErrorMessage(INVALID_SYMBOL))
+ return messages
+ }
+ val change = json.getBigDecimal("d")
+ val changePercent = json.getBigDecimal("dp")
+ val high = json.getBigDecimal("h")
+ val low = json.getBigDecimal("l")
+ val open = json.getBigDecimal("o")
+ val previous = json.getBigDecimal("pc")
+ val t = json.getInt("t")
+
+ val latest = formatter.format(
+ ZonedDateTime.ofInstant(
+ Instant.ofEpochSecond(t.toLong()),
+ ZoneId.of("UTC")
+ )
+ )
+
+ add(
+ PublicMessage(
+ "Symbol: $tickerSymbol"
+ )
+ )
+
+ val pad = 10
+
+ add(
+ PublicMessage(
+ "Price: ".padEnd(pad).prependIndent() + c
+ )
+ )
+ add(
+ NoticeMessage(
+ "Change: ".padEnd(pad).prependIndent() + change + " [$changePercent%]"
+ )
+ )
+ add(
+ NoticeMessage(
+ "High: ".padEnd(pad).prependIndent() + high
+ )
+ )
+ add(
+ NoticeMessage(
+ "Low: ".padEnd(pad).prependIndent() + low
+ )
+ )
+ add(
+ NoticeMessage(
+ "Open: ".padEnd(pad).prependIndent() + open
+ )
+ )
+ add(
+ PublicMessage(
+ "Previous: ".padEnd(pad).prependIndent() + previous
+ )
+ )
+ add(
+ NoticeMessage(
+ "Latest: ".padEnd(pad).prependIndent() + latest
+ )
+ )
+ }
+ } catch (e: IOException) {
+ throw ModuleException(
+ "$debugMessage: IOE",
+ "An IO error has occurred retrieving a stock quote.", e
+ )
+ } catch (e: NullPointerException) {
+ throw ModuleException(
+ "$debugMessage: NPE",
+ "An error has occurred retrieving a stock quote.", e
+ )
+ }
+ } else {
+ messages.add(ErrorMessage(INVALID_SYMBOL))
+ }
+ return messages
+ }
+
+ /**
+ * Retrieves a stock quote.
+ */
+ @JvmStatic
+ @Throws(ModuleException::class)
+ fun lookup(keywords: String, apiKey: String?): List {
+ if (apiKey.isNullOrBlank()) {
+ throw ModuleException(
+ "$SERVICE_NAME is disabled.",
+ "$SERVICE_NAME is disabled. The API key is missing."
+ )
+ }
+ val messages = mutableListOf()
+ if (keywords.isNotBlank()) {
+ val debugMessage = "getQuote($keywords)"
+ val response: String
+ try {
+ with(messages) {
+ // Search for symbol/keywords
+ response = URL(
+ "${API_URL}search?q=" + keywords.encodeUrl() + "&exchange=US&token="
+ + apiKey.encodeUrl()
+ ).reader().body
+ val json = getJsonResponse(response, debugMessage)
+ val count = json.getInt("count")
+ if (count == 0) {
+ add(ErrorMessage("Nothing found."))
+ return messages
+ }
+
+ val results = json.getJSONArray("result")
+
+ for (i in 0 until count) {
+ val result = results.getJSONObject(i)
+ val symbol = result.getString("symbol")
+ val name = result.getString("description")
+
+ add(
+ NoticeMessage("${symbol.bold()}: $name")
+ )
+
+ if (i >= 4) {
+ break
+ }
+ }
+
+ }
+ } catch (e: IOException) {
+ throw ModuleException(
+ "$debugMessage: IOE",
+ "An IO error has occurred looking up.", e
+ )
+ } catch (e: NullPointerException) {
+ throw ModuleException(
+ "$debugMessage: NPE",
+ "An error has occurred looking up.", e
+ )
+ }
+ } else {
+ messages.add(ErrorMessage("Please specify at least one search term."))
+ }
+ return messages
+ }
+ }
+
+ init {
+ commands.add(STOCK_CMD)
+ help.add("To retrieve a stock quote:")
+ help.add(helpFormat("%c $STOCK_CMD symbol"))
+ help.add("To lookup a symbol:")
+ help.add(helpFormat("%c $STOCK_CMD $LOOKUP_KEYWORD "))
+ initProperties(API_KEY_PROP)
+ }
+
+ /**
+ * Returns the specified stock quote from Alpha Vantage.
+ */
+ override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
+ if (args.isNotBlank()) {
+ try {
+ val messages = if (args.startsWith(LOOKUP_KEYWORD)) {
+ lookup(
+ args.substring(LOOKUP_KEYWORD.length).trim(),
+ properties[API_KEY_PROP]
+ )
+ } else {
+ getQuote(args, properties[API_KEY_PROP])
+ }
+ for (msg in messages) {
+ event.sendMessage(channel, msg)
+ }
+ } catch (e: ModuleException) {
+ if (logger.isWarnEnabled) logger.warn(e.debugMessage, e)
+ e.message?.let {
+ event.respond(it)
+ }
+ }
+ } else {
+ helpResponse(event)
+ }
+ }
+}
diff --git a/src/test/kotlin/net/thauvin/erik/mobibot/modules/StockQuoteTest.kt b/src/test/kotlin/net/thauvin/erik/mobibot/modules/StockQuote2Test.kt
similarity index 65%
rename from src/test/kotlin/net/thauvin/erik/mobibot/modules/StockQuoteTest.kt
rename to src/test/kotlin/net/thauvin/erik/mobibot/modules/StockQuote2Test.kt
index 8f321c2..5ba8414 100644
--- a/src/test/kotlin/net/thauvin/erik/mobibot/modules/StockQuoteTest.kt
+++ b/src/test/kotlin/net/thauvin/erik/mobibot/modules/StockQuote2Test.kt
@@ -36,7 +36,8 @@ import assertk.assertThat
import assertk.assertions.*
import net.thauvin.erik.mobibot.ExceptionSanitizer.sanitize
import net.thauvin.erik.mobibot.LocalProperties
-import net.thauvin.erik.mobibot.modules.StockQuote.Companion.getQuote
+import net.thauvin.erik.mobibot.modules.StockQuote2.Companion.getQuote
+import net.thauvin.erik.mobibot.modules.StockQuote2.Companion.lookup
import net.thauvin.erik.mobibot.msg.ErrorMessage
import net.thauvin.erik.mobibot.msg.Message
import org.junit.jupiter.api.DisplayName
@@ -46,12 +47,8 @@ import org.mockito.Mockito
import org.pircbotx.hooks.types.GenericMessageEvent
import kotlin.test.Test
-class StockQuoteTest : LocalProperties() {
- private val apiKey = getProperty(StockQuote.API_KEY_PROP)
-
- private fun buildMatch(label: String): String {
- return "${label}:[ ]+[0-9.]+".prependIndent()
- }
+class StockQuote2Test : LocalProperties() {
+ private val apiKey = getProperty(StockQuote2.API_KEY_PROP)
private fun getSanitizedQuote(symbol: String, apiKey: String): List {
try {
@@ -66,19 +63,33 @@ class StockQuoteTest : LocalProperties() {
}
}
+ private fun getSanitizedLookup(keywords: String, apiKey: String): List {
+ try {
+ return lookup(keywords, apiKey)
+ } catch (e: ModuleException) {
+ // Avoid displaying api keys in CI logs
+ if ("true" == System.getenv("CI")) {
+ throw e.sanitize(apiKey)
+ } else {
+ throw e
+ }
+ }
+ }
+
@Nested
@DisplayName("Command Response Tests")
inner class CommandResponseTests {
@Test
fun `API Key is missing`() {
- val stockQuote = StockQuote()
+ val stockQuote = StockQuote2()
val event = Mockito.mock(GenericMessageEvent::class.java)
val captor = ArgumentCaptor.forClass(String::class.java)
stockQuote.commandResponse("channel", "stock", "goog", event)
Mockito.verify(event, Mockito.atLeastOnce()).respond(captor.capture())
- assertThat(captor.value).isEqualTo("${StockQuote.SERVICE_NAME} is disabled. The API key is missing.")
+ assertThat(captor.value)
+ .isEqualTo("${StockQuote2.SERVICE_NAME} is disabled. The API key is missing.")
}
}
@@ -97,24 +108,22 @@ class StockQuoteTest : LocalProperties() {
fun `Symbol should not be empty`() {
assertThat(getSanitizedQuote("", "apikey").first(), "getQuote(empty)").all {
isInstanceOf(ErrorMessage::class.java)
- prop(Message::msg).isEqualTo(StockQuote.INVALID_SYMBOL)
+ prop(Message::msg).isEqualTo(StockQuote2.INVALID_SYMBOL)
}
}
@Test
@Throws(ModuleException::class)
fun `Get stock quote for Apple`() {
- val symbol = "apple inc"
+ val symbol = "aapl"
val messages = getSanitizedQuote(symbol, apiKey)
assertThat(messages, "response not empty").isNotEmpty()
assertThat(messages, "getQuote($symbol)").index(0).prop(Message::msg)
- .matches("Symbol: AAPL .*".toRegex())
+ .isEqualTo("Symbol: AAPL")
assertThat(messages, "getQuote($symbol)").index(1).prop(Message::msg)
- .matches(buildMatch("Price").toRegex())
- assertThat(messages, "getQuote($symbol)").index(2).prop(Message::msg)
- .matches(buildMatch("Previous").toRegex())
- assertThat(messages, "getQuote($symbol)").index(3).prop(Message::msg)
- .matches(buildMatch("Open").toRegex())
+ .matches("\\s+Price:\\s+\\d+\\.\\d+.*".toRegex())
+ assertThat(messages, "getQuote($symbol)").index(7).prop(Message::msg)
+ .matches("\\s+Latest:\\s+\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2} UTC".toRegex())
}
@Test
@@ -124,7 +133,7 @@ class StockQuoteTest : LocalProperties() {
val messages = getSanitizedQuote(symbol, apiKey)
assertThat(messages, "response not empty").isNotEmpty()
assertThat(messages, "getQuote($symbol)").index(0).prop(Message::msg)
- .matches("Symbol: GOOG .*".toRegex())
+ .equals("Symbol: GOOG")
}
@Test
@@ -133,8 +142,42 @@ class StockQuoteTest : LocalProperties() {
val symbol = "foobar"
assertThat(getSanitizedQuote(symbol, apiKey).first(), "getQuote($symbol)").all {
isInstanceOf(ErrorMessage::class.java)
- prop(Message::msg).isEqualTo(StockQuote.INVALID_SYMBOL)
+ prop(Message::msg).isEqualTo(StockQuote2.INVALID_SYMBOL)
}
}
}
+
+ @Nested
+ @DisplayName("Lookup Tests")
+ inner class LookupTests {
+ @Test
+ @Throws(ModuleException::class)
+ fun `Lookup alphabet`() {
+ val keywords = "alphabet inc"
+ val messages = getSanitizedLookup(keywords, apiKey)
+ assertThat(messages, "messages should not be empty").isNotEmpty()
+ assertThat(messages, "lookup($keywords)").index(1).prop(Message::msg)
+ .matches("\u0002\\w+\u0002: .*".toRegex())
+
+ var hasGoog = false
+ for (msg in messages) {
+ if (msg.msg.matches("\u0002GOOG\u0002: .*".toRegex())) {
+ hasGoog = true
+ break
+ }
+ }
+ assertThat(hasGoog, "GOOG not found").isTrue()
+ }
+
+ @Test
+ @Throws(ModuleException::class)
+ fun `Lookup empty keywords`() {
+ val keywords = ""
+ val messages = getSanitizedLookup(keywords, apiKey)
+ assertThat(messages, "response not empty").isNotEmpty()
+ assertThat(messages, "lookup($keywords)").index(0).prop(Message::msg)
+ .isEqualTo("Please specify at least one search term.")
+ }
+
+ }
}