From d24e5a70b9f54c7e2e98bbae6bdf6f9fc8469c90 Mon Sep 17 00:00:00 2001 From: AmirMuratov Date: Mon, 2 Apr 2018 15:24:45 +0300 Subject: [PATCH] Initial commit --- .gitignore | 4 + README.md | 57 +++++ build.sbt | 10 + project/build.properties | 1 + .../scala/bot/twitchirc/TCPClientActor.scala | 79 +++++++ .../scala/bot/twitchirc/TwitchIRCActor.scala | 198 ++++++++++++++++++ .../bot/twitchirc/messages/Message.scala | 37 ++++ .../twitchirc/messages/MessageParser.scala | 88 ++++++++ 8 files changed, 474 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build.sbt create mode 100644 project/build.properties create mode 100644 src/main/scala/bot/twitchirc/TCPClientActor.scala create mode 100644 src/main/scala/bot/twitchirc/TwitchIRCActor.scala create mode 100644 src/main/scala/bot/twitchirc/messages/Message.scala create mode 100644 src/main/scala/bot/twitchirc/messages/MessageParser.scala diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ca519e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target/ +.idea/ +src/main/resources +src/main/scala/example \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..16f1691 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# Twitch IRC +Twitch irc implementation on scala using akka actors. Twitch server API described [here](https://dev.twitch.tv/docs/irc) +### Usage +Twitch bot is an actor which sends messages about all events to its listeners. +### Messages that can be sent to TwitchIRCActor +* ```Join(channelName: String)``` +* ```Part(channelName: String)``` +* ```SendMessage(channelName: String, message: String)``` +* ```SendWhisper(userName: String, message: String)``` +* ```AddListener(listener: ActorRef)``` +* ```DeleteListener(listener: ActorRef)``` +### Events +if ```tags``` is disabled then ```tags``` are empty + +success/fail log in events: +* ```ConnectionFailed(err: String)``` +* ```Connected``` + +event after logging in(contains information about user in ```tags```) +* ```GlobalUserState(tags: Map[String, String])``` + +message events: +* ```IncomingMessage(tags: Map[String, String], user: String, channel: String, message: String)``` +* ```IncomingWhisper(tags: Map[String, String], user: String, message: String)``` + +sent only if ```membership``` is enabled: +* ```UserJoinedChannel(user: String, channel: String)``` +* ```UserLeftChannel(user: String, channel: String)``` +* ```UserGainModeMessage(user: String, channel: String)``` +* ```UserLostModeMessage(user: String, channel: String)``` +* ```ChannelUserList(channel: String, users: Seq[String])``` + +sent only if ```commands``` is enabled: +* ```UserBan(tags: Map[String, String], channel: String, user: String)``` +* ```HostStart(channel: String, hostChannel: String, viewers: Option[Int])``` +* ```HostStop(channel: String, viewers: Option[Int])``` +* ```Notice(tags: Map[String, String], channel: String, message: String)``` +* ```RoomState(tags: Map[String, String], channel: String)``` +* ```UserNotice(tags: Map[String, String], channel: String, message: String)``` +* ```UserState(tags: Map[String, String], channel: String)``` + +### Creating example +Code inside actor: +``` +import bot.twitchirc.TwitchIRCActor +import bot.twitchirc.TwitchIRCActor.{AddListener, TwitchIRCProps} + +val twitchIRCActor = context.actorOf(TwitchIRCActor.props(nick, oauth, + props = TwitchIRCProps(membership = true, tags = true, commands = true)), "TwitchIRCActor") + twitchIRCActor ! AddListener(self) +``` +```nick``` - twitch login +```oauth``` - can be obtained [here](https://twitchapps.com/tmi/) +### TwitchIRCProps - case class with properties: +* membership - server send information about users join/left. Described [here](https://dev.twitch.tv/docs/irc#twitch-irc-capability-membership). +* tags - additional information about users/events in messages. Described [here](https://dev.twitch.tv/docs/irc#twitch-irc-capability-tags). +* commands - adds additional commands. Described [here](https://dev.twitch.tv/docs/irc#twitch-irc-capability-commands). \ No newline at end of file diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..6aab859 --- /dev/null +++ b/build.sbt @@ -0,0 +1,10 @@ +name := "TwitchIRC" + +version := "0.1" + +scalaVersion := "2.12.5" + +libraryDependencies ++= Seq( + "com.typesafe.akka" %% "akka-actor" % "2.5.11", + "com.typesafe.akka" %% "akka-testkit" % "2.5.11" % Test +) \ No newline at end of file diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..44fcb4a --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version = 1.1.2 \ No newline at end of file diff --git a/src/main/scala/bot/twitchirc/TCPClientActor.scala b/src/main/scala/bot/twitchirc/TCPClientActor.scala new file mode 100644 index 0000000..cc9eb8c --- /dev/null +++ b/src/main/scala/bot/twitchirc/TCPClientActor.scala @@ -0,0 +1,79 @@ +package bot.twitchirc + +import java.net.InetSocketAddress + +import akka.actor.{Actor, ActorLogging, Props} +import akka.io.{IO, Tcp} +import akka.util.ByteString +import bot.twitchirc.TCPClientActor.{CloseConnection, DataReceived, SendData, WriteFailed, _} + +/** + * Created by amir. + */ +class TCPClientActor(remote: InetSocketAddress) extends Actor with ActorLogging { + + import Tcp._ + import context.system + + log.info(s"trying to connect ${remote.getHostName}:${remote.getPort} ") + private val manager = IO(Tcp) + manager ! Connect(remote) + + private var buffer: String = "" + + override def postStop(): Unit = { + manager ! Close + } + + private val delimiter = List(13, 10).map(_.toChar).mkString + + def receive: Receive = { + case CommandFailed(_: Connect) ⇒ + context.parent ! ConnectionFailed("can't connect to server") + context stop self + case Connected(_, _) ⇒ + context.parent ! TCPClientActor.Connected + val connection = sender() + connection ! Register(self) + context become { + case SendData(data) ⇒ + //log.info(s"sending: $data") + connection ! Write(ByteString(data + "\n")) + case CloseConnection ⇒ + connection ! Close + context stop self + + case CommandFailed(w: Write) ⇒ + // O/S buffer was full + context.parent ! WriteFailed(w.data.toString()) + case Received(data) ⇒ + val split = (buffer ++ data.utf8String).split(delimiter, -1) + buffer = split.last + split.init foreach { line => + //log.info(s"received: $line") + context.parent ! DataReceived(line) + } + } + } +} + +object TCPClientActor { + def props(remote: InetSocketAddress) = + Props(classOf[TCPClientActor], remote) + + //send result of connecting to parent + case object Connected + + case class ConnectionFailed(message: String) + + //messages sent to listener on events + case class DataReceived(data: String) + + case class WriteFailed(data: String) + + //messages that can be sent + case class SendData(data: String) + + case object CloseConnection + +} \ No newline at end of file diff --git a/src/main/scala/bot/twitchirc/TwitchIRCActor.scala b/src/main/scala/bot/twitchirc/TwitchIRCActor.scala new file mode 100644 index 0000000..55967b9 --- /dev/null +++ b/src/main/scala/bot/twitchirc/TwitchIRCActor.scala @@ -0,0 +1,198 @@ +package bot.twitchirc + +import java.net.InetSocketAddress + +import akka.actor.{Actor, ActorLogging, ActorRef, Cancellable, Props} +import bot.twitchirc.TCPClientActor.{Connected, ConnectionFailed, DataReceived, SendData} +import bot.twitchirc.TwitchIRCActor._ +import bot.twitchirc.messages.Message._ +import bot.twitchirc.messages.MessageParser + +import scala.concurrent.duration._ + +/** + * Created by amir. + */ +class TwitchIRCActor(address: InetSocketAddress, nick: String, oauth: String, props: TwitchIRCProps) extends Actor with ActorLogging { + + import context.dispatcher + + assert(nick.forall(!_.isUpper)) + + private val respondTimeout = 30 seconds + + private val successfulConnectionMessage = s":tmi.twitch.tv 376 $nick :>" + private val successfulMembership = ":tmi.twitch.tv CAP * ACK :twitch.tv/membership" + private val successfulTags = ":tmi.twitch.tv CAP * ACK :twitch.tv/tags" + private val successfulCommands = ":tmi.twitch.tv CAP * ACK :twitch.tv/commands" + + private val tcpClientActor = context.actorOf(TCPClientActor.props(address), "TCPClient") + private var timeoutTerminator: Cancellable = _ + private var membershipTimeout: Cancellable = _ + private var tagsTimeout: Cancellable = _ + private var commandsTimeout: Cancellable = _ + private val messageParser = new MessageParser(nick) + private var listeners: Set[ActorRef] = Set.empty + + private var sentMessages: Map[(String, String), Cancellable] = Map.empty + private var joins: Map[String, Cancellable] = Map.empty + private var parts: Map[String, Cancellable] = Map.empty + private var channelsToUsers: Map[String, Seq[String]] = Map.empty + + override def preStart(): Unit = { + log.info("starting TwitchIrc") + } + + override def receive: Receive = { + case err@ConnectionFailed(_) => + log.error("Connection to twitch server failed. Shutting down.") + listeners.foreach(_ ! err) + context stop self + case Connected => + log.info("Successfully connected to twitch server") + if (props.membership) { + tcpClientActor ! SendData("CAP REQ :twitch.tv/membership") + membershipTimeout = context.system.scheduler.scheduleOnce(respondTimeout)({ + log.error("Twitch server doesn't respond for membership capability request") + }) + } + if (props.tags) { + tcpClientActor ! SendData("CAP REQ :twitch.tv/tags") + tagsTimeout = context.system.scheduler.scheduleOnce(respondTimeout)({ + log.error("Twitch server doesn't respond for tags capability request") + }) + } + if (props.commands) { + tcpClientActor ! SendData("CAP REQ :twitch.tv/commands") + commandsTimeout = context.system.scheduler.scheduleOnce(respondTimeout)({ + log.error("Twitch server doesn't respond for commands capability request") + }) + } + //try to login + tcpClientActor ! SendData(s"PASS $oauth") + tcpClientActor ! SendData(s"NICK $nick") + timeoutTerminator = context.system.scheduler.scheduleOnce(respondTimeout)({ + log.error("Connected to server, but can't log in") + listeners.foreach(_ ! ConnectionFailed("Connected to server, but can't log in")) + context stop self + }) + case DataReceived(`successfulMembership`) if props.membership => + membershipTimeout.cancel() + log.info("membership capability included") + case DataReceived(`successfulTags`) if props.tags => + tagsTimeout.cancel() + log.info("tags capability included") + case DataReceived(`successfulCommands`) if props.commands => + commandsTimeout.cancel() + log.info("commands capability included") + case DataReceived(`successfulConnectionMessage`) => + timeoutTerminator.cancel() + log.info("Successfully logged in") + self ! Join("#" + nick) + context become commandParser + listeners.foreach(_ ! Connected) + case AddListener(listener: ActorRef) => listeners += listener + case DeleteListener(listener: ActorRef) => listeners -= listener + } + + private def commandParser: Receive = { + case DataReceived(line) => + messageParser.parseLine(line) match { + case Ping => + tcpClientActor ! SendData("PONG :tmi.twitch.tv") + case JoinConfirmation(channel) => + joins.get(channel).foreach(_.cancel()) + joins -= channel + case PartConfirmation(channel) => + parts.get(channel).foreach(_.cancel()) + parts -= channel + case MessageDeliverConfirmation(channel, message) => + sentMessages.get((channel, message)).foreach(_.cancel()) + sentMessages -= ((channel, message)) + + case ChannelUserList(channel, users) => + channelsToUsers += channel -> (users ++ channelsToUsers.getOrElse(channel, Seq())) + case EndOfUserList(channel) => + listeners.foreach(_ ! ChannelUserList(channel, channelsToUsers.getOrElse(channel, Seq()))) + channelsToUsers -= channel + + case msg@(UserJoinedChannel(_, _) + | UserLeftChannel(_, _) + | UserGainModeMessage(_, _) + | UserLostModeMessage(_, _) + | IncomingMessage(_, _, _, _) + | UserBan(_, _, _) + | GlobalUserState(_) + | RoomState(_, _) + | UserNotice(_, _, _) + | UserState(_, _) + | HostStart(_, _, _) + | HostStop(_, _) + | Notice(_, _, _)) => listeners.foreach(_ ! msg) + case msg@UnknownMessage(_) => listeners.foreach(_ ! msg) + } + case Join(name) => + val channelName = name.toLowerCase + tcpClientActor ! SendData(s"JOIN $channelName") + joins += channelName -> context.system.scheduler.scheduleOnce(respondTimeout)({ + log.error(s"Twitch server doesn't respond JOIN $channelName request") + joins -= channelName + }) + case Part(name) => + val channelName = name.toLowerCase + tcpClientActor ! SendData(s"PART $channelName") + parts += channelName -> context.system.scheduler.scheduleOnce(respondTimeout)({ + log.error(s"Twitch server doesn't respond PART $channelName request") + parts -= channelName + }) + case SendMessage(name, message) => + val channelName = name.toLowerCase + tcpClientActor ! SendData(s"PRIVMSG $channelName :$message") + sentMessages += (channelName, message) -> context.system.scheduler.scheduleOnce(respondTimeout)({ + log.error(s"Twitch server doesn't respond PRIVMSG $channelName, $message request") + parts -= (channelName, message) + }) + case SendWhisper(name, message) => + val userName = name.toLowerCase + tcpClientActor ! SendData(s"PRIVMSG #$nick :/w $userName $message") + case AddListener(listener: ActorRef) => listeners += listener + case DeleteListener(listener: ActorRef) => listeners -= listener + } + +} + +object TwitchIRCActor { + + /** + * + * @param membership Adds membership state event data + * @param tags Adds IRC V3 message tags to several commands, if enabled with the commands capability. + * @param commands Enables several Twitch-specific commands + * @param botType throttling parameter + */ + case class TwitchIRCProps(membership: Boolean = true, tags: Boolean = true, commands: Boolean = true, botType: BotType = KnownBot) + + sealed trait BotType + case object KnownBot extends BotType + + private val defaultAddress = new InetSocketAddress("irc.chat.twitch.tv", 6667) + + private val defaultProps = TwitchIRCProps() + + def props(nick: String, oauth: String, address: InetSocketAddress = defaultAddress, props: TwitchIRCProps = defaultProps): Props = + Props(new TwitchIRCActor(address, nick.toLowerCase, oauth, props)) + + case class Join(channelName: String) + + case class Part(channelName: String) + + case class SendMessage(channelName: String, message: String) + + case class SendWhisper(userName: String, message: String) + + case class AddListener(listener: ActorRef) + + case class DeleteListener(listener: ActorRef) + + +} \ No newline at end of file diff --git a/src/main/scala/bot/twitchirc/messages/Message.scala b/src/main/scala/bot/twitchirc/messages/Message.scala new file mode 100644 index 0000000..3d58af4 --- /dev/null +++ b/src/main/scala/bot/twitchirc/messages/Message.scala @@ -0,0 +1,37 @@ +package bot.twitchirc.messages + +/** + * Created by amir. + */ +sealed trait Message + +object Message { + + private[twitchirc] case object Ping extends Message + + case class UnknownMessage(message: String) extends Message + + private[twitchirc] case class JoinConfirmation(channel: String) extends Message + private[twitchirc] case class PartConfirmation(channel: String) extends Message + private[twitchirc] case class MessageDeliverConfirmation(channel: String, message: String) extends Message + + case class UserJoinedChannel(user: String, channel: String) extends Message + case class UserLeftChannel(user: String, channel: String) extends Message + case class UserGainModeMessage(user: String, channel: String) extends Message + case class UserLostModeMessage(user: String, channel: String) extends Message + case class ChannelUserList(channel: String, users: Seq[String]) extends Message + private[twitchirc] case class EndOfUserList(channel: String) extends Message + + case class IncomingMessage(tags: Map[String, String], user: String, channel: String, message: String) extends Message + case class IncomingWhisper(tags: Map[String, String], user: String, message: String) extends Message + case class UserBan(tags: Map[String, String], channel: String, user: String) extends Message + case class GlobalUserState(tags: Map[String, String]) extends Message + case class RoomState(tags: Map[String, String], channel: String) extends Message + case class UserNotice(tags: Map[String, String], channel: String, message: String) extends Message + case class UserState(tags: Map[String, String], channel: String) extends Message + + case class HostStart(channel: String, hostChannel: String, viewers: Option[Int]) extends Message + case class HostStop(channel: String, viewers: Option[Int]) extends Message + case class Notice(tags: Map[String, String], channel: String, message: String) extends Message + +} \ No newline at end of file diff --git a/src/main/scala/bot/twitchirc/messages/MessageParser.scala b/src/main/scala/bot/twitchirc/messages/MessageParser.scala new file mode 100644 index 0000000..ee79358 --- /dev/null +++ b/src/main/scala/bot/twitchirc/messages/MessageParser.scala @@ -0,0 +1,88 @@ +package bot.twitchirc.messages + +import bot.twitchirc.messages.Message._ + +import scala.util.Try + +/** + * Created by amir. + */ +class MessageParser(nick: String) { + + private val nameTemplate = "[^ @!:-]*" + + private val pingRegexp = "PING :tmi.twitch.tv".r + private val successfulJoin = s":$nick!$nick@$nick.tmi.twitch.tv JOIN ($nameTemplate)".r + private val successfulPart = s":$nick!$nick@$nick.tmi.twitch.tv PART ($nameTemplate)".r + private val successfulPrivmsg = s":$nick!$nick@$nick.tmi.twitch.tv PRIVMSG ($nameTemplate) :(.*)".r //should be before incomingChatMessage + + //membership + private val userJoinedChannel = s":($nameTemplate)!$nameTemplate@$nameTemplate.tmi.twitch.tv JOIN ($nameTemplate)".r + private val userLeftChannel = s":($nameTemplate)!$nameTemplate@$nameTemplate.tmi.twitch.tv PART ($nameTemplate)".r + private val userGainMode = s":jtv MODE ($nameTemplate) \\+o ($nameTemplate)".r + private val userLostMode = s":jtv MODE ($nameTemplate) -o ($nameTemplate)".r + private val channelUserList = s":$nick.tmi.twitch.tv 353 $nick = ($nameTemplate) :(.*)".r + private val endOfUserList = s":$nick.tmi.twitch.tv 366 $nick ($nameTemplate) :End of /NAMES list".r + + //tags + private val incomingChatMessage = s"([^ ]*) :($nameTemplate)!$nameTemplate@$nameTemplate.tmi.twitch.tv PRIVMSG ($nameTemplate) :(.*)".r + private val incomingWhisper = s"([^ ]*) :($nameTemplate)!$nameTemplate@$nameTemplate.tmi.twitch.tv WHISPER $nick :(.*)".r + private val userBan = s"([^ ]*) :tmi.twitch.tv CLEARCHAT ($nameTemplate) :($nameTemplate)".r + private val globalUserState = s"([^ ]*) :tmi.twitch.tv GLOBALUSERSTATE".r + private val roomState = s"([^ ]*) :tmi.twitch.tv ROOMSTATE ($nameTemplate)".r + private val userNotice = s"([^ ]*) :tmi.twitch.tv USERNOTICE ($nameTemplate)( :.*)?".r + private val userState = s"([^ ]*) :tmi.twitch.tv USERSTATE ($nameTemplate)".r + + //UnknownMessage(:tmi.twitch.tv HOSTTARGET #dota2ruhub :lightofheaven -) + + //commands + private val hostStart = s":tmi.twitch.tv HOSTTARGET ($nameTemplate) :($nameTemplate) (.*)".r + private val hostStop = s":tmi.twitch.tv HOSTTARGET ($nameTemplate) :- (.*)".r + private val notice = s"([^ ]*) :tmi.twitch.tv NOTICE ($nameTemplate) :(.*)".r + //todo + //??? RECONNECT Rejoin channels after a restart. + + def parseLine(line: String): Message = { + line match { + case pingRegexp() => Ping + case successfulJoin(channel) => JoinConfirmation(channel) + case successfulPart(channel) => PartConfirmation(channel) + case successfulPrivmsg(channel, message) => MessageDeliverConfirmation(channel, message) + + case userJoinedChannel(user, channel) => UserJoinedChannel(user, channel) + case userLeftChannel(user, channel) => UserLeftChannel(user, channel) + case userGainMode(user, channel) => UserGainModeMessage(user, channel) + case userLostMode(user, channel) => UserLostModeMessage(user, channel) + case channelUserList(channel, users) => ChannelUserList(channel, parseUsers(users)) + case endOfUserList(channel) => EndOfUserList(channel) + + case incomingChatMessage(tags, user, channel, message) => IncomingMessage(parseTags(tags), user, channel, message) + case incomingWhisper(tags, user, message) => IncomingWhisper(parseTags(tags), user, message) + case userBan(tags, channel, user) => UserBan(parseTags(tags), channel, user) + case globalUserState(tags) => GlobalUserState(parseTags(tags)) + case roomState(tags, channel) => RoomState(parseTags(tags), channel) + case userNotice(tags, channel, message) => UserNotice(parseTags(tags), channel, if (message != null) message.substring(2) else "") + case userState(tags, channel) => UserState(parseTags(tags), channel) + + case hostStart(channel, hostChannel, viewers) => HostStart(channel, hostChannel, Try(viewers.toInt).toOption) + case hostStop(channel, viewers) => HostStop(channel, Try(viewers.toInt).toOption) + case notice(tags, channel, message) => Notice(parseTags(tags), channel, message) + + case message => UnknownMessage(message) + } + } + + private def parseUsers(users: String): Seq[String] = { + users.split(" ") + } + + //@ban-duration=;ban-reason= + private def parseTags(tags: String): Map[String, String] = { + tags.tail split ";" map { tag => + val parsedTag = tag.split("=", -1) + (parsedTag(0), parsedTag(1)) + } toMap + } +} + +