Reimplement StockQuote module using Finnhub
This commit is contained in:
parent
ea2d1a86ba
commit
da04d43ce8
9 changed files with 407 additions and 274 deletions
2
.github/workflows/bld.yml
vendored
2
.github/workflows/bld.yml
vendored
|
@ -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
|
||||
|
|
8
.idea/detekt.xml
generated
Normal file
8
.idea/detekt.xml
generated
Normal 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>
|
|
@ -6,7 +6,7 @@
|
|||
<ID>CyclomaticComplexMethod:Weather2.kt$Weather2.Companion$@JvmStatic @Throws(ModuleException::class) fun getWeather(query: String, apiKey: String?): List<Message></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<String>)</ID>
|
||||
<ID>LongMethod:StockQuote.kt$StockQuote.Companion$@JvmStatic @Throws(ModuleException::class) fun getQuote(symbol: String, apiKey: String?): List<Message></ID>
|
||||
<ID>LongMethod:StockQuote2.kt$StockQuote2.Companion$@JvmStatic @Throws(ModuleException::class) fun getQuote(symbol: String, apiKey: String?): List<Message></ID>
|
||||
<ID>LongMethod:Weather2.kt$Weather2.Companion$@JvmStatic @Throws(ModuleException::class) fun getWeather(query: String, apiKey: String?): List<Message></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<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 )</ID>
|
||||
|
@ -27,7 +27,8 @@
|
|||
<ID>MagicNumber:Seen.kt$Seen$7</ID>
|
||||
<ID>MagicNumber:SocialManager.kt$SocialManager$1000L</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$7</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<Message></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: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: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<Message></ID>
|
||||
<ID>NestedBlockDepth:StockQuote2.kt$StockQuote2.Companion$@JvmStatic @Throws(ModuleException::class) fun getQuote(symbol: String, apiKey: String?): List<Message></ID>
|
||||
<ID>NestedBlockDepth:StockQuote2.kt$StockQuote2.Companion$@JvmStatic @Throws(ModuleException::class) fun lookup(keywords: String, apiKey: String?): List<Message></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 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>ThrowsCount:GoogleSearch.kt$GoogleSearch.Companion$@JvmStatic @Throws(ModuleException::class) fun searchGoogle( query: String, apiKey: String?, cseKey: String?, quotaUser: String = ReleaseInfo.PROJECT ): List<Message></ID>
|
||||
<ID>ThrowsCount:Joke.kt$Joke.Companion$@JvmStatic @Throws(ModuleException::class) fun randomJoke(): List<Message></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:StockQuote.kt$StockQuote.Companion$@JvmStatic @Throws(ModuleException::class) fun getQuote(symbol: String, apiKey: String?): List<Message></ID>
|
||||
<ID>ThrowsCount:StockQuote.kt$StockQuote.Companion$@Throws(ModuleException::class) private fun getJsonResponse(response: String, debugMessage: String): JSONObject</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:StockQuote2.kt$StockQuote2.Companion$@JvmStatic @Throws(ModuleException::class) fun getQuote(symbol: String, apiKey: String?): List<Message></ID>
|
||||
<ID>ThrowsCount:StockQuote2.kt$StockQuote2.Companion$@JvmStatic @Throws(ModuleException::class) fun lookup(keywords: String, apiKey: String?): List<Message></ID>
|
||||
<ID>ThrowsCount:Weather2.kt$Weather2.Companion$@JvmStatic @Throws(ModuleException::class) fun getWeather(query: String, apiKey: String?): List<Message></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: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>TooManyFunctions:EntryLink.kt$EntryLink : Serializable</ID>
|
||||
<ID>TooManyFunctions:Mobibot.kt$Mobibot : ListenerAdapter</ID>
|
||||
|
@ -101,7 +103,7 @@
|
|||
<ID>WildcardImport:Mobibot.kt$import org.pircbotx.hooks.events.*</ID>
|
||||
<ID>WildcardImport:ModuleExceptionTest.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:Utils.kt$import java.io.*</ID>
|
||||
<ID>WildcardImport:Weather2Test.kt$import assertk.assertions.*</ID>
|
||||
|
|
|
@ -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/
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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/"
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
320
src/main/kotlin/net/thauvin/erik/mobibot/modules/StockQuote2.kt
Normal file
320
src/main/kotlin/net/thauvin/erik/mobibot/modules/StockQuote2.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<Message> {
|
||||
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
|
||||
@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.")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue