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.") + } + + } }