diff --git a/.gitignore b/.gitignore index 1ee0cbe..ed5eb84 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,7 @@ target/ # IntelliJ -.idea \ No newline at end of file +.idea + +# Do not accidentally check in this file if the default example.conf is used and it's created here +pronouns.txt diff --git a/README.md b/README.md index 03d5c08..877ed81 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ To run the bot without making a jar, use `sbt "run "` This bot is tested on openJDK 11. -In the configuration file, the array of `bot-admins` is used to decide who is allowed to run admin-only commands (currently - only !quit in the adminListener). The separate `puppet-masters` array is used to decide who may puppet the bot. +In the configuration file, the array of `bot-admins` is used to decide who is allowed to run admin-only commands. +The separate `puppet-masters` array is used to decide who may puppet the bot. The `listeners` array is the most important. This decides which functionality your instance of the bot will have. Every listener has an ignore-channels settings which can be used to ignore all messages from those channels for that @@ -32,3 +32,12 @@ given channel or anything, use at your discretion. ### linkListener This listener reacts to messages and actions containing http/https links. It attempts to retrieve the tag in html pages and if it can find one, it will send the title to the channel. + +### pronounListener +This listener stores users' personal pronouns (he, she, they, it, other) and can be used to look up the pronouns. It +writes these into a file to keep them between restarts. + +### User documentation +If you want to run your own instance of the bot please host your own documentation specific to that instance. +Feel free to use [Isaac's documentation](https://co2.codes/xkcd/isaac-docs.php) (the original instance of this bot) +as a basis for your own. \ No newline at end of file diff --git a/build.sbt b/build.sbt index bfb0c4e..b96e893 100644 --- a/build.sbt +++ b/build.sbt @@ -1,8 +1,8 @@ name := "Scala-IRC-bot" -version := "0.2.3" +version := "0.3.0" -scalaVersion := "2.13.0" +scalaVersion := "2.13.1" // Recommended flags from https://tpolecat.github.io/2017/04/25/scalac-flags.html (Removed scala 2.13 deprecated flags) scalacOptions ++= Seq( @@ -43,24 +43,24 @@ scalacOptions ++= Seq( "-Ywarn-unused:privates", // Warn if a private member is unused. "-Ywarn-value-discard", // Warn when non-Unit expression results are unused. ) -lazy val silencerVersion = "1.4.2" +lazy val silencerVersion = "1.6.0" lazy val ircBot = project.in(file(".")) .settings( libraryDependencies += compilerPlugin( - "com.github.ghik" %% "silencer-plugin" % silencerVersion + "com.github.ghik" %% "silencer-plugin" % silencerVersion cross CrossVersion.full ), libraryDependencies ++= Seq( "org.pircbotx" % "pircbotx" % "2.1", - "com.github.pureconfig" %% "pureconfig" % "0.12.2", + "com.github.pureconfig" %% "pureconfig" % "0.12.3", "ch.qos.logback" % "logback-classic" % "1.2.3", "com.typesafe.akka" %% "akka-http" % "10.1.11", - "com.typesafe.akka" %% "akka-actor" % "2.6.3", - "com.typesafe.akka" %% "akka-stream" % "2.6.3", + "com.typesafe.akka" %% "akka-actor" % "2.6.4", + "com.typesafe.akka" %% "akka-stream" % "2.6.4", "org.apache.commons" % "commons-text" % "1.8", - "com.github.ghik" %% "silencer-lib" % silencerVersion, - "org.scalatest" %% "scalatest" % "3.1.0" % "test", + "com.github.ghik" %% "silencer-lib" % silencerVersion % Provided cross CrossVersion.full, + "org.scalatest" %% "scalatest" % "3.1.1" % "test", ) ) diff --git a/example.conf b/example.conf index bcba1cb..2e45709 100644 --- a/example.conf +++ b/example.conf @@ -11,18 +11,26 @@ bot-configuration { channels = ["#array", "#of", "#channels"] finger-msg = "Some message" // Optional listeners = ["adminListener", "linkListener"] // Most important setting, decides which functionality is enabled. - ignore = ["nickname1", "nickname2"] // Optional, nicknames the bot should ignore + + general-config { + ignore-nicks = ["nickname1", "nickname2"] // Optional, nicknames the bot should ignore + ignore-channels = ["#of"] // Optional, channels the bot should ignore. Useful if you want to use it to just keep a channel alive. + bot-admins = ["Array", "of", "nicknames"] // Decides who can use admin-only commands. + } + + } // Specific configurations per listener, only needed if the listener is enabled. admin-listener { help-text = "Some help text" - bot-admins = ["Array", "of", "nicknames"] // Decides who can use admin-only commands. puppet-masters = ["nicknames"] // Decides who can use !say and !act in PM. Optional. - ignore-channels = ["#of"] // Channels ignored by this listener. Optional. } link-listener { bold-titles = true // Optional, sets whether titles sent to channel by this bot should be bold or not. - ignore-channels = ["#of"] // Channels ignored by this listener. Optional. +} + +pronoun-listener { + file-path = "pronouns.txt" } \ 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 05c3811..cbb26da 100644 --- a/src/main/scala/codes/co2/ircbot/config/BotConfiguration.scala +++ b/src/main/scala/codes/co2/ircbot/config/BotConfiguration.scala @@ -14,25 +14,25 @@ case class BotConfiguration( channels: Seq[String], fingerMsg: Option[String], listeners: Seq[String], - ignore: Option[Seq[String]], + generalConfig: GeneralConfig, ) case class Connection(serverName: String, port: Int, ssl: Boolean) -trait ListenerConfig { - val ignoreChannels: Option[Seq[String]] +case class GeneralConfig( + ignoreNicks: Option[Seq[String]], + ignoreChannels: Option[Seq[String]], + botAdmins: Seq[String], + ) { val ignoredChannels: Seq[String] = ignoreChannels.getOrElse(Seq.empty) + val ignoredNicks: Seq[String] = ignoreNicks.getOrElse(Seq.empty) } -case class LinkListenerConfig( - boldTitles: Option[Boolean], - ignoreChannels: Option[Seq[String]]) extends ListenerConfig +case class LinkListenerConfig(boldTitles: Option[Boolean]) -case class AdminListenerConfig( - helpText: String, - botAdmins: Seq[String], - puppetMasters: Option[Seq[String]], - ignoreChannels: Option[Seq[String]]) extends ListenerConfig +case class AdminListenerConfig(helpText: String, puppetMasters: Option[Seq[String]]) + +case class PronounListenerConfig(filePath: String) object BotConfiguration { val log: Logger = LoggerFactory.getLogger(getClass) @@ -45,15 +45,21 @@ object BotConfiguration { .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) + LinkListenerConfig(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("", Seq.empty, None, None) + 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) } diff --git a/src/main/scala/codes/co2/ircbot/listeners/GenericListener.scala b/src/main/scala/codes/co2/ircbot/listeners/GenericListener.scala index 7926d99..d4e8890 100644 --- a/src/main/scala/codes/co2/ircbot/listeners/GenericListener.scala +++ b/src/main/scala/codes/co2/ircbot/listeners/GenericListener.scala @@ -1,13 +1,16 @@ package codes.co2.ircbot.listeners -import codes.co2.ircbot.config.ListenerConfig +import codes.co2.ircbot.config.GeneralConfig import com.github.ghik.silencer.silent +import org.pircbotx.PircBotX import org.pircbotx.hooks.ListenerAdapter import org.pircbotx.hooks.events.{ActionEvent, MessageEvent, PrivateMessageEvent} -import org.pircbotx.hooks.types.GenericChannelUserEvent +import org.pircbotx.hooks.types.{GenericChannelUserEvent, GenericEvent} -abstract class GenericListener(config: ListenerConfig, nicksToIgnore: Seq[String]) extends ListenerAdapter { +abstract class GenericListener(config: GeneralConfig) extends ListenerAdapter { private val channelsToIgnore = config.ignoredChannels + private val nicksToIgnore = config.ignoredNicks + protected val admins: Seq[String] = config.botAdmins private def sentInIgnoredChannel(event: GenericChannelUserEvent): Boolean = { // Wrap in an option because getChannel returns null for PM action events. @@ -32,3 +35,12 @@ abstract class GenericListener(config: ListenerConfig, nicksToIgnore: Seq[String if (!nicksToIgnore.contains(event.getUser.getNick) && !sentInIgnoredChannel(event)) onAcceptedUserMsg(event) } } + +object GenericListener { + + // Scala doesn't seem to like Java's Generics very much... + def getBot(event: GenericEvent): PircBotX = { + event.getBot[PircBotX] + } + +} \ No newline at end of file diff --git a/src/main/scala/codes/co2/ircbot/listeners/administration/AdminListener.scala b/src/main/scala/codes/co2/ircbot/listeners/administration/AdminListener.scala index 4728ffb..723d0c3 100644 --- a/src/main/scala/codes/co2/ircbot/listeners/administration/AdminListener.scala +++ b/src/main/scala/codes/co2/ircbot/listeners/administration/AdminListener.scala @@ -1,49 +1,50 @@ package codes.co2.ircbot.listeners.administration import akka.actor.ActorSystem -import codes.co2.ircbot.config.AdminListenerConfig +import codes.co2.ircbot.config.{AdminListenerConfig, GeneralConfig} import codes.co2.ircbot.listeners.GenericListener +import codes.co2.ircbot.listeners.GenericListener._ import org.pircbotx.PircBotX -import org.pircbotx.hooks.Event import org.pircbotx.hooks.events.{ConnectEvent, MessageEvent, PrivateMessageEvent} -class AdminListener(config: AdminListenerConfig, nicksToIgnore: Seq[String], actorSystem: ActorSystem) extends GenericListener(config, nicksToIgnore) { +class AdminListener(config: AdminListenerConfig, generalConfig: GeneralConfig)(implicit actorSystem: ActorSystem) extends GenericListener(generalConfig) { private val puppetMasters = config.puppetMasters.getOrElse(Seq.empty) - // Scala doesn't seem to like Java's Generics very much... - private def getBot(event: Event): PircBotX = { - event.getBot[PircBotX] - } - override def onConnect(event: ConnectEvent): Unit = { // Set IRC server bot user mode getBot(event).send().mode(getBot(event).getNick, "+B") } override def onAcceptedUserPrivateMsg(event: PrivateMessageEvent): Unit = { - event.getMessage match { - case "!quit" if config.botAdmins.contains(event.getUser.getNick) && event.getUser.isVerified => - shutdown(getBot(event)) - - case msg if puppetMasters.contains(event.getUser.getNick) && event.getUser.isVerified && - (msg.startsWith("!say") || msg.startsWith("!act")) => - val command = msg.split(" ", 3) - command.headOption match { - case Some("!say") if command.sizeIs >= 2 => - getBot(event).send().message(command(1), command(2)) - case Some("!act") if command.sizeIs >= 2 => - getBot(event).send().action(command(1), command(2)) - } + if (event.getMessage.startsWith("!")) { + event.getMessage match { + case "!help" => event.respondWith(config.helpText) + case "!quit" if admins.contains(event.getUser.getNick) && event.getUser.isVerified => + shutdown(getBot(event)) + + case msg if puppetMasters.contains(event.getUser.getNick) && event.getUser.isVerified && + (msg.startsWith("!say") || msg.startsWith("!act")) => + val command = msg.split(" ", 3) + command.headOption match { + case Some("!say") if command.sizeIs >= 2 => + getBot(event).send().message(command(1), command(2)) + case Some("!act") if command.sizeIs >= 2 => + getBot(event).send().action(command(1), command(2)) + } + } } } override def onAcceptedUserMsg(event: MessageEvent): Unit = { - event.getMessage match { - case string if string.equalsIgnoreCase("botsnack") => event.getChannel.send().message(":D") - case "!help" => event.getChannel.send().message(config.helpText) - case "!quit" if config.botAdmins.contains(event.getUser.getNick) && event.getUser.isVerified => - shutdown(getBot(event)) + if (event.getMessage.startsWith("!")) { + event.getMessage match { + case "!help" => event.respondWith(config.helpText) + case "!quit" if admins.contains(event.getUser.getNick) && event.getUser.isVerified => + shutdown(getBot(event)) + } + } else if (event.getMessage.equalsIgnoreCase("botsnack")) { + event.getChannel.send().message(":D") } } 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 a83395b..e2fa0e2 100644 --- a/src/main/scala/codes/co2/ircbot/listeners/links/LinkListener.scala +++ b/src/main/scala/codes/co2/ircbot/listeners/links/LinkListener.scala @@ -1,6 +1,6 @@ package codes.co2.ircbot.listeners.links -import codes.co2.ircbot.config.LinkListenerConfig +import codes.co2.ircbot.config.{GeneralConfig, LinkListenerConfig} import codes.co2.ircbot.http.HttpClient import codes.co2.ircbot.listeners.GenericListener import org.pircbotx.hooks.events.{ActionEvent, MessageEvent} @@ -10,7 +10,7 @@ import org.slf4j.{Logger, LoggerFactory} import scala.concurrent.ExecutionContext -class LinkListener(httpClient: HttpClient, config: LinkListenerConfig, nicksToIgnore: Seq[String])(implicit ec: ExecutionContext) extends GenericListener(config, nicksToIgnore) { +class LinkListener(httpClient: HttpClient, config: LinkListenerConfig, generalConfig: GeneralConfig)(implicit ec: ExecutionContext) extends GenericListener(generalConfig) { val log: Logger = LoggerFactory.getLogger(getClass) diff --git a/src/main/scala/codes/co2/ircbot/listeners/pronouns/PronounListener.scala b/src/main/scala/codes/co2/ircbot/listeners/pronouns/PronounListener.scala new file mode 100644 index 0000000..354656b --- /dev/null +++ b/src/main/scala/codes/co2/ircbot/listeners/pronouns/PronounListener.scala @@ -0,0 +1,142 @@ +package codes.co2.ircbot.listeners.pronouns + +import java.io.File + +import akka.actor.{ActorRef, ActorSystem} +import codes.co2.ircbot.config.{GeneralConfig, PronounListenerConfig} +import codes.co2.ircbot.listeners.GenericListener +import codes.co2.ircbot.listeners.GenericListener._ +import codes.co2.ircbot.listeners.pronouns.PronounListener._ +import codes.co2.ircbot.pronouns.PronounsActor +import codes.co2.ircbot.pronouns.PronounsActor.Contract._ +import codes.co2.ircbot.pronouns.PronounsActor.Pronoun +import org.pircbotx.hooks.WaitForQueue +import org.pircbotx.hooks.events.{MessageEvent, PrivateMessageEvent, WhoisEvent} +import org.pircbotx.hooks.types.GenericMessageEvent +import org.slf4j.{Logger, LoggerFactory} + +import scala.annotation.tailrec +import scala.concurrent.duration._ +import scala.concurrent.{Await, ExecutionContext, Future} +import scala.util.Try +import scala.util.control.NonFatal + +class PronounListener(config: PronounListenerConfig, generalConfig: GeneralConfig)(implicit as: ActorSystem) extends GenericListener(generalConfig) { + + val pronounsActor: ActorRef = as.actorOf(PronounsActor.props(new File(config.filePath))) + + implicit val ec: ExecutionContext = as.dispatcher + + override def onAcceptedUserPrivateMsg(event: PrivateMessageEvent): Unit = { + onAcceptedMsg(event) + } + + override def onAcceptedUserMsg(event: MessageEvent): Unit = { + onAcceptedMsg(event) + } + + private def onAcceptedMsg(event: GenericMessageEvent): Unit = { + if (event.getMessage.startsWith("!pronouns-admin")) { + handleAdminEvent(event: GenericMessageEvent) + } + + if (event.getMessage.startsWith("!pronouns")) { + val msg = event.getMessage + + val splitByWords = msg.split(" ") + + splitByWords.head match { + case "!pronouns" if splitByWords.length > 1 => pronounsActor ! Get(splitByWords(1), event) + + case "!pronouns-add" if splitByWords.length > 1 => + getPronoun(splitByWords(1), event) + .foreach(pronoun => getNickservNick(event) + .foreach(nick => pronounsActor ! Add(nick, pronoun, event))) + + case "!pronouns-remove" if splitByWords.length > 1 => + getPronoun(splitByWords(1), event) + .foreach(pronoun => getNickservNick(event) + .foreach(nick => pronounsActor ! Remove(nick, pronoun, event))) + + case "!pronouns-forget" => + getNickservNick(event).foreach(nick => pronounsActor ! Forget(nick, event)) + + } + + } + } + + private def handleAdminEvent(event: GenericMessageEvent): Unit = { + if (admins.contains(event.getUser.getNick) && event.getUser.isVerified) { + val msg = event.getMessage + + val splitByWords = msg.split(" ") + + splitByWords.head match { + case "!pronouns-admin-add" if splitByWords.length > 2 => + getPronoun(splitByWords(1), event).foreach(pronoun => pronounsActor ! Add(splitByWords(2), pronoun, event)) + + case "!pronouns-admin-remove" if splitByWords.length > 2 => + getPronoun(splitByWords(1), event).foreach(pronoun => pronounsActor ! Remove(splitByWords(2), pronoun, event)) + + case "!pronouns-admin-forget" if splitByWords.length > 1 => + pronounsActor ! Forget(splitByWords(1), event) + } + } + + } + + private def getPronoun(pronoun: String, event: GenericMessageEvent): Option[Pronoun] = { + Pronoun.fromString(pronoun).orElse { + event.respondWith(s"Unsupported pronoun. Use one of: ${Pronoun.validPronouns}") + None + } + } +} + +object PronounListener { + + val log: Logger = LoggerFactory.getLogger(getClass) + + private def getNickservNick(event: GenericMessageEvent)(implicit as: ActorSystem): Option[String] = { + + implicit val ec: ExecutionContext = as.dispatcher + + event.getUser.send.whoisDetail() + + Try { + + val registeredNickname = Await.result(Future(tailRecWaitForWhoisResponse(new WaitForQueue(getBot(event)), event.getUser.getNick)), 10.seconds) + if (registeredNickname.isEmpty) { + event.respond("You are not logged in to NickServ. For security reasons, you cannot edit your pronouns.") + } + + registeredNickname + + }.recover { + case NonFatal(ex) => + log.warn("Got a timeout on waiting for whois", ex) + None + + }.get + + } + + @tailrec + private def tailRecWaitForWhoisResponse(waitForQueue: WaitForQueue, nick: String): Option[String] = { + + val whoisEvent = waitForQueue.waitFor(classOf[WhoisEvent]) + + if (whoisEvent.getNick == nick) { + //Got our event + waitForQueue.close() + Option(whoisEvent.getRegisteredAs) // can be null, so wrap in an option + // Handle the case where the server does not explicitly send the nickserv nick. Nickserv instances that have this + // normally do not support staying logged in when changing nicks, so using the regular nick is okay in that case. + .map(nickServNick => if (nickServNick.isEmpty) whoisEvent.getNick else nickServNick) + } else { + tailRecWaitForWhoisResponse(waitForQueue, nick) + } + } + +} diff --git a/src/main/scala/codes/co2/ircbot/pircbotx/pircbotx.scala b/src/main/scala/codes/co2/ircbot/pircbotx/pircbotx.scala index 870a9bd..d480252 100644 --- a/src/main/scala/codes/co2/ircbot/pircbotx/pircbotx.scala +++ b/src/main/scala/codes/co2/ircbot/pircbotx/pircbotx.scala @@ -7,6 +7,7 @@ import codes.co2.ircbot.config.BotConfiguration import codes.co2.ircbot.http.HttpClient import codes.co2.ircbot.listeners.administration.AdminListener import codes.co2.ircbot.listeners.links.LinkListener +import codes.co2.ircbot.listeners.pronouns.PronounListener import org.pircbotx.hooks.Listener import org.pircbotx.{Configuration, UtilSSLSocketFactory} @@ -26,11 +27,11 @@ package object pircbotx { } def addListeners(config: BotConfiguration, configPath: Path)(implicit ac: ActorSystem, ec: ExecutionContext): Configuration.Builder = { - val ignoreList = config.ignore.getOrElse(Seq.empty) val listeners: Seq[Listener] = config.listeners.map{ - case "adminListener" => new AdminListener(BotConfiguration.loadAdminListenerConfig(configPath), ignoreList, ac) - case "linkListener" => new LinkListener(new HttpClient, BotConfiguration.loadLinkListenerConfig(configPath), ignoreList) + case "adminListener" => new AdminListener(BotConfiguration.loadAdminListenerConfig(configPath), config.generalConfig) + case "linkListener" => new LinkListener(new HttpClient, BotConfiguration.loadLinkListenerConfig(configPath), config.generalConfig) + case "pronounListener" => new PronounListener(BotConfiguration.loadPronounListenerConfig(configPath), config.generalConfig) case other => throw new Exception(s"$other is not a valid listener type.") } diff --git a/src/main/scala/codes/co2/ircbot/pronouns/PronounsActor.scala b/src/main/scala/codes/co2/ircbot/pronouns/PronounsActor.scala new file mode 100644 index 0000000..9fc1051 --- /dev/null +++ b/src/main/scala/codes/co2/ircbot/pronouns/PronounsActor.scala @@ -0,0 +1,180 @@ +package codes.co2.ircbot.pronouns + +import java.io.{BufferedWriter, File, FileWriter} + +import akka.actor.{Actor, ActorLogging, Props} +import codes.co2.ircbot.pronouns.PronounsActor.Pronoun +import org.pircbotx.hooks.types.GenericMessageEvent + +import scala.io.Source + +class PronounsActor(file: File) extends Actor with ActorLogging { + + import PronounsActor.Contract._ + + private def pronounsToString(name: String, pronouns: Set[Pronoun]) = { + if (pronouns.isEmpty) { + s"$name has no pronouns set." + } else { + val pronounString = pronouns.foldLeft("") { case (soFar, next) => s"$soFar ${next.description}," }.init + s"$name's pronouns are:$pronounString." + } + } + + override def preStart(): Unit = { + self ! Init + } + + override def receive: Receive = { + case Init => + val newFileCreated = file.createNewFile() + if (newFileCreated) { + log.info("PronounsActor file did not yet exist, created.") + } + + val source = Source.fromFile(file) + + val pronounMap = source.getLines().map { rawLine => + val split = rawLine.split(" ") + split.head -> split(1) + + }.toMap.view.mapValues { rawValue => + rawValue.flatMap(pronounLetter => Pronoun.validPronouns.find(pronoun => pronoun.letter == pronounLetter)).toSet + }.toMap + + log.info(s"Read ${pronounMap.size} users with pronouns from file.") + + source.close() + + context.become(running(pronounMap)) + case other => log.error(s"Got non-init message before initiating. $other") + } + + private def updateState(newState: Map[String, Set[Pronoun]]): Map[String, Set[Pronoun]] = { + writeAsNewFile(newState) + context.become(running(newState)) + newState + } + + // Yeah this is not optimized but it should work for now. + private def writeAsNewFile(state: Map[String, Set[Pronoun]]): Unit = { + val lines = state.map(Pronoun.convertToStrForFile) + file.delete() + file.createNewFile() + val writer = new BufferedWriter(new FileWriter(file)) + lines.foreach(line => writer.write(line)) + writer.close() + } + + def running(state: Map[String, Set[Pronoun]]): Receive = { + case Get(name, resp) => resp.respondWith(pronounsToString(name, state.getOrElse(name.toLowerCase, Set.empty))) + + case Add(name, pronoun, resp) => + val currentPronouns = state.getOrElse(name.toLowerCase, Set.empty) + if (currentPronouns.contains(pronoun)) { + resp.respondWith("I already had it that way.") + } else { + val newPronouns = currentPronouns + pronoun + + updateState(state.updated(name.toLowerCase, newPronouns)) + + resp.respondWith(s"Okay, ${pronounsToString(name, newPronouns)}") + } + + case Remove(name, pronoun, resp) => + val currentPronouns = state.getOrElse(name.toLowerCase, Set.empty) + if (!currentPronouns.contains(pronoun)) { + resp.respondWith("I already had it that way.") + } else { + val newPronouns = currentPronouns - pronoun + + updateState(state.updated(name.toLowerCase, newPronouns)) + + resp.respondWith(s"Okay, ${pronounsToString(name, newPronouns)}") + } + + case Forget(name, resp) => + if (state.contains(name.toLowerCase)) { + + updateState(state.removed(name.toLowerCase)) + + resp.respondWith(s"Okay, I have forgotten all pronoun info for $name.") + } else { + resp.respondWith(s"I already don't have any pronoun info for $name.") + } + + case Init => log.warning("Actor was already initiated. Ignoring this message.") + + } +} + + +object PronounsActor { + + sealed trait Contract + + object Contract { + + private[PronounsActor] case object Init extends Contract + + case class Get(name: String, event: GenericMessageEvent) extends Contract + case class Add(name: String, pronoun: Pronoun, event: GenericMessageEvent) extends Contract + case class Remove(name: String, pronoun: Pronoun, event: GenericMessageEvent) extends Contract + case class Forget(name: String, event: GenericMessageEvent) extends Contract + + } + + def props(file: File): Props = Props(new PronounsActor(file)) + + sealed trait Pronoun { + val letter: Char + val description: String + } + + object Pronoun { + + case object He extends Pronoun { + override val letter: Char = 'm' // male + override val description: String = "he/him" + } + + case object She extends Pronoun { + override val letter: Char = 'f' // female + override val description: String = "she/her" + } + + case object They extends Pronoun { + override val letter: Char = 'n' // neutral + override val description: String = "they/them" + } + + case object It extends Pronoun { + override val letter: Char = 'i' // inanimate + override val description: String = "it/its" + } + + case object Other extends Pronoun { + override val letter: Char = 'o' // other + override val description: String = "other, please ask" + } + + val validPronouns: Seq[Pronoun] = Seq(He, She, They, It, Other) + + def fromString(string: String): Option[Pronoun] = { + string.toLowerCase match { + case "he" => Some(He) + case "she" => Some(She) + case "they" => Some(They) + case "it" => Some(It) + case "other" => Some(Other) + case _ => None + } + } + + private[pronouns] def convertToStrForFile(entry: (String, Set[Pronoun])): String = { + val pronouns = new String(entry._2.map(pr => pr.letter).toArray) + s"${entry._1} $pronouns\n" + } + + } +}