Skip to content

Commit

Permalink
Merge pull request #8 from CO2-Codes/scala3-migration
Browse files Browse the repository at this point in the history
Migrated code to scala3 and upgraded dependencies.
  • Loading branch information
CO2-Codes authored Nov 29, 2021
2 parents 99aaef7 + 2f8217b commit 4798aa1
Show file tree
Hide file tree
Showing 8 changed files with 150 additions and 126 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
54 changes: 14 additions & 40 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,65 +1,39 @@
name := "Scala-IRC-bot"

ThisBuild / version := "1.1.1"
ThisBuild / version := "1.2.0"

scalaVersion := "2.13.5"
scalaVersion := "3.1.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",
"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",
// Use snapshot to prevent an issue with incompatible transitive dependencies.
"com.github.pircbotx" % "pircbotx" % "master-SNAPSHOT",
"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",
"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.2",
"com.google.apis" % "google-api-services-youtube" % "v3-rev20210915-1.32.1",
"org.scalatest" %% "scalatest" % "3.2.10" % "test",
),
)
.settings(assembly / test := {})
Expand Down
2 changes: 2 additions & 0 deletions example.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion project/assembly.sbt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.15.0")
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "1.1.0")
addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.5.3")
2 changes: 1 addition & 1 deletion project/build.properties
Original file line number Diff line number Diff line change
@@ -1 +1 @@
sbt.version=1.5.1
sbt.version=1.5.5
107 changes: 68 additions & 39 deletions src/main/scala/codes/co2/ircbot/config/BotConfiguration.scala
Original file line number Diff line number Diff line change
@@ -1,43 +1,57 @@
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],
spamList: Option[Seq[String]],
) derives ConfigReader {
val lowerCaseSpamList = spamList.map(_.map(_.toLowerCase)).getOrElse(Seq.empty)
}

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)
Expand All @@ -48,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,
)

}
31 changes: 21 additions & 10 deletions src/main/scala/codes/co2/ircbot/listeners/links/LinkListener.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down Expand Up @@ -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://")) {
Expand Down Expand Up @@ -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,
Expand All @@ -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())
Expand Down
76 changes: 42 additions & 34 deletions src/main/scala/codes/co2/ircbot/pircbotx/Main.scala
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()

}

0 comments on commit 4798aa1

Please sign in to comment.