Compare commits

...

5 commits

106 changed files with 3292 additions and 1717 deletions

7
.idea/kotlinc.xml generated
View file

@ -7,10 +7,7 @@
<option name="jvmTarget" value="17" />
</component>
<component name="KotlinCommonCompilerArguments">
<option name="apiVersion" value="2.0" />
<option name="languageVersion" value="2.0" />
</component>
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.1.0" />
<option name="apiVersion" value="2.1" />
<option name="languageVersion" value="2.1" />
</component>
</project>

110
.idea/misc.xml generated
View file

@ -1,12 +1,122 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EntryPointsManager">
<entry_points version="2.0">
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.modules.Calc name" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.ChannelFeed help" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.Comment help" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Cycle help" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Die help" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Info help" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.LinksManager help" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Me help" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Modules help" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Msg help" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Nick help" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.Posting help" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Recap help" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Say help" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.Tags help" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Users help" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Versions help" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.View help" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.ChannelFeed isOpOnly" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.Comment isOpOnly" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Cycle isOpOnly" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Die isOpOnly" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Ignore isOpOnly" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Info isOpOnly" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.LinksManager isOpOnly" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Me isOpOnly" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Modules isOpOnly" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Msg isOpOnly" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Nick isOpOnly" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.Posting isOpOnly" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Recap isOpOnly" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Say isOpOnly" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.seen.Seen isOpOnly" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.Tags isOpOnly" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.tell.Tell isOpOnly" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Users isOpOnly" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Versions isOpOnly" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.View isOpOnly" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.modules.WorldTime isPrivateMsgEnabled" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.ChannelFeed isPublic" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.Comment isPublic" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Cycle isPublic" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Die isPublic" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Ignore isPublic" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Info isPublic" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.LinksManager isPublic" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Me isPublic" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Modules isPublic" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Msg isPublic" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Nick isPublic" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.Posting isPublic" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Recap isPublic" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Say isPublic" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.seen.Seen isPublic" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.Tags isPublic" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.tell.Tell isPublic" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Users isPublic" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Versions isPublic" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.View isPublic" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.ChannelFeed isVisible" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.Comment isVisible" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Cycle isVisible" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Die isVisible" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Ignore isVisible" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Info isVisible" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.LinksManager isVisible" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Me isVisible" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Modules isVisible" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Msg isVisible" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Nick isVisible" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.Posting isVisible" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Recap isVisible" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Say isVisible" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.seen.Seen isVisible" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.Tags isVisible" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.tell.Tell isVisible" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Users isVisible" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Versions isVisible" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.View isVisible" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.ChannelFeed name" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.Comment name" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.modules.CryptoPrices name" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.modules.CurrencyConverter name" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.modules.Dice name" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.Die name" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.modules.GoogleSearch name" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.modules.Joke name" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.LinksManager name" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.modules.Lookup name" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.modules.Mastodon name" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.modules.Ping name" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.Posting name" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.modules.RockPaperScissors name" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.modules.StockQuote name" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.commands.links.Tags name" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.modules.War name" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.modules.Weather2 name" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.modules.WolframAlpha name" />
<entry_point TYPE="field" FQNAME="net.thauvin.erik.mobibot.modules.WorldTime name" />
</entry_points>
<pattern value="net.thauvin.erik.MobibotBuild" method="deploy" />
<pattern value="net.thauvin.erik.MobibotBuild" method="jacoco" />
<pattern value="net.thauvin.erik.MobibotBuild" />
<pattern value="net.thauvin.erik.MobibotBuild" method="detekt" />
<pattern value="net.thauvin.erik.MobibotBuild" method="detektBaseline" />
<pattern value="net.thauvin.erik.MobibotBuild" method="rootPom" />
<pattern value="net.thauvin.erik.mobibot.Addons" />
<pattern value="net.thauvin.erik.mobibot.Addons" method="match" />
<pattern value="net.thauvin.erik.mobibot.modules.Calc" />
<pattern value="net.thauvin.erik.mobibot.modules.Calc.Companion" />
<pattern value="net.thauvin.erik.mobibot.modules.Mastodon" method="formatEntry" />
<pattern value="net.thauvin.erik.mobibot.Mobibot.Companion" method="main" />
<pattern value="net.thauvin.erik.mobibot.DisableOnCi" />
<pattern value="net.thauvin.erik.mobibot.modules.ModuleExceptionTest.Companion" method="moduleExceptions" />
<pattern value="net.thauvin.erik.mobibot.modules.WolframAlphaTest.Companion" method="queries" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build" />

View file

@ -1,9 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run Tests" type="Application" factoryName="Application" nameIsGenerated="true">
<option name="MAIN_CLASS_NAME" value="net.thauvin.erik.MobibotTest" />
<module name="app" />
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
</component>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues></ManuallySuppressedIssues>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>CyclomaticComplexMethod:FeedsManager.kt$FeedsManager.Companion$@JvmStatic fun saveFeed(entries: Entries, currentFile: String = CURRENT_XML)</ID>
<ID>CyclomaticComplexMethod:Weather2.kt$Weather2.Companion$@JvmStatic @Throws(ModuleException::class) fun getWeather(query: String, apiKey: String?): List&lt;Message&gt;</ID>
@ -10,8 +10,6 @@
<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:EntryLink.kt$EntryLink$( // Link's comments val comments: MutableList&lt;EntryComment&gt; = mutableListOf(), // Tags/categories val tags: MutableList&lt;SyndCategory&gt; = mutableListOf(), // Channel var channel: String, // Creation date var date: Date = Calendar.getInstance().time, // Link's URL var link: String, // Author's login var login: String = "", // Author's nickname var nick: String, // Link's title var title: String )</ID>
<ID>MagicNumber:ChatGpt.kt$ChatGpt.Companion$200</ID>
<ID>MagicNumber:ChatGpt.kt$ChatGpt.Companion$429</ID>
<ID>MagicNumber:Comment.kt$Comment$3</ID>
<ID>MagicNumber:CryptoPrices.kt$CryptoPrices$10</ID>
<ID>MagicNumber:CurrencyConverter.kt$CurrencyConverter$11</ID>
@ -47,7 +45,6 @@
<ID>MagicNumber:WorldTime.kt$WorldTime.Companion$86.4</ID>
<ID>NestedBlockDepth:Addons.kt$Addons$fun add(command: AbstractCommand): Boolean</ID>
<ID>NestedBlockDepth:Addons.kt$Addons$fun add(module: AbstractModule): Boolean</ID>
<ID>NestedBlockDepth:ChatGpt.kt$ChatGpt.Companion$@JvmStatic @Throws(ModuleException::class) fun chat(query: String, apiKey: String?, maxTokens: Int): String</ID>
<ID>NestedBlockDepth:Comment.kt$Comment$override fun commandResponse(channel: String, args: String, event: GenericMessageEvent)</ID>
<ID>NestedBlockDepth:CurrencyConverter.kt$CurrencyConverter.Companion$@JvmStatic @Throws(ModuleException::class) fun loadSymbols(apiKey: String?)</ID>
<ID>NestedBlockDepth:CurrencyConverter.kt$CurrencyConverter.Companion$@JvmStatic fun convertCurrency(apiKey: String?, query: String): Message</ID>
@ -71,7 +68,6 @@
<ID>ReturnCount:Addons.kt$Addons$fun help(channel: String, topic: String, event: GenericMessageEvent): Boolean</ID>
<ID>ReturnCount:ExceptionSanitizer.kt$ExceptionSanitizer$fun ModuleException.sanitize(vararg sanitize: String): ModuleException</ID>
<ID>ReturnCount:Seen.kt$Seen$override fun commandResponse(channel: String, args: String, event: GenericMessageEvent)</ID>
<ID>ThrowsCount:ChatGpt.kt$ChatGpt.Companion$@JvmStatic @Throws(ModuleException::class) fun chat(query: String, apiKey: String?, maxTokens: Int): String</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:Mastodon.kt$Mastodon.Companion$@JvmStatic @Throws(ModuleException::class) fun toot(apiKey: String?, instance: String?, handle: String?, message: String, isDm: Boolean): String</ID>
@ -80,7 +76,6 @@
<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>TooGenericExceptionCaught:ChatGpt2.kt$ChatGpt2.Companion$e: Exception</ID>
<ID>TooGenericExceptionCaught:Gemini.kt$Gemini.Companion$e: Exception</ID>
<ID>TooGenericExceptionCaught:Gemini2.kt$Gemini2.Companion$e: Exception</ID>
<ID>TooGenericExceptionCaught:StockQuote.kt$StockQuote.Companion$e: NullPointerException</ID>
<ID>TooGenericExceptionCaught:Weather2.kt$Weather2.Companion$e: NullPointerException</ID>
@ -89,14 +84,16 @@
<ID>TooManyFunctions:Tell.kt$Tell : AbstractCommand</ID>
<ID>UtilityClassWithPublicConstructor:LocalProperties.kt$LocalProperties</ID>
<ID>WildcardImport:AddonsTest.kt$import net.thauvin.erik.mobibot.modules.*</ID>
<ID>WildcardImport:CryptoPricesTest.kt$import assertk.assertions.*</ID>
<ID>WildcardImport:CurrencyConverterTest.kt$import assertk.assertions.*</ID>
<ID>WildcardImport:EntryLinkTest.kt$import assertk.assertions.*</ID>
<ID>WildcardImport:FeedMgrTest.kt$import assertk.assertions.*</ID>
<ID>WildcardImport:FeedReaderTest.kt$import assertk.assertions.*</ID>
<ID>WildcardImport:FeedsManager.kt$import com.rometools.rome.feed.synd.*</ID>
<ID>WildcardImport:Gemini2Test.kt$import assertk.assertions.*</ID>
<ID>WildcardImport:GeminiTest.kt$import assertk.assertions.*</ID>
<ID>WildcardImport:GoogleSearchTest.kt$import assertk.assertions.*</ID>
<ID>WildcardImport:JokeTest.kt$import assertk.assertions.*</ID>
<ID>WildcardImport:LinksManagerTest.kt$import assertk.assertions.*</ID>
<ID>WildcardImport:Mobibot.kt$import java.io.*</ID>
<ID>WildcardImport:Mobibot.kt$import net.thauvin.erik.mobibot.commands.*</ID>
<ID>WildcardImport:Mobibot.kt$import net.thauvin.erik.mobibot.commands.links.*</ID>

View file

@ -4,7 +4,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>net.thauvin.erik.mobibot</groupId>
<artifactId>mobibot</artifactId>
<version>0.8.0-rc+20250424113056</version>
<version>0.8.0-rc+20250509073816</version>
<name>mobibot</name>
<description></description>
<url></url>
@ -168,7 +168,7 @@
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.19.1</version>
<version>1.20.1</version>
<scope>compile</scope>
</dependency>
<dependency>

View file

@ -16,6 +16,8 @@ ident=changeme
logs=./logs
ignore=chanserv,nickserv
tags=mobile mobitopia
# Keywords to add as tags if found in links titles
tags-keywords=android ios apple google
feed=http://www.mobitopia.org/rss.xml
@ -68,7 +70,7 @@ disabled-modules=mastodon
#alphavantage-api-key=
#
# Get Wolfram Alpha AppID from: https://developer.wolframalpha.com/portal/
# Get Wolfram Alpha AppID from: https://developer.wolframalpha.com/
#
#wolfram-appid=
#wolfram-units=imperial

View file

@ -38,7 +38,6 @@ import rife.bld.extension.CompileKotlinOperation;
import rife.bld.extension.DetektOperation;
import rife.bld.extension.GeneratedVersionOperation;
import rife.bld.extension.JacocoReportOperation;
import rife.bld.extension.kotlin.CompileOptions;
import rife.bld.operations.exceptions.ExitStatusException;
import rife.bld.publish.PomBuilder;
import rife.tools.FileUtils;
@ -72,8 +71,10 @@ public class MobibotBuild extends Project {
mainClass = pkg + ".Mobibot";
javaRelease = 17;
downloadSources = true;
autoDownloadPurge = true;
downloadSources = true;
repositories = List.of(
MAVEN_LOCAL,
MAVEN_CENTRAL,
@ -117,14 +118,19 @@ public class MobibotBuild extends Project {
.include(dependency("net.aksingh", "owm-japis", "2.5.3.0"))
.include(dependency("net.objecthunter", "exp4j", "0.4.8"))
.include(dependency("org.json", "json", "20250107"))
.include(dependency("org.jsoup", "jsoup", "1.19.1"))
.include(dependency("org.jsoup", "jsoup", "1.20.1"))
// Thauvin
.include(dependency("net.thauvin.erik", "cryptoprice", "1.0.3-SNAPSHOT"))
.include(dependency("net.thauvin.erik", "jokeapi", "1.0.1-SNAPSHOT"))
.include(dependency("net.thauvin.erik", "pinboard-poster", "1.2.1-SNAPSHOT"))
.include(dependency("net.thauvin.erik.urlencoder", "urlencoder-lib-jvm", "1.6.0"));
scope(test)
// Mockito
.include(dependency("net.bytebuddy", "byte-buddy", version(1, 17, 5)))
.include(dependency("org.mockito.kotlin", "mockito-kotlin", version(5, 4, 0)))
// AssertK
.include(dependency("com.willowtreeapps.assertk", "assertk-jvm", version(0, 28, 1)))
// JUnit
.include(dependency("org.jetbrains.kotlin", "kotlin-test-junit5", kotlin))
.include(dependency("org.junit.jupiter", "junit-jupiter", version(5, 12, 2)))
.include(dependency("org.junit.platform", "junit-platform-console-standalone", version(1, 12, 2)))
@ -166,10 +172,9 @@ public class MobibotBuild extends Project {
@Override
public void compile() throws Exception {
releaseInfo();
new CompileKotlinOperation()
.compileOptions(new CompileOptions().progressive(true).verbose(true))
.fromProject(this)
.execute();
var op = new CompileKotlinOperation().fromProject(this);
op.compileOptions().languageVersion("2.1").progressive(true).verbose(true);
op.execute();
}
@Override

View file

@ -41,7 +41,7 @@ import org.slf4j.LoggerFactory
import java.util.*
/**
* Modules and Commands addons.
* Registers and manages commands and modules.
*/
class Addons(private val props: Properties) {
private val logger: Logger = LoggerFactory.getLogger(Addons::class.java)

View file

@ -30,10 +30,16 @@
*/
package net.thauvin.erik.mobibot
/**
* The `Constants`.
* The global constants.
*/
object Constants {
/**
* CLI command for usage.
*/
const val CLI_CMD = "java -jar ${ReleaseInfo.PROJECT}.jar"
/**
* The connect/read timeout in ms.
*/
@ -54,16 +60,6 @@ object Constants {
*/
const val DEFAULT_SERVER = "irc.libera.chat"
/**
* CLI command for usage.
*/
const val CLI_CMD = "java -jar ${ReleaseInfo.PROJECT}.jar"
/**
* User-Agent
*/
const val USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0"
/**
* The help command.
*/
@ -80,7 +76,7 @@ object Constants {
const val NO_TITLE = "No Title"
/**
* Properties command line argument.
* `Properties` command line argument.
*/
const val PROPS_ARG = "properties"
@ -94,6 +90,11 @@ object Constants {
*/
const val TIMER_DELAY = 10L
/**
* User-Agent
*/
const val USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0"
/**
* Properties version line argument.
*/

View file

@ -51,23 +51,6 @@ import java.net.URL
class FeedReader(private val url: String, val event: GenericMessageEvent) : Runnable {
private val logger: Logger = LoggerFactory.getLogger(FeedsManager::class.java)
/**
* Fetches the Feed's items.
*/
override fun run() {
try {
readFeed(url).forEach {
event.sendMessage("", it)
}
} catch (e: FeedException) {
if (logger.isWarnEnabled) logger.warn("Unable to parse the feed at $url", e)
event.sendMessage("An error has occurred while parsing the feed: ${e.message}")
} catch (e: IOException) {
if (logger.isWarnEnabled) logger.warn("Unable to fetch the feed at $url", e)
event.sendMessage("An IO error has occurred while fetching the feed: ${e.message}")
}
}
companion object {
@JvmStatic
@Throws(FeedException::class, IOException::class)
@ -89,4 +72,21 @@ class FeedReader(private val url: String, val event: GenericMessageEvent) : Runn
return messages
}
}
/**
* Fetches the Feed's items.
*/
override fun run() {
try {
readFeed(url).forEach {
event.sendMessage("", it)
}
} catch (e: FeedException) {
if (logger.isWarnEnabled) logger.warn("Unable to parse the feed at $url", e)
event.sendMessage("An error has occurred while parsing the feed: ${e.message}")
} catch (e: IOException) {
if (logger.isWarnEnabled) logger.warn("Unable to fetch the feed at $url", e)
event.sendMessage("An IO error has occurred while fetching the feed: ${e.message}")
}
}
}

View file

@ -36,7 +36,7 @@ import kotlinx.cli.ArgType
import kotlinx.cli.default
import net.thauvin.erik.mobibot.Utils.appendIfMissing
import net.thauvin.erik.mobibot.Utils.bot
import net.thauvin.erik.mobibot.Utils.capitalise
import net.thauvin.erik.mobibot.Utils.capitalize
import net.thauvin.erik.mobibot.Utils.getIntProperty
import net.thauvin.erik.mobibot.Utils.helpCmdSyntax
import net.thauvin.erik.mobibot.Utils.helpFormat
@ -81,148 +81,6 @@ class Mobibot(nickname: String, val channel: String, logsDirPath: String, p: Pro
/** Logger. */
val logger: Logger = LoggerFactory.getLogger(Mobibot::class.java)
/**
* Connects to the server and joins the channel.
*/
fun connect() {
PircBotX(config).startBot()
}
/**
* Responds with the default help.
*/
private fun helpDefault(event: GenericMessageEvent) {
event.sendMessage("Type a URL on $channel to post it.")
event.sendMessage("For more information on a specific command, type:")
event.sendMessage(
helpFormat(
helpCmdSyntax("%c ${Constants.HELP_CMD} <command>", event.bot().nick, event is PrivateMessageEvent)
)
)
event.sendMessage("The commands are:")
event.sendList(addons.names.commands, 8, isBold = true, isIndent = true)
if (event.isChannelOp(channel)) {
if (addons.names.disabledCommands.isNotEmpty()) {
event.sendMessage("The disabled commands are:")
event.sendList(addons.names.disabledCommands, 8, isBold = false, isIndent = true)
}
event.sendMessage("The op commands are:")
event.sendList(addons.names.ops, 8, isBold = true, isIndent = true)
}
}
/**
* Responds with the default, commands or modules help.
*/
private fun helpResponse(event: GenericMessageEvent, topic: String) {
if (topic.isBlank() || !addons.help(channel, topic.lowercase().trim(), event)) {
helpDefault(event)
}
}
override fun onAction(event: ActionEvent?) {
event?.channel?.let {
if (channel == it.name) {
event.user?.let { user ->
storeRecap(user.nick, event.action, true)
}
}
}
}
override fun onDisconnect(event: DisconnectEvent?) {
event?.let {
with(event.getBot<PircBotX>()) {
LinksManager.socialManager.notification("$nick disconnected from $serverHostname")
seen.add(userChannelDao.getChannel(channel).users)
}
}
LinksManager.socialManager.shutdown()
}
override fun onPrivateMessage(event: PrivateMessageEvent?) {
event?.user?.let { user ->
if (logger.isTraceEnabled) logger.trace("<<< ${user.nick}: ${event.message}")
val cmds = event.message.trim().split(" ".toRegex(), 2)
val cmd = cmds[0].lowercase()
val args = cmds.lastOrEmpty().trim()
if (cmd.startsWith(Constants.HELP_CMD)) { // help
helpResponse(event, args)
} else if (!addons.exec(channel, cmd, args, event)) { // Execute command or module
helpDefault(event)
}
}
}
override fun onJoin(event: JoinEvent?) {
event?.user?.let { user ->
with(event.getBot<PircBotX>()) {
if (user.nick == nick) {
LinksManager.socialManager.notification(
"$nick has joined ${event.channel.name} on $serverHostname"
)
seen.add(userChannelDao.getChannel(channel).users)
} else {
tell.send(event)
seen.add(user.nick)
}
}
}
}
override fun onMessage(event: MessageEvent?) {
event?.user?.let { user ->
tell.send(event)
if (event.message.matches("(?i)${Pattern.quote(event.bot().nick)}:.*".toRegex())) { // mobibot: <command>
if (logger.isTraceEnabled) logger.trace(">>> ${user.nick}: ${event.message}")
val cmds = event.message.substring(event.bot().nick.length + 1).trim().split(" ".toRegex(), 2)
val cmd = cmds[0].lowercase()
val args = cmds.lastOrEmpty().trim()
if (cmd.startsWith(Constants.HELP_CMD)) { // mobibot: help
helpResponse(event, args)
} else {
// Execute module or command
addons.exec(channel, cmd, args, event)
}
} else if (addons.match(channel, event)) { // Links, e.g.: https://www.example.com/ or L1: , etc.
if (logger.isTraceEnabled) logger.trace(">>> ${user.nick}: ${event.message}")
}
storeRecap(user.nick, event.message, false)
seen.add(user.nick)
}
}
override fun onNickChange(event: NickChangeEvent?) {
event?.let {
tell.send(event)
if (!it.oldNick.equals(it.newNick, true)) {
seen.add(it.oldNick)
}
seen.add(it.newNick)
}
}
override fun onPart(event: PartEvent?) {
event?.user?.let { user ->
with(event.getBot<PircBotX>()) {
if (user.nick == nick) {
LinksManager.socialManager.notification(
"$nick has left ${event.channel.name} on $serverHostname"
)
seen.add(userChannelDao.getChannel(channel).users)
} else {
seen.add(user.nick)
}
}
}
}
override fun onQuit(event: QuitEvent?) {
event?.user?.let { user ->
seen.add(user.nick)
}
}
companion object {
@JvmStatic
@Throws(Exception::class)
@ -254,7 +112,7 @@ class Mobibot(nickname: String, val channel: String, logsDirPath: String, p: Pro
if (version) {
// Output the version
println(
"${ReleaseInfo.PROJECT.capitalise()} ${ReleaseInfo.VERSION}" +
"${ReleaseInfo.PROJECT.capitalize()} ${ReleaseInfo.VERSION}" +
" (${ReleaseInfo.BUILD_DATE.toIsoLocalDate()})"
)
println(ReleaseInfo.WEBSITE)
@ -267,10 +125,10 @@ class Mobibot(nickname: String, val channel: String, logsDirPath: String, p: Pro
).use { fis ->
p.load(fis)
}
} catch (ignore: FileNotFoundException) {
} catch (_: FileNotFoundException) {
System.err.println("Unable to find properties file.")
exitProcess(1)
} catch (ignore: IOException) {
} catch (_: IOException) {
System.err.println("Unable to open properties file.")
exitProcess(1)
}
@ -289,7 +147,7 @@ class Mobibot(nickname: String, val channel: String, logsDirPath: String, p: Pro
), true
)
System.setOut(stdout)
} catch (ignore: IOException) {
} catch (_: IOException) {
System.err.println("Unable to open output (stdout) log file.")
exitProcess(1)
}
@ -300,7 +158,7 @@ class Mobibot(nickname: String, val channel: String, logsDirPath: String, p: Pro
), true
)
System.setErr(stderr)
} catch (ignore: IOException) {
} catch (_: IOException) {
System.err.println("Unable to open error (stderr) log file.")
exitProcess(1)
}
@ -416,5 +274,145 @@ class Mobibot(nickname: String, val channel: String, logsDirPath: String, p: Pro
// Sort the addons
addons.names.sort()
}
/**
* Connects to the server and joins the channel.
*/
fun connect() {
PircBotX(config).startBot()
}
/**
* Responds with the default help.
*/
private fun helpDefault(event: GenericMessageEvent) {
event.sendMessage("Type a URL on $channel to post it.")
event.sendMessage("For more information on a specific command, type:")
event.sendMessage(
helpFormat(
helpCmdSyntax("%c ${Constants.HELP_CMD} <command>", event.bot().nick, event is PrivateMessageEvent)
)
)
event.sendMessage("The commands are:")
event.sendList(addons.names.commands, 8, isBold = true, isIndent = true)
if (event.isChannelOp(channel)) {
if (addons.names.disabledCommands.isNotEmpty()) {
event.sendMessage("The disabled commands are:")
event.sendList(addons.names.disabledCommands, 8, isBold = false, isIndent = true)
}
event.sendMessage("The op commands are:")
event.sendList(addons.names.ops, 8, isBold = true, isIndent = true)
}
}
/**
* Responds with the default, commands or modules help.
*/
private fun helpResponse(event: GenericMessageEvent, topic: String) {
if (topic.isBlank() || !addons.help(channel, topic.lowercase().trim(), event)) {
helpDefault(event)
}
}
override fun onAction(event: ActionEvent?) {
event?.channel?.let {
if (channel == it.name) {
event.user?.let { user ->
storeRecap(user.nick, event.action, true)
}
}
}
}
override fun onDisconnect(event: DisconnectEvent?) {
event?.let {
with(event.getBot<PircBotX>()) {
LinksManager.socialManager.notification("$nick disconnected from $serverHostname")
seen.add(userChannelDao.getChannel(channel).users)
}
}
LinksManager.socialManager.shutdown()
}
override fun onPrivateMessage(event: PrivateMessageEvent?) {
event?.user?.let { user ->
if (logger.isTraceEnabled) logger.trace("<<< ${user.nick}: ${event.message}")
val cmds = event.message.trim().split(" ".toRegex(), 2)
val cmd = cmds[0].lowercase()
val args = cmds.lastOrEmpty().trim()
if (cmd.startsWith(Constants.HELP_CMD)) { // help
helpResponse(event, args)
} else if (!addons.exec(channel, cmd, args, event)) { // Execute command or module
helpDefault(event)
}
}
}
override fun onJoin(event: JoinEvent?) {
event?.user?.let { user ->
with(event.getBot<PircBotX>()) {
if (user.nick == nick) {
LinksManager.socialManager.notification(
"$nick has joined ${event.channel.name} on $serverHostname"
)
seen.add(userChannelDao.getChannel(channel).users)
} else {
tell.send(event)
seen.add(user.nick)
}
}
}
}
override fun onMessage(event: MessageEvent?) {
event?.user?.let { user ->
if (logger.isTraceEnabled) logger.trace(">>> ${user.nick}: ${event.message}")
tell.send(event)
if (event.message.matches("(?i)${Pattern.quote(event.bot().nick)}:.*".toRegex())) { // mobibot: <command>
val cmds = event.message.substring(event.bot().nick.length + 1).trim().split(" ".toRegex(), 2)
val cmd = cmds[0].lowercase()
val args = cmds.lastOrEmpty().trim()
if (cmd.startsWith(Constants.HELP_CMD)) { // mobibot: help
helpResponse(event, args)
} else {
// Execute module or command
addons.exec(channel, cmd, args, event)
}
}
storeRecap(user.nick, event.message, false)
seen.add(user.nick)
}
}
override fun onNickChange(event: NickChangeEvent?) {
event?.let {
tell.send(event)
if (!it.oldNick.equals(it.newNick, true)) {
seen.add(it.oldNick)
}
seen.add(it.newNick)
}
}
override fun onPart(event: PartEvent?) {
event?.user?.let { user ->
with(event.getBot<PircBotX>()) {
if (user.nick == nick) {
LinksManager.socialManager.notification(
"$nick has left ${event.channel.name} on $serverHostname"
)
seen.add(userChannelDao.getChannel(channel).users)
} else {
seen.add(user.nick)
}
}
}
}
override fun onQuit(event: QuitEvent?) {
event?.user?.let { user ->
seen.add(user.nick)
}
}
}

View file

@ -40,7 +40,7 @@ import java.time.temporal.ChronoUnit
import java.util.*
/**
* Handles posts to pinboard.in.
* Handles posts to `pinboard.in`.
*/
class Pinboard {
private val poster = PinboardPoster()
@ -56,13 +56,6 @@ class Pinboard {
}
}
/**
* Sets the pinboard API token.
*/
fun setApiToken(apiToken: String) {
poster.apiToken = apiToken
}
/**
* Deletes a pin.
*/
@ -73,6 +66,13 @@ class Pinboard {
}
/**
* Sets the pinboard API token.
*/
fun setApiToken(apiToken: String) {
poster.apiToken = apiToken
}
/**
* Updates a pin.
*/
@ -87,15 +87,6 @@ class Pinboard {
}
}
/**
* Formats a date to a UTC timestamp.
*/
private fun Date.toTimestamp(): String {
return ZonedDateTime.ofInstant(
toInstant().truncatedTo(ChronoUnit.SECONDS), ZoneId.systemDefault()
).format(DateTimeFormatter.ISO_INSTANT)
}
/**
* Formats the tags for pinboard.
*/
@ -109,5 +100,14 @@ class Pinboard {
private fun EntryLink.postedBy(ircServer: String): String {
return "Posted by $nick on $channel ( $ircServer )"
}
/**
* Formats a date to a UTC timestamp.
*/
private fun Date.toTimestamp(): String {
return ZonedDateTime.ofInstant(
toInstant().truncatedTo(ChronoUnit.SECONDS), ZoneId.systemDefault()
).format(DateTimeFormatter.ISO_INSTANT)
}
}

View file

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

View file

@ -52,7 +52,7 @@ import kotlin.io.path.exists
import kotlin.io.path.fileSize
/**
* Miscellaneous utilities.
* Miscellaneous utility functions.
*/
@Suppress("TooManyFunctions")
object Utils {
@ -111,13 +111,13 @@ object Utils {
* Capitalize a string.
*/
@JvmStatic
fun String.capitalise(): String = lowercase().replaceFirstChar { it.uppercase() }
fun String.capitalize(): String = lowercase().replaceFirstChar { it.uppercase() }
/**
* Capitalize words
*/
@JvmStatic
fun String.capitalizeWords(): String = split(" ").joinToString(" ") { it.capitalise() }
fun String.capitalizeWords(): String = split(" ").joinToString(" ") { it.capitalize() }
/**
* Colorize a string.
@ -204,7 +204,7 @@ object Utils {
fun Int.isHttpSuccess() = this in 200..399
/**
* Returns the last item of a list of strings or empty if none.
* Returns the last item of a list or empty if the list only has one item.
*/
@JvmStatic
fun List<String>.lastOrEmpty(): String {
@ -261,6 +261,32 @@ object Utils {
return if (count > 1) "${this}s" else this
}
/**
* Reads contents of a URL.
*/
@JvmStatic
@Throws(IOException::class)
fun URL.reader(): UrlReaderResponse {
val connection = this.openConnection() as HttpURLConnection
try {
connection.setRequestProperty(
"User-Agent",
Constants.USER_AGENT
)
return if (connection.responseCode.isHttpSuccess()) {
UrlReaderResponse(
connection.responseCode,
connection.inputStream.bufferedReader().use { it.readText() })
} else {
UrlReaderResponse(
connection.responseCode,
connection.errorStream.bufferedReader().use { it.readText() })
}
} finally {
connection.disconnect()
}
}
/**
* Makes the given string red.
*/
@ -305,7 +331,7 @@ object Utils {
}
/**
* Send a formatted commands/modules, etc. list.
* Send a formatted list to the channel.
*/
@JvmStatic
@JvmOverloads
@ -368,7 +394,7 @@ object Utils {
fun String.toIntOrDefault(defaultValue: Int): Int {
return try {
toInt()
} catch (e: NumberFormatException) {
} catch (_: NumberFormatException) {
defaultValue
}
}
@ -401,45 +427,19 @@ object Utils {
@JvmStatic
fun LocalDateTime.toUtcDateTime(): String = format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
/**
* Makes the given string bold.
*/
@JvmStatic
fun String?.underline(): String = colorize(Colors.UNDERLINE)
/**
* Converts XML/XHTML entities to plain text.
*/
@JvmStatic
fun String.unescapeXml(): String = Jsoup.parse(this).text()
/**
* Reads contents of a URL.
*/
@JvmStatic
@Throws(IOException::class)
fun URL.reader(): UrlReaderResponse {
val connection = this.openConnection() as HttpURLConnection
try {
connection.setRequestProperty(
"User-Agent",
Constants.USER_AGENT
)
return if (connection.responseCode.isHttpSuccess()) {
UrlReaderResponse(
connection.responseCode,
connection.inputStream.bufferedReader().use { it.readText() })
} else {
UrlReaderResponse(
connection.responseCode,
connection.errorStream.bufferedReader().use { it.readText() })
}
} finally {
connection.disconnect()
}
}
/**
* Holds the [URL.reader] response code and body text.
*/

View file

@ -38,6 +38,12 @@ import net.thauvin.erik.mobibot.Utils.sendMessage
import org.pircbotx.hooks.events.PrivateMessageEvent
import org.pircbotx.hooks.types.GenericMessageEvent
/**
* Represents an abstract base class for implementing bot commands.
*
* A command is characterized by its name, visibility, access restrictions, and other properties. This class provides a
* framework for handling command-specific responses and help information.
*/
abstract class AbstractCommand {
abstract val name: String
abstract val help: List<String>

View file

@ -35,6 +35,9 @@ import net.thauvin.erik.mobibot.FeedReader
import net.thauvin.erik.mobibot.Utils.helpFormat
import org.pircbotx.hooks.types.GenericMessageEvent
/**
* Lists the last 5 posts from the channel's weblog feed.
*/
class ChannelFeed(channel: String) : AbstractCommand() {
override val name = channel
override val help = listOf("To list the last 5 posts from the channel's weblog feed:", helpFormat("%c $channel"))

View file

@ -39,6 +39,9 @@ import net.thauvin.erik.mobibot.Utils.helpFormat
import net.thauvin.erik.mobibot.Utils.isChannelOp
import org.pircbotx.hooks.types.GenericMessageEvent
/**
* Allows an operator to have the bot leave the channel and come back.
*/
class Cycle : AbstractCommand() {
private val wait = 10
override val name = "cycle"

View file

@ -35,6 +35,9 @@ import net.thauvin.erik.mobibot.Utils.bot
import net.thauvin.erik.mobibot.Utils.isChannelOp
import org.pircbotx.hooks.types.GenericMessageEvent
/**
* Allows an operator to terminate the bot's operations on the server.
*/
class Die : AbstractCommand() {
override val name = "die"
override val help = emptyList<String>()
@ -42,6 +45,14 @@ class Die : AbstractCommand() {
override val isPublic = false
override val isVisible = false
companion object {
const val DIE_PROP = "die"
}
init {
initProperties(DIE_PROP)
}
override fun commandResponse(channel: String, args: String, event: GenericMessageEvent) {
with(event.bot()) {
if (event.isChannelOp(channel) && (properties[DIE_PROP].isNullOrBlank() || args == properties[DIE_PROP])) {
@ -51,12 +62,4 @@ class Die : AbstractCommand() {
}
}
}
companion object {
const val DIE_PROP = "die"
}
init {
initProperties(DIE_PROP)
}
}

View file

@ -41,9 +41,23 @@ import net.thauvin.erik.mobibot.Utils.sendMessage
import net.thauvin.erik.mobibot.commands.links.LinksManager
import org.pircbotx.hooks.types.GenericMessageEvent
/**
* Adds or removed nicks from the ignored list.
*/
class Ignore : AbstractCommand() {
private val me = "me"
companion object {
const val IGNORE_CMD = "ignore"
const val IGNORE_PROP = IGNORE_CMD
private val ignored = mutableSetOf<String>()
@JvmStatic
fun isNotIgnored(nick: String): Boolean {
return !ignored.contains(nick.lowercase())
}
}
init {
initProperties(IGNORE_PROP)
}
@ -65,17 +79,6 @@ class Ignore : AbstractCommand() {
override val isPublic = true
override val isVisible = true
companion object {
const val IGNORE_CMD = "ignore"
const val IGNORE_PROP = IGNORE_CMD
private val ignored = mutableSetOf<String>()
@JvmStatic
fun isNotIgnored(nick: String): Boolean {
return !ignored.contains(nick.lowercase())
}
}
override fun commandResponse(channel: String, args: String, event: GenericMessageEvent) {
val isMe = args.trim().equals(me, true)
if (isMe || !event.isChannelOp(channel)) {

View file

@ -31,7 +31,7 @@
package net.thauvin.erik.mobibot.commands
import net.thauvin.erik.mobibot.ReleaseInfo
import net.thauvin.erik.mobibot.Utils.capitalise
import net.thauvin.erik.mobibot.Utils.capitalize
import net.thauvin.erik.mobibot.Utils.green
import net.thauvin.erik.mobibot.Utils.helpFormat
import net.thauvin.erik.mobibot.Utils.isChannelOp
@ -46,9 +46,12 @@ import java.lang.management.ManagementFactory
import kotlin.time.DurationUnit
import kotlin.time.toDuration
/**
* Provides detailed bot and channel statistics.
*/
class Info(private val tell: Tell, private val seen: Seen) : AbstractCommand() {
private val allVersions = listOf(
"${ReleaseInfo.PROJECT.capitalise()} ${ReleaseInfo.VERSION} (${ReleaseInfo.WEBSITE.green()})",
"${ReleaseInfo.PROJECT.capitalize()} ${ReleaseInfo.VERSION} (${ReleaseInfo.WEBSITE.green()})",
"Written by ${ReleaseInfo.AUTHOR} (${ReleaseInfo.AUTHOR_URL.green()})"
)
override val name = "info"
@ -71,30 +74,32 @@ class Info(private val tell: Tell, private val seen: Seen) : AbstractCommand() {
val weeks = days / 7
days %= 7
with(StringBuffer()) {
with(mutableListOf<String>()) {
if (years > 0) {
append(years).append(" year".plural(years)).append(' ')
add("$years".plus(" year".plural(years)))
}
if (months > 0) {
append(months).append(" month".plural(months)).append(' ')
add("$months".plus(" month".plural(months)))
}
if (weeks > 0) {
append(weeks).append(" week".plural(weeks)).append(' ')
add("$weeks".plus(" week".plural(weeks)))
}
if (days > 0) {
append(days).append(" day".plural(days)).append(' ')
add("$days".plus(" day".plural(days)))
}
if (hours > 0) {
append(hours).append(" hour".plural(hours.toLong())).append(' ')
add("$hours".plus(" hour".plural(hours.toLong())))
}
if (minutes > 0) {
append(minutes).append(" minute".plural(minutes.toLong()))
} else {
append(seconds).append(" second".plural(seconds.toLong()))
add("$minutes".plus(" minute".plural(minutes.toLong())))
} else if (seconds > 0) {
add("$seconds seconds")
} else if (isEmpty()) {
return "0 second"
}
return toString()
return this.joinToString(" ")
}
}
}

View file

@ -36,6 +36,9 @@ import net.thauvin.erik.mobibot.Utils.helpFormat
import net.thauvin.erik.mobibot.Utils.isChannelOp
import org.pircbotx.hooks.types.GenericMessageEvent
/**
* Allows an operator to have the bot perform an action in the specified channel.
*/
class Me : AbstractCommand() {
override val name = "me"
override val help = listOf("To have the bot perform an action:", helpFormat("%c $name <action>"))

View file

@ -36,6 +36,9 @@ import net.thauvin.erik.mobibot.Utils.isChannelOp
import net.thauvin.erik.mobibot.Utils.sendList
import org.pircbotx.hooks.types.GenericMessageEvent
/**
* List the enabled/disabled modules.
*/
class Modules(private val modules: List<String>, private val disabledModules: List<String>) : AbstractCommand() {
override val name = "modules"
override val help = listOf("To view a list of enabled/disabled modules:", helpFormat("%c $name"))

View file

@ -36,6 +36,9 @@ import net.thauvin.erik.mobibot.Utils.helpFormat
import net.thauvin.erik.mobibot.Utils.isChannelOp
import org.pircbotx.hooks.types.GenericMessageEvent
/**
* Allows an operator to send a private message to the specified user.
*/
class Msg : AbstractCommand() {
override val name = "msg"
override val help = listOf(

View file

@ -36,6 +36,9 @@ import net.thauvin.erik.mobibot.Utils.helpFormat
import net.thauvin.erik.mobibot.Utils.isChannelOp
import org.pircbotx.hooks.types.GenericMessageEvent
/**
* Allows an operator to change the bot's nickname.
*/
class Nick : AbstractCommand() {
override val name = "nick"
override val help = listOf("To change the bot's nickname:", helpFormat("%c $name <new_nick>"))

View file

@ -38,6 +38,9 @@ import org.pircbotx.hooks.types.GenericMessageEvent
import java.time.Clock
import java.time.LocalDateTime
/**
* Lists the last 10 public channel messages.
*/
class Recap : AbstractCommand() {
override val name = "recap"
override val help = listOf(

View file

@ -36,6 +36,9 @@ import net.thauvin.erik.mobibot.Utils.helpFormat
import net.thauvin.erik.mobibot.Utils.isChannelOp
import org.pircbotx.hooks.types.GenericMessageEvent
/**
* Allows an operator to have the bot say something in the specified channel.
*/
class Say : AbstractCommand() {
override val name = "say"
override val help = listOf("To have the bot say something on the channel:", helpFormat("%c $name <text>"))

View file

@ -36,6 +36,9 @@ import net.thauvin.erik.mobibot.Utils.helpFormat
import net.thauvin.erik.mobibot.Utils.sendList
import org.pircbotx.hooks.types.GenericMessageEvent
/**
* Lists the users present on the channel.
*/
class Users : AbstractCommand() {
override val name = "users"
override val help = listOf("To list the users present on the channel:", helpFormat("%c $name"))

View file

@ -38,6 +38,9 @@ import net.thauvin.erik.mobibot.Utils.toIsoLocalDate
import org.pircbotx.PircBotX
import org.pircbotx.hooks.types.GenericMessageEvent
/**
* Lists the bot version, OS version, JVM version, Kotlin version and PircBotX version.
*/
class Versions : AbstractCommand() {
private val allVersions = listOf(
"Version: ${ReleaseInfo.VERSION} (${ReleaseInfo.BUILD_DATE.toIsoLocalDate()})",

View file

@ -42,6 +42,10 @@ import net.thauvin.erik.mobibot.entries.EntriesUtils.toLinkLabel
import net.thauvin.erik.mobibot.entries.EntryLink
import org.pircbotx.hooks.types.GenericMessageEvent
/**
* Processes commands such as viewing, deleting, editing, or changing the author of a comment based on the input
* arguments and the state of the entries.
*/
class Comment : AbstractCommand() {
override val name = COMMAND
override val help = listOf(

View file

@ -49,6 +49,12 @@ import org.jsoup.Jsoup
import org.pircbotx.hooks.types.GenericMessageEvent
import java.io.IOException
/**
* Processes a URL, fetch its metadata, and register it with associated details.
*
* It checks for duplicate entries, retrieves or assigns a title, associates tags, and adds the URL to a collection of
* entries for further processing.
*/
class LinksManager : AbstractCommand() {
private val defaultTags: MutableList<String> = mutableListOf()
private val keywords: MutableList<String> = mutableListOf()
@ -59,10 +65,6 @@ class LinksManager : AbstractCommand() {
override val isPublic = false
override val isVisible = false
init {
initProperties(TAGS_PROP, KEYWORDS_PROP)
}
companion object {
val LINK_MATCH = "^[hH][tT][tT][pP](|[sS])://.*".toRegex()
const val KEYWORDS_PROP = "tags-keywords"
@ -100,6 +102,10 @@ class LinksManager : AbstractCommand() {
}
}
init {
initProperties(TAGS_PROP, KEYWORDS_PROP)
}
override fun commandResponse(channel: String, args: String, event: GenericMessageEvent) {
val cmds = args.split(" ".toRegex(), 2)
val sender = event.user.nick
@ -120,10 +126,11 @@ class LinksManager : AbstractCommand() {
}
if (title.isBlank()) {
title = fetchTitle(link)
title = fetchPageTitle(link)
}
if (title != Constants.NO_TITLE) {
// Add keywords as tags if found in the title
matchTagKeywords(title, tags)
}
@ -139,7 +146,7 @@ class LinksManager : AbstractCommand() {
pinboard.addPin(event.bot().serverHostname, entry)
// Queue link for posting to social media.
// Queue the entry for posting to social media.
socialManager.queueEntry(index)
entries.save()
@ -158,7 +165,12 @@ class LinksManager : AbstractCommand() {
return message.matches(LINK_MATCH)
}
internal fun fetchTitle(link: String): String {
/**
* Fetches and returns the page title of the given URL.
*
* If the title cannot be fetched or is blank, [Constants.NO_TITLE] is returned.
*/
internal fun fetchPageTitle(link: String): String {
try {
val html = Jsoup.connect(link)
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:74.0) Gecko/20100101 Firefox/74.0")
@ -167,7 +179,7 @@ class LinksManager : AbstractCommand() {
if (title.isNotBlank()) {
return title
}
} catch (ignore: IOException) {
} catch (_: IOException) {
// Do nothing
}
return Constants.NO_TITLE
@ -181,12 +193,16 @@ class LinksManager : AbstractCommand() {
"Duplicate".bold() + " >> " + printLink(entries.links.indexOf(match), match)
)
true
} catch (ignore: NoSuchElementException) {
} catch (_: NoSuchElementException) {
false
}
}
}
/**
* Matches [keywords] in the given title and adds them to the provided tag list.
*/
internal fun matchTagKeywords(title: String, tags: MutableList<String>) {
for (match in keywords) {
val m = Regex.escape(match)

View file

@ -44,6 +44,11 @@ import net.thauvin.erik.mobibot.entries.EntriesUtils.toLinkLabel
import net.thauvin.erik.mobibot.entries.EntryLink
import org.pircbotx.hooks.types.GenericMessageEvent
/**
* Modifies or displays the content of an entry.
*
* It handles actions such as adding comments, changing author, title, or URL, and removing or printing entries.
*/
class Posting : AbstractCommand() {
override val name = "posting"
override val help = listOf(
@ -97,6 +102,20 @@ class Posting : AbstractCommand() {
entries.save()
}
private fun changeAuthor(channel: String, cmd: String, index: Int, event: GenericMessageEvent) {
if (event.isChannelOp(channel)) {
if (cmd.length > 1) {
val entry: EntryLink = entries.links[index]
entry.nick = cmd.substring(1)
LinksManager.pinboard.updatePin(event.bot().serverHostname, entry.link, entry)
event.sendMessage(EntriesUtils.printLink(index, entry))
entries.save()
}
} else {
event.sendMessage("Please ask a channel op to change the author of this link for you.")
}
}
private fun changeTitle(cmd: String, entryIndex: Int, event: GenericMessageEvent) {
if (cmd.length > 1) {
val entry: EntryLink = entries.links[entryIndex]
@ -121,20 +140,6 @@ class Posting : AbstractCommand() {
}
}
private fun changeAuthor(channel: String, cmd: String, index: Int, event: GenericMessageEvent) {
if (event.isChannelOp(channel)) {
if (cmd.length > 1) {
val entry: EntryLink = entries.links[index]
entry.nick = cmd.substring(1)
LinksManager.pinboard.updatePin(event.bot().serverHostname, entry.link, entry)
event.sendMessage(EntriesUtils.printLink(index, entry))
entries.save()
}
} else {
event.sendMessage("Please ask a channel op to change the author of this link for you.")
}
}
private fun removeEntry(channel: String, index: Int, event: GenericMessageEvent) {
val entry: EntryLink = entries.links[index]
if (entry.login == event.user.login || event.isChannelOp(channel)) {

View file

@ -41,6 +41,12 @@ import net.thauvin.erik.mobibot.entries.EntriesUtils
import net.thauvin.erik.mobibot.entries.EntryLink
import org.pircbotx.hooks.types.GenericMessageEvent
/**
* Manages tags associated with a specific link entry.
*
* Allows users to modify or view tags associated with a link. Users can only change tags for their own links unless
* they are channel operators.
*/
class Tags : AbstractCommand() {
override val name = COMMAND
override val help = listOf(
@ -55,6 +61,7 @@ class Tags : AbstractCommand() {
const val COMMAND = "tags"
}
override fun commandResponse(channel: String, args: String, event: GenericMessageEvent) {
val cmds = args.substring(1).split("${Constants.TAG_CMD}:", limit = 2)
val index = cmds[0].toInt() - 1

View file

@ -43,6 +43,9 @@ import net.thauvin.erik.mobibot.entries.EntryLink
import org.pircbotx.hooks.events.PrivateMessageEvent
import org.pircbotx.hooks.types.GenericMessageEvent
/**
* Displays a list of entries or an appropriate message if no entries exist.
*/
class View : AbstractCommand() {
override val name = VIEW_CMD
override val help = listOf(
@ -61,12 +64,17 @@ class View : AbstractCommand() {
override fun commandResponse(channel: String, args: String, event: GenericMessageEvent) {
if (entries.links.isNotEmpty()) {
val p = parseArgs(args)
showPosts(p.first, p.second, event)
viewEntries(p.first, p.second, event)
} else {
event.sendMessage("There is currently nothing to view. Why don't you post something?")
}
}
/**
* Parses the view command input arguments and determines a starting index and query string.
*
*`view [<start>] [<query>]`
*/
internal fun parseArgs(args: String): Pair<Int, String> {
var query = args.lowercase().trim()
var start = 0
@ -81,14 +89,14 @@ class View : AbstractCommand() {
if (start > entries.links.size) {
start = 0
}
} catch (ignore: NumberFormatException) {
} catch (_: NumberFormatException) {
// Do nothing
}
}
return Pair(start, query)
}
private fun showPosts(start: Int, query: String, event: GenericMessageEvent) {
private fun viewEntries(start: Int, query: String, event: GenericMessageEvent) {
var index = start
var entry: EntryLink
var sent = 0

View file

@ -33,13 +33,16 @@ package net.thauvin.erik.mobibot.commands.seen
import java.io.Serializable
/**
* A comparator implementation for comparing nicknames in a case-insensitive manner.
*/
class NickComparator : Comparator<String>, Serializable {
override fun compare(a: String, b: String): Int {
return a.lowercase().compareTo(b.lowercase())
}
companion object {
@Suppress("ConstPropertyName")
@Suppress("unused")
private const val serialVersionUID = 1L
}
}

View file

@ -49,7 +49,9 @@ import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.util.*
/**
* Displays when a user was last seen, or all seen nicks.
*/
class Seen(private val serialObject: String) : AbstractCommand() {
private val logger: Logger = LoggerFactory.getLogger(Seen::class.java)
private val allKeyword = "all"
@ -64,6 +66,9 @@ class Seen(private val serialObject: String) : AbstractCommand() {
override val isPublic = true
override val isVisible = true
init {
load()
}
override fun commandResponse(channel: String, args: String, event: GenericMessageEvent) {
if (isEnabled()) {
@ -143,8 +148,4 @@ class Seen(private val serialObject: String) : AbstractCommand() {
fun save() {
saveSerialData(serialObject, seenNicks, logger, "seen nicknames")
}
init {
load()
}
}

View file

@ -33,9 +33,12 @@ package net.thauvin.erik.mobibot.commands.seen
import java.io.Serializable
/**
* Holds a [Seen] nickname.
*/
data class SeenNick(val nick: String, val lastSeen: Long) : Serializable {
companion object {
@Suppress("ConstPropertyName")
@Suppress("unused")
private const val serialVersionUID = 1L
}
}

View file

@ -48,7 +48,7 @@ import org.pircbotx.hooks.types.GenericMessageEvent
import org.pircbotx.hooks.types.GenericUserEvent
/**
* The `Tell` command.
* Queues a message to be sent to someone when they join or are active on the channel.
*/
class Tell(private val serialObject: String) : AbstractCommand() {
// Messages queue
@ -284,7 +284,7 @@ class Tell(private val serialObject: String) : AbstractCommand() {
// Arrow
private const val ARROW = " --> "
// All keyword
// The `all` keyword
private const val TELL_ALL_KEYWORD = "all"
// The delete command.

View file

@ -38,7 +38,7 @@ import java.time.Clock
import java.time.LocalDateTime
/**
* The Tell Messages Manager.
* Manages the [Tell] messages queue.
*/
object TellManager {
private val logger: Logger = LoggerFactory.getLogger(TellManager::class.java)

View file

@ -36,7 +36,7 @@ import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
/**
* Tell Message.
* Holds a [Tell] message.
*/
class TellMessage(
/**
@ -98,7 +98,7 @@ class TellMessage(
}
companion object {
@Suppress("ConstPropertyName")
@Suppress("unused")
private const val serialVersionUID = 2L
}
}

View file

@ -33,6 +33,9 @@ package net.thauvin.erik.mobibot.entries
import net.thauvin.erik.mobibot.Utils.today
/**
* Holds [EntryLink] entries.
*/
class Entries(
var channel: String = "",
var ircServer: String = "",

View file

@ -35,7 +35,7 @@ import net.thauvin.erik.mobibot.Utils.bold
import net.thauvin.erik.mobibot.Utils.green
/**
* Entries utilities.
* Provides functions used to manage [Entries].
*/
object EntriesUtils {
/**
@ -69,14 +69,14 @@ object EntriesUtils {
}
/**
* Prints an entry's tags/categories for display on the channel. e.g. L1T: tag1, tag2
* Prints an entry's tags/categories for display on the channel. (e.g., L1T: tag1, tag2)
*/
@JvmStatic
fun printTags(entryIndex: Int, entry: EntryLink): String =
entryIndex.toLinkLabel() + "${Constants.TAG_CMD}: " + entry.formatTags(", ")
/**
* Builds link label based on its index. e.g: L1
* Builds link label based on its index. (e.g., L1)
*/
@JvmStatic
fun Int.toLinkLabel(): String = Constants.LINK_CMD + (this + 1)

View file

@ -34,7 +34,7 @@ import java.io.Serializable
import java.time.LocalDateTime
/**
* Entry comments data class.
* [Entries] comment.
*/
data class EntryComment(var comment: String, var nick: String) : Serializable {
/**
@ -45,8 +45,7 @@ data class EntryComment(var comment: String, var nick: String) : Serializable {
override fun toString(): String = "EntryComment{comment='$comment', date=$date, nick='$nick'}"
companion object {
// Serial version UID
@Suppress("ConstPropertyName")
private const val serialVersionUID: Long = 1L
@Suppress("unused")
private const val serialVersionUID = 1L
}
}

View file

@ -37,7 +37,7 @@ import java.io.Serializable
import java.util.*
/**
* The class used to store link entries.
* Holds [Entries] link.
*/
class EntryLink(
// Link's comments
@ -92,6 +92,11 @@ class EntryLink(
this.tags.addAll(tags)
}
companion object {
@Suppress("unused")
private const val serialVersionUID = 1L
}
/**
* Adds a new comment
*/
@ -204,10 +209,4 @@ class EntryLink(
return ("EntryLink{channel='$channel', comments=$comments, date=$date, link='$link', login='$login'," +
"nick='$nick', tags=$tags, title='$title'}")
}
companion object {
// Serial version UID
@Suppress("ConstPropertyName")
private const val serialVersionUID: Long = 1L
}
}

View file

@ -37,7 +37,9 @@ import org.pircbotx.hooks.events.PrivateMessageEvent
import org.pircbotx.hooks.types.GenericMessageEvent
/**
* The `Module` abstract class.
* Represents an abstract module, which can be extended to implement specific functionality.
*
* This class provides a foundation for creating modules with configurable properties, commands, and help features.
*/
abstract class AbstractModule {
/**
@ -76,6 +78,7 @@ abstract class AbstractModule {
/**
* Responds with the module's help.
*/
@Suppress("SameReturnValue")
open fun helpResponse(event: GenericMessageEvent): Boolean {
for (h in help) {
event.sendMessage(helpCmdSyntax(h, event.bot().nick, isPrivateMsgEnabled && event is PrivateMessageEvent))

View file

@ -40,35 +40,19 @@ import org.slf4j.LoggerFactory
import java.text.DecimalFormat
/**
* The Calc module.
* Performs a calculation.
*/
class Calc : AbstractModule() {
private val logger: Logger = LoggerFactory.getLogger(Calc::class.java)
override val name = "Calc"
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
if (args.isNotBlank()) {
try {
event.respond(calculate(args))
} catch (e: IllegalArgumentException) {
if (logger.isWarnEnabled) logger.warn("Failed to calculate: $args", e)
event.respond("No idea. This is the kind of math I don't get.")
} catch (e: UnknownFunctionOrVariableException) {
if (logger.isWarnEnabled) logger.warn("Unable to calculate: $args", e)
event.respond("No idea. I must've some form of Dyscalculia.")
}
} else {
helpResponse(event)
}
}
companion object {
// Calc command
private const val CALC_CMD = "calc"
/**
* Performs a calculation. e.g.: 1 + 1 * 2
* Performs a calculation (e.g.: 1 + 1 * 2)
*/
@JvmStatic
@Throws(IllegalArgumentException::class)
@ -84,4 +68,20 @@ class Calc : AbstractModule() {
help.add("To solve a mathematical calculation:")
help.add(helpFormat("%c $CALC_CMD <calculation>"))
}
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
if (args.isNotBlank()) {
try {
event.respond(calculate(args))
} catch (e: UnknownFunctionOrVariableException) {
if (logger.isWarnEnabled) logger.warn("Unable to calculate: $args", e)
event.respond("No idea. This is the kind of math I don't get.")
} catch (e: IllegalArgumentException) {
if (logger.isWarnEnabled) logger.warn("Failed to calculate: $args", e)
event.respond("No idea. I must've some form of Dyscalculia.")
}
} else {
helpResponse(event)
}
}
}

View file

@ -39,37 +39,14 @@ import org.pircbotx.hooks.types.GenericMessageEvent
import org.slf4j.Logger
import org.slf4j.LoggerFactory
/**
* Allows user to interact with ChatGPT.
*/
class ChatGpt2 : AbstractModule() {
val logger: Logger = LoggerFactory.getLogger(ChatGpt2::class.java)
override val name = CHATGPT_NAME
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
if (args.isNotBlank()) {
try {
val answer = chat(
args.trim(), properties[API_KEY_PROP],
properties.getOrDefault(MAX_TOKENS_PROP, "1024").toInt()
)
if (answer.isNotBlank()) {
event.sendMessage(answer)
} else {
event.respond("$name is stumped.")
}
} catch (e: ModuleException) {
if (logger.isWarnEnabled) logger.warn(e.debugMessage, e)
e.message?.let {
event.respond(it)
}
} catch (e: NumberFormatException) {
if (logger.isErrorEnabled) logger.error("Invalid $MAX_TOKENS_PROP property.", e)
event.respond("The $name module is misconfigured.")
}
} else {
helpResponse(event)
}
}
companion object {
/**
* The service name.
@ -82,7 +59,7 @@ class ChatGpt2 : AbstractModule() {
const val API_KEY_PROP = "chatgpt-api-key"
/**
* The max tokens property.
* The max-tokens property.
*/
const val MAX_TOKENS_PROP = "chatgpt-max-tokens"
@ -125,4 +102,33 @@ class ChatGpt2 : AbstractModule() {
}
initProperties(API_KEY_PROP, MAX_TOKENS_PROP)
}
/**
* Gets answers by chatting with ChatGPT.
*/
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
if (args.isNotBlank()) {
try {
val answer = chat(
args.trim(), properties[API_KEY_PROP],
properties.getOrDefault(MAX_TOKENS_PROP, "1024").toInt()
)
if (answer.isNotBlank()) {
event.sendMessage(answer)
} else {
event.respond("$name is stumped.")
}
} catch (e: ModuleException) {
if (logger.isWarnEnabled) logger.warn(e.debugMessage, e)
e.message?.let {
event.respond(it)
}
} catch (e: NumberFormatException) {
if (logger.isErrorEnabled) logger.error("Invalid $MAX_TOKENS_PROP property.", e)
event.respond("The $name module is misconfigured.")
}
} else {
helpResponse(event)
}
}
}

View file

@ -43,54 +43,13 @@ import org.slf4j.LoggerFactory
import java.io.IOException
/**
* The Cryptocurrency Prices module.
* Retrieves cryptocurrency market prices.
*/
class CryptoPrices : AbstractModule() {
private val logger: Logger = LoggerFactory.getLogger(CryptoPrices::class.java)
override val name = "CryptoPrices"
/**
* Returns the cryptocurrency market price from
* [Coinbase](https://docs.cdp.coinbase.com/coinbase-app/docs/api-prices#get-spot-price).
*/
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
if (CURRENCIES.isEmpty()) {
try {
loadCurrencies()
} catch (e: ModuleException) {
if (logger.isWarnEnabled) logger.warn(e.debugMessage, e)
}
}
val debugMessage = "crypto($cmd $args)"
if (args == CODES_KEYWORD) {
event.sendMessage("The supported currencies are:")
event.sendList(ArrayList(CURRENCIES.keys), 10, isIndent = true)
} else if (args.matches("\\w+( [a-zA-Z]{3}+)?".toRegex())) {
try {
val price = currentPrice(args.split(' '))
val amount = try {
price.toCurrency()
} catch (ignore: IllegalArgumentException) {
price.amount
}
event.respond("${price.base} current price is $amount [${CURRENCIES[price.currency]}]")
} catch (e: CryptoException) {
if (logger.isWarnEnabled) logger.warn("$debugMessage => ${e.statusCode}", e)
e.message?.let {
event.respond(it)
}
} catch (e: IOException) {
if (logger.isErrorEnabled) logger.error(debugMessage, e)
event.respond("An IO error has occurred while retrieving the cryptocurrency market price.")
}
} else {
helpResponse(event)
}
}
companion object {
// Crypto command
private const val CRYPTO_CMD = "crypto"
@ -101,8 +60,11 @@ class CryptoPrices : AbstractModule() {
// Currency codes keyword
private const val CODES_KEYWORD = "codes"
// Default error message
const val DEFAULT_ERROR_MESSAGE = "An error has occurred while retrieving the cryptocurrency market price"
/**
* Get current market price.
* Get the current market price.
*/
@JvmStatic
fun currentPrice(args: List<String>): CryptoPrice {
@ -156,4 +118,47 @@ class CryptoPrices : AbstractModule() {
}
loadCurrencies()
}
/**
* Returns the cryptocurrency market price from
* [Coinbase](https://docs.cdp.coinbase.com/coinbase-app/docs/api-prices#get-spot-price).
*/
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
if (CURRENCIES.isEmpty()) {
try {
loadCurrencies()
} catch (e: ModuleException) {
if (logger.isWarnEnabled) logger.warn(e.debugMessage, e)
}
}
val debugMessage = "crypto($cmd $args)"
if (args == CODES_KEYWORD) {
event.sendMessage("The supported currencies are:")
event.sendList(ArrayList(CURRENCIES.keys), 10, isIndent = true)
} else if (args.matches("\\w+( [a-zA-Z]{3}+)?".toRegex())) {
try {
val price = currentPrice(args.split(' '))
val amount = try {
price.toCurrency()
} catch (_: IllegalArgumentException) {
price.amount
}
event.respond("${price.base} current price is $amount [${CURRENCIES[price.currency]}]")
} catch (e: CryptoException) {
if (logger.isWarnEnabled) logger.warn("$debugMessage => ${e.statusCode}", e)
if (e.message != null) {
event.respond("$DEFAULT_ERROR_MESSAGE: ${e.message}")
} else {
event.respond("$DEFAULT_ERROR_MESSAGE.")
}
} catch (e: IOException) {
if (logger.isErrorEnabled) logger.error(debugMessage, e)
event.respond("$DEFAULT_ERROR_MESSAGE: ${e.message}")
}
} else {
helpResponse(event)
}
}
}

View file

@ -48,80 +48,12 @@ import java.net.URL
import java.text.DecimalFormat
import java.util.*
/**
* The CurrencyConverter module.
* Converts between currencies.
*/
class CurrencyConverter : AbstractModule() {
private val logger: Logger = LoggerFactory.getLogger(CurrencyConverter::class.java)
override val name = "CurrencyConverter"
// Reload currency codes
private fun reload(apiKey: String?) {
if (!apiKey.isNullOrEmpty() && SYMBOLS.isEmpty()) {
try {
loadSymbols(apiKey)
} catch (e: ModuleException) {
if (logger.isWarnEnabled) logger.warn(e.debugMessage, e)
}
}
}
/**
* Converts the specified currencies.
*/
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
reload(properties[API_KEY_PROP])
when {
SYMBOLS.isEmpty() -> {
event.respond(EMPTY_SYMBOLS_TABLE)
}
args.matches("\\d+([,\\d]+)?(\\.\\d+)? [a-zA-Z]{3}+ (to|in) [a-zA-Z]{3}+".toRegex()) -> {
val msg = convertCurrency(properties[API_KEY_PROP], args)
event.respond(msg.msg)
if (msg.isError) {
helpResponse(event)
}
}
args.contains(CODES_KEYWORD) -> {
event.sendMessage("The supported currency codes are:")
event.sendList(SYMBOLS.keys.toList(), 11, isIndent = true)
}
else -> {
helpResponse(event)
}
}
}
override fun helpResponse(event: GenericMessageEvent): Boolean {
reload(properties[API_KEY_PROP])
if (SYMBOLS.isEmpty()) {
event.sendMessage(EMPTY_SYMBOLS_TABLE)
} else {
val nick = event.bot().nick
event.sendMessage("To convert from one currency to another:")
event.sendMessage(helpFormat(helpCmdSyntax("%c $CURRENCY_CMD 100 USD to EUR", nick, isPrivateMsgEnabled)))
event.sendMessage(
helpFormat(
helpCmdSyntax("%c $CURRENCY_CMD 50,000 GBP to USD", nick, isPrivateMsgEnabled)
)
)
event.sendMessage("To list the supported currency codes:")
event.sendMessage(
helpFormat(
helpCmdSyntax("%c $CURRENCY_CMD $CODES_KEYWORD", nick, isPrivateMsgEnabled)
)
)
}
return true
}
companion object {
/**
* The API Key property.
@ -134,14 +66,23 @@ class CurrencyConverter : AbstractModule() {
// Currency codes keyword
private const val CODES_KEYWORD = "codes"
// Decimal format
private val DECIMAL_FORMAT = DecimalFormat("0.00#")
// Empty symbols table.
private const val EMPTY_SYMBOLS_TABLE = "Sorry, but the currency table is empty."
// Logger
private val LOGGER: Logger = LoggerFactory.getLogger(CurrencyConverter::class.java)
// Currency symbols
private val SYMBOLS: TreeMap<String, String> = TreeMap()
// Decimal format
private val DECIMAL_FORMAT = DecimalFormat("0.00#")
/**
* No API key error message.
*/
const val ERROR_MESSAGE_NO_API_KEY = "No Exchange Rate API key specified."
/**
* Converts from a currency to another.
@ -149,7 +90,7 @@ class CurrencyConverter : AbstractModule() {
@JvmStatic
fun convertCurrency(apiKey: String?, query: String): Message {
if (apiKey.isNullOrEmpty()) {
throw ModuleException("${CURRENCY_CMD}($query)", "No Exchange Rate API key specified.")
throw ModuleException("${CURRENCY_CMD}($query)", ERROR_MESSAGE_NO_API_KEY)
}
val cmds = query.split(" ")
@ -174,7 +115,10 @@ class CurrencyConverter : AbstractModule() {
} else {
ErrorMessage("Sorry, an error occurred while converting the currencies.")
}
} catch (ignore: IOException) {
} catch (ioe: IOException) {
if (LOGGER.isWarnEnabled) {
LOGGER.warn("IO error while converting currencies: ${ioe.message}", ioe)
}
ErrorMessage("Sorry, an IO error occurred while converting the currencies.")
}
} else {
@ -219,4 +163,74 @@ class CurrencyConverter : AbstractModule() {
initProperties(API_KEY_PROP)
loadSymbols(properties[ChatGpt2.API_KEY_PROP])
}
// Reload currency codes
private fun reload(apiKey: String?) {
if (!apiKey.isNullOrEmpty() && SYMBOLS.isEmpty()) {
try {
loadSymbols(apiKey)
} catch (e: ModuleException) {
if (LOGGER.isWarnEnabled) LOGGER.warn(e.debugMessage, e)
}
}
}
/**
* Converts the specified currencies.
*/
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
reload(properties[API_KEY_PROP])
when {
SYMBOLS.isEmpty() -> {
event.respond(EMPTY_SYMBOLS_TABLE)
}
args.matches("\\d+([,\\d]+)?(\\.\\d+)? [a-zA-Z]{3}+ (to|in) [a-zA-Z]{3}+".toRegex()) -> {
try {
val msg = convertCurrency(properties[API_KEY_PROP], args)
if (msg.isError) {
helpResponse(event)
} else {
event.respond(msg.msg)
}
} catch (e: ModuleException) {
if (LOGGER.isWarnEnabled) LOGGER.warn(e.debugMessage, e)
event.respond(e.message)
}
}
args.contains(CODES_KEYWORD) -> {
event.sendMessage("The supported currency codes are:")
event.sendList(SYMBOLS.keys.toList(), 11, isIndent = true)
}
else -> {
helpResponse(event)
}
}
}
override fun helpResponse(event: GenericMessageEvent): Boolean {
reload(properties[API_KEY_PROP])
if (SYMBOLS.isEmpty()) {
event.sendMessage(EMPTY_SYMBOLS_TABLE)
} else {
val nick = event.bot().nick
event.sendMessage("To convert from one currency to another:")
event.sendMessage(helpFormat(helpCmdSyntax("%c $CURRENCY_CMD 100 USD to EUR", nick, isPrivateMsgEnabled)))
event.sendMessage(
helpFormat(
helpCmdSyntax("%c $CURRENCY_CMD 50,000 GBP to USD", nick, isPrivateMsgEnabled)
)
)
event.sendMessage("To list the supported currency codes:")
event.sendMessage(
helpFormat(
helpCmdSyntax("%c $CURRENCY_CMD $CODES_KEYWORD", nick, isPrivateMsgEnabled)
)
)
}
return true
}
}

View file

@ -35,22 +35,11 @@ import net.thauvin.erik.mobibot.Utils.helpFormat
import org.pircbotx.hooks.types.GenericMessageEvent
/**
* The Dice module.
* Rolls dice.
*/
class Dice : AbstractModule() {
override val name = "Dice"
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
val arg = if (args.isBlank()) "2d6" else args.trim()
val match = Regex("^([1-9]|[12]\\d|3[0-2])[dD]([1-9]|[12]\\d|3[0-2])$").find(arg)
if (match != null) {
val (dice, sides) = match.destructured
event.respond("you rolled " + roll(dice.toInt(), sides.toInt()))
} else {
helpResponse(event)
}
}
companion object {
// Dice command
private const val DICE_CMD = "dice"
@ -84,4 +73,15 @@ class Dice : AbstractModule() {
help.add("To roll 2 dice with 6 sides:")
help.add(helpFormat("%c $DICE_CMD [2d6]"))
}
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
val arg = if (args.isBlank()) "2d6" else args.trim()
val match = Regex("^([1-9]|[12]\\d|3[0-2])[dD]([1-9]|[12]\\d|3[0-2])$").find(arg)
if (match != null) {
val (dice, sides) = match.destructured
event.respond("you rolled " + roll(dice.toInt(), sides.toInt()))
} else {
helpResponse(event)
}
}
}

View file

@ -37,47 +37,35 @@ import net.thauvin.erik.mobibot.Utils.sendMessage
import org.pircbotx.hooks.types.GenericMessageEvent
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.util.*
/**
* Allows user to interact with Gemini.
*/
class Gemini2 : AbstractModule() {
private val logger: Logger = LoggerFactory.getLogger(Gemini2::class.java)
override val name = GEMINI_NAME
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
if (args.isNotBlank()) {
try {
val answer = chat(
args.trim(),
properties[GEMINI_API_KEY],
properties.getOrDefault(MAX_TOKENS_PROP, "1024").toInt()
)
if (!answer.isNullOrEmpty()) {
event.sendMessage(answer)
} else {
event.respond("$name is stumped.")
}
} catch (e: ModuleException) {
if (logger.isWarnEnabled) logger.warn(e.debugMessage, e)
e.message?.let {
event.respond(it)
}
}
} else {
helpResponse(event)
}
}
companion object {
/**
* API Key error message
*/
const val API_KEY_ERROR = "Please specify an API key."
/**
* The API key
*/
const val GEMINI_API_KEY = "gemini-api-key"
/**
* The service name.
*/
const val GEMINI_NAME = "Gemini"
/**
* The API key
* IO error message
*/
const val GEMINI_API_KEY = "gemini-api-key"
const val IO_ERROR = "An IO error has occurred while conversing with $GEMINI_NAME."
/**
* The max number of output tokens property.
@ -104,14 +92,10 @@ class Gemini2 : AbstractModule() {
return gemini.generate(query)
} catch (e: Exception) {
throw ModuleException(
"$GEMINI_CMD($query): IO",
"An IO error has occurred while conversing with ${GEMINI_NAME}.",
e
)
throw ModuleException("$GEMINI_CMD($query): IO", IO_ERROR, e)
}
} else {
throw ModuleException("${GEMINI_CMD}($query)", "No $GEMINI_NAME Project ID or Location specified.")
throw ModuleException("${GEMINI_CMD}($query)", API_KEY_ERROR)
}
}
}
@ -127,4 +111,31 @@ class Gemini2 : AbstractModule() {
}
initProperties(GEMINI_API_KEY, MAX_TOKENS_PROP)
}
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
if (args.isNotBlank()) {
try {
val answer = chat(
args.trim(),
properties[GEMINI_API_KEY],
properties.getOrDefault(MAX_TOKENS_PROP, "1024").toInt()
)
if (!answer.isNullOrEmpty()) {
event.sendMessage(answer)
} else {
event.respond("$name is stumped.")
}
} catch (e: ModuleException) {
if (logger.isWarnEnabled) logger.warn(e.debugMessage, e)
e.message?.let {
event.respond(it)
}
} catch (e: NumberFormatException) {
if (logger.isErrorEnabled) logger.error("Invalid $MAX_TOKENS_PROP property.", e)
event.respond("The $name module is misconfigured.")
}
} else {
helpResponse(event)
}
}
}

View file

@ -31,7 +31,6 @@
package net.thauvin.erik.mobibot.modules
import net.thauvin.erik.mobibot.ReleaseInfo
import net.thauvin.erik.mobibot.Utils.capitalise
import net.thauvin.erik.mobibot.Utils.colorize
import net.thauvin.erik.mobibot.Utils.encodeUrl
import net.thauvin.erik.mobibot.Utils.helpFormat
@ -51,50 +50,29 @@ import java.io.IOException
import java.net.URL
/**
* The GoogleSearch module.
* Allows user to search Google.
*/
class GoogleSearch : AbstractModule() {
private val logger: Logger = LoggerFactory.getLogger(GoogleSearch::class.java)
override val name = "GoogleSearch"
/**
* Searches Google.
*/
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
if (args.isNotBlank()) {
try {
val results = searchGoogle(
args,
properties[API_KEY_PROP],
properties[CSE_KEY_PROP],
event.user.nick
)
for (msg in results) {
if (msg.isError) {
event.respond(msg.msg.colorize(msg.color))
} else {
event.sendMessage(channel, msg)
}
}
} catch (e: ModuleException) {
if (logger.isWarnEnabled) logger.warn(e.debugMessage, e)
e.message?.let {
event.respond(it)
}
}
} else {
helpResponse(event)
}
}
override val name = SERVICE_NAME
companion object {
// Google API Key property
/**
* API Key property
*/
const val API_KEY_PROP = "google-api-key"
// Google Custom Search Engine ID property
/**
* Google Custom Search Engine ID property
*/
const val CSE_KEY_PROP = "google-cse-cx"
/**
* The service name
*/
const val SERVICE_NAME = "GoogleSearch"
// Google command
private const val GOOGLE_CMD = "google"
@ -112,7 +90,7 @@ class GoogleSearch : AbstractModule() {
if (apiKey.isNullOrBlank() || cseKey.isNullOrBlank()) {
throw ModuleException(
"${GoogleSearch::class.java.name} is disabled.",
"${GOOGLE_CMD.capitalise()} is disabled. The API keys are missing."
"$SERVICE_NAME is disabled. The API keys are missing."
)
}
val results = mutableListOf<Message>()
@ -159,4 +137,34 @@ class GoogleSearch : AbstractModule() {
help.add(helpFormat("%c $GOOGLE_CMD <query>"))
initProperties(API_KEY_PROP, CSE_KEY_PROP)
}
/**
* Searches Google.
*/
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
if (args.isNotBlank()) {
try {
val results = searchGoogle(
args,
properties[API_KEY_PROP],
properties[CSE_KEY_PROP],
event.user.nick
)
for (msg in results) {
if (msg.isError) {
event.respond(msg.msg.colorize(msg.color))
} else {
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

@ -47,31 +47,13 @@ import org.slf4j.LoggerFactory
import java.io.IOException
/**
* The Joke module.
* Displays jokes from [JokeAPI](https://v2.jokeapi.dev/).
*/
class Joke : AbstractModule() {
private val logger: Logger = LoggerFactory.getLogger(Joke::class.java)
override val name = "Joke"
/**
* Returns a random joke from [JokeAPI](https://v2.jokeapi.dev/).
*/
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
with(event.bot()) {
try {
randomJoke().forEach {
sendIRC().notice(channel, it.msg.colorize(it.color))
}
} catch (e: ModuleException) {
if (logger.isWarnEnabled) logger.warn(e.debugMessage, e)
e.message?.let {
event.respond(it)
}
}
}
}
companion object {
// Joke command
private const val JOKE_CMD = "joke"
@ -102,4 +84,20 @@ class Joke : AbstractModule() {
help.add("To display a random joke:")
help.add(helpFormat("%c $JOKE_CMD"))
}
/**
* Returns a random joke from [JokeAPI](https://v2.jokeapi.dev/).
*/
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
try {
randomJoke().forEach {
event.bot().sendIRC().notice(channel, it.msg.colorize(it.color))
}
} catch (e: ModuleException) {
if (logger.isWarnEnabled) logger.warn(e.debugMessage, e)
e.message?.let {
event.respond(it)
}
}
}
}

View file

@ -42,57 +42,13 @@ import java.net.InetAddress
import java.net.UnknownHostException
/**
* The Lookup module.
* Performs a DNS lookup or Whois IP query.
*/
class Lookup : AbstractModule() {
private val logger: Logger = LoggerFactory.getLogger(Lookup::class.java)
override val name = "Lookup"
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
if (args.matches("(\\S.)+(\\S)+".toRegex())) {
try {
event.respondWith(nslookup(args).prependIndent())
} catch (ignore: UnknownHostException) {
if (args.matches(
("(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)")
.toRegex()
)
) {
try {
val lines = whois(args)
if (lines.isNotEmpty()) {
var line: String
var hasData = false
for (rawLine in lines) {
line = rawLine.trim()
if (line.matches("^\\b(?!\\b[Cc]omment\\b)\\w+\\b: .*$".toRegex())) {
if (!hasData) {
event.respondWith(line)
hasData = true
} else {
event.bot().sendIRC().notice(event.user.nick, line)
}
}
}
} else {
event.respond("Unknown host.")
}
} catch (ioe: IOException) {
if (logger.isWarnEnabled) {
logger.warn("Unable to perform whois IP lookup: $args", ioe)
}
event.respond("Unable to perform whois IP lookup: ${ioe.message}")
}
} else {
event.respond("Unknown host.")
}
}
} else {
helpResponse(event)
}
}
companion object {
/**
* The whois default host.
@ -168,4 +124,48 @@ class Lookup : AbstractModule() {
help.add("To perform a DNS lookup query:")
help.add(helpFormat("%c $LOOKUP_CMD <ip address or hostname>"))
}
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
if (args.matches("(\\S.)+(\\S)+".toRegex())) {
try {
event.respondWith(nslookup(args).prependIndent())
} catch (_: UnknownHostException) {
if (args.matches(
("(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)")
.toRegex()
)
) {
try {
val lines = whois(args)
if (lines.isNotEmpty()) {
var line: String
var hasData = false
for (rawLine in lines) {
line = rawLine.trim()
if (line.matches("^\\b(?!\\b[Cc]omment\\b)\\w+\\b: .*$".toRegex())) {
if (!hasData) {
event.respondWith(line)
hasData = true
} else {
event.bot().sendIRC().notice(event.user.nick, line)
}
}
}
} else {
event.respond("Unknown host.")
}
} catch (ioe: IOException) {
if (logger.isWarnEnabled) {
logger.warn("Unable to perform whois IP lookup: $args", ioe)
}
event.respond("Unable to perform whois IP lookup: ${ioe.message}")
}
} else {
event.respond("Unknown host.")
}
}
} else {
helpResponse(event)
}
}
}

View file

@ -44,6 +44,9 @@ import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
/**
* Allows users to post on Mastodon.
*/
class Mastodon : SocialModule() {
override val name = "Mastodon"
@ -56,6 +59,77 @@ class Mastodon : SocialModule() {
override val isValidProperties: Boolean
get() = !(properties[INSTANCE_PROP].isNullOrBlank() || properties[ACCESS_TOKEN_PROP].isNullOrBlank())
companion object {
// Property keys
const val ACCESS_TOKEN_PROP = "mastodon-access-token"
const val AUTO_POST_PROP = "mastodon-auto-post"
const val HANDLE_PROP = "mastodon-handle"
const val INSTANCE_PROP = "mastodon-instance"
private const val MASTODON_CMD = "mastodon"
private const val TOOT_CMD = "toot"
/**
* Post on Mastodon.
*/
@JvmStatic
@Throws(ModuleException::class)
fun toot(accessToken: String?, instance: String?, handle: String?, message: String, isDm: Boolean): String {
if (accessToken.isNullOrBlank()) {
throw ModuleException("Missing access token", "The access token is missing.")
} else if (instance.isNullOrBlank()) {
throw ModuleException("Missing instance", "The Mastodon instance is missing.")
} else if (isDm && handle.isNullOrBlank()) {
throw ModuleException("Missing handle", "The Mastodon handle is missing.")
}
val request = HttpRequest.newBuilder()
.uri(URI.create("https://$instance/api/v1/statuses"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer $accessToken")
.POST(
HttpRequest.BodyPublishers.ofString(
JSONWriter.valueToString(
if (isDm) {
mapOf("status" to "${handle?.prefixIfMissing('@')} $message", "visibility" to "direct")
} else {
mapOf("status" to message)
}
)
)
).build()
try {
val response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString())
if (response.statusCode() == 200) {
return try {
val jsonResponse = JSONObject(response.body())
if (isDm) {
jsonResponse.getString("content")
} else {
"Your message was posted to ${jsonResponse.getString("url")}"
}
} catch (e: JSONException) {
throw ModuleException("mastodonPost($message)", "A JSON error has occurred: ${e.message}", e)
}
} else {
throw IOException("HTTP Status Code: " + response.statusCode())
}
} catch (e: IOException) {
throw ModuleException("mastodonPost($message)", "An IO error has occurred: ${e.message}", e)
} catch (e: InterruptedException) {
throw ModuleException("mastodonPost($message)", "An error has occurred: ${e.message}", e)
}
}
}
init {
commands.add(MASTODON_CMD)
commands.add(TOOT_CMD)
help.add("To toot on Mastodon:")
help.add(Utils.helpFormat("%c $TOOT_CMD <message>"))
properties[AUTO_POST_PROP] = "false"
initProperties(ACCESS_TOKEN_PROP, HANDLE_PROP, INSTANCE_PROP)
}
/**
* Formats the entry for posting.
*/
@ -74,76 +148,11 @@ class Mastodon : SocialModule() {
@Throws(ModuleException::class)
override fun post(message: String, isDm: Boolean): String {
return toot(
apiKey = properties[ACCESS_TOKEN_PROP],
accessToken = properties[ACCESS_TOKEN_PROP],
instance = properties[INSTANCE_PROP],
handle = handle,
message = message,
isDm = isDm
)
}
companion object {
// Property keys
const val ACCESS_TOKEN_PROP = "mastodon-access-token"
const val AUTO_POST_PROP = "mastodon-auto-post"
const val HANDLE_PROP = "mastodon-handle"
const val INSTANCE_PROP = "mastodon-instance"
private const val MASTODON_CMD = "mastodon"
private const val TOOT_CMD = "toot"
/**
* Post on Mastodon.
*/
@JvmStatic
@Throws(ModuleException::class)
fun toot(apiKey: String?, instance: String?, handle: String?, message: String, isDm: Boolean): String {
val request = HttpRequest.newBuilder()
.uri(URI.create("https://$instance/api/v1/statuses"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer $apiKey")
.POST(
HttpRequest.BodyPublishers.ofString(
JSONWriter.valueToString(
if (isDm) {
mapOf("status" to "${handle?.prefixIfMissing('@')} $message", "visibility" to "direct")
} else {
mapOf("status" to message)
}
)
)
)
.build()
try {
val response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString())
if (response.statusCode() == 200) {
return try {
val jsonResponse = JSONObject(response.body())
if (isDm) {
jsonResponse.getString("content")
} else {
"Your message was posted to ${jsonResponse.getString("url")}"
}
} catch (e: JSONException) {
throw ModuleException("mastodonPost($message)", "A JSON error has occurred: ${e.message}", e)
}
} else {
throw IOException("Status Code: " + response.statusCode())
}
} catch (e: IOException) {
throw ModuleException("mastodonPost($message)", "An IO error has occurred: ${e.message}", e)
} catch (e: InterruptedException) {
throw ModuleException("mastodonPost($message)", "An error has occurred: ${e.message}", e)
}
}
}
init {
commands.add(MASTODON_CMD)
commands.add(TOOT_CMD)
help.add("To toot on Mastodon:")
help.add(Utils.helpFormat("%c $TOOT_CMD <message>"))
properties[AUTO_POST_PROP] = "false"
initProperties(ACCESS_TOKEN_PROP, HANDLE_PROP, INSTANCE_PROP)
}
}

View file

@ -30,16 +30,13 @@
*/
package net.thauvin.erik.mobibot.modules
/**
* The `ModuleException` class.
*/
class ModuleException @JvmOverloads constructor(
val debugMessage: String,
message: String? = null,
cause: Throwable? = null
) : Exception(message, cause) {
companion object {
@Suppress("ConstPropertyName")
@Suppress("unused")
private const val serialVersionUID = 1L
}
}

View file

@ -35,15 +35,11 @@ import net.thauvin.erik.mobibot.Utils.helpFormat
import org.pircbotx.hooks.types.GenericMessageEvent
/**
* The Ping module.
* Responds with a random quirky response.
*/
class Ping : AbstractModule() {
override val name = "Ping"
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
event.bot().sendIRC().action(channel, randomPing())
}
companion object {
/**
* The ping responses.
@ -80,4 +76,8 @@ class Ping : AbstractModule() {
help.add("To ping the bot:")
help.add(helpFormat("%c $PING_CMD"))
}
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
event.bot().sendIRC().action(channel, randomPing())
}
}

View file

@ -37,11 +37,25 @@ import org.pircbotx.hooks.types.GenericMessageEvent
/**
* Simple module example in Kotlin.
* Allows users to play Rock Paper Scissors.
*/
class RockPaperScissors : AbstractModule() {
override val name = "RockPaperScissors"
companion object {
// For testing.
fun winLoseOrDraw(player: String, bot: String): String {
val hand = Hands.valueOf(player.uppercase())
val botHand = Hands.valueOf(bot.uppercase())
return when {
hand == botHand -> "draw"
hand.beats(botHand) -> "win"
else -> "lose"
}
}
}
init {
with(commands) {
add(Hands.ROCK.name.lowercase())
@ -80,23 +94,9 @@ class RockPaperScissors : AbstractModule() {
abstract fun beats(hand: Hands): Boolean
}
companion object {
// For testing.
fun winLoseOrDraw(player: String, bot: String): String {
val hand = Hands.valueOf(player.uppercase())
val botHand = Hands.valueOf(bot.uppercase())
return when {
hand == botHand -> "draw"
hand.beats(botHand) -> "win"
else -> "lose"
}
}
}
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
val hand = Hands.valueOf(cmd.uppercase())
val botHand = Hands.entries[(0..Hands.entries.size).random()]
val botHand = Hands.entries[(0..Hands.entries.size - 1).random()]
when {
hand == botHand -> {
event.respond("${hand.name} vs. ${botHand.name} » You ${"tie".bold()}.")

View file

@ -30,7 +30,6 @@
*/
package net.thauvin.erik.mobibot.modules
import net.thauvin.erik.mobibot.Utils.capitalise
import net.thauvin.erik.mobibot.Utils.encodeUrl
import net.thauvin.erik.mobibot.Utils.helpFormat
import net.thauvin.erik.mobibot.Utils.reader
@ -49,33 +48,12 @@ import java.io.IOException
import java.net.URL
/**
* The StockQuote module.
* Retrieves stock quotes from Alpha Vantage.
*/
class StockQuote : AbstractModule() {
private val logger: Logger = LoggerFactory.getLogger(StockQuote::class.java)
override val name = "StockQuote"
/**
* 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)
}
}
override val name = SERVICE_NAME
companion object {
/**
@ -88,6 +66,11 @@ class StockQuote : AbstractModule() {
*/
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="
@ -103,7 +86,7 @@ class StockQuote : AbstractModule() {
if (info.isNotEmpty()) {
throw ModuleException(debugMessage, info.unescapeXml())
}
} catch (ignore: JSONException) {
} catch (_: JSONException) {
// Do nothing
}
try {
@ -115,7 +98,7 @@ class StockQuote : AbstractModule() {
if (error.isNotEmpty()) {
throw ModuleException(debugMessage, error.unescapeXml())
}
} catch (ignore: JSONException) {
} catch (_: JSONException) {
// Do nothing
}
json
@ -132,8 +115,8 @@ class StockQuote : AbstractModule() {
fun getQuote(symbol: String, apiKey: String?): List<Message> {
if (apiKey.isNullOrBlank()) {
throw ModuleException(
"${StockQuote::class.java.name} is disabled.",
"${STOCK_CMD.capitalise()} is disabled. The API key is missing."
"$SERVICE_NAME is disabled.",
"$SERVICE_NAME is disabled. The API key is missing."
)
}
val messages = mutableListOf<Message>()
@ -233,4 +216,25 @@ class StockQuote : AbstractModule() {
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

@ -36,14 +36,31 @@ import org.pircbotx.hooks.types.GenericMessageEvent
import java.security.SecureRandom
/**
* The War module.
*
* @author [Erik C. Thauvin](https://erik.thauvin.net/)
* @since 1.0
* Plays the `war` card game.
*/
class War : AbstractModule() {
override val name = "War"
companion object {
private val CLUBS = arrayOf("🃑", "🃞", "🃝", "🃛", "🃚", "🃙", "🃘", "🃗", "🃖", "🃕", "🃔", "🃓", "🃒")
private val DIAMONDS = arrayOf("🃁", "🃎", "🃍", "🃋", "🃊", "🃉", "🃈", "🃇", "🃆", "🃅", "🃄", "🃃", "🃂")
private val HEARTS = arrayOf("🂱", "🂾", "🂽", "🂻", "🂺", "🂹", "🂸", "🂷", "🂶", "🂵", "🂴", "🂳", "🂲")
// Random
private val RANDOM = SecureRandom()
private val SPADES = arrayOf("🂡", "🂮", "🂭", "🂫", "🂪", "🂩", "🂨", "🂧", "🂦", "🂥", "🂤", "🂣", "🂢")
private val DECK = arrayOf(HEARTS, SPADES, DIAMONDS, CLUBS)
// War command
private const val WAR_CMD = "war"
}
init {
commands.add(WAR_CMD)
help.add("To play war:")
help.add(helpFormat("%c $WAR_CMD"))
}
override fun commandResponse(
channel: String, cmd: String, args: String,
event: GenericMessageEvent
@ -66,24 +83,4 @@ class War : AbstractModule() {
)
} while (i == y)
}
companion object {
private val CLUBS = arrayOf("🃑", "🃞", "🃝", "🃛", "🃚", "🃙", "🃘", "🃗", "🃖", "🃕", "🃔", "🃓", "🃒")
private val DIAMONDS = arrayOf("🃁", "🃎", "🃍", "🃋", "🃊", "🃉", "🃈", "🃇", "🃆", "🃅", "🃄", "🃃", "🃂")
private val HEARTS = arrayOf("🂱", "🂾", "🂽", "🂻", "🂺", "🂹", "🂸", "🂷", "🂶", "🂵", "🂴", "🂳", "🂲")
// Random
private val RANDOM = SecureRandom()
private val SPADES = arrayOf("🂡", "🂮", "🂭", "🂫", "🂪", "🂩", "🂨", "🂧", "🂦", "🂥", "🂤", "🂣", "🂢")
private val DECK = arrayOf(HEARTS, SPADES, DIAMONDS, CLUBS)
// War command
private const val WAR_CMD = "war"
}
init {
commands.add(WAR_CMD)
help.add("To play war:")
help.add(helpFormat("%c $WAR_CMD"))
}
}

View file

@ -35,7 +35,7 @@ import net.aksingh.owmjapis.core.OWM
import net.aksingh.owmjapis.core.OWM.Country
import net.aksingh.owmjapis.model.CurrentWeather
import net.thauvin.erik.mobibot.Utils.bold
import net.thauvin.erik.mobibot.Utils.capitalise
import net.thauvin.erik.mobibot.Utils.capitalize
import net.thauvin.erik.mobibot.Utils.capitalizeWords
import net.thauvin.erik.mobibot.Utils.encodeUrl
import net.thauvin.erik.mobibot.Utils.helpFormat
@ -51,37 +51,12 @@ import org.slf4j.LoggerFactory
import kotlin.math.roundToInt
/**
* The `Weather2` module.
* Retrieve weather information from OpenWeatherMap.
*/
class Weather2 : AbstractModule() {
private val logger: Logger = LoggerFactory.getLogger(Weather2::class.java)
override val name = "Weather"
/**
* Fetches the weather data from a specific city.
*/
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
if (args.isNotBlank()) {
try {
val messages = getWeather(args, properties[API_KEY_PROP])
if (messages[0].isError) {
helpResponse(event)
} else {
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)
}
}
override val name = WEATHER_NAME
companion object {
/**
@ -89,6 +64,11 @@ class Weather2 : AbstractModule() {
*/
const val API_KEY_PROP = "owm-api-key"
/**
* The service name.
*/
const val WEATHER_NAME = "Weather"
// Weather command
private const val WEATHER_CMD = "weather"
@ -121,7 +101,7 @@ class Weather2 : AbstractModule() {
if (apiKey.isNullOrBlank()) {
throw ModuleException(
"${Weather2::class.java.name} is disabled.",
"${WEATHER_CMD.capitalise()} is disabled. The API key is missing."
"${WEATHER_CMD.capitalize()} is disabled. The API key is missing."
)
}
val owm = OWM(apiKey)
@ -181,7 +161,7 @@ class Weather2 : AbstractModule() {
for (w in it) {
w?.let {
condition.append(' ')
.append(w.getDescription().capitalise())
.append(w.getDescription().capitalize())
.append('.')
}
}
@ -247,4 +227,29 @@ class Weather2 : AbstractModule() {
}
initProperties(API_KEY_PROP)
}
/**
* Fetches the weather data from a specific location.
*/
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
if (args.isNotBlank()) {
try {
val messages = getWeather(args, properties[API_KEY_PROP])
if (messages[0].isError) {
helpResponse(event)
} else {
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

@ -42,10 +42,84 @@ import org.slf4j.LoggerFactory
import java.io.IOException
import java.net.URL
/**
* Allows user to query Wolfram Alpha.
*/
class WolframAlpha : AbstractModule() {
private val logger: Logger = LoggerFactory.getLogger(WolframAlpha::class.java)
override val name = "WolframAlpha"
override val name = SERVICE_NAME
companion object {
/**
* The Wolfram Alpha AppID property.
*/
const val APPID_KEY_PROP = "wolfram-appid"
/**
* Metric unit
*/
const val METRIC = "metric"
/**
* Imperial unit
*/
const val IMPERIAL = "imperial"
/**
* The service name.
*/
const val SERVICE_NAME = "WolframAlpha"
/**
* The Wolfram units properties
*/
const val UNITS_PROP = "wolfram-units"
// Wolfram command
private const val WOLFRAM_CMD = "wolfram"
// Wolfram Alpha API URL
private const val API_URL = "https://api.wolframalpha.com/v1/spoken?appid="
@JvmStatic
@Throws(ModuleException::class)
fun queryWolfram(query: String, units: String = IMPERIAL, appId: String?): String {
if (!appId.isNullOrEmpty()) {
try {
val urlReader = URL("${API_URL}${appId}&units=${units}&i=" + query.encodeUrl()).reader()
if (urlReader.responseCode.isHttpSuccess()) {
return urlReader.body
} else {
throw ModuleException(
"wolfram($query): ${urlReader.responseCode} : ${urlReader.body} ",
urlReader.body.ifEmpty {
"Looks like $SERVICE_NAME isn't able to answer that. (${urlReader.responseCode})"
}
)
}
} catch (ioe: IOException) {
throw ModuleException(
"wolfram($query): IOE", "An IO Error occurred while querying $SERVICE_NAME.", ioe
)
}
} else {
throw ModuleException("wolfram($query): No API Key", "No $SERVICE_NAME AppID specified.")
}
}
}
init {
commands.add(WOLFRAM_CMD)
with(help) {
add("To get answers from Wolfram Alpha:")
add(Utils.helpFormat("%c $WOLFRAM_CMD <query> [units=(${METRIC}|${IMPERIAL})]"))
add("For example:")
add(Utils.helpFormat("%c $WOLFRAM_CMD days until christmas"))
add(Utils.helpFormat("%c $WOLFRAM_CMD distance earth moon units=metric"))
}
initProperties(APPID_KEY_PROP, UNITS_PROP)
}
private fun getUnits(unit: String?): String {
return if (unit?.lowercase() == METRIC) {
@ -55,6 +129,9 @@ class WolframAlpha : AbstractModule() {
}
}
/**
* Queries Wolfram Alpha.
*/
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
if (args.isNotBlank()) {
try {
@ -80,63 +157,4 @@ class WolframAlpha : AbstractModule() {
helpResponse(event)
}
}
companion object {
/**
* The Wolfram Alpha API Key property.
*/
const val APPID_KEY_PROP = "wolfram-appid"
/**
* The Wolfram units properties
*/
const val UNITS_PROP = "wolfram-units"
const val METRIC = "metric"
const val IMPERIAL = "imperial"
// Wolfram command
private const val WOLFRAM_CMD = "wolfram"
// Wolfram Alpha API URL
private const val API_URL = "http://api.wolframalpha.com/v1/spoken?appid="
@JvmStatic
@Throws(ModuleException::class)
fun queryWolfram(query: String, units: String = IMPERIAL, appId: String?): String {
if (!appId.isNullOrEmpty()) {
try {
val urlReader = URL("${API_URL}${appId}&units=${units}&i=" + query.encodeUrl()).reader()
if (urlReader.responseCode.isHttpSuccess()) {
return urlReader.body
} else {
throw ModuleException(
"wolfram($query): ${urlReader.responseCode} : ${urlReader.body} ",
urlReader.body.ifEmpty {
"Looks like Wolfram Alpha isn't able to answer that. (${urlReader.responseCode})"
}
)
}
} catch (ioe: IOException) {
throw ModuleException(
"wolfram($query): IOE", "An IO Error occurred while querying Wolfram Alpha.", ioe
)
}
} else {
throw ModuleException("wolfram($query): No API Key", "No Wolfram Alpha API key specified.")
}
}
}
init {
commands.add(WOLFRAM_CMD)
with(help) {
add("To get answers from Wolfram Alpha:")
add(Utils.helpFormat("%c $WOLFRAM_CMD <query> [units=(${METRIC}|${IMPERIAL})]"))
add("For example:")
add(Utils.helpFormat("%c $WOLFRAM_CMD days until christmas"))
add(Utils.helpFormat("%c $WOLFRAM_CMD distance earth moon units=metric"))
}
initProperties(APPID_KEY_PROP, UNITS_PROP)
}
}

View file

@ -328,7 +328,7 @@ class WorldTime : AbstractModule() {
// The Time command
private const val TIME_CMD = "time"
// The zones arguments
// The `zones` arguments
private const val ZONES_ARGS = "zones"
// The default zone
@ -367,6 +367,16 @@ class WorldTime : AbstractModule() {
}
}
init {
with(help) {
add("To display a country's current date/time:")
add(helpFormat("%c $TIME_CMD [<country code or zone>]"))
add("For a listing of the supported countries/zones:")
add(helpFormat("%c $TIME_CMD $ZONES_ARGS"))
}
commands.add(TIME_CMD)
}
override fun commandResponse(channel: String, cmd: String, args: String, event: GenericMessageEvent) {
if (args.equals(ZONES_ARGS, true)) {
event.sendMessage("The supported countries/zones are: ")
@ -377,14 +387,4 @@ class WorldTime : AbstractModule() {
}
override val isPrivateMsgEnabled = true
init {
with(help) {
add("To display a country's current date/time:")
add(helpFormat("%c $TIME_CMD [<country code or zone>]"))
add("For a listing of the supported countries/zones:")
add(helpFormat("%c $TIME_CMD $ZONES_ARGS"))
}
commands.add(TIME_CMD)
}
}

View file

@ -31,7 +31,7 @@
package net.thauvin.erik.mobibot.msg
/**
* The `ErrorMessage` class.
* Holds an error message.
*/
class ErrorMessage @JvmOverloads constructor(msg: String, color: String = DEFAULT_COLOR) :
Message(msg, color, isError = true)

View file

@ -30,8 +30,9 @@
*/
package net.thauvin.erik.mobibot.msg
/**
* The `Message` class.
* Holds a message.
*/
open class Message @JvmOverloads constructor(
var msg: String,

View file

@ -31,7 +31,7 @@
package net.thauvin.erik.mobibot.msg
/**
* The `NoticeMessage` class.
* Holds a notice message.
*/
class NoticeMessage @JvmOverloads constructor(msg: String, color: String = DEFAULT_COLOR) :
Message(msg, color, isNotice = true)

View file

@ -30,8 +30,9 @@
*/
package net.thauvin.erik.mobibot.msg
/**
* The `PrivateMessage` class.
* Holds a private message.
*/
class PrivateMessage @JvmOverloads constructor(msg: String, color: String = DEFAULT_COLOR) :
Message(msg, color, isPrivate = true)

View file

@ -31,6 +31,6 @@
package net.thauvin.erik.mobibot.msg
/**
* The `PublicMessage` class.
* Holds a public message.
*/
class PublicMessage @JvmOverloads constructor(msg: String, color: String = DEFAULT_COLOR) : Message(msg, color)

View file

@ -39,7 +39,7 @@ import org.slf4j.LoggerFactory
import java.util.*
/**
* Social Manager.
* Manages social media modules and handles operations such as notifications, posting, and queuing entries.
*/
class SocialManager {
private val entries: MutableSet<Int> = HashSet()

View file

@ -40,6 +40,9 @@ import org.pircbotx.hooks.types.GenericMessageEvent
import org.slf4j.Logger
import org.slf4j.LoggerFactory
/**
* Provides the ability to handle notifications, entries and manage interaction with a specific social media service.
*/
abstract class SocialModule : AbstractModule() {
private val logger: Logger = LoggerFactory.getLogger(SocialManager::class.java)
@ -65,7 +68,7 @@ abstract class SocialModule : AbstractModule() {
abstract fun post(message: String, isDm: Boolean): String
/**
* Post entry to social media.
* Post an entry to social media.
*/
fun postEntry(index: Int) {
if (isAutoPost && LinksManager.entries.links.size >= index) {

View file

@ -33,6 +33,9 @@ package net.thauvin.erik.mobibot.social
import java.util.*
/**
* Timer used to post social entries.
*/
class SocialTimer(private var socialManager: SocialManager, private var index: Int) : TimerTask() {
override fun run() {
socialManager.postEntry(index)

View file

@ -53,7 +53,7 @@ class AddonsTest {
private val addons = Addons(p)
@Test
fun addTest() {
fun `Add modules and commands`() {
// Modules
addons.add(Joke())
addons.add(RockPaperScissors())

View file

@ -34,9 +34,6 @@ import org.junit.jupiter.api.extension.ExtendWith
/**
* Disables tests on CI annotation.
*
* @author [Erik C. Thauvin](https://erik.thauvin.net/)
* @since 1.0
*/
@Target(AnnotationTarget.TYPE, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)

View file

@ -36,9 +36,6 @@ import org.junit.jupiter.api.extension.ExtensionContext
/**
* Disables tests on CI condition.
*
* @author [Erik C. Thauvin](https://erik.thauvin.net/)
* @since 1.0
*/
class DisableOnCiCondition : ExecutionCondition {
override fun evaluateExecutionCondition(context: ExtensionContext): ConditionEvaluationResult {

View file

@ -37,39 +37,68 @@ import assertk.assertions.*
import com.rometools.rome.io.FeedException
import net.thauvin.erik.mobibot.FeedReader.Companion.readFeed
import net.thauvin.erik.mobibot.msg.Message
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import java.io.IOException
import java.net.MalformedURLException
import java.net.UnknownHostException
import kotlin.test.Test
class FeedReaderTest {
@Test
fun readFeedTest() {
var messages = readFeed("https://feeds.thauvin.net/ethauvin")
assertThat(messages, "messages").all {
size().isEqualTo(10)
index(1).prop(Message::msg).contains("erik.thauvin.net")
@Nested
@DisplayName("Failure Tests")
inner class FailureTests {
@Test
fun invalidFeed() {
assertFailure { readFeed("https://www.example.com") }.isInstanceOf(FeedException::class.java)
}
messages = readFeed("https://lorem-rss.herokuapp.com/feed?length=0")
assertThat(messages, "messages").index(0).prop(Message::msg).contains("nothing")
@Test
fun invalidHost() {
assertFailure { readFeed("https://www.examplesfoo.com/") }.isInstanceOf(UnknownHostException::class.java)
}
messages = readFeed("https://lorem-rss.herokuapp.com/feed?length=84", 42)
assertThat(messages, "messages").size().isEqualTo(84)
messages.forEachIndexed { i, m ->
if (i % 2 == 0) {
assertThat(m, "messages($i)").prop(Message::msg).startsWith("Lorem ipsum")
} else {
assertThat(m, "messages($i)").prop(Message::msg).contains("http://example.com/test/")
@Test
fun invalidLocation() {
assertFailure { readFeed("https://www.thauvin.net/foo") }.isInstanceOf(IOException::class.java)
}
@Test
fun invalidUrl() {
assertFailure { readFeed("blah") }.isInstanceOf(MalformedURLException::class.java)
}
}
@Nested
@DisplayName("Read Feed Tests")
inner class ReadFeedTests {
@Test
fun readEmptyFeed() {
val messages = readFeed("https://lorem-rss.herokuapp.com/feed?length=0")
assertThat(messages, "messages").index(0).prop(Message::msg).contains("nothing")
}
@Test
fun readFeed() {
val messages = readFeed("https://feeds.thauvin.net/ethauvin")
assertThat(messages, "messages").all {
size().isEqualTo(10)
index(1).prop(Message::msg).contains("erik.thauvin.net")
}
}
assertFailure { readFeed("blah") }.isInstanceOf(MalformedURLException::class.java)
assertFailure { readFeed("https://www.example.com") }.isInstanceOf(FeedException::class.java)
assertFailure { readFeed("https://www.thauvin.net/foo") }.isInstanceOf(IOException::class.java)
assertFailure { readFeed("https://www.examplesfoo.com/") }.isInstanceOf(UnknownHostException::class.java)
@Test
fun readThenValidateFeedContent() {
val messages = readFeed("https://lorem-rss.herokuapp.com/feed?length=84", 42)
assertThat(messages, "messages").size().isEqualTo(84)
messages.forEachIndexed { i, m ->
if (i % 2 == 0) {
assertThat(m, "messages($i)").prop(Message::msg).startsWith("Lorem ipsum")
} else {
@Suppress("HttpUrlsUsage")
assertThat(m, "messages($i)").prop(Message::msg).contains("http://example.com/test/")
}
}
}
}
}

View file

@ -38,7 +38,7 @@ import java.nio.file.Paths
import java.util.*
/**
* Access to `local.properties`.
* Provides functions to access local properties and environment variables.
*/
open class LocalProperties {
init {
@ -72,7 +72,7 @@ open class LocalProperties {
env?.let {
localProps.setProperty(key, env)
}
env
throw IOException("The $key property not found in local.properties or environment variables.")
}
}

View file

@ -40,35 +40,26 @@ import kotlin.test.assertFalse
import kotlin.test.assertTrue
class PinboardTest : LocalProperties() {
private val pinboard = Pinboard()
private val apiToken = getProperty("pinboard-api-token")
@Test
fun testPinboard() {
val apiToken = getProperty("pinboard-api-token")
val url = "https://www.example.com/${(1000..5000).random()}"
val ircServer = "irc.test.com"
val entry = EntryLink(url, "Test Example", "ErikT", "", "#mobitopia", listOf("test"))
private val ircServer = "irc.test.com"
private val pinboard = Pinboard().apply { setApiToken(apiToken) }
pinboard.setApiToken(apiToken)
private fun newEntry(): EntryLink {
return EntryLink(
randomUrl(), "Test Example", "ErikT", "", "#mobitopia", listOf("test")
)
}
pinboard.addPin(ircServer, entry)
assertTrue(validatePin(apiToken, url = entry.link, entry.title, entry.nick, entry.channel), "addPin")
entry.link = "https://www.example.com/${(5001..9999).random()}"
pinboard.updatePin(ircServer, url, entry)
assertTrue(validatePin(apiToken, url = entry.link, ircServer), "updatePin")
entry.title = "Foo Title"
pinboard.updatePin(ircServer, entry.link, entry)
assertTrue(validatePin(apiToken, url = entry.link, entry.title), "updatePin(${entry.title}")
pinboard.deletePin(entry)
assertFalse(validatePin(apiToken, url = entry.link), "deletePin")
private fun randomUrl(): String {
return "https://www.example.com/${(5001..9999).random()}"
}
private fun validatePin(apiToken: String, url: String, vararg matches: String): Boolean {
val response =
URL("https://api.pinboard.in/v1/posts/get?auth_token=${apiToken}&tag=test&" + url.encodeUrl()).reader().body
URL(
"https://api.pinboard.in/v1/posts/get?auth_token=${apiToken}&tag=test&" + url.encodeUrl()
).reader().body
matches.forEach {
if (!response.contains(it)) {
@ -78,4 +69,43 @@ class PinboardTest : LocalProperties() {
return response.contains(url)
}
@Test
fun addPin() {
val entry = newEntry()
pinboard.addPin(ircServer, entry)
assertTrue(validatePin(apiToken, url = entry.link, entry.title, entry.nick, entry.channel), "addPin")
pinboard.deletePin(entry)
assertFalse(validatePin(apiToken, url = entry.link, entry.title, entry.nick, entry.channel), "deletePin")
}
@Test
fun updatePin() {
val entry = newEntry()
pinboard.addPin(ircServer, entry)
assertTrue(validatePin(apiToken, url = entry.link, entry.title, entry.nick, entry.channel), "addPin")
val url = entry.link
entry.link = randomUrl()
pinboard.updatePin(ircServer, url, entry)
assertTrue(validatePin(apiToken, url = entry.link, ircServer), "updatePin")
pinboard.deletePin(entry)
assertFalse(validatePin(apiToken, url = entry.link, entry.title, entry.nick, entry.channel), "deletePin")
}
@Test
fun updatePinTitle() {
val entry = newEntry()
pinboard.addPin(ircServer, entry)
assertTrue(validatePin(apiToken, url = entry.link, entry.title, entry.nick, entry.channel), "addPin")
pinboard.updatePin(ircServer, entry.link, entry)
assertTrue(validatePin(apiToken, url = entry.link, entry.title), "updatePin")
pinboard.deletePin(entry)
assertFalse(validatePin(apiToken, url = entry.link, entry.title, entry.nick, entry.channel), "deletePin")
}
}

View file

@ -36,7 +36,7 @@ import assertk.assertions.isEqualTo
import assertk.assertions.length
import net.thauvin.erik.mobibot.Utils.appendIfMissing
import net.thauvin.erik.mobibot.Utils.bold
import net.thauvin.erik.mobibot.Utils.capitalise
import net.thauvin.erik.mobibot.Utils.capitalize
import net.thauvin.erik.mobibot.Utils.capitalizeWords
import net.thauvin.erik.mobibot.Utils.colorize
import net.thauvin.erik.mobibot.Utils.cyan
@ -60,6 +60,8 @@ import net.thauvin.erik.mobibot.Utils.underline
import net.thauvin.erik.mobibot.Utils.unescapeXml
import net.thauvin.erik.mobibot.msg.Message.Companion.DEFAULT_COLOR
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.pircbotx.Colors
import java.io.File
import java.io.IOException
@ -71,205 +73,361 @@ import kotlin.test.Test
class UtilsTest {
private val ascii =
" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
private val cal = Calendar.getInstance()
private val localDateTime = LocalDateTime.of(1952, 2, 17, 12, 30, 0)
private val p = Properties().apply {
setProperty("one", "1")
setProperty("two", "two")
}
private val test = "This is a test."
@BeforeEach
fun setUp() {
cal[1952, Calendar.FEBRUARY, 17, 12, 30] = 0
}
@Nested
@DisplayName("Date Tests")
inner class DateTests {
private val cal = Calendar.getInstance()
private val localDateTime = LocalDateTime.of(1952, 2, 17, 12, 30, 0)
@Test
fun testAppendIfMissing() {
val dir = "dir"
val sep = '/'
val url = "https://erik.thauvin.net"
assertThat(dir.appendIfMissing(File.separatorChar), "appendIfMissing(dir)")
.isEqualTo(dir + File.separatorChar)
assertThat(url.appendIfMissing(sep), "appendIfMissing(url)").isEqualTo("$url$sep")
assertThat("$url$sep".appendIfMissing(sep), "appendIfMissing($url$sep)").isEqualTo("$url$sep")
}
@Test
fun testBold() {
assertThat(1.bold(), "bold(1)").isEqualTo(Colors.BOLD + "1" + Colors.BOLD)
assertThat(2L.bold(), "bold(2L)").isEqualTo(Colors.BOLD + "2" + Colors.BOLD)
assertThat(ascii.bold(), "ascii.bold()").isEqualTo(Colors.BOLD + ascii + Colors.BOLD)
assertThat("test".bold(), "test.bold()").isEqualTo(Colors.BOLD + "test" + Colors.BOLD)
}
@Test
fun testCapitalise() {
assertThat("test".capitalise(), "capitalize(test)").isEqualTo("Test")
assertThat("Test".capitalise(), "capitalize(Test)").isEqualTo("Test")
assertThat(test.capitalise(), "capitalize($test)").isEqualTo(test)
assertThat("".capitalise(), "capitalize()").isEqualTo("")
}
@Test
fun textCapitaliseWords() {
assertThat(test.capitalizeWords(), "captiatlizeWords(test)").isEqualTo("This Is A Test.")
assertThat("Already Capitalized".capitalizeWords(), "already capitalized")
.isEqualTo("Already Capitalized")
assertThat(" a test ".capitalizeWords(), "with spaces").isEqualTo(" A Test ")
}
@Test
fun testColorize() {
assertThat(ascii.colorize(Colors.REVERSE), "reverse.colorize()").isEqualTo(
Colors.REVERSE + ascii + Colors.REVERSE
)
assertThat(ascii.colorize(Colors.RED), "red.colorize()")
.isEqualTo(Colors.RED + ascii + Colors.NORMAL)
assertThat(ascii.colorize(Colors.BOLD), "colorized(bold)")
.isEqualTo(Colors.BOLD + ascii + Colors.BOLD)
assertThat(null.colorize(Colors.RED), "null.colorize()").isEqualTo("")
assertThat("".colorize(Colors.RED), "colorize()").isEqualTo("")
assertThat(ascii.colorize(DEFAULT_COLOR), "ascii.colorize()").isEqualTo(ascii)
assertThat(" ".colorize(Colors.NORMAL), "blank.colorize()")
.isEqualTo(Colors.NORMAL + " " + Colors.NORMAL)
}
@Test
fun testCyan() {
assertThat(ascii.cyan()).isEqualTo(Colors.CYAN + ascii + Colors.NORMAL)
}
@Test
fun testEncodeUrl() {
assertThat("Hello Günter".encodeUrl()).isEqualTo("Hello%20G%C3%BCnter")
}
@Test
fun testGetIntProperty() {
val p = Properties()
p["one"] = "1"
p["two"] = "two"
assertThat(p.getIntProperty("one", 9), "getIntProperty(one)").isEqualTo(1)
assertThat(p.getIntProperty("two", 2), "getIntProperty(two)").isEqualTo(2)
assertThat(p.getIntProperty("foo", 3), "getIntProperty(foo)").isEqualTo(3)
}
@Test
fun testGreen() {
assertThat(ascii.green()).isEqualTo(Colors.DARK_GREEN + ascii + Colors.NORMAL)
}
@Test
fun testHelpCmdSyntax() {
val bot = "mobibot"
assertThat(helpCmdSyntax("%c $test %n $test", bot, false), "helpCmdSyntax(private)")
.isEqualTo("$bot: $test $bot $test")
assertThat(helpCmdSyntax("%c %n $test %c $test %n", bot, true), "helpCmdSyntax(public)")
.isEqualTo("/msg $bot $bot $test /msg $bot $test $bot")
}
@Test
fun testHelpFormat() {
assertThat(helpFormat(test, isBold = true, isIndent = false), "helpFormat(bold)")
.isEqualTo("${Colors.BOLD}$test${Colors.BOLD}")
assertThat(helpFormat(test, isBold = false, isIndent = true), "helpFormat(indent)")
.isEqualTo(test.prependIndent())
assertThat(helpFormat(test, isBold = true, isIndent = true), "helpFormat(bold,indent)")
.isEqualTo(test.colorize(Colors.BOLD).prependIndent())
}
@Test
fun testIsoLocalDate() {
assertThat(cal.time.toIsoLocalDate(), "isoLocalDate(date)").isEqualTo("1952-02-17")
assertThat(localDateTime.toIsoLocalDate(), "isoLocalDate(localDate)").isEqualTo("1952-02-17")
}
@Test
fun testLastOrEmpty() {
val two = listOf("1", "2")
assertThat(two.lastOrEmpty(), "lastOrEmpty(1,2)").isEqualTo("2")
val one = listOf("1")
assertThat(one.lastOrEmpty(), "lastOrEmpty(1)").isEqualTo("")
}
@Test
fun testObfuscate() {
assertThat(ascii.obfuscate(), "obfuscate()").all {
length().isEqualTo(ascii.length)
isEqualTo(("x".repeat(ascii.length)))
@BeforeEach
fun beforeEach() {
cal[1952, Calendar.FEBRUARY, 17, 12, 30] = 0
}
assertThat(" ".obfuscate(), "obfuscate(blank)").isEqualTo(" ")
}
@Test
fun testPlural() {
val week = "week"
val weeks = "weeks"
@Test
fun `Convert a Date to an ISO date`() {
assertThat(cal.time.toIsoLocalDate(), "isoLocalDate(date)").isEqualTo("1952-02-17")
}
for (i in -1..3) {
assertThat(week.plural(i.toLong()), "plural($i)").isEqualTo(if (i > 1) weeks else week)
@Test
fun `Convert a LocalDate to an ISO date`() {
assertThat(localDateTime.toIsoLocalDate(), "isoLocalDate(localDate)").isEqualTo("1952-02-17")
}
@Test
fun `Convert a Date to a UTC date-time`() {
assertThat(cal.time.toUtcDateTime(), "utcDateTime(date)").isEqualTo("1952-02-17 12:30")
}
@Test
fun `Convert a LocalDate to a UTC date-time`() {
assertThat(localDateTime.toUtcDateTime(), "utcDateTime(localDate)").isEqualTo("1952-02-17 12:30")
}
@Test
fun `Today should return the current date in ISO format`() {
assertThat(today()).isEqualTo(LocalDateTime.now().toIsoLocalDate())
}
}
@Test
fun testReplaceEach() {
val search = arrayOf("one", "two", "three")
val replace = arrayOf("1", "2", "3")
assertThat(search.joinToString(",").replaceEach(search, replace), "replaceEach(1,2,3")
.isEqualTo(replace.joinToString(","))
@Nested
@DisplayName("Help Tests")
inner class HelpTests {
private val bot = "mobibot"
assertThat(test.replaceEach(search, replace), "replaceEach(nothing)").isEqualTo(test)
@Test
fun `Construct help string for public message`() {
assertThat(helpCmdSyntax("%c %n $test %c $test %n", bot, true), "helpCmdSyntax(public)")
.isEqualTo("/msg $bot $bot $test /msg $bot $test $bot")
}
assertThat(test.replaceEach(arrayOf("t", "e"), arrayOf("", "E")), "replaceEach($test)")
.isEqualTo(test.replace("t", "").replace("e", "E"))
@Test
fun `Construct help string for private message`() {
assertThat(helpCmdSyntax("%c $test %n $test", bot, false), "helpCmdSyntax(private)")
.isEqualTo("$bot: $test $bot $test")
}
assertThat(test.replaceEach(search, emptyArray()), "replaceEach(search, empty)")
.isEqualTo(test)
@Test
fun `Format help string with bold`() {
assertThat(helpFormat(test, isBold = true, isIndent = false), "helpFormat(bold)")
.isEqualTo("${Colors.BOLD}$test${Colors.BOLD}")
}
@Test
fun `Format help string with indent`() {
assertThat(helpFormat(test, isBold = false, isIndent = true), "helpFormat(indent)")
.isEqualTo(test.prependIndent())
}
@Test
fun `Format help string with bold and indent`() {
assertThat(helpFormat(test, isBold = true, isIndent = true), "helpFormat(bold,indent)")
.isEqualTo(test.colorize(Colors.BOLD).prependIndent())
}
}
@Test
fun testRed() {
assertThat(ascii.red()).isEqualTo(ascii.colorize(Colors.RED))
@Nested
@DisplayName("Properties Tests")
inner class PropertiesTests {
@Test
fun `Convert properties to int`() {
assertThat(p.getIntProperty("one", 9), "getIntProperty(one)").isEqualTo(1)
assertThat(p.getIntProperty("two", 2), "getIntProperty(two)").isEqualTo(2)
}
@Test
fun `Convert property to int using default value`() {
assertThat(p.getIntProperty("foo", 3), "getIntProperty(foo)").isEqualTo(3)
}
}
@Test
fun testReverseColor() {
assertThat(ascii.reverseColor()).isEqualTo(Colors.REVERSE + ascii + Colors.REVERSE)
@Nested
@DisplayName("List Tests")
inner class ListTests {
@Test
fun `Get last item of list`() {
val two = listOf("1", "2")
assertThat(two.lastOrEmpty(), "lastOrEmpty(1,2)").isEqualTo("2")
}
@Test
fun `Return empty if list only has one item`() {
val one = listOf("1")
assertThat(one.lastOrEmpty(), "lastOrEmpty(1)").isEqualTo("")
}
}
@Test
fun testToday() {
assertThat(today()).isEqualTo(LocalDateTime.now().toIsoLocalDate())
}
@Nested
@DisplayName("String Manipulation Tests")
inner class StringManipulationTests {
private val dir = "dir"
private val sep = '/'
private val url = "https://erik.thauvin.net"
@Test
fun testToIntOrDefault() {
assertThat("10".toIntOrDefault(1), "toIntOrDefault(10, 1)").isEqualTo(10)
assertThat("a".toIntOrDefault(2), "toIntOrDefault(a, 2)").isEqualTo(2)
}
@Nested
@DisplayName("Appending Tests")
inner class AppendingTests {
@Test
fun `Append separator char if missing`() {
assertThat(dir.appendIfMissing(File.separatorChar), "appendIfMissing(dir)")
.isEqualTo(dir + File.separatorChar)
}
@Test
fun testUnderline() {
assertThat(ascii.underline()).isEqualTo(ascii.colorize(Colors.UNDERLINE))
}
@Test
fun `Append separator char if already present`() {
assertThat(url.appendIfMissing(sep), "appendIfMissing(url)").isEqualTo("$url$sep")
}
@Test
fun testUnescapeXml() {
assertThat("&lt;a name=&quot;test &amp; &apos;&#39;&quot;&gt;".unescapeXml()).isEqualTo(
"<a name=\"test & ''\">"
)
@Test
fun `Append separator char if not present`() {
assertThat("$url$sep".appendIfMissing(sep), "appendIfMissing($url$sep)").isEqualTo("$url$sep")
}
}
@Nested
@DisplayName("Capitalization Tests")
inner class CapitalizationTests {
@Test
fun `Capitalize string`() {
assertThat("test".capitalize(), "capitalize(test)").isEqualTo("Test")
}
@Test
fun `Capitalize string already capitalized`() {
assertThat("Test".capitalize(), "capitalize(Test)").isEqualTo("Test")
}
@Test
fun `Capitalize string with spaces`() {
assertThat(test.capitalize(), "capitalize($test)").isEqualTo(test)
}
@Test
fun `Capitalize empty string`() {
assertThat("".capitalize(), "capitalize()").isEqualTo("")
}
@Test
fun `Capitalize words`() {
assertThat(test.capitalizeWords(), "capitalizeWords(test)").isEqualTo("This Is A Test.")
}
@Test
fun `Capitalize words already capitalized`() {
assertThat("Already Capitalized".capitalizeWords(), "already capitalized")
.isEqualTo("Already Capitalized")
}
@Test
fun `Capitalize words with leading and ending spaces`() {
assertThat(" a test ".capitalizeWords(), "with spaces").isEqualTo(" A Test ")
}
}
@Nested
@DisplayName("Conversion Tests")
inner class ConversionTests {
@Test
fun `Convert string to int`() {
assertThat("10".toIntOrDefault(1), "toIntOrDefault(10, 1)").isEqualTo(10)
}
@Test
fun `Convert string to int using default value`() {
assertThat("a".toIntOrDefault(2), "toIntOrDefault(a, 2)").isEqualTo(2)
}
}
@Test
fun `Encode URL`() {
assertThat("Hello Günter".encodeUrl()).isEqualTo("Hello%20G%C3%BCnter")
}
@Nested
@DisplayName("Obfuscation Tests")
inner class ObfuscationTests {
@Test
fun `Obfuscate string`() {
assertThat(ascii.obfuscate(), "obfuscate()").all {
length().isEqualTo(ascii.length)
isEqualTo(("x".repeat(ascii.length)))
}
}
@Test
fun `Obfuscate empty string`() {
assertThat(" ".obfuscate(), "obfuscate(blank)").isEqualTo(" ")
}
}
@Test
fun `Pluralize string`() {
val week = "week"
val weeks = "weeks"
for (i in -1..3) {
assertThat(week.plural(i.toLong()), "plural($i)").isEqualTo(if (i > 1) weeks else week)
}
}
@Nested
@DisplayName("Replace Tests")
inner class ReplaceTests {
private val replace = arrayOf("1", "2", "3")
private val search = arrayOf("one", "two", "three")
@Test
fun `Replace occurrences in string`() {
assertThat(search.joinToString(",").replaceEach(search, replace), "replaceEach(1,2,3")
.isEqualTo(replace.joinToString(","))
}
@Test
fun `Replace occurrences not found in string`() {
assertThat(test.replaceEach(search, replace), "replaceEach(nothing)").isEqualTo(test)
}
@Test
fun `Replace and remove occurrences in string`() {
assertThat(test.replaceEach(arrayOf("t", "e"), arrayOf("", "E")), "replaceEach($test)")
.isEqualTo(test.replace("t", "").replace("e", "E"))
}
@Test
fun `Replace empty occurrences in string`() {
assertThat(test.replaceEach(search, emptyArray()), "replaceEach(search, empty)")
.isEqualTo(test)
}
}
@Test
fun `Unescape XML`() {
assertThat("&lt;a name=&quot;test &amp; &apos;&#39;&quot;&gt;".unescapeXml()).isEqualTo(
"<a name=\"test & ''\">"
)
}
}
@Test
@Throws(IOException::class)
fun testUrlReader() {
fun `URL reader`() {
val reader = URL("https://postman-echo.com/status/200").reader()
assertThat(reader.body).isEqualTo("{\n \"status\": 200\n}")
assertThat(reader.responseCode).isEqualTo(200)
}
@Test
fun testUtcDateTime() {
assertThat(cal.time.toUtcDateTime(), "utcDateTime(date)").isEqualTo("1952-02-17 12:30")
assertThat(localDateTime.toUtcDateTime(), "utcDateTime(localDate)").isEqualTo("1952-02-17 12:30")
@Nested
@DisplayName("Text Styling Tests")
inner class TextStylingTests {
@Nested
@DisplayName("Colorize Tests")
inner class ColorizeTests {
@Test
fun `Colorize ASCII characters red`() {
assertThat(ascii.colorize(Colors.RED), "red.colorize()")
.isEqualTo(Colors.RED + ascii + Colors.NORMAL)
}
@Test
fun `Colorize blank string`() {
assertThat(" ".colorize(Colors.NORMAL), "blank.colorize()")
.isEqualTo(Colors.NORMAL + " " + Colors.NORMAL)
}
@Test
fun `Colorize default color`() {
assertThat(ascii.colorize(DEFAULT_COLOR), "ascii.colorize()").isEqualTo(ascii)
}
@Test
fun `Colorize empty string`() {
assertThat("".colorize(Colors.RED), "colorize()").isEqualTo("")
}
@Test
fun `Colorize null`() {
assertThat(null.colorize(Colors.RED), "null.colorize()").isEqualTo("")
}
}
@Nested
@DisplayName("Color Formatting Tests")
inner class ColorFormattingTests {
@Test
fun `Make ASCII characters bold`() {
assertThat(ascii.bold(), "ascii.bold()").isEqualTo(Colors.BOLD + ascii + Colors.BOLD)
}
@Test
fun `Make int bold`() {
assertThat(1.bold(), "bold(1)").isEqualTo(Colors.BOLD + "1" + Colors.BOLD)
}
@Test
fun `Make long bold`() {
assertThat(2L.bold(), "bold(2L)").isEqualTo(Colors.BOLD + "2" + Colors.BOLD)
}
@Test
fun `Make string bold`() {
assertThat("test".bold(), "test.bold()").isEqualTo(Colors.BOLD + "test" + Colors.BOLD)
}
@Test
fun `Make text cyan`() {
assertThat(ascii.cyan()).isEqualTo(Colors.CYAN + ascii + Colors.NORMAL)
}
@Test
fun `Make text green`() {
assertThat(ascii.green()).isEqualTo(Colors.DARK_GREEN + ascii + Colors.NORMAL)
}
@Test
fun `Make text red`() {
assertThat(ascii.red()).isEqualTo(ascii.colorize(Colors.RED))
}
}
@Test
fun `Reversed text`() {
assertThat(ascii.reverseColor()).isEqualTo(Colors.REVERSE + ascii + Colors.REVERSE)
}
@Test
fun `Underline text`() {
assertThat(ascii.underline()).isEqualTo(ascii.colorize(Colors.UNDERLINE))
}
}
}

View file

@ -34,25 +34,72 @@ package net.thauvin.erik.mobibot.commands
import assertk.assertThat
import assertk.assertions.isEqualTo
import net.thauvin.erik.mobibot.commands.Info.Companion.toUptime
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import kotlin.test.Test
class InfoTest {
@Test
fun testToUptime() {
assertThat(
547800300076L.toUptime(),
"upTime(full)"
).isEqualTo("17 years 4 months 2 weeks 1 day 6 hours 45 minutes")
assertThat(24300000L.toUptime(), "upTime(hours minutes)").isEqualTo("6 hours 45 minutes")
assertThat(110700000L.toUptime(), "upTime(days hours minutes)").isEqualTo("1 day 6 hours 45 minutes")
assertThat(
1320300000L.toUptime(),
"upTime(weeks days hours minutes)"
).isEqualTo("2 weeks 1 day 6 hours 45 minutes")
assertThat(2700000L.toUptime(), "upTime(45 minutes)").isEqualTo("45 minutes")
assertThat(60000L.toUptime(), "upTime(1 minute)").isEqualTo("1 minute")
assertThat(59000L.toUptime(), "upTime(59 seconds)").isEqualTo("59 seconds")
assertThat(0L.toUptime(), "upTime(0 second)").isEqualTo("0 second")
@Nested
@DisplayName("Uptime Tests")
inner class UptimeTests {
@Test
fun `Years, Months, Weeks, Days, Hours and Minutes`() {
assertThat(547800300076L.toUptime()).isEqualTo("17 years 4 months 2 weeks 1 day 6 hours 45 minutes")
}
@Test
fun `Hours and Minutes`() {
assertThat(24300000L.toUptime()).isEqualTo("6 hours 45 minutes")
}
@Test
fun `Days, Hours and Minutes`() {
assertThat(110700000L.toUptime(), "upTime(days hours minutes)").isEqualTo("1 day 6 hours 45 minutes")
}
@Test
fun `Weeks, Days, Hours and Minutes`() {
assertThat(1320300000L.toUptime()).isEqualTo("2 weeks 1 day 6 hours 45 minutes")
}
@Test
fun `1 Month`() {
assertThat(2592000000L.toUptime(), "upTime(1 month)").isEqualTo("1 month")
}
@Test
fun `3 Days`() {
assertThat(259200000L.toUptime(), "upTime(3 days)").isEqualTo("3 days")
}
@Test
fun `1 Week`() {
assertThat(604800000L.toUptime(), "upTime(1 week)").isEqualTo("1 week")
}
@Test
fun `2 Hours`() {
assertThat(7200000L.toUptime(), "upTime(2 hours)").isEqualTo("2 hours")
}
@Test
fun `45 Minutes`() {
assertThat(2700000L.toUptime(), "upTime(45 minutes)").isEqualTo("45 minutes")
}
@Test
fun `1 Minute`() {
assertThat(60000L.toUptime(), "upTime(1 minute)").isEqualTo("1 minute")
}
@Test
fun `59 Seconds`() {
assertThat(59000L.toUptime(), "upTime(59 seconds)").isEqualTo("59 seconds")
}
@Test
fun `0 Second`() {
assertThat(0L.toUptime(), "upTime(0 second)").isEqualTo("0 second")
}
}
}

View file

@ -41,7 +41,7 @@ import kotlin.test.Test
class RecapTest {
@Test
fun storeRecapTest() {
fun storeRecap() {
for (i in 1..20) {
Recap.storeRecap("sender$i", "test $i", false)
}
@ -54,7 +54,7 @@ class RecapTest {
}
Recap.storeRecap("sender", "test action", true)
assertThat(Recap.recaps.last())
assertThat(Recap.recaps.last(), "Recap.recaps.last()")
.matches("[1-2]\\d{3}-[01]\\d-[0-3]\\d [0-2]\\d:[0-6]\\d - sender test action".toRegex())
}
}

View file

@ -33,45 +33,122 @@ package net.thauvin.erik.mobibot.commands.links
import assertk.all
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.isEqualTo
import assertk.assertions.isTrue
import assertk.assertions.size
import assertk.assertions.*
import net.thauvin.erik.mobibot.Constants
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import kotlin.test.Test
class LinksManagerTest {
private val linksManager = LinksManager()
@Test
fun fetchTitle() {
assertThat(linksManager.fetchTitle("https://erik.thauvin.net/"), "fetchTitle(Erik)").contains("Erik's Weblog")
assertThat(
linksManager.fetchTitle("https://www.google.com/foo"),
"fetchTitle(Foo)"
).isEqualTo(Constants.NO_TITLE)
@Nested
@DisplayName("Fetch Page Title Tests")
inner class FetchPageTitleTests {
@Test
fun fetchPageTitle() {
assertThat(linksManager.fetchPageTitle("https://erik.thauvin.net/")).contains("Erik's Weblog")
}
@Test
fun fetchPageNoTitle() {
assertThat(linksManager.fetchPageTitle("https://www.google.com/foo")).isEqualTo(Constants.NO_TITLE)
}
}
@Test
fun testMatches() {
assertThat(linksManager.matches("https://www.example.com/"), "matches(url)").isTrue()
assertThat(linksManager.matches("HTTP://erik.thauvin.net/blog/ Erik's Weblog"), "matches(HTTP)").isTrue()
}
@Nested
@DisplayName("Match Tests")
inner class MatchTests {
@Nested
@DisplayName("Link Tests")
inner class LinkTests {
@Test
@Suppress("HttpUrlsUsage")
fun matchInsecureLink() {
assertThat(linksManager.matches("http://erik.thauvin.net/blog/ Erik's Weblog")).isTrue()
}
@Test
fun matchTagKeywordsTest() {
linksManager.setProperty(LinksManager.KEYWORDS_PROP, "key1 key2,key3")
val tags = mutableListOf<String>()
@Test
fun matchInvalidProtocol() {
assertThat(linksManager.matches("ftp://erik.thauvin.net/blog/")).isFalse()
}
linksManager.matchTagKeywords("Test title with key2", tags)
assertThat(tags, "tags").contains("key2")
tags.clear()
@Test
fun matchLink() {
assertThat(linksManager.matches("https://erik.thauvin.net/blog/")).isTrue()
}
linksManager.matchTagKeywords("Test key3 title with key1", tags)
assertThat(tags, "tags(key1, key3)").all {
contains("key1")
contains("key3")
size().isEqualTo(2)
@Test
fun matchLinkWithAnchor() {
assertThat(linksManager.matches("https://erik.thauvin.net/blog/search?tag=java#foo")).isTrue()
}
@Test
fun matchLinkWithParams() {
assertThat(linksManager.matches("https://erik.thauvin.net/blog/search?tag=bld&cat=java")).isTrue()
}
@Test
fun matchLinkWithSingleParam() {
assertThat(linksManager.matches("https://erik.thauvin.net/blog/search?tag=java")).isTrue()
}
@Test
fun matchLinkWithTitle() {
assertThat(linksManager.matches("https://erik.thauvin.net/blog/ Erik's Weblog")).isTrue()
}
@Test
fun matchLinkWithWhitespace() {
assertThat(linksManager.matches("https://erik.thauvin.net/blog/ ")).isTrue()
}
@Test
fun matchMixedCaseLink() {
assertThat(linksManager.matches("https://Erik.Thauvin.Net/blog/")).isTrue()
}
@Test
fun matchNonURLText() {
assertThat(linksManager.matches("This is just a text string")).isFalse()
}
@Test
fun matchNumericURL() {
assertThat(linksManager.matches("https://123.456.789.0/")).isTrue()
}
@Test
fun matchSpecialCharacterLink() {
assertThat(linksManager.matches("https://erik.thauvin.net/blog/search?tag=java&name=%20foo")).isTrue()
}
@Test
fun matchUpperCaseLink() {
assertThat(linksManager.matches("HTTPS://ERIK.THAUVIN.NET/BLOG/")).isTrue()
}
}
@Nested
@DisplayName("Tags Parsing Tests")
inner class TagsParsingTests {
@Test
fun matchTagSingleKeyword() {
linksManager.setProperty(LinksManager.KEYWORDS_PROP, "key1 key2,key3")
val tags = mutableListOf<String>()
linksManager.matchTagKeywords("Test title with key2", tags)
assertThat(tags, "tags").containsExactly("key2")
}
@Test
fun matchTagKeywords() {
val tags = mutableListOf("key1", "key3")
linksManager.matchTagKeywords("Test key3 title with key1", tags)
assertThat(tags, "tags(key1, key3)").all {
containsExactlyInAnyOrder("key1", "key3")
size().isEqualTo(2)
}
}
}
}
}

View file

@ -36,76 +36,103 @@ import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.prop
import net.thauvin.erik.mobibot.entries.EntryLink
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import kotlin.test.Test
class ViewTest {
@Test
fun testParseArgs() {
val view = View()
for (i in 1..10) {
LinksManager.entries.links.add(
EntryLink(
"https://www.example.com/$i",
"Example $i",
"nick$i",
"login$i",
"#channel",
emptyList()
companion object {
init {
for (i in 1..10) {
LinksManager.entries.links.add(
EntryLink(
"https://www.example.com/$i",
"Example $i",
"nick$i",
"login$i",
"#channel",
emptyList()
)
)
)
}
}
}
@Nested
@DisplayName("Parse Args Tests")
inner class ParseArgsTests {
private val view = View()
@Test
fun `Parse alphanumeric query`() {
assertThat(view.parseArgs("1a"), "parseArgs(1a)").all {
prop(Pair<Int, String>::first).isEqualTo(0)
prop(Pair<Int, String>::second).isEqualTo("1a")
}
}
assertThat(view.parseArgs("1"), "parseArgs(1)").all {
prop(Pair<Int, String>::first).isEqualTo(0)
prop(Pair<Int, String>::second).isEqualTo("")
@Test
fun `Parse empty arguments`() {
assertThat(view.parseArgs(""), "parseArgs()").all {
prop(Pair<Int, String>::first).isEqualTo(LinksManager.entries.links.size - View.MAX_ENTRIES)
prop(Pair<Int, String>::second).isEqualTo("")
}
}
assertThat(view.parseArgs("2 foo"), "parseArgs(2, foo)").all {
prop(Pair<Int, String>::first).isEqualTo(1)
prop(Pair<Int, String>::second).isEqualTo("foo")
@Test
fun `Parse first item`() {
assertThat(view.parseArgs("1"), "parseArgs(1)").all {
prop(Pair<Int, String>::first).isEqualTo(0)
prop(Pair<Int, String>::second).isEqualTo("")
}
}
assertThat(view.parseArgs("3 FOO"), "parseArgs(3, FOO)").all {
prop(Pair<Int, String>::first).isEqualTo(2)
prop(Pair<Int, String>::second).isEqualTo("foo")
@Test
fun `Parse fourth item with query needing trimming`() {
assertThat(view.parseArgs(" 4 foo bar "), "parseArgs( 4 foo bar )").all {
prop(Pair<Int, String>::first).isEqualTo(3)
prop(Pair<Int, String>::second).isEqualTo("foo bar")
}
}
assertThat(view.parseArgs(" 4 foo bar "), "parseArgs( 4 foo bar )").all {
prop(Pair<Int, String>::first).isEqualTo(3)
prop(Pair<Int, String>::second).isEqualTo("foo bar")
@Test
fun `Parse overflowed item as query`() {
assertThat(view.parseArgs("${Int.MAX_VALUE}1"), "parseArgs(overflow)").all {
prop(Pair<Int, String>::first).isEqualTo(0)
prop(Pair<Int, String>::second).isEqualTo("${Int.MAX_VALUE}1")
}
}
assertThat(view.parseArgs("foo bar"), "parseArgs(foo bar)").all {
prop(Pair<Int, String>::first).isEqualTo(0)
prop(Pair<Int, String>::second).isEqualTo("foo bar")
@Test
fun `Parse out of bounds item`() {
assertThat(view.parseArgs("20"), "parseArgs(20)").all {
prop(Pair<Int, String>::first).isEqualTo(0)
prop(Pair<Int, String>::second).isEqualTo("")
}
}
assertThat(view.parseArgs("${Int.MAX_VALUE}1"), "parseArgs(overflow)").all {
prop(Pair<Int, String>::first).isEqualTo(0)
prop(Pair<Int, String>::second).isEqualTo("${Int.MAX_VALUE}1")
@Test
fun `Parse query only`() {
assertThat(view.parseArgs("foo bar"), "parseArgs(foo bar)").all {
prop(Pair<Int, String>::first).isEqualTo(0)
prop(Pair<Int, String>::second).isEqualTo("foo bar")
}
}
assertThat(view.parseArgs("1a"), "parseArgs(1a)").all {
prop(Pair<Int, String>::first).isEqualTo(0)
prop(Pair<Int, String>::second).isEqualTo("1a")
@Test
fun `Parse second item with query`() {
assertThat(view.parseArgs("2 foo"), "parseArgs(2, foo)").all {
prop(Pair<Int, String>::first).isEqualTo(1)
prop(Pair<Int, String>::second).isEqualTo("foo")
}
}
assertThat(view.parseArgs("20"), "parseArgs(20)").all {
prop(Pair<Int, String>::first).isEqualTo(0)
prop(Pair<Int, String>::second).isEqualTo("")
}
assertThat(view.parseArgs(""), "parseArgs()").all {
prop(Pair<Int, String>::first).isEqualTo(LinksManager.entries.links.size - View.MAX_ENTRIES)
prop(Pair<Int, String>::second).isEqualTo("")
}
LinksManager.entries.links.clear()
assertThat(view.parseArgs("4"), "parseArgs(4)").all {
prop(Pair<Int, String>::first).isEqualTo(0)
prop(Pair<Int, String>::second).isEqualTo("")
@Test
fun `Parse third item with query ignoring capitalization`() {
assertThat(view.parseArgs("3 FOO"), "parseArgs(3, FOO)").all {
prop(Pair<Int, String>::first).isEqualTo(2)
prop(Pair<Int, String>::second).isEqualTo("foo")
}
}
}
}

View file

@ -34,60 +34,50 @@ package net.thauvin.erik.mobibot.commands.seen
import assertk.all
import assertk.assertThat
import assertk.assertions.*
import org.junit.AfterClass
import org.junit.BeforeClass
import org.junit.jupiter.api.MethodOrderer
import org.junit.jupiter.api.Order
import kotlin.io.path.deleteIfExists
import kotlin.io.path.fileSize
import org.junit.jupiter.api.TestMethodOrder
import kotlin.test.Test
@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
class SeenTest {
companion object {
private val tmpFile = kotlin.io.path.createTempFile(SeenTest::class.java.simpleName, suffix = ".ser")
private val seen = Seen(tmpFile.toAbsolutePath().toString())
private const val NICK = "ErikT"
init {
tmpFile.toFile().deleteOnExit()
}
}
@Test
@Order(1)
fun loadTest() {
fun add() {
val last = seen.seenNicks[NICK]?.lastSeen
seen.add(NICK)
assertThat(seen).all {
prop(Seen::seenNicks).size().isEqualTo(1)
prop(Seen::seenNicks).key(NICK).isNotNull().prop(SeenNick::lastSeen).isNotEqualTo(last)
prop(Seen::seenNicks).key(NICK).isNotNull().prop(SeenNick::nick).isNotNull().isEqualTo(NICK)
}
}
@Test
@Order(2)
fun load() {
seen.clear()
assertThat(seen::seenNicks).isEmpty()
seen.load()
assertThat(seen::seenNicks).key(NICK).isNotNull()
}
@Test
@Order(2)
fun addTest() {
val last = seen.seenNicks[NICK]?.lastSeen
seen.add(NICK.lowercase())
assertThat(seen).all {
prop(Seen::seenNicks).size().isEqualTo(1)
prop(Seen::seenNicks).key(NICK).isNotNull().prop(SeenNick::lastSeen).isNotEqualTo(last)
prop(Seen::seenNicks).key(NICK).isNotNull().prop(SeenNick::nick).isNotNull().isEqualTo(NICK.lowercase())
}
}
@Test
@Order(3)
fun clearTest() {
fun clear() {
seen.clear()
seen.save()
seen.load()
assertThat(seen::seenNicks).size().isEqualTo(0)
}
companion object {
private val tmpFile = kotlin.io.path.createTempFile(suffix = ".ser")
private val seen = Seen(tmpFile.toAbsolutePath().toString())
private const val NICK = "ErikT"
@JvmStatic
@BeforeClass
fun beforeClass() {
seen.add(NICK)
assertThat(tmpFile.fileSize(), "tmpFile.size").isGreaterThan(0)
}
@JvmStatic
@AfterClass
fun afterClass() {
tmpFile.deleteIfExists()
}
assertThat(seen::seenNicks).isEmpty()
}
}

View file

@ -47,7 +47,7 @@ class TellMessageTest {
}
@Test
fun testTellMessage() {
fun validateTellMessage() {
val message = "Test message."
val recipient = "recipient"
val sender = "sender"

View file

@ -34,10 +34,8 @@ package net.thauvin.erik.mobibot.commands.tell
import assertk.all
import assertk.assertThat
import assertk.assertions.*
import org.junit.AfterClass
import java.time.LocalDateTime
import kotlin.io.path.createTempFile
import kotlin.io.path.deleteIfExists
import kotlin.io.path.fileSize
import kotlin.test.Test
@ -49,13 +47,21 @@ class TellMessagesMgrTest {
}
}
companion object {
private val testFile = createTempFile(TellMessagesMgrTest::class.java.simpleName, suffix = ".ser")
init {
testFile.toFile().deleteOnExit()
}
}
init {
TellManager.save(testFile.toAbsolutePath().toString(), testMessages)
assertThat(testFile.fileSize()).isGreaterThan(0)
}
@Test
fun cleanTest() {
fun clean() {
testMessages.add(TellMessage("sender", "recipient", "message").apply {
queued = LocalDateTime.now().minusDays(maxDays)
})
@ -66,7 +72,7 @@ class TellMessagesMgrTest {
}
@Test
fun loadTest() {
fun load() {
val messages = TellManager.load(testFile.toAbsolutePath().toString())
for (i in messages.indices) {
assertThat(messages).index(i).all {
@ -76,14 +82,4 @@ class TellMessagesMgrTest {
}
}
}
companion object {
private val testFile = createTempFile(suffix = ".ser")
@JvmStatic
@AfterClass
fun afterClass() {
testFile.deleteIfExists()
}
}
}

View file

@ -39,6 +39,8 @@ import net.thauvin.erik.mobibot.entries.EntriesUtils.printComment
import net.thauvin.erik.mobibot.entries.EntriesUtils.printLink
import net.thauvin.erik.mobibot.entries.EntriesUtils.printTags
import net.thauvin.erik.mobibot.entries.EntriesUtils.toLinkLabel
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import kotlin.test.Test
class EntriesUtilsTest {
@ -58,34 +60,38 @@ class EntriesUtilsTest {
}
}
@Test
fun printCommentTest() {
assertThat(printComment(0, 0, comment)).isEqualTo("${Constants.LINK_CMD}1.1: [nick] comment")
}
@Test
fun printLinkTest() {
for (i in links.indices) {
assertThat(
printLink(i - 1, links[i]), "link $i"
).isEqualTo("L$i: [Skynx$i] \u0002Mobitopia$i\u0002 ( \u000303https://www.mobitopia.org/$i\u000F )")
@Nested
@DisplayName("Print Tests")
inner class PrintTests {
@Test
fun printComment() {
assertThat(printComment(0, 0, comment)).isEqualTo("${Constants.LINK_CMD}1.1: [nick] comment")
}
assertThat(links.first().addComment(comment), "addComment()").isEqualTo(0)
assertThat(printLink(0, links.first(), isView = true), "printLink(isView=true)").contains("[+1]")
}
@Test
fun printLink() {
for (i in links.indices) {
assertThat(
printLink(i - 1, links[i]), "link $i"
).isEqualTo("L$i: [Skynx$i] \u0002Mobitopia$i\u0002 ( \u000303https://www.mobitopia.org/$i\u000F )")
}
@Test
fun printTagsTest() {
for (i in links.indices) {
assertThat(
printTags(i - 1, links[i]), "tag $i"
).isEqualTo("L${i}T: tag1, tag2, tag3, tag4, tag5")
assertThat(links.first().addComment(comment), "addComment()").isEqualTo(0)
assertThat(printLink(0, links.first(), isView = true), "printLink(isView=true)").contains("[+1]")
}
@Test
fun printTags() {
for (i in links.indices) {
assertThat(
printTags(i - 1, links[i]), "tag $i"
).isEqualTo("L${i}T: tag1, tag2, tag3, tag4, tag5")
}
}
}
@Test
fun toLinkLabelTest() {
fun toLinkLabel() {
assertThat(1.toLinkLabel()).isEqualTo("${Constants.LINK_CMD}2")
}
}

View file

@ -35,6 +35,8 @@ import assertk.assertThat
import assertk.assertions.*
import com.rometools.rome.feed.synd.SyndCategory
import com.rometools.rome.feed.synd.SyndCategoryImpl
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import java.security.SecureRandom
import java.util.*
import kotlin.test.Test
@ -46,7 +48,7 @@ class EntryLinkTest {
)
@Test
fun testAddDeleteComment() {
fun `Add then delete comment`() {
var i = 0
while (i < 5) {
entryLink.addComment("c$i", "u$i")
@ -63,7 +65,7 @@ class EntryLinkTest {
}
val r = SecureRandom()
while (entryLink.comments.size > 0) {
while (entryLink.comments.isNotEmpty()) {
entryLink.deleteComment(r.nextInt(entryLink.comments.size))
}
assertThat(entryLink.comments, "hasComments()").isEmpty()
@ -79,7 +81,7 @@ class EntryLinkTest {
}
@Test
fun testConstructor() {
fun `Validate EntryLink constructor`() {
val tags = listOf(SyndCategoryImpl().apply { name = "tag1" }, SyndCategoryImpl().apply { name = "tag2" })
val link = EntryLink("link", "title", "nick", "channel", Date(), tags)
assertThat(link, "link").all {
@ -89,7 +91,7 @@ class EntryLinkTest {
}
@Test
fun testMatches() {
fun `Validate EntryLink matches`() {
assertThat(entryLink.matches("mobitopia"), "matches(mobitopia)").isTrue()
assertThat(entryLink.matches("skynx"), "match(nick)").isTrue()
assertThat(entryLink.matches("www.mobitopia.org"), "matches(url)").isTrue()
@ -98,29 +100,52 @@ class EntryLinkTest {
assertThat(entryLink.matches(null), "matches(null)").isFalse()
}
@Test
fun testTags() {
val tags: List<SyndCategory> = entryLink.tags
for ((i, tag) in tags.withIndex()) {
assertThat(tag.name, "tag.name($i)").isEqualTo("tag${i + 1}")
@Nested
@DisplayName("Validate Tags Test")
inner class ValidateTagsTest {
@Test
fun `Validate tags parsing in constructor`() {
val tags: List<SyndCategory> = entryLink.tags
for ((i, tag) in tags.withIndex()) {
assertThat(tag.name, "tag.name($i)").isEqualTo("tag${i + 1}")
}
assertThat(entryLink::tags).size().isEqualTo(5)
}
@Test
fun `Validate attempting to remove channel tag`() {
val link = entryLink
link.setTags("+mobitopia")
link.setTags("-mobitopia") // can't remove the channel tag
assertThat(
link.formatTags(",")
).isEqualTo("tag1,tag2,tag3,tag4,tag5,mobitopia")
}
@Test
fun `Validate formatting tags with spaces`() {
val link = entryLink
link.setTags("-tag4")
assertThat(
link.formatTags(" ", ",")
).isEqualTo(",tag1 tag2 tag3 tag5")
}
@Test
fun `Validate setting blank tags`() {
val link = entryLink
val size = link.tags.size
link.setTags(" ")
assertThat(link.tags, "setTags(' ')").size().isEqualTo(size)
}
@Test
fun `Validate setting empty tags`() {
val link = entryLink
val size = link.tags.size
link.setTags("")
assertThat(link.tags, "setTags(\"\")").size().isEqualTo(size)
}
assertThat(entryLink::tags).size().isEqualTo(5)
entryLink.setTags("-tag5, tag4")
entryLink.setTags("+mobitopia")
entryLink.setTags("-mobitopia")
assertThat(
entryLink.formatTags(","),
"formatTags(',')"
).isEqualTo("tag1,tag2,tag3,tag4,mobitopia")
entryLink.setTags("-tag4 tag5")
assertThat(
entryLink.formatTags(" ", ","), "formatTag(' ',',')"
).isEqualTo(",tag1 tag2 tag3 mobitopia tag5")
val size = entryLink.tags.size
entryLink.setTags("")
assertThat(entryLink.tags, "setTags('')").size().isEqualTo(size)
entryLink.setTags(" ")
assertThat(entryLink.tags, "setTags(' ')").size().isEqualTo(size)
}
}

View file

@ -35,6 +35,10 @@ import assertk.all
import assertk.assertThat
import assertk.assertions.*
import net.thauvin.erik.mobibot.Utils.today
import org.junit.jupiter.api.MethodOrderer
import org.junit.jupiter.api.Order
import org.junit.jupiter.api.TestMethodOrder
import java.nio.file.Path
import java.nio.file.Paths
import java.util.*
import kotlin.io.path.deleteIfExists
@ -42,19 +46,26 @@ import kotlin.io.path.fileSize
import kotlin.io.path.name
import kotlin.test.Test
@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
class FeedMgrTest {
private val entries = Entries()
private val channel = "mobibot"
private var currentFile: Path
private var backlogFile: Path
init {
entries.logsDir = "src/test/resources/"
entries.ircServer = "irc.example.com"
entries.channel = channel
entries.backlogs = "https://www.mobitopia.org/mobibot/logs"
currentFile = Paths.get("${entries.logsDir}test.xml")
backlogFile = Paths.get("${entries.logsDir}${today()}.xml")
}
@Test
fun testFeedMgr() {
@Order(1)
fun loadFeed() {
// Load the feed
assertThat(FeedsManager.loadFeed(entries), "loadFeed()").isEqualTo("2021-10-31")
@ -87,26 +98,33 @@ class FeedMgrTest {
prop(EntryLink::nick).isEqualTo("Skynx")
prop(EntryLink::date).isEqualTo(Date(1635638460000L))
}
}
val currentFile = Paths.get("${entries.logsDir}test.xml")
val backlogFile = Paths.get("${entries.logsDir}${today()}.xml")
// Save the feed
@Test
@Order(2)
fun saveFeed() {
FeedsManager.saveFeed(entries, currentFile.name)
assertThat(currentFile, "currentFile").exists()
assertThat(backlogFile, "backlogFile").exists()
assertThat(currentFile.fileSize(), "currentFile == backlogFile").isEqualTo(backlogFile.fileSize())
}
// Load the test feed
@Test
@Order(3)
fun loadTestFeed() {
entries.links.clear()
FeedsManager.loadFeed(entries, currentFile.name)
entries.links.forEachIndexed { i, entryLink ->
assertThat(entryLink.title, "entryLink.title[${i + 1}]").isEqualTo("Example ${i + 1}")
}
}
@Test
@Order(4)
fun deleteFeeds() {
assertThat(currentFile.deleteIfExists(), "currentFile.deleteIfExists()").isTrue()
assertThat(backlogFile.deleteIfExists(), "backlogFile.deleteIfExists()").isTrue()
}

View file

@ -37,14 +37,70 @@ import assertk.assertions.isInstanceOf
import net.objecthunter.exp4j.tokenizer.UnknownFunctionOrVariableException
import net.thauvin.erik.mobibot.Utils.bold
import net.thauvin.erik.mobibot.modules.Calc.Companion.calculate
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.mockito.Mockito
import org.pircbotx.hooks.types.GenericMessageEvent
import kotlin.test.Test
class CalcTest {
@Test
fun testCalculate() {
assertThat(calculate("1 + 1"), "calculate(1+1)").isEqualTo("1+1 = ${2.bold()}")
assertThat(calculate("1 -3"), "calculate(1-3)").isEqualTo("1-3 = ${(-2).bold()}")
assertThat(calculate("pi+π+e+φ"), "calculate(pi+π+e+φ)").isEqualTo("pi+π+e+φ = ${"10.62".bold()}")
assertFailure { calculate("one + one") }.isInstanceOf(UnknownFunctionOrVariableException::class.java)
@Nested
@DisplayName("Calculate Tests")
inner class CalculateTests {
@Test
fun `Calculate basic addition`() {
assertThat(calculate("1 + 1"), "calculate(1+1)").isEqualTo("1+1 = ${2.bold()}")
}
@Test
fun `Calculate basic subtraction`() {
assertThat(calculate("1 -3"), "calculate(1-3)").isEqualTo("1-3 = ${(-2).bold()}")
}
@Test
fun `Calculate mathematical constants`() {
assertThat(calculate("pi+π+e+φ"), "calculate(pi+π+e+φ)").isEqualTo("pi+π+e+φ = ${"10.62".bold()}")
}
@Test
fun `Calculate scientific notations`() {
assertThat(calculate("3e2 - 3e1"), "calculate(3e2-3e1 )").isEqualTo("3e2-3e1 = ${"270".bold()}")
}
@Test
fun `Calculate trigonometric functions`() {
assertThat(calculate("3*sin(10)-cos(2)"), "calculate(3*sin(10)-cos(2)")
.isEqualTo("3*sin(10)-cos(2) = ${"-1.22".bold()}")
}
@Test
fun `Empty calculation should throw exception`() {
assertFailure { calculate(" ") }.isInstanceOf(IllegalArgumentException::class.java)
}
@Test
fun `Invalid calculation should throw exception`() {
assertFailure { calculate("a + b = c") }.isInstanceOf(UnknownFunctionOrVariableException::class.java)
}
}
@Nested
@DisplayName("Command Response Tests")
inner class CommandResponseTests {
@Test
fun `Basic calculation`() {
val calc = Calc()
val event = Mockito.mock(GenericMessageEvent::class.java)
calc.commandResponse("channel", "calc", "1 + 1 * 2", event)
Mockito.verify(event, Mockito.times(1)).respond("1+1*2 = ${"3".bold()}")
}
@Test
fun `Invalid calculation`() {
val calc = Calc()
val event = Mockito.mock(GenericMessageEvent::class.java)
calc.commandResponse("channel", "calc", "two + two", event)
Mockito.verify(event, Mockito.times(1)).respond("No idea. This is the kind of math I don't get.")
}
}
}

View file

@ -34,28 +34,62 @@ import assertk.assertFailure
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.hasNoCause
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import net.thauvin.erik.mobibot.DisableOnCi
import net.thauvin.erik.mobibot.LocalProperties
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.pircbotx.hooks.types.GenericMessageEvent
import kotlin.test.Test
class ChatGpt2Test : LocalProperties() {
@Test
fun testApiKey() {
assertFailure { ChatGpt2.chat("1 gallon to liter", "", 0) }
.isInstanceOf(ModuleException::class.java)
.hasNoCause()
@Nested
@DisplayName("Command Response Tests")
inner class CommandResponseTests {
@Test
fun moduleMisconfigured() {
val chatGpt2 = ChatGpt2()
val event = Mockito.mock(GenericMessageEvent::class.java)
val captor = ArgumentCaptor.forClass(String::class.java)
chatGpt2.commandResponse("channel", "chatgpt", "1 liter to gallon", event)
Mockito.verify(event, Mockito.atLeastOnce()).respond(captor.capture())
assertThat(captor.value).isEqualTo("The ${ChatGpt2.CHATGPT_NAME} module is misconfigured.")
}
}
@Test
@DisableOnCi
fun testChat() {
val apiKey = getProperty(ChatGpt2.API_KEY_PROP)
assertThat(
ChatGpt2.chat("how do I make an HTTP request in Javascript?", apiKey, 200)
).contains("XMLHttpRequest")
@Nested
@DisplayName("Chat Tests")
inner class ChatTests {
@Test
fun apiKey() {
assertFailure { ChatGpt2.chat("1 gallon to liter", "", 0) }
.isInstanceOf(ModuleException::class.java)
.hasNoCause()
}
private val apiKey = getProperty(ChatGpt2.API_KEY_PROP)
assertFailure { ChatGpt2.chat("1 liter to gallon", apiKey, -1) }
.isInstanceOf(ModuleException::class.java)
@Test
@DisableOnCi
fun chat() {
assertThat(
ChatGpt2.chat(
"javascript function to make a request with XMLHttpRequest, just code",
apiKey,
50
)
).contains("```javascript")
}
@Test
@DisableOnCi
fun chatFailure() {
assertFailure { ChatGpt2.chat("1 liter to gallon", apiKey, -1) }
.isInstanceOf(ModuleException::class.java)
}
}
}

View file

@ -32,47 +32,22 @@ package net.thauvin.erik.mobibot.modules
import assertk.all
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isGreaterThan
import assertk.assertions.prop
import assertk.assertions.*
import net.thauvin.erik.crypto.CryptoPrice
import net.thauvin.erik.mobibot.modules.CryptoPrices.Companion.currentPrice
import net.thauvin.erik.mobibot.modules.CryptoPrices.Companion.getCurrencyName
import net.thauvin.erik.mobibot.modules.CryptoPrices.Companion.loadCurrencies
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.pircbotx.hooks.types.GenericMessageEvent
import java.util.logging.ConsoleHandler
import java.util.logging.Level
import kotlin.test.Test
class CryptoPricesTest {
init {
loadCurrencies()
}
@Test
@Throws(ModuleException::class)
fun testMarketPrice() {
var price = currentPrice(listOf("BTC"))
assertThat(price, "currentPrice(BTC)").all {
prop(CryptoPrice::base).isEqualTo("BTC")
prop(CryptoPrice::currency).isEqualTo("USD")
prop(CryptoPrice::amount).transform { it.signum() }.isGreaterThan(0)
}
price = currentPrice(listOf("ETH", "EUR"))
assertThat(price, "currentPrice(ETH, EUR)").all {
prop(CryptoPrice::base).isEqualTo("ETH")
prop(CryptoPrice::currency).isEqualTo("EUR")
prop(CryptoPrice::amount).transform { it.signum() }.isGreaterThan(0)
}
}
@Test
fun testGetCurrencyName() {
assertThat(getCurrencyName("USD"), "USD").isEqualTo("United States Dollar")
assertThat(getCurrencyName("EUR"), "EUR").isEqualTo("Euro")
}
companion object {
@JvmStatic
@BeforeAll
@ -84,4 +59,89 @@ class CryptoPricesTest {
}
}
}
init {
loadCurrencies()
}
@Nested
@DisplayName("Command Response Tests")
inner class CommandResponseTests {
@Test
fun `Current price for BTC`() {
val cryptoPrices = CryptoPrices()
val event = Mockito.mock(GenericMessageEvent::class.java)
val captor = ArgumentCaptor.forClass(String::class.java)
cryptoPrices.commandResponse("channel", "crypto", "btc", event)
Mockito.verify(event, Mockito.times(1)).respond(captor.capture())
assertThat(captor.value).startsWith("BTC current price is $")
}
@Test
fun `Current price for BTC in EUR`() {
val cryptoPrices = CryptoPrices()
val event = Mockito.mock(GenericMessageEvent::class.java)
val captor = ArgumentCaptor.forClass(String::class.java)
cryptoPrices.commandResponse("channel", "crypto", "eth eur", event)
Mockito.verify(event, Mockito.times(1)).respond(captor.capture())
assertThat(captor.value).matches(Regex("ETH current price is €.* \\[Euro]"))
}
@Test
fun `Invalid crypto symbol`() {
val cryptoPrices = CryptoPrices()
val event = Mockito.mock(GenericMessageEvent::class.java)
val captor = ArgumentCaptor.forClass(String::class.java)
cryptoPrices.commandResponse("channel", "crypto", "foo", event)
Mockito.verify(event, Mockito.times(1)).respond(captor.capture())
assertThat(captor.value)
.isEqualTo("${CryptoPrices.DEFAULT_ERROR_MESSAGE}: not found")
}
}
@Nested
@DisplayName("Currency Name Tests")
inner class CurrencyNameTests {
@Test
fun `Currency name for USD`() {
assertThat(getCurrencyName("USD"), "USD").isEqualTo("United States Dollar")
}
@Test
fun `Currency name for EUR`() {
assertThat(getCurrencyName("EUR"), "EUR").isEqualTo("Euro")
}
}
@Nested
@DisplayName("Current Price Tests")
inner class CurrentPriceTests {
@Test
@Throws(ModuleException::class)
fun `Current price for Bitcoin`() {
val price = currentPrice(listOf("BTC"))
assertThat(price, "currentPrice(BTC)").all {
prop(CryptoPrice::base).isEqualTo("BTC")
prop(CryptoPrice::currency).isEqualTo("USD")
prop(CryptoPrice::amount).transform { it.signum() }.isGreaterThan(0)
}
}
@Test
@Throws(ModuleException::class)
fun `Current price for Ethereum in Euro`() {
val price = currentPrice(listOf("ETH", "EUR"))
assertThat(price, "currentPrice(ETH, EUR)").all {
prop(CryptoPrice::base).isEqualTo("ETH")
prop(CryptoPrice::currency).isEqualTo("EUR")
prop(CryptoPrice::amount).transform { it.signum() }.isGreaterThan(0)
}
}
}
}

View file

@ -32,16 +32,18 @@ package net.thauvin.erik.mobibot.modules
import assertk.all
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.isInstanceOf
import assertk.assertions.matches
import assertk.assertions.prop
import assertk.assertions.*
import net.thauvin.erik.mobibot.LocalProperties
import net.thauvin.erik.mobibot.modules.CurrencyConverter.Companion.convertCurrency
import net.thauvin.erik.mobibot.modules.CurrencyConverter.Companion.loadSymbols
import net.thauvin.erik.mobibot.msg.ErrorMessage
import net.thauvin.erik.mobibot.msg.Message
import net.thauvin.erik.mobibot.msg.PublicMessage
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.pircbotx.hooks.types.GenericMessageEvent
import kotlin.test.Test
class CurrencyConverterTest : LocalProperties() {
@ -50,28 +52,80 @@ class CurrencyConverterTest : LocalProperties() {
loadSymbols(apiKey)
}
@Test
fun testConvertCurrency() {
val apiKey = getProperty(CurrencyConverter.API_KEY_PROP)
assertThat(
convertCurrency(apiKey, "100 USD to EUR").msg,
"convertCurrency(100 USD to EUR)"
).matches("100 United States Dollar = \\d{2,3}\\.\\d{2,3} Euro".toRegex())
assertThat(
convertCurrency(apiKey, "1 USD to GBP").msg,
"convertCurrency(1 USD to BGP)"
).matches("1 United States Dollar = 0\\.\\d{2,3} Pound Sterling".toRegex())
assertThat(
convertCurrency(apiKey, "100,000.00 CAD to USD").msg,
"convertCurrency(100,000.00 GBP to USD)"
).matches("100,000.00 Canadian Dollar = \\d+\\.\\d{2,3} United States Dollar".toRegex())
assertThat(convertCurrency(apiKey, "100 USD to USD"), "convertCurrency(100 USD to USD)").all {
prop(Message::msg).contains("You're kidding, right?")
isInstanceOf(PublicMessage::class.java)
@Nested
@DisplayName("Command Response Tests")
inner class CommandResponseTests {
@Test
fun `USD to CAD`() {
val currencyConverter = CurrencyConverter()
val event = Mockito.mock(GenericMessageEvent::class.java)
val captor = ArgumentCaptor.forClass(String::class.java)
currencyConverter.properties.put(
CurrencyConverter.API_KEY_PROP, getProperty(CurrencyConverter.API_KEY_PROP)
)
currencyConverter.commandResponse("channel", "currency", "1 USD to CAD", event)
Mockito.verify(event, Mockito.atLeastOnce()).respond(captor.capture())
assertThat(captor.value).matches("1 United States Dollar = \\d+\\.\\d{2,3} Canadian Dollar".toRegex())
}
assertThat(convertCurrency(apiKey, "100 USD"), "convertCurrency(100 USD)").all {
prop(Message::msg).contains("Invalid query.")
isInstanceOf(ErrorMessage::class.java)
@Test
fun `API Key is not specified`() {
val currencyConverter = CurrencyConverter()
val event = Mockito.mock(GenericMessageEvent::class.java)
val captor = ArgumentCaptor.forClass(String::class.java)
currencyConverter.commandResponse("channel", "currency", "1 USD to CAD", event)
Mockito.verify(event, Mockito.atLeastOnce()).respond(captor.capture())
assertThat(captor.value).isEqualTo(CurrencyConverter.ERROR_MESSAGE_NO_API_KEY)
}
}
@Nested
@DisplayName("Currency Converter Tests")
inner class CurrencyConverterTests {
private val apiKey = getProperty(CurrencyConverter.API_KEY_PROP)
@Test
fun `Convert CAD to USD`() {
assertThat(
convertCurrency(apiKey, "100,000.00 CAD to USD").msg,
"convertCurrency(100,000.00 GBP to USD)"
).matches("100,000.00 Canadian Dollar = \\d+\\.\\d{2,3} United States Dollar".toRegex())
}
@Test
fun `Convert USD to EUR`() {
assertThat(
convertCurrency(apiKey, "100 USD to EUR").msg,
"convertCurrency(100 USD to EUR)"
).matches("100 United States Dollar = \\d{2,3}\\.\\d{2,3} Euro".toRegex())
}
@Test
fun `Convert USD to GBP`() {
assertThat(
convertCurrency(apiKey, "1 USD to GBP").msg,
"convertCurrency(1 USD to BGP)"
).matches("1 United States Dollar = 0\\.\\d{2,3} Pound Sterling".toRegex())
}
@Test
fun `Convert USD to USD`() {
assertThat(convertCurrency(apiKey, "100 USD to USD"), "convertCurrency(100 USD to USD)").all {
prop(Message::msg).contains("You're kidding, right?")
isInstanceOf(PublicMessage::class.java)
}
}
@Test
fun `Invalid Query should throw exception`() {
assertThat(convertCurrency(apiKey, "100 USD"), "convertCurrency(100 USD)").all {
prop(Message::msg).contains("Invalid query.")
isInstanceOf(ErrorMessage::class.java)
}
}
}
}

View file

@ -35,19 +35,109 @@ package net.thauvin.erik.mobibot.modules
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.matches
import assertk.assertions.startsWith
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.RepeatedTest
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.pircbotx.hooks.types.GenericMessageEvent
import kotlin.random.Random
import kotlin.test.Test
class DiceTest {
@Test
fun testRoll() {
assertThat(Dice.roll(1, 1), "roll(1d1)").isEqualTo("\u00021\u0002")
assertThat(Dice.roll(2, 1), "roll(2d1)")
.isEqualTo("\u00021\u0002 + \u00021\u0002 = \u00022\u0002")
assertThat(Dice.roll(5, 1), "roll(5d1)")
.isEqualTo("\u00021\u0002 + \u00021\u0002 + \u00021\u0002 + \u00021\u0002 + \u00021\u0002 = \u00025\u0002")
assertThat(Dice.roll(2, 6), "roll(2d6)")
.matches("\u0002[1-6]\u0002 \\+ \u0002[1-6]\u0002 = \u0002[1-9][0-2]?\u0002".toRegex())
assertThat(Dice.roll(3, 7), "roll(3d7)")
.matches("\u0002[1-7]\u0002 \\+ \u0002[1-7]\u0002 \\+ \u0002[1-7]\u0002 = \u0002\\d{1,2}\u0002".toRegex())
@Nested
@DisplayName("Command Response Tests")
inner class CommandResponseTests {
@Test
fun `Roll die`() {
val dice = Dice()
val event = Mockito.mock(GenericMessageEvent::class.java)
val captor = ArgumentCaptor.forClass(String::class.java)
dice.commandResponse("channel", "dice", "", event)
Mockito.verify(event, Mockito.times(1)).respond(captor.capture())
assertThat(captor.value).startsWith("you rolled")
}
@RepeatedTest(3)
fun `Roll die with 9 sides`() {
val dice = Dice()
val event = Mockito.mock(GenericMessageEvent::class.java)
val captor = ArgumentCaptor.forClass(String::class.java)
dice.commandResponse("channel", "dice", "1d9", event)
Mockito.verify(event, Mockito.times(1)).respond(captor.capture())
assertThat(captor.value).matches("you rolled \u0002[1-9]\u0002".toRegex())
}
@RepeatedTest(3)
fun `Roll dice`() {
val dice = Dice()
val event = Mockito.mock(GenericMessageEvent::class.java)
val captor = ArgumentCaptor.forClass(String::class.java)
dice.commandResponse("channel", "dice", "2d6", event)
Mockito.verify(event, Mockito.times(1)).respond(captor.capture())
assertThat(captor.value)
.matches("you rolled \u0002[1-6]\u0002 \\+ \u0002[1-6]\u0002 = \u0002\\d{1,2}\\u0002".toRegex())
}
}
@Nested
@DisplayName("Roll Tests")
inner class RollTests {
@Test
fun `Roll die`() {
assertThat(Dice.roll(1, 6)).matches("\u0002[1-6]\u0002".toRegex())
}
@Test
fun `Roll die with 1 side`() {
assertThat(Dice.roll(1, 1)).isEqualTo("\u00021\u0002")
}
@RepeatedTest(5)
fun `Roll die with random sides`() {
assertThat(Dice.roll(1, Random.nextInt(1, 11))).matches("\u0002([1-9]|10)\u0002".toRegex())
}
@Test
fun `Roll 2 dice`() {
assertThat(Dice.roll(2, 6))
.matches("\u0002[1-6]\u0002 \\+ \u0002[1-6]\u0002 = \u0002[1-9][0-2]?\u0002".toRegex())
}
@Test
fun `Roll 2 dice with 1 side`() {
assertThat(Dice.roll(2, 1)).isEqualTo("\u00021\u0002 + \u00021\u0002 = \u00022\u0002")
}
@Test
fun `Roll 3 dice with 1 side`() {
assertThat(Dice.roll(4, 1))
.isEqualTo("\u00021\u0002 + \u00021\u0002 + \u00021\u0002 + \u00021\u0002 = \u00024\u0002")
}
@Test
fun `Roll 3 dice with 7 sides`() {
assertThat(Dice.roll(3, 7))
.matches(
"\u0002[1-7]\u0002 \\+ \u0002[1-7]\u0002 \\+ \u0002[1-7]\u0002 = \u0002\\d{1,2}\u0002"
.toRegex()
)
}
@RepeatedTest(3)
fun `Roll 3 dice with random sides`() {
assertThat(Dice.roll(3, Random.nextInt(1, 6)))
.matches(
"\u0002[1-5]\u0002 \\+ \u0002[1-5]\u0002 \\+ \u0002[1-5]\u0002 = \u0002\\d{1,2}\u0002"
.toRegex()
)
}
}
}

View file

@ -35,31 +35,72 @@ import assertk.assertThat
import assertk.assertions.*
import net.thauvin.erik.mobibot.DisableOnCi
import net.thauvin.erik.mobibot.LocalProperties
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.pircbotx.hooks.types.GenericMessageEvent
import kotlin.test.Test
class Gemini2Test : LocalProperties() {
@Test
fun testApiKey() {
assertFailure { Gemini2.chat("1 gallon to liter", "", 0) }
.isInstanceOf(ModuleException::class.java)
.hasNoCause()
@Nested
@DisplayName("Chat Tests")
inner class ChatTests {
private val apiKey = getProperty(Gemini2.GEMINI_API_KEY)
private val maxTokens = getProperty(Gemini2.MAX_TOKENS_PROP).toInt()
@Test
@DisableOnCi
fun chatHttpRequestInJavascript() {
assertThat(
Gemini2.chat(
"javascript function to make a request with XMLHttpRequest, just code",
apiKey,
maxTokens
)
).isNotNull().contains("```javascript")
}
@Test
@DisableOnCi
fun chatEncodeUrlInJava() {
assertThat(
Gemini2.chat("encode a url in java, one line, just code", apiKey, 60)
).isNotNull().contains("UrlEncoder", true)
}
}
@Test
@DisableOnCi
fun chatPrompt() {
val apiKey = getProperty(Gemini2.GEMINI_API_KEY)
val maxTokens = getProperty(Gemini2.MAX_TOKENS_PROP).toInt()
@Nested
@DisplayName("Command Response Tests")
inner class CommandResponseTests {
@Test
fun moduleMisconfigured() {
val gemini2 = Gemini2()
val event = Mockito.mock(GenericMessageEvent::class.java)
val captor = ArgumentCaptor.forClass(String::class.java)
assertThat(
Gemini2.chat("how do I make an HTTP request in Javascript?", apiKey, maxTokens)
).isNotNull().contains("XMLHttpRequest")
gemini2.commandResponse("channel", "gemini", "1 liter to gallon", event)
assertThat(
Gemini2.chat("how do I encode a URL in java?", apiKey, 60)
).isNotNull().contains("URLEncoder")
Mockito.verify(event, Mockito.atLeastOnce()).respond(captor.capture())
assertThat(captor.value).isEqualTo("The ${Gemini2.GEMINI_NAME} module is misconfigured.")
}
}
assertFailure { Gemini2.chat("1 liter to gallon", "foo", 40) }
.isInstanceOf(ModuleException::class.java)
@Nested
@DisplayName("API Keys Test")
inner class ApiKeysTest {
@Test
fun invalidApiKey() {
assertFailure { Gemini2.chat("1 liter to gallon", "foo", 40) }
.isInstanceOf(ModuleException::class.java)
.hasMessage(Gemini2.IO_ERROR)
}
@Test
fun emptyApiKey() {
assertFailure { Gemini2.chat("1 liter to gallon", "", 40) }
.isInstanceOf(ModuleException::class.java)
.hasMessage(Gemini2.API_KEY_ERROR)
}
}
}

View file

@ -40,48 +40,22 @@ import net.thauvin.erik.mobibot.LocalProperties
import net.thauvin.erik.mobibot.modules.GoogleSearch.Companion.searchGoogle
import net.thauvin.erik.mobibot.msg.ErrorMessage
import net.thauvin.erik.mobibot.msg.Message
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.mockito.kotlin.whenever
import org.pircbotx.hooks.types.GenericMessageEvent
import kotlin.test.Test
class GoogleSearchTest : LocalProperties() {
@Test
fun testAPIKeys() {
assertThat(
searchGoogle("", "apikey", "cssKey").first(),
"searchGoogle(empty)"
).isInstanceOf(ErrorMessage::class.java)
private val apiKey = getProperty(GoogleSearch.API_KEY_PROP)
private val cseKey = getProperty(GoogleSearch.CSE_KEY_PROP)
assertFailure { searchGoogle("test", "", "apiKey") }
.isInstanceOf(ModuleException::class.java).hasNoCause()
assertFailure { searchGoogle("test", "apiKey", "") }
.isInstanceOf(ModuleException::class.java).hasNoCause()
assertFailure { searchGoogle("test", "apiKey", "cssKey") }
.isInstanceOf(ModuleException::class.java)
.hasMessage("API key not valid. Please pass a valid API key.")
}
@Test
@DisableOnCi
@Throws(ModuleException::class)
fun testSearchGoogle() {
val apiKey = getProperty(GoogleSearch.API_KEY_PROP)
val cseKey = getProperty(GoogleSearch.CSE_KEY_PROP)
fun sanitizedSearch(query: String, apiKey: String, cseKey: String): List<Message> {
try {
var query = "mobibot"
var messages = searchGoogle(query, apiKey, cseKey)
assertThat(messages, "searchGoogle($query)").all {
isNotEmpty()
index(0).prop(Message::msg).contains(query, true)
}
query = "adadflkjl"
messages = searchGoogle(query, apiKey, cseKey)
assertThat(messages, "searchGoogle($query)").index(0).all {
isInstanceOf(ErrorMessage::class.java)
prop(Message::msg).isEqualTo("No results found.")
}
return searchGoogle(query, apiKey, cseKey)
} catch (e: ModuleException) {
// Avoid displaying api keys in CI logs
if ("true" == System.getenv("CI")) {
@ -91,4 +65,101 @@ class GoogleSearchTest : LocalProperties() {
}
}
}
@Nested
@DisplayName("Command Response Tests")
inner class CommandResponseTests {
@Test
fun `API Keys are missing`() {
val googleSearch = GoogleSearch()
val event = Mockito.mock(GenericMessageEvent::class.java)
val captor = ArgumentCaptor.forClass(String::class.java)
val user = Mockito.mock(org.pircbotx.User::class.java)
whenever(event.user).thenReturn(user)
whenever(user.nick).thenReturn("mock")
googleSearch.commandResponse("channel", "google", "seattle seahawks", event)
Mockito.verify(event, Mockito.atLeastOnce()).respond(captor.capture())
assertThat(captor.value)
.isEqualTo("${GoogleSearch.SERVICE_NAME} is disabled. The API keys are missing.")
}
@Test
fun `No results found`() {
val googleSearch = GoogleSearch()
val event = Mockito.mock(GenericMessageEvent::class.java)
val captor = ArgumentCaptor.forClass(String::class.java)
val user = Mockito.mock(org.pircbotx.User::class.java)
whenever(event.user).thenReturn(user)
whenever(user.nick).thenReturn("mock")
googleSearch.properties.put(GoogleSearch.API_KEY_PROP, apiKey)
googleSearch.properties.put(GoogleSearch.CSE_KEY_PROP, cseKey)
googleSearch.commandResponse("channel", "google", "\"foobarbarfoofoobarblahblah\"", event)
Mockito.verify(event, Mockito.atLeastOnce()).respond(captor.capture())
assertThat(captor.value)
.isEqualTo("\u000304No results found.\u000F")
}
}
@Nested
@DisplayName("API Keys Test")
inner class ApiKeysTest {
@Test
fun `API key should not be empty`() {
assertFailure { sanitizedSearch("test", "", "apiKey") }
.isInstanceOf(ModuleException::class.java).hasNoCause()
}
@Test
fun `CSE key should not empty`() {
assertFailure { sanitizedSearch("test", "apiKey", "") }
.isInstanceOf(ModuleException::class.java).hasNoCause()
}
@Test
fun `Invalid API key should throw exception`() {
assertFailure { sanitizedSearch("test", "apiKey", "cssKey") }
.isInstanceOf(ModuleException::class.java)
.hasMessage("API key not valid. Please pass a valid API key.")
}
}
@Nested
@DisplayName("Search Tests")
inner class SearchTests {
@Test
fun `Query should not be empty`() {
assertThat(sanitizedSearch("", apiKey, cseKey).first()).isInstanceOf(ErrorMessage::class.java)
}
@Test
@DisableOnCi
@Throws(ModuleException::class)
fun `No results found`() {
val query = "adadflkjl"
val messages = sanitizedSearch(query, apiKey, cseKey)
assertThat(messages, "searchGoogle($query)").index(0).all {
isInstanceOf(ErrorMessage::class.java)
prop(Message::msg).isEqualTo("No results found.")
}
}
@Test
@DisableOnCi
@Throws(ModuleException::class)
fun `Search Google`() {
val query = "mobibot"
val messages = sanitizedSearch(query, apiKey, cseKey)
assertThat(messages, "searchGoogle($query)").all {
isNotEmpty()
index(0).prop(Message::msg).contains(query, true)
}
}
}
}

View file

@ -41,7 +41,7 @@ import kotlin.test.Test
class JokeTest {
@Test
@Throws(ModuleException::class)
fun testRandomJoke() {
fun `Get a random joke`() {
val joke = randomJoke()
assertThat(joke, "randomJoke()").all {
size().isGreaterThan(0)

View file

@ -33,24 +33,80 @@ package net.thauvin.erik.mobibot.modules
import assertk.assertThat
import assertk.assertions.any
import assertk.assertions.contains
import assertk.assertions.isEqualTo
import net.thauvin.erik.mobibot.modules.Lookup.Companion.nslookup
import net.thauvin.erik.mobibot.modules.Lookup.Companion.whois
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.pircbotx.hooks.types.GenericMessageEvent
import kotlin.test.Test
class LookupTest {
@Test
@Throws(Exception::class)
fun testLookup() {
var result = nslookup("apple.com")
assertThat(result, "lookup(apple.com)").contains("17.253.144.10")
@Nested
@DisplayName("Command Response Tests")
inner class CommandResponseTests {
@Test
fun lookupByHostname() {
val lookup = Lookup()
val event = Mockito.mock(GenericMessageEvent::class.java)
val captor = ArgumentCaptor.forClass(String::class.java)
result = nslookup("37.27.52.13")
assertThat(result, "lookup(37.27.52.13)").contains("nix4.thauvin.us")
lookup.commandResponse("channel", "lookup", "ec2-54-234-237-183.compute-1.amazonaws.com", event)
Mockito.verify(event, Mockito.times(1)).respondWith(captor.capture())
assertThat(captor.value).contains("54.234.237.183")
}
@Test
fun lookupByAddress() {
val lookup = Lookup()
val event = Mockito.mock(GenericMessageEvent::class.java)
val captor = ArgumentCaptor.forClass(String::class.java)
lookup.commandResponse("channel", "lookup", "54.234.237.183", event)
Mockito.verify(event, Mockito.times(1)).respondWith(captor.capture())
assertThat(captor.value).contains("ec2-54-234-237-183.compute-1.amazonaws.com")
}
@Test
fun lookupUnknownHostname() {
val lookup = Lookup()
val event = Mockito.mock(GenericMessageEvent::class.java)
val captor = ArgumentCaptor.forClass(String::class.java)
lookup.commandResponse("channel", "lookup", "foobar", event)
Mockito.verify(event, Mockito.times(1)).respond(captor.capture())
assertThat(captor.value).isEqualTo("Unknown host.")
}
}
@Nested
@DisplayName("Lookup Tests")
inner class LookupTests {
@Test
@Throws(Exception::class)
fun lookupByHostname() {
val result = nslookup("apple.com")
assertThat(result, "lookup(apple.com)").contains("17.253.144.10")
}
@Test
@Throws(Exception::class)
fun lookupByIpAddress() {
val result = nslookup("37.27.52.13")
assertThat(result, "lookup(37.27.52.13)").contains("nix4.thauvin.us")
}
}
@Test
@Throws(Exception::class)
fun testWhois() {
fun whois() {
val result = whois("17.178.96.59", Lookup.WHOIS_HOST)
assertThat(result, "whois(17.178.96.59").any { it.contains("Apple Inc.") }
}

View file

@ -30,25 +30,104 @@
*/
package net.thauvin.erik.mobibot.modules
import assertk.assertFailure
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.hasMessage
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import net.thauvin.erik.mobibot.LocalProperties
import net.thauvin.erik.mobibot.modules.Mastodon.Companion.toot
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.mockito.kotlin.whenever
import org.pircbotx.hooks.types.GenericMessageEvent
import kotlin.test.Test
class MastodonTest : LocalProperties() {
@Test
@Throws(ModuleException::class)
fun testToot() {
val msg = "Testing Mastodon API from ${getHostName()}"
assertThat(
toot(
getProperty(Mastodon.ACCESS_TOKEN_PROP),
getProperty(Mastodon.INSTANCE_PROP),
getProperty(Mastodon.HANDLE_PROP),
msg,
true
)
).contains(msg)
@Nested
@DisplayName("Command Response Tests")
inner class CommandResponseTests {
@Test
fun `API Key is not specified`() {
val mastodon = Mastodon()
val event = Mockito.mock(GenericMessageEvent::class.java)
val captor = ArgumentCaptor.forClass(String::class.java)
val user = Mockito.mock(org.pircbotx.User::class.java)
whenever(event.user).thenReturn(user)
whenever(user.nick).thenReturn("mock")
mastodon.commandResponse("channel", "toot", "This is a test.", event)
Mockito.verify(event, Mockito.atLeastOnce()).respond(captor.capture())
assertThat(captor.value).isEqualTo("The access token is missing.")
}
}
@Nested
@DisplayName("Toot Tests")
inner class TootTests {
@Test
@Throws(ModuleException::class)
fun `Empty Access Token should throw exception`() {
val msg = "Testing Mastodon API from ${getHostName()}"
assertFailure {
toot(
"",
getProperty(Mastodon.INSTANCE_PROP),
getProperty(Mastodon.HANDLE_PROP),
msg,
true
)
}.isInstanceOf(ModuleException::class.java).hasMessage("The access token is missing.")
}
@Test
@Throws(ModuleException::class)
fun `Empty Handle should throw exception`() {
val msg = "Testing Mastodon API from ${getHostName()}"
assertFailure {
toot(
getProperty(Mastodon.ACCESS_TOKEN_PROP),
getProperty(Mastodon.INSTANCE_PROP),
"",
msg,
true
)
}.isInstanceOf(ModuleException::class.java).hasMessage("The Mastodon handle is missing.")
}
@Test
@Throws(ModuleException::class)
fun `Empty Instance should throw exception`() {
val msg = "Testing Mastodon API from ${getHostName()}"
assertFailure {
toot(
getProperty(Mastodon.ACCESS_TOKEN_PROP),
"",
getProperty(Mastodon.HANDLE_PROP),
msg,
true
)
}.isInstanceOf(ModuleException::class.java).hasMessage("The Mastodon instance is missing.")
}
@Test
@Throws(ModuleException::class)
fun `Toot on Mastodon`() {
val msg = "Testing Mastodon API from ${getHostName()}"
assertThat(
toot(
getProperty(Mastodon.ACCESS_TOKEN_PROP),
getProperty(Mastodon.INSTANCE_PROP),
getProperty(Mastodon.HANDLE_PROP),
msg,
true
)
).contains(msg)
}
}
}

View file

@ -34,6 +34,8 @@ import assertk.all
import assertk.assertThat
import assertk.assertions.*
import net.thauvin.erik.mobibot.ExceptionSanitizer.sanitize
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import java.io.IOException
@ -45,60 +47,64 @@ class ModuleExceptionTest {
const val MESSAGE = "message"
@JvmStatic
fun dataProviders(): List<ModuleException> {
fun moduleExceptions(): List<ModuleException> {
return listOf(
ModuleException(DEBUG_MESSAGE, MESSAGE, IOException("URL http://foobar.com")),
ModuleException(DEBUG_MESSAGE, MESSAGE, IOException("URL http://foobar.com?")),
ModuleException(DEBUG_MESSAGE, MESSAGE, IOException("foo")),
ModuleException(DEBUG_MESSAGE, MESSAGE, IllegalArgumentException("bar")),
ModuleException(DEBUG_MESSAGE, MESSAGE)
)
}
}
@ParameterizedTest
@MethodSource("dataProviders")
fun testGetDebugMessage(e: ModuleException) {
assertThat(e::debugMessage).isEqualTo(DEBUG_MESSAGE)
}
@ParameterizedTest
@MethodSource("dataProviders")
fun testGetMessage(e: ModuleException) {
assertThat(e).hasMessage(MESSAGE)
}
@Test
fun testSanitizeMessage() {
val apiKey = "1234567890"
var e = ModuleException(DEBUG_MESSAGE, MESSAGE, IOException("URL http://foo.com?apiKey=$apiKey&userID=me"))
assertThat(
e.sanitize(apiKey, "", "me").message, "ModuleException(debugMessage, message, IOException(url))"
).isNotNull().all {
contains("xxxxxxxxxx", "userID=xx", "java.io.IOException")
doesNotContain(apiKey, "me")
@Nested
@DisplayName("Message Tests")
inner class MessageTests {
@ParameterizedTest
@MethodSource("net.thauvin.erik.mobibot.modules.ModuleExceptionTest#moduleExceptions")
fun getDebugMessage(e: ModuleException) {
assertThat(e::debugMessage).isEqualTo(DEBUG_MESSAGE)
}
e = ModuleException(DEBUG_MESSAGE, MESSAGE, null)
assertThat(e.sanitize(apiKey), "ModuleException(debugMessage, message, null)").hasMessage(MESSAGE)
e = ModuleException(DEBUG_MESSAGE, MESSAGE, IOException())
assertThat(e.sanitize(apiKey), "ModuleException(debugMessage, message, IOException())").hasMessage(MESSAGE)
e = ModuleException(DEBUG_MESSAGE, apiKey)
assertThat(e.sanitize(apiKey).message, "ModuleException(debugMessage, apiKey)").isNotNull()
.doesNotContain(apiKey)
val msg: String? = null
e = ModuleException(DEBUG_MESSAGE, msg, IOException(msg))
assertThat(e.sanitize(apiKey).message, "ModuleException(debugMessage, msg, IOException(msg))").isNull()
e = ModuleException(DEBUG_MESSAGE, msg, IOException("foo is $apiKey"))
assertThat(
e.sanitize(" ", apiKey, "foo").message,
"ModuleException(debugMessage, msg, IOException(foo is $apiKey))"
).isNotNull().all {
doesNotContain(apiKey)
endsWith("xxx is xxxxxxxxxx")
@ParameterizedTest
@MethodSource("net.thauvin.erik.mobibot.modules.ModuleExceptionTest#moduleExceptions")
fun getMessage(e: ModuleException) {
assertThat(e).hasMessage(MESSAGE)
}
@Test
fun sanitizeMessage() {
val apiKey = "1234567890"
var e = ModuleException(DEBUG_MESSAGE, MESSAGE, IOException("URL https://foo.com?apiKey=$apiKey&userID=me"))
assertThat(
e.sanitize(apiKey, "", "me").message, "ModuleException(debugMessage, message, IOException(url))"
).isNotNull().all {
contains("xxxxxxxxxx", "userID=xx", "java.io.IOException")
doesNotContain(apiKey, "me")
}
e = ModuleException(DEBUG_MESSAGE, MESSAGE, null)
assertThat(e.sanitize(apiKey), "ModuleException(debugMessage, message, null)").hasMessage(MESSAGE)
e = ModuleException(DEBUG_MESSAGE, MESSAGE, IOException())
assertThat(e.sanitize(apiKey), "ModuleException(debugMessage, message, IOException())").hasMessage(MESSAGE)
e = ModuleException(DEBUG_MESSAGE, apiKey)
assertThat(e.sanitize(apiKey).message, "ModuleException(debugMessage, apiKey)").isNotNull()
.doesNotContain(apiKey)
val msg: String? = null
e = ModuleException(DEBUG_MESSAGE, msg, IOException(msg))
assertThat(e.sanitize(apiKey).message, "ModuleException(debugMessage, msg, IOException(msg))").isNull()
e = ModuleException(DEBUG_MESSAGE, msg, IOException("foo is $apiKey"))
assertThat(
e.sanitize(" ", apiKey, "foo").message,
"ModuleException(debugMessage, msg, IOException(foo is $apiKey))"
).isNotNull().all {
doesNotContain(apiKey)
endsWith("xxx is xxxxxxxxxx")
}
assertThat(e.sanitize(), "exception should be unchanged").isEqualTo(e)
}
assertThat(e.sanitize(), "exception should be unchanged").isEqualTo(e)
}
}

View file

@ -38,14 +38,14 @@ import kotlin.test.Test
class PingTest {
@Test
fun testPingsArray() {
assertThat(Ping.PINGS, "Ping.PINGS").isNotEmpty()
}
@Test
fun testRandomPing() {
fun `Get a random ping`() {
for (i in 0..9) {
assertThat(Ping.PINGS, "Ping.PINGS[$i]").contains(randomPing())
}
}
@Test
fun `Pings array should not be empty`() {
assertThat(Ping.PINGS, "Ping.PINGS").isNotEmpty()
}
}

View file

@ -33,18 +33,82 @@ package net.thauvin.erik.mobibot.modules
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.matches
import net.thauvin.erik.mobibot.modules.RockPaperScissors.Companion.winLoseOrDraw
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.RepeatedTest
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.pircbotx.hooks.types.GenericMessageEvent
import kotlin.test.Test
class RockPaperScissorsTest {
@Test
fun testWinLoseOrDraw() {
assertThat(winLoseOrDraw("scissors", "paper"), "scissors vs. paper").isEqualTo("win")
assertThat(winLoseOrDraw("paper", "rock"), "paper vs. rock").isEqualTo("win")
assertThat(winLoseOrDraw("rock", "scissors"), "rock vs. scissors").isEqualTo("win")
assertThat(winLoseOrDraw("paper", "scissors"), "paper vs. scissors").isEqualTo("lose")
assertThat(winLoseOrDraw("rock", "paper"), "rock vs. paper").isEqualTo("lose")
assertThat(winLoseOrDraw("scissors", "rock"), "scissors vs. rock").isEqualTo("lose")
assertThat(winLoseOrDraw("scissors", "scissors"), "scissors vs. scissors").isEqualTo("draw")
@Nested
@DisplayName("Command Response Tests")
inner class CommandResponseTests {
@RepeatedTest(3)
fun `Play Rock Paper Scissors`() {
val rockPaperScissors = RockPaperScissors()
val event = Mockito.mock(GenericMessageEvent::class.java)
val captor = ArgumentCaptor.forClass(String::class.java)
rockPaperScissors.commandResponse("channel", "rock", "", event)
Mockito.verify(event, Mockito.atLeastOnce()).respond(captor.capture())
assertThat(captor.value)
.matches(
".* (vs\\.|crushes|covers|cuts) (ROCK|PAPER|SCISSORS) » You \u0002(tie|win|lose)\u0002.".toRegex()
)
}
}
@Nested
@DisplayName("Win, Lose or Draw Tests")
inner class WinLoseOrDrawTests {
@Test
fun `Paper versus Paper draws`() {
assertThat(winLoseOrDraw("paper", "paper")).isEqualTo("draw")
}
@Test
fun `Paper versus Rock wins`() {
assertThat(winLoseOrDraw("paper", "rock")).isEqualTo("win")
}
@Test
fun `Paper versus Scissors loses`() {
assertThat(winLoseOrDraw("paper", "scissors")).isEqualTo("lose")
}
@Test
fun `Rock versus Paper loses`() {
assertThat(winLoseOrDraw("rock", "paper")).isEqualTo("lose")
}
@Test
fun `Rock versus Rock draws`() {
assertThat(winLoseOrDraw("rock", "rock")).isEqualTo("draw")
}
@Test
fun `Rock versus Scissors wins`() {
assertThat(winLoseOrDraw("rock", "scissors")).isEqualTo("win")
}
@Test
fun `Scissors versus Paper wins`() {
assertThat(winLoseOrDraw("scissors", "paper")).isEqualTo("win")
}
@Test
fun `Scissors versus Rock loses`() {
assertThat(winLoseOrDraw("scissors", "rock")).isEqualTo("lose")
}
@Test
fun `Scissors versus Scissors draws`() {
assertThat(winLoseOrDraw("scissors", "scissors")).isEqualTo("draw")
}
}
}

Some files were not shown because too many files have changed in this diff Show more