Reimplement StockQuote module using Finnhub

This commit is contained in:
Erik C. Thauvin 2025-05-09 17:58:36 -07:00
parent ea2d1a86ba
commit da04d43ce8
Signed by: erik
GPG key ID: 776702A6A2DA330E
9 changed files with 407 additions and 274 deletions

View file

@ -3,12 +3,12 @@ name: bld-ci
on: [push, pull_request, workflow_dispatch] on: [push, pull_request, workflow_dispatch]
env: env:
ALPHAVANTAGE_API_KEY: ${{ secrets.ALPHAVANTAGE_API_KEY }}
CHATGPT_API_KEY: ${{ secrets.CHATGPT_API_KEY }} CHATGPT_API_KEY: ${{ secrets.CHATGPT_API_KEY }}
CI_NAME: "GitHub CI" CI_NAME: "GitHub CI"
COVERAGE_JDK: "21" COVERAGE_JDK: "21"
COVERAGE_KOTLIN: "2.1.20" COVERAGE_KOTLIN: "2.1.20"
EXCHANGERATE_API_KEY: ${{ secrets.EXCHANGERATE_API_KEY }} EXCHANGERATE_API_KEY: ${{ secrets.EXCHANGERATE_API_KEY }}
FINNHUB_API_KEY: ${{ secrets.FINNHUB_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GOOGLE_CSE_CX: ${{ secrets.GOOGLE_CSE_CX }} GOOGLE_CSE_CX: ${{ secrets.GOOGLE_CSE_CX }}
KOTLIN_HOME: /usr/share/kotlinc KOTLIN_HOME: /usr/share/kotlinc

8
.idea/detekt.xml generated Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DetektPluginSettings">
<option name="baselinePath" value="$PROJECT_DIR$/config/detekt/baseline.xml" />
<option name="enableDetekt" value="true" />
<option name="enableForProjectResult" value="Accepted" />
</component>
</project>

View file

@ -6,7 +6,7 @@
<ID>CyclomaticComplexMethod:Weather2.kt$Weather2.Companion$@JvmStatic @Throws(ModuleException::class) fun getWeather(query: String, apiKey: String?): List&lt;Message&gt;</ID> <ID>CyclomaticComplexMethod:Weather2.kt$Weather2.Companion$@JvmStatic @Throws(ModuleException::class) fun getWeather(query: String, apiKey: String?): List&lt;Message&gt;</ID>
<ID>LongMethod:FeedsManager.kt$FeedsManager.Companion$@JvmStatic fun saveFeed(entries: Entries, currentFile: String = CURRENT_XML)</ID> <ID>LongMethod:FeedsManager.kt$FeedsManager.Companion$@JvmStatic fun saveFeed(entries: Entries, currentFile: String = CURRENT_XML)</ID>
<ID>LongMethod:Mobibot.kt$Mobibot.Companion$@JvmStatic @Throws(Exception::class) fun main(args: Array&lt;String&gt;)</ID> <ID>LongMethod:Mobibot.kt$Mobibot.Companion$@JvmStatic @Throws(Exception::class) fun main(args: Array&lt;String&gt;)</ID>
<ID>LongMethod:StockQuote.kt$StockQuote.Companion$@JvmStatic @Throws(ModuleException::class) fun getQuote(symbol: String, apiKey: String?): List&lt;Message&gt;</ID> <ID>LongMethod:StockQuote2.kt$StockQuote2.Companion$@JvmStatic @Throws(ModuleException::class) fun getQuote(symbol: String, apiKey: String?): List&lt;Message&gt;</ID>
<ID>LongMethod:Weather2.kt$Weather2.Companion$@JvmStatic @Throws(ModuleException::class) fun getWeather(query: String, apiKey: String?): List&lt;Message&gt;</ID> <ID>LongMethod:Weather2.kt$Weather2.Companion$@JvmStatic @Throws(ModuleException::class) fun getWeather(query: String, apiKey: String?): List&lt;Message&gt;</ID>
<ID>LongParameterList:Comment.kt$Comment$( channel: String, cmd: String, entry: EntryLink, entryIndex: Int, commentIndex: Int, event: GenericMessageEvent )</ID> <ID>LongParameterList:Comment.kt$Comment$( channel: String, cmd: String, entry: EntryLink, entryIndex: Int, commentIndex: Int, event: GenericMessageEvent )</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>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>
@ -27,7 +27,8 @@
<ID>MagicNumber:Seen.kt$Seen$7</ID> <ID>MagicNumber:Seen.kt$Seen$7</ID>
<ID>MagicNumber:SocialManager.kt$SocialManager$1000L</ID> <ID>MagicNumber:SocialManager.kt$SocialManager$1000L</ID>
<ID>MagicNumber:SocialManager.kt$SocialManager$60L</ID> <ID>MagicNumber:SocialManager.kt$SocialManager$60L</ID>
<ID>MagicNumber:StockQuote.kt$StockQuote.Companion$10</ID> <ID>MagicNumber:StockQuote2.kt$StockQuote2.Companion$10</ID>
<ID>MagicNumber:StockQuote2.kt$StockQuote2.Companion$4</ID>
<ID>MagicNumber:Tell.kt$Tell$50</ID> <ID>MagicNumber:Tell.kt$Tell$50</ID>
<ID>MagicNumber:Tell.kt$Tell$7</ID> <ID>MagicNumber:Tell.kt$Tell$7</ID>
<ID>MagicNumber:Users.kt$Users$8</ID> <ID>MagicNumber:Users.kt$Users$8</ID>
@ -55,10 +56,11 @@
<ID>NestedBlockDepth:GoogleSearch.kt$GoogleSearch.Companion$@JvmStatic @Throws(ModuleException::class) fun searchGoogle( query: String, apiKey: String?, cseKey: String?, quotaUser: String = ReleaseInfo.PROJECT ): List&lt;Message&gt;</ID> <ID>NestedBlockDepth:GoogleSearch.kt$GoogleSearch.Companion$@JvmStatic @Throws(ModuleException::class) fun searchGoogle( query: String, apiKey: String?, cseKey: String?, quotaUser: String = ReleaseInfo.PROJECT ): List&lt;Message&gt;</ID>
<ID>NestedBlockDepth:LinksManager.kt$LinksManager$override fun commandResponse(channel: String, args: String, event: GenericMessageEvent)</ID> <ID>NestedBlockDepth:LinksManager.kt$LinksManager$override fun commandResponse(channel: String, args: String, event: GenericMessageEvent)</ID>
<ID>NestedBlockDepth:Lookup.kt$Lookup$override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent)</ID> <ID>NestedBlockDepth:Lookup.kt$Lookup$override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent)</ID>
<ID>NestedBlockDepth:Mastodon.kt$Mastodon.Companion$@JvmStatic @Throws(ModuleException::class) fun toot(apiKey: String?, instance: String?, handle: String?, message: String, isDm: Boolean): String</ID> <ID>NestedBlockDepth:Mastodon.kt$Mastodon.Companion$@JvmStatic @Throws(ModuleException::class) fun toot(accessToken: String?, instance: String?, handle: String?, message: String, isDm: Boolean): String</ID>
<ID>NestedBlockDepth:Posting.kt$Posting$override fun commandResponse(channel: String, args: String, event: GenericMessageEvent)</ID> <ID>NestedBlockDepth:Posting.kt$Posting$override fun commandResponse(channel: String, args: String, event: GenericMessageEvent)</ID>
<ID>NestedBlockDepth:Seen.kt$Seen$override fun commandResponse(channel: String, args: String, event: GenericMessageEvent)</ID> <ID>NestedBlockDepth:Seen.kt$Seen$override fun commandResponse(channel: String, args: String, event: GenericMessageEvent)</ID>
<ID>NestedBlockDepth:StockQuote.kt$StockQuote.Companion$@JvmStatic @Throws(ModuleException::class) fun getQuote(symbol: String, apiKey: String?): List&lt;Message&gt;</ID> <ID>NestedBlockDepth:StockQuote2.kt$StockQuote2.Companion$@JvmStatic @Throws(ModuleException::class) fun getQuote(symbol: String, apiKey: String?): List&lt;Message&gt;</ID>
<ID>NestedBlockDepth:StockQuote2.kt$StockQuote2.Companion$@JvmStatic @Throws(ModuleException::class) fun lookup(keywords: String, apiKey: String?): List&lt;Message&gt;</ID>
<ID>NestedBlockDepth:Tell.kt$Tell$fun send(event: GenericUserEvent)</ID> <ID>NestedBlockDepth:Tell.kt$Tell$fun send(event: GenericUserEvent)</ID>
<ID>NestedBlockDepth:Utils.kt$Utils$@JvmStatic fun loadSerialData(file: String, default: Any, logger: Logger, description: String): Any</ID> <ID>NestedBlockDepth:Utils.kt$Utils$@JvmStatic fun loadSerialData(file: String, default: Any, logger: Logger, description: String): Any</ID>
<ID>NestedBlockDepth:Utils.kt$Utils$@JvmStatic fun saveSerialData(file: String, data: Any, logger: Logger, description: String)</ID> <ID>NestedBlockDepth:Utils.kt$Utils$@JvmStatic fun saveSerialData(file: String, data: Any, logger: Logger, description: String)</ID>
@ -70,14 +72,14 @@
<ID>ReturnCount:Seen.kt$Seen$override fun commandResponse(channel: String, args: String, event: GenericMessageEvent)</ID> <ID>ReturnCount:Seen.kt$Seen$override fun commandResponse(channel: String, args: String, event: GenericMessageEvent)</ID>
<ID>ThrowsCount:GoogleSearch.kt$GoogleSearch.Companion$@JvmStatic @Throws(ModuleException::class) fun searchGoogle( query: String, apiKey: String?, cseKey: String?, quotaUser: String = ReleaseInfo.PROJECT ): List&lt;Message&gt;</ID> <ID>ThrowsCount:GoogleSearch.kt$GoogleSearch.Companion$@JvmStatic @Throws(ModuleException::class) fun searchGoogle( query: String, apiKey: String?, cseKey: String?, quotaUser: String = ReleaseInfo.PROJECT ): List&lt;Message&gt;</ID>
<ID>ThrowsCount:Joke.kt$Joke.Companion$@JvmStatic @Throws(ModuleException::class) fun randomJoke(): List&lt;Message&gt;</ID> <ID>ThrowsCount:Joke.kt$Joke.Companion$@JvmStatic @Throws(ModuleException::class) fun randomJoke(): List&lt;Message&gt;</ID>
<ID>ThrowsCount:Mastodon.kt$Mastodon.Companion$@JvmStatic @Throws(ModuleException::class) fun toot(apiKey: String?, instance: String?, handle: String?, message: String, isDm: Boolean): String</ID> <ID>ThrowsCount:Mastodon.kt$Mastodon.Companion$@JvmStatic @Throws(ModuleException::class) fun toot(accessToken: String?, instance: String?, handle: String?, message: String, isDm: Boolean): String</ID>
<ID>ThrowsCount:StockQuote.kt$StockQuote.Companion$@JvmStatic @Throws(ModuleException::class) fun getQuote(symbol: String, apiKey: String?): List&lt;Message&gt;</ID> <ID>ThrowsCount:StockQuote2.kt$StockQuote2.Companion$@JvmStatic @Throws(ModuleException::class) fun getQuote(symbol: String, apiKey: String?): List&lt;Message&gt;</ID>
<ID>ThrowsCount:StockQuote.kt$StockQuote.Companion$@Throws(ModuleException::class) private fun getJsonResponse(response: String, debugMessage: String): JSONObject</ID> <ID>ThrowsCount:StockQuote2.kt$StockQuote2.Companion$@JvmStatic @Throws(ModuleException::class) fun lookup(keywords: String, apiKey: String?): List&lt;Message&gt;</ID>
<ID>ThrowsCount:Weather2.kt$Weather2.Companion$@JvmStatic @Throws(ModuleException::class) fun getWeather(query: String, apiKey: String?): List&lt;Message&gt;</ID> <ID>ThrowsCount:Weather2.kt$Weather2.Companion$@JvmStatic @Throws(ModuleException::class) fun getWeather(query: String, apiKey: String?): List&lt;Message&gt;</ID>
<ID>ThrowsCount:WolframAlpha.kt$WolframAlpha.Companion$@JvmStatic @Throws(ModuleException::class) fun queryWolfram(query: String, units: String = IMPERIAL, appId: String?): String</ID> <ID>ThrowsCount:WolframAlpha.kt$WolframAlpha.Companion$@JvmStatic @Throws(ModuleException::class) fun queryWolfram(query: String, units: String = IMPERIAL, appId: String?): String</ID>
<ID>TooGenericExceptionCaught:ChatGpt2.kt$ChatGpt2.Companion$e: Exception</ID> <ID>TooGenericExceptionCaught:ChatGpt2.kt$ChatGpt2.Companion$e: Exception</ID>
<ID>TooGenericExceptionCaught:Gemini2.kt$Gemini2.Companion$e: Exception</ID> <ID>TooGenericExceptionCaught:Gemini2.kt$Gemini2.Companion$e: Exception</ID>
<ID>TooGenericExceptionCaught:StockQuote.kt$StockQuote.Companion$e: NullPointerException</ID> <ID>TooGenericExceptionCaught:StockQuote2.kt$StockQuote2.Companion$e: NullPointerException</ID>
<ID>TooGenericExceptionCaught:Weather2.kt$Weather2.Companion$e: NullPointerException</ID> <ID>TooGenericExceptionCaught:Weather2.kt$Weather2.Companion$e: NullPointerException</ID>
<ID>TooManyFunctions:EntryLink.kt$EntryLink : Serializable</ID> <ID>TooManyFunctions:EntryLink.kt$EntryLink : Serializable</ID>
<ID>TooManyFunctions:Mobibot.kt$Mobibot : ListenerAdapter</ID> <ID>TooManyFunctions:Mobibot.kt$Mobibot : ListenerAdapter</ID>
@ -101,7 +103,7 @@
<ID>WildcardImport:Mobibot.kt$import org.pircbotx.hooks.events.*</ID> <ID>WildcardImport:Mobibot.kt$import org.pircbotx.hooks.events.*</ID>
<ID>WildcardImport:ModuleExceptionTest.kt$import assertk.assertions.*</ID> <ID>WildcardImport:ModuleExceptionTest.kt$import assertk.assertions.*</ID>
<ID>WildcardImport:SeenTest.kt$import assertk.assertions.*</ID> <ID>WildcardImport:SeenTest.kt$import assertk.assertions.*</ID>
<ID>WildcardImport:StockQuoteTest.kt$import assertk.assertions.*</ID> <ID>WildcardImport:StockQuote2Test.kt$import assertk.assertions.*</ID>
<ID>WildcardImport:TellMessagesMgrTest.kt$import assertk.assertions.*</ID> <ID>WildcardImport:TellMessagesMgrTest.kt$import assertk.assertions.*</ID>
<ID>WildcardImport:Utils.kt$import java.io.*</ID> <ID>WildcardImport:Utils.kt$import java.io.*</ID>
<ID>WildcardImport:Weather2Test.kt$import assertk.assertions.*</ID> <ID>WildcardImport:Weather2Test.kt$import assertk.assertions.*</ID>

View file

@ -65,9 +65,9 @@ disabled-modules=mastodon
#owm-api-key= #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/ # Get Wolfram Alpha AppID from: https://developer.wolframalpha.com/

View file

@ -265,7 +265,7 @@ class Mobibot(nickname: String, val channel: String, logsDirPath: String, p: Pro
addons.add(Lookup()) addons.add(Lookup())
addons.add(Ping()) addons.add(Ping())
addons.add(RockPaperScissors()) addons.add(RockPaperScissors())
addons.add(StockQuote()) addons.add(StockQuote2())
addons.add(War()) addons.add(War())
addons.add(Weather2()) addons.add(Weather2())
addons.add(WolframAlpha()) addons.add(WolframAlpha())

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+20250509075545" const val VERSION = "0.8.0-rc+20250509175846"
@JvmField @JvmField
@Suppress("MagicNumber") @Suppress("MagicNumber")
val BUILD_DATE: LocalDateTime = LocalDateTime.ofInstant( val BUILD_DATE: LocalDateTime = LocalDateTime.ofInstant(
Instant.ofEpochMilli(1746802545281L), ZoneId.systemDefault() Instant.ofEpochMilli(1746838726462L), ZoneId.systemDefault()
) )
const val WEBSITE = "https://mobitopia.org/mobibot/" const val WEBSITE = "https://mobitopia.org/mobibot/"

View file

@ -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<Message> {
if (apiKey.isNullOrBlank()) {
throw ModuleException(
"$SERVICE_NAME is disabled.",
"$SERVICE_NAME is disabled. The API key is missing."
)
}
val messages = mutableListOf<Message>()
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 <symbol|keywords>"))
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)
}
}
}

View file

@ -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<Message> {
if (apiKey.isNullOrBlank()) {
throw ModuleException(
"$SERVICE_NAME is disabled.",
"$SERVICE_NAME is disabled. The API key is missing."
)
}
val messages = mutableListOf<Message>()
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<Message> {
if (apiKey.isNullOrBlank()) {
throw ModuleException(
"$SERVICE_NAME is disabled.",
"$SERVICE_NAME is disabled. The API key is missing."
)
}
val messages = mutableListOf<Message>()
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 <keywords>"))
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)
}
}
}

View file

@ -36,7 +36,8 @@ import assertk.assertThat
import assertk.assertions.* import assertk.assertions.*
import net.thauvin.erik.mobibot.ExceptionSanitizer.sanitize import net.thauvin.erik.mobibot.ExceptionSanitizer.sanitize
import net.thauvin.erik.mobibot.LocalProperties 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.ErrorMessage
import net.thauvin.erik.mobibot.msg.Message import net.thauvin.erik.mobibot.msg.Message
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
@ -46,12 +47,8 @@ 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 StockQuoteTest : LocalProperties() { class StockQuote2Test : LocalProperties() {
private val apiKey = getProperty(StockQuote.API_KEY_PROP) private val apiKey = getProperty(StockQuote2.API_KEY_PROP)
private fun buildMatch(label: String): String {
return "${label}:[ ]+[0-9.]+".prependIndent()
}
private fun getSanitizedQuote(symbol: String, apiKey: String): List<Message> { private fun getSanitizedQuote(symbol: String, apiKey: String): List<Message> {
try { try {
@ -66,19 +63,33 @@ class StockQuoteTest : LocalProperties() {
} }
} }
private fun getSanitizedLookup(keywords: String, apiKey: String): List<Message> {
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 @Nested
@DisplayName("Command Response Tests") @DisplayName("Command Response Tests")
inner class CommandResponseTests { inner class CommandResponseTests {
@Test @Test
fun `API Key is missing`() { fun `API Key is missing`() {
val stockQuote = StockQuote() val stockQuote = StockQuote2()
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)
stockQuote.commandResponse("channel", "stock", "goog", event) stockQuote.commandResponse("channel", "stock", "goog", event)
Mockito.verify(event, Mockito.atLeastOnce()).respond(captor.capture()) 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`() { fun `Symbol should not be empty`() {
assertThat(getSanitizedQuote("", "apikey").first(), "getQuote(empty)").all { assertThat(getSanitizedQuote("", "apikey").first(), "getQuote(empty)").all {
isInstanceOf(ErrorMessage::class.java) isInstanceOf(ErrorMessage::class.java)
prop(Message::msg).isEqualTo(StockQuote.INVALID_SYMBOL) prop(Message::msg).isEqualTo(StockQuote2.INVALID_SYMBOL)
} }
} }
@Test @Test
@Throws(ModuleException::class) @Throws(ModuleException::class)
fun `Get stock quote for Apple`() { fun `Get stock quote for Apple`() {
val symbol = "apple inc" val symbol = "aapl"
val messages = getSanitizedQuote(symbol, apiKey) val messages = getSanitizedQuote(symbol, apiKey)
assertThat(messages, "response not empty").isNotEmpty() assertThat(messages, "response not empty").isNotEmpty()
assertThat(messages, "getQuote($symbol)").index(0).prop(Message::msg) 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) assertThat(messages, "getQuote($symbol)").index(1).prop(Message::msg)
.matches(buildMatch("Price").toRegex()) .matches("\\s+Price:\\s+\\d+\\.\\d+.*".toRegex())
assertThat(messages, "getQuote($symbol)").index(2).prop(Message::msg) assertThat(messages, "getQuote($symbol)").index(7).prop(Message::msg)
.matches(buildMatch("Previous").toRegex()) .matches("\\s+Latest:\\s+\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2} UTC".toRegex())
assertThat(messages, "getQuote($symbol)").index(3).prop(Message::msg)
.matches(buildMatch("Open").toRegex())
} }
@Test @Test
@ -124,7 +133,7 @@ class StockQuoteTest : LocalProperties() {
val messages = getSanitizedQuote(symbol, apiKey) val messages = getSanitizedQuote(symbol, apiKey)
assertThat(messages, "response not empty").isNotEmpty() assertThat(messages, "response not empty").isNotEmpty()
assertThat(messages, "getQuote($symbol)").index(0).prop(Message::msg) assertThat(messages, "getQuote($symbol)").index(0).prop(Message::msg)
.matches("Symbol: GOOG .*".toRegex()) .equals("Symbol: GOOG")
} }
@Test @Test
@ -133,8 +142,42 @@ class StockQuoteTest : LocalProperties() {
val symbol = "foobar" val symbol = "foobar"
assertThat(getSanitizedQuote(symbol, apiKey).first(), "getQuote($symbol)").all { assertThat(getSanitizedQuote(symbol, apiKey).first(), "getQuote($symbol)").all {
isInstanceOf(ErrorMessage::class.java) 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.")
}
}
} }