From bc42e4b60ae619d7d3e35f566aa21ce78f5778a6 Mon Sep 17 00:00:00 2001 From: CO2-Codes <48807676+CO2-Codes@users.noreply.github.com> Date: Sat, 3 Jul 2021 21:50:14 +0200 Subject: [PATCH 1/2] Migrated code to scala3 and upgraded dependencies. --- README.md | 2 +- build.sbt | 52 ++++--------- project/assembly.sbt | 2 +- project/build.properties | 2 +- .../co2/ircbot/config/BotConfiguration.scala | 61 +++++++++------ .../codes/co2/ircbot/pircbotx/Main.scala | 76 ++++++++++--------- 6 files changed, 94 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 6c8b275..f3c56ab 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Scala-IRC-bot -This is a Scala implementation of an IRC bot, on top of https://github.com/pircbotx/pircbotx. +This is a Scala 3 implementation of an IRC bot, on top of https://github.com/pircbotx/pircbotx. ## How to install First, make a configuration file, based on [the example](example.conf). diff --git a/build.sbt b/build.sbt index 17db500..b8287fa 100644 --- a/build.sbt +++ b/build.sbt @@ -1,65 +1,39 @@ name := "Scala-IRC-bot" -ThisBuild / version := "1.1.1" +ThisBuild / version := "1.2.0" -scalaVersion := "2.13.5" +scalaVersion := "3.0.0" -// Recommended flags from https://tpolecat.github.io/2017/04/25/scalac-flags.html (Removed scala 2.13 deprecated flags) scalacOptions ++= Seq( "-deprecation", // Emit warning and location for usages of deprecated APIs. "-encoding", "utf-8", // Specify character encoding used by source files. - "-explaintypes", // Explain type errors in more detail. + "-explain-types", // Explain type errors in more detail. "-feature", // Emit warning and location for usages of features that should be imported explicitly. "-language:existentials", // Existential types (besides wildcard types) can be written and inferred "-language:experimental.macros", // Allow macro definition (besides implementation and application) "-language:higherKinds", // Allow higher-kinded types "-language:implicitConversions", // Allow definition of implicit functions called views "-unchecked", // Enable additional warnings where generated code depends on assumptions. - "-Xcheckinit", // Wrap field accessors to throw an exception on uninitialized access. "-Xfatal-warnings", // Fail the compilation if there are any warnings. - "-Xlint:adapted-args", // Warn if an argument list is modified to match the receiver. - "-Xlint:constant", // Evaluation of a constant arithmetic expression results in an error. - "-Xlint:delayedinit-select", // Selecting member of DelayedInit. - "-Xlint:doc-detached", // A Scaladoc comment appears to be detached from its element. - "-Xlint:inaccessible", // Warn about inaccessible types in method signatures. - "-Xlint:infer-any", // Warn when a type argument is inferred to be `Any`. - "-Xlint:missing-interpolator", // A string literal appears to be missing an interpolator id. - "-Xlint:nullary-unit", // Warn when nullary methods return Unit. - "-Xlint:option-implicit", // Option.apply used implicit view. - "-Xlint:package-object-classes", // Class or object defined in package object. - "-Xlint:poly-implicit-overload", // Parameterized overloaded implicit methods are not visible as view bounds. - "-Xlint:private-shadow", // A private field (or class parameter) shadows a superclass field. - "-Xlint:stars-align", // Pattern sequence wildcard must align with sequence component. - "-Xlint:type-parameter-shadow", // A local type parameter shadows a type already in scope. - "-Ywarn-dead-code", // Warn when dead code is identified. - "-Ywarn-extra-implicit", // Warn when more than one implicit parameter section is defined. - "-Ywarn-numeric-widen", // Warn when numerics are widened. - "-Ywarn-unused:implicits", // Warn if an implicit parameter is unused. - "-Ywarn-unused:imports", // Warn if an import selector is not referenced. - "-Ywarn-unused:locals", // Warn if a local definition is unused. - "-Ywarn-unused:params", // Warn if a value parameter is unused. - "-Ywarn-unused:patvars", // Warn if a variable bound in a pattern is unused. - "-Ywarn-unused:privates", // Warn if a private member is unused. - "-Ywarn-value-discard", // Warn when non-Unit expression results are unused. ) lazy val ircBot = project.in(file(".")) .settings( resolvers += "jitpack" at "https://jitpack.io", libraryDependencies ++= Seq( - "com.github.pircbotx" % "pircbotx" % "master-SNAPSHOT", // Use snapshot to prevent an issue with incompatible transitive dependencies. - "com.github.pureconfig" %% "pureconfig" % "0.15.0", + // Use snapshot to prevent an issue with incompatible transitive dependencies. + "com.github.pircbotx" % "pircbotx" % "master-SNAPSHOT", + "com.github.pureconfig" %% "pureconfig-core" % "0.16.0", "ch.qos.logback" % "logback-classic" % "1.2.3", - "com.typesafe.akka" %% "akka-http" % "10.2.4", - "com.typesafe.akka" %% "akka-actor" % "2.6.14", - "com.typesafe.akka" %% "akka-stream" % "2.6.14", + "com.typesafe.akka" %% "akka-http" % "10.2.4" cross CrossVersion.for3Use2_13, + "com.typesafe.akka" %% "akka-actor" % "2.6.15" cross CrossVersion.for3Use2_13, + "com.typesafe.akka" %% "akka-stream" % "2.6.15" cross CrossVersion.for3Use2_13, "org.apache.commons" % "commons-text" % "1.9", - "com.danielasfregola" %% "twitter4s" % "7.0", - "com.google.api-client" % "google-api-client" % "1.31.4", - "com.google.apis" % "google-api-services-youtube" % "v3-rev20210410-1.31.0", - "org.scalatest" %% "scalatest" % "3.2.8" % "test", - "com.typesafe.akka" %% "akka-stream" % "2.6.14", + "com.danielasfregola" %% "twitter4s" % "7.0" cross CrossVersion.for3Use2_13, + "com.google.api-client" % "google-api-client" % "1.32.1", + "com.google.apis" % "google-api-services-youtube" % "v3-rev20210624-1.32.1", + "org.scalatest" %% "scalatest" % "3.2.9" % "test", ), ) .settings(assembly / test := {}) diff --git a/project/assembly.sbt b/project/assembly.sbt index 0b6fb67..3e31b02 100644 --- a/project/assembly.sbt +++ b/project/assembly.sbt @@ -1,2 +1,2 @@ -addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.15.0") +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "1.0.0") addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.5.3") diff --git a/project/build.properties b/project/build.properties index 2d46a93..77df8ac 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.5.1 \ No newline at end of file +sbt.version=1.5.4 \ No newline at end of file diff --git a/src/main/scala/codes/co2/ircbot/config/BotConfiguration.scala b/src/main/scala/codes/co2/ircbot/config/BotConfiguration.scala index 9ca382c..1078e5a 100644 --- a/src/main/scala/codes/co2/ircbot/config/BotConfiguration.scala +++ b/src/main/scala/codes/co2/ircbot/config/BotConfiguration.scala @@ -1,43 +1,54 @@ package codes.co2.ircbot.config +import codes.co2.ircbot.config +import com.danielasfregola.twitter4s.entities.{AccessToken, ConsumerToken} +import org.slf4j.{Logger, LoggerFactory} +import pureconfig.generic.derivation.default._ +import pureconfig.{ConfigReader, ConfigSource, _} + import java.nio.file.Path -import org.slf4j.{Logger, LoggerFactory} -import pureconfig.ConfigSource -import pureconfig.generic.auto._ // IntelliJ might see this as an unused import. IntelliJ is wrong. -import com.danielasfregola.twitter4s.entities.{AccessToken, ConsumerToken} +/* Pureconfig's new Scala 3 derivations with the ConfigReader is still in beta +but seems to work well enough for our usecase. Just make sure to add the ConfigReader +to every case class or it might not be able to parse them at runtime. + */ case class BotConfiguration( - connection: Connection, - serverPassword: Option[String], - nickname: String, - ident: Option[String], - realname: Option[String], - nickservPassword: Option[String], - channels: Seq[String], - fingerMsg: Option[String], - listeners: Seq[String], - generalConfig: GeneralConfig, - ) - -case class Connection(serverName: String, port: Int, ssl: Boolean) + connection: Connection, + serverPassword: Option[String], + nickname: String, + ident: Option[String], + realname: Option[String], + nickservPassword: Option[String], + channels: Seq[String], + fingerMsg: Option[String], + listeners: Seq[String], + generalConfig: GeneralConfig, +) derives ConfigReader + +case class Connection(serverName: String, port: Int, ssl: Boolean) derives ConfigReader case class GeneralConfig( - ignoreNicks: Option[Seq[String]], - ignoreChannels: Option[Seq[String]], - botAdmins: Seq[String], - ) { + ignoreNicks: Option[Seq[String]], + ignoreChannels: Option[Seq[String]], + botAdmins: Seq[String], +) derives ConfigReader { val ignoredChannels: Seq[String] = ignoreChannels.getOrElse(Seq.empty) val ignoredNicks: Seq[String] = ignoreNicks.getOrElse(Seq.empty) } -case class TwitterApi(consumerToken: ConsumerToken, accessToken: AccessToken) +case class TwitterApi(consumerToken: ConsumerToken, accessToken: AccessToken) derives ConfigReader -case class LinkListenerConfig(boldTitles: Option[Boolean], twitterApi: Option[TwitterApi], youtubeApiKey: Option[String], useHttpProxy: Option[Boolean]) +case class LinkListenerConfig( + boldTitles: Option[Boolean], + twitterApi: Option[TwitterApi], + youtubeApiKey: Option[String], + useHttpProxy: Option[Boolean], +) derives ConfigReader -case class AdminListenerConfig(helpText: String, puppetMasters: Option[Seq[String]]) +case class AdminListenerConfig(helpText: String, puppetMasters: Option[Seq[String]]) derives ConfigReader -case class PronounListenerConfig(filePath: String) +case class PronounListenerConfig(filePath: String) derives ConfigReader object BotConfiguration { val log: Logger = LoggerFactory.getLogger(getClass) diff --git a/src/main/scala/codes/co2/ircbot/pircbotx/Main.scala b/src/main/scala/codes/co2/ircbot/pircbotx/Main.scala index c2d154d..3043c13 100644 --- a/src/main/scala/codes/co2/ircbot/pircbotx/Main.scala +++ b/src/main/scala/codes/co2/ircbot/pircbotx/Main.scala @@ -1,7 +1,6 @@ package codes.co2.ircbot.pircbotx import java.nio.file.Paths - import akka.actor.ActorSystem import codes.co2.ircbot.config.BotConfiguration import org.pircbotx.delay.AdaptingDelay @@ -10,40 +9,49 @@ import org.slf4j.{Logger, LoggerFactory} import scala.concurrent.ExecutionContext import scala.jdk.CollectionConverters._ +import java.nio.file.Path + +object Main { + + /* The Scala3 top level main function syntax doesn't seem to allow for throwing custom errors, or at least not as + easily as this syntax does. + */ + + def main(args: Array[String]) = { + val log: Logger = LoggerFactory.getLogger(getClass) + + if (args.sizeIs != 1) { + log.error("Please have (only) the path to the .conf file as a command line argument") + System.exit(1) + } + + val path = Paths.get(args.head) + + val config = BotConfiguration.loadConfig(path) + + implicit val actorSystem: ActorSystem = ActorSystem() + implicit val ec: ExecutionContext = actorSystem.dispatcher + + def pircConfiguration: Configuration = { + new Configuration.Builder() + .addServer(config.connection.serverName, config.connection.port).setSocket(config.connection.ssl) + .addAutoJoinChannels(config.channels.asJava) + .setAutoReconnect(true).setAutoReconnectAttempts(5).setAutoReconnectDelay(new AdaptingDelay(1, 120000)) + .setAutoSplitMessage(false) + .setFinger(config.fingerMsg) + .setName(config.nickname) + .setLogin(config.ident.getOrElse(config.nickname)) + .setRealName(config.realname.getOrElse(config.nickname)) + .setServerPassword(config.serverPassword.orNull) + .setNickservPassword(config.nickservPassword.orNull).setNickservDelayJoin(config.nickservPassword.nonEmpty) + .setAutoNickChange(true) + .addListeners(config, path) + .buildConfiguration() + } + + val bot = new PircBotX(pircConfiguration) + bot.startBot() -object Main extends App { - val log: Logger = LoggerFactory.getLogger(getClass) - - if (args.sizeIs != 1) { - log.error("Please have (only) the path to the .conf file as a command line argument") - System.exit(1) } - val path = Paths.get(args.head) - - val config = BotConfiguration.loadConfig(path) - - implicit val actorSystem: ActorSystem = ActorSystem() - implicit val ec: ExecutionContext = actorSystem.dispatcher - - def pircConfiguration: Configuration = { - new Configuration.Builder() - .addServer(config.connection.serverName, config.connection.port).setSocket(config.connection.ssl) - .addAutoJoinChannels(config.channels.asJava) - .setAutoReconnect(true).setAutoReconnectAttempts(5).setAutoReconnectDelay(new AdaptingDelay(1, 120000)) - .setAutoSplitMessage(false) - .setFinger(config.fingerMsg) - .setName(config.nickname) - .setLogin(config.ident.getOrElse(config.nickname)) - .setRealName(config.realname.getOrElse(config.nickname)) - .setServerPassword(config.serverPassword.orNull) - .setNickservPassword(config.nickservPassword.orNull).setNickservDelayJoin(config.nickservPassword.nonEmpty) - .setAutoNickChange(true) - .addListeners(config, path) - .buildConfiguration() - } - - val bot = new PircBotX(pircConfiguration) - bot.startBot() - } From 2f8217b972e3c4be53afed7f0f2460f06678e2cf Mon Sep 17 00:00:00 2001 From: CO2-Codes <48807676+CO2-Codes@users.noreply.github.com> Date: Mon, 29 Nov 2021 20:26:16 +0100 Subject: [PATCH 2/2] Added spam-list functionality to the link listener for things that should not be sent to the channel. --- build.sbt | 18 +++---- example.conf | 2 + project/assembly.sbt | 2 +- project/build.properties | 2 +- .../co2/ircbot/config/BotConfiguration.scala | 48 +++++++++++++------ .../ircbot/listeners/links/LinkListener.scala | 31 ++++++++---- 6 files changed, 67 insertions(+), 36 deletions(-) diff --git a/build.sbt b/build.sbt index b8287fa..7b89147 100644 --- a/build.sbt +++ b/build.sbt @@ -2,7 +2,7 @@ name := "Scala-IRC-bot" ThisBuild / version := "1.2.0" -scalaVersion := "3.0.0" +scalaVersion := "3.1.0" scalacOptions ++= Seq( "-deprecation", // Emit warning and location for usages of deprecated APIs. @@ -24,16 +24,16 @@ lazy val ircBot = project.in(file(".")) libraryDependencies ++= Seq( // Use snapshot to prevent an issue with incompatible transitive dependencies. "com.github.pircbotx" % "pircbotx" % "master-SNAPSHOT", - "com.github.pureconfig" %% "pureconfig-core" % "0.16.0", - "ch.qos.logback" % "logback-classic" % "1.2.3", - "com.typesafe.akka" %% "akka-http" % "10.2.4" cross CrossVersion.for3Use2_13, - "com.typesafe.akka" %% "akka-actor" % "2.6.15" cross CrossVersion.for3Use2_13, - "com.typesafe.akka" %% "akka-stream" % "2.6.15" cross CrossVersion.for3Use2_13, + "com.github.pureconfig" %% "pureconfig-core" % "0.17.1", + "ch.qos.logback" % "logback-classic" % "1.2.7", + "com.typesafe.akka" %% "akka-http" % "10.2.7" cross CrossVersion.for3Use2_13, + "com.typesafe.akka" %% "akka-actor" % "2.6.17" cross CrossVersion.for3Use2_13, + "com.typesafe.akka" %% "akka-stream" % "2.6.17" cross CrossVersion.for3Use2_13, "org.apache.commons" % "commons-text" % "1.9", "com.danielasfregola" %% "twitter4s" % "7.0" cross CrossVersion.for3Use2_13, - "com.google.api-client" % "google-api-client" % "1.32.1", - "com.google.apis" % "google-api-services-youtube" % "v3-rev20210624-1.32.1", - "org.scalatest" %% "scalatest" % "3.2.9" % "test", + "com.google.api-client" % "google-api-client" % "1.32.2", + "com.google.apis" % "google-api-services-youtube" % "v3-rev20210915-1.32.1", + "org.scalatest" %% "scalatest" % "3.2.10" % "test", ), ) .settings(assembly / test := {}) diff --git a/example.conf b/example.conf index 76e4e7e..cb353d9 100644 --- a/example.conf +++ b/example.conf @@ -47,6 +47,8 @@ link-listener { youtube-api-key = "" // Optional, if set, tries to parse youtube link titles using the youtube API which seems more stable than the website + spam-list = ["bad words", "other bad words"] // Case insensitive list of terms that, when they appear in a title, are never sent to the channel. Optional + use-http-proxy = true // Optional, if set, will use the default akka http proxy settings for all non-youtube/non-twitter http requests // Additionally, akka needs to be told the proxy server's host and port. diff --git a/project/assembly.sbt b/project/assembly.sbt index 3e31b02..8496f41 100644 --- a/project/assembly.sbt +++ b/project/assembly.sbt @@ -1,2 +1,2 @@ -addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "1.0.0") +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "1.1.0") addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.5.3") diff --git a/project/build.properties b/project/build.properties index 77df8ac..bb5389d 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.5.4 \ No newline at end of file +sbt.version=1.5.5 \ No newline at end of file diff --git a/src/main/scala/codes/co2/ircbot/config/BotConfiguration.scala b/src/main/scala/codes/co2/ircbot/config/BotConfiguration.scala index 1078e5a..ee98c09 100644 --- a/src/main/scala/codes/co2/ircbot/config/BotConfiguration.scala +++ b/src/main/scala/codes/co2/ircbot/config/BotConfiguration.scala @@ -44,7 +44,10 @@ case class LinkListenerConfig( twitterApi: Option[TwitterApi], youtubeApiKey: Option[String], useHttpProxy: Option[Boolean], -) derives ConfigReader + spamList: Option[Seq[String]], +) derives ConfigReader { + val lowerCaseSpamList = spamList.map(_.map(_.toLowerCase)).getOrElse(Seq.empty) +} case class AdminListenerConfig(helpText: String, puppetMasters: Option[Seq[String]]) derives ConfigReader @@ -59,23 +62,38 @@ object BotConfiguration { def loadLinkListenerConfig(path: Path): LinkListenerConfig = ConfigSource.default(ConfigSource.file(path)) .at("link-listener").load[LinkListenerConfig] - .fold(failures => { - log.info(s"Could not load link-listener config, reason ${failures.toList.map(_.description)} Using default config.") - LinkListenerConfig(None, None, None, None) - }, success => success) + .fold( + failures => { + log.info( + s"Could not load link-listener config, reason ${failures.toList.map(_.description)} Using default config." + ) + LinkListenerConfig(None, None, None, None, None) + }, + success => success, + ) def loadAdminListenerConfig(path: Path): AdminListenerConfig = ConfigSource.default(ConfigSource.file(path)) .at("admin-listener").load[AdminListenerConfig] - .fold(failures => { - log.info(s"Could not load admin-listener config, reason ${failures.toList.map(_.description)} Using default config.") - AdminListenerConfig("", None) - }, success => success) - - def loadPronounListenerConfig(path: Path): PronounListenerConfig = ConfigSource.default(ConfigSource.file(path)) - .at("pronoun-listener").load[PronounListenerConfig] - .fold(failures => { - log.info(s"Could not load pronoun-listener config, reason ${failures.toList.map(_.description)} Using default config.") + .fold( + failures => { + log.info( + s"Could not load admin-listener config, reason ${failures.toList.map(_.description)} Using default config." + ) + AdminListenerConfig("", None) + }, + success => success, + ) + + def loadPronounListenerConfig(path: Path): PronounListenerConfig = ConfigSource.default(ConfigSource.file(path)) + .at("pronoun-listener").load[PronounListenerConfig] + .fold( + failures => { + log.info( + s"Could not load pronoun-listener config, reason ${failures.toList.map(_.description)} Using default config." + ) PronounListenerConfig("pronouns.txt") - }, success => success) + }, + success => success, + ) } diff --git a/src/main/scala/codes/co2/ircbot/listeners/links/LinkListener.scala b/src/main/scala/codes/co2/ircbot/listeners/links/LinkListener.scala index c594faf..12f32a2 100644 --- a/src/main/scala/codes/co2/ircbot/listeners/links/LinkListener.scala +++ b/src/main/scala/codes/co2/ircbot/listeners/links/LinkListener.scala @@ -27,13 +27,14 @@ class LinkListener(httpClient: HttpClient, config: LinkListenerConfig, generalCo ) extends GenericListener(generalConfig) { val log: Logger = LoggerFactory.getLogger(getClass) - implicit val httpSettings: ConnectionPoolSettings = if (config.useHttpProxy.getOrElse(false)) { - ConnectionPoolSettings(system) - .withConnectionSettings( - ClientConnectionSettings(system) - .withTransport(ClientTransport.httpsProxy()) - ) - } else ConnectionPoolSettings(system) + implicit val httpSettings: ConnectionPoolSettings = + if (config.useHttpProxy.getOrElse(false)) { + ConnectionPoolSettings(system) + .withConnectionSettings( + ClientConnectionSettings(system) + .withTransport(ClientTransport.httpsProxy()) + ) + } else ConnectionPoolSettings(system) val twitterClientOpt: Option[TwitterRestClient] = config.twitterApi.map { twitterApi => @@ -71,7 +72,15 @@ class LinkListener(httpClient: HttpClient, config: LinkListenerConfig, generalCo def send(title: String): Unit = { log.info(s"Sending $title to ${channel.getName}") - channel.send().message(s"$boldTag$title$normalTag") + + val lowerCaseTitle = title.toLowerCase() + + if (config.lowerCaseSpamList.exists(spamWord => lowerCaseTitle.contains(spamWord))) { + channel.send().message("Are you a spammer?") + } else { + channel.send().message(s"$boldTag$title$normalTag") + } + } if (lowerCase.contains("http://") || lowerCase.contains("https://")) { @@ -108,7 +117,8 @@ class LinkListener(httpClient: HttpClient, config: LinkListenerConfig, generalCo private def getAsTweetOpt(link: String): Option[Future[String]] = { for { - twitterClient <- twitterClientOpt // This order because don't even bother the regex if the twitterClient doesn't exist + twitterClient <- + twitterClientOpt // This order because don't even bother the regex if the twitterClient doesn't exist tweetId <- LinkParser.tryGetTwitterId(link) tweet = twitterClient.getTweet( tweetId, @@ -128,7 +138,8 @@ class LinkListener(httpClient: HttpClient, config: LinkListenerConfig, generalCo private def getAsYoutubeOpt(link: String): Option[Future[Option[String]]] = { for { - youtubeClient <- youtubeClientOpt // This order because don't even bother the regex if the youtubeClient doesn't exist + youtubeClient <- + youtubeClientOpt // This order because don't even bother the regex if the youtubeClient doesn't exist youtubeId <- LinkParser.tryGetYoutubeId(link) request = youtubeClient.client.videos().list(List("snippet").asJava) response = Future(request.setId(List(youtubeId).asJava).setKey(youtubeClient.key).execute())