├── .gitignore ├── src ├── test │ ├── resources │ │ └── application.conf │ └── scala │ │ └── io │ │ └── scalac │ │ └── slack │ │ ├── common │ │ ├── MessageCounterTest.scala │ │ ├── MessageJsonProtocolTest.scala │ │ ├── AttachmentTest.scala │ │ └── UsersStorageTest.scala │ │ ├── websockets │ │ └── WSActorTest.scala │ │ ├── api │ │ └── ApiActorTest.scala │ │ ├── SlackDateTimeTest.scala │ │ ├── IncomingMessageProcessorTest.scala │ │ └── UnmarshallerTest.scala └── main │ ├── scala │ └── io │ │ └── scalac │ │ └── slack │ │ ├── common │ │ ├── Shutdownable.scala │ │ ├── BotInfoKeeper.scala │ │ ├── SlackbotDatabase.scala │ │ ├── MessageCounter.scala │ │ ├── AbstractRepository.scala │ │ ├── JsonProtocols.scala │ │ ├── MessageJsonProtocol.scala │ │ ├── SlackDateTime.scala │ │ ├── UsersStorage.scala │ │ ├── actors │ │ │ └── SlackBotActor.scala │ │ └── Messages.scala │ │ ├── api │ │ ├── APIKey.scala │ │ ├── ApiClient.scala │ │ ├── Message.scala │ │ ├── ResponseObject.scala │ │ ├── SlackApiClient.scala │ │ ├── Unmarshallers.scala │ │ └── ApiActor.scala │ │ ├── models │ │ ├── ChannelInfo.scala │ │ ├── DirectChannel.scala │ │ ├── Channel.scala │ │ └── SlackUser.scala │ │ ├── BotModules.scala │ │ ├── OutgoingRichMessageProcessor.scala │ │ ├── bots │ │ ├── system │ │ │ ├── HelpBot.scala │ │ │ └── CommandsRecognizerBot.scala │ │ └── MessageListener.scala │ │ ├── MessageEventBus.scala │ │ ├── OutgoingMessageProcessor.scala │ │ ├── Config.scala │ │ ├── SlackError.scala │ │ ├── IncomingMessageProcessor.scala │ │ └── websockets │ │ └── WSActor.scala │ └── resources │ └── log4j.xml ├── README.md └── LICENCE /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *secret.conf 3 | target 4 | core/target 5 | bundle/target 6 | *.DS_Store -------------------------------------------------------------------------------- /src/test/resources/application.conf: -------------------------------------------------------------------------------- 1 | api { 2 | base.url ="https://slack.com/api/" 3 | } 4 | 5 | akka { 6 | loglevel = "DEBUG" 7 | } 8 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/common/Shutdownable.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.common 2 | 3 | trait Shutdownable { 4 | 5 | def shutdown(): Unit 6 | } 7 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/api/APIKey.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.api 2 | 3 | /** 4 | * Created on 20.01.15 22:56 5 | */ 6 | case class APIKey(key: String){ 7 | override def toString: String = key 8 | } 9 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/common/BotInfoKeeper.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.common 2 | 3 | import io.scalac.slack.api.BotInfo 4 | 5 | object BotInfoKeeper { 6 | var current: Option[BotInfo] = None 7 | } 8 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/models/ChannelInfo.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.models 2 | 3 | /** 4 | * Created on 28.01.15 23:05 5 | */ 6 | case class ChannelInfo(value: String, creator: String, last_set: Long) 7 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/common/SlackbotDatabase.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.common 2 | 3 | import scala.slick.driver.H2Driver.simple._ 4 | 5 | object SlackbotDatabase { 6 | lazy val db = Database.forConfig("h2") 7 | } 8 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/BotModules.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack 2 | 3 | import akka.actor.{ActorContext, ActorRef} 4 | 5 | trait BotModules { 6 | def registerModules(context: ActorContext, websocketClient: ActorRef): Unit 7 | } 8 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/models/DirectChannel.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.models 2 | 3 | /** 4 | * Direct channel object between bot and user 5 | * Object holds user's ID along with channel's ID 6 | */ 7 | case class DirectChannel(id: String, userId: String) 8 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/common/MessageCounter.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.common 2 | 3 | import java.util.concurrent.atomic.AtomicInteger 4 | 5 | object MessageCounter { 6 | private val cc = new AtomicInteger(0) 7 | 8 | def next = cc.incrementAndGet() 9 | 10 | def reset() = cc.set(0) 11 | 12 | } 13 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/models/Channel.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.models 2 | 3 | import org.joda.time.DateTime 4 | 5 | /** 6 | * Created on 28.01.15 23:04 7 | */ 8 | case class Channel(name: String, creator: String, isMember: Boolean, isChannel: Boolean, id: String, isGeneral: Boolean, isArchived: Boolean, created: DateTime, purpose: Option[ChannelInfo], topic: Option[ChannelInfo], unreadCount: Option[Int], lastRead :Option[DateTime],members: Option[List[String]]) -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/common/AbstractRepository.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.common 2 | 3 | import scala.slick.driver.H2Driver.simple._ 4 | import scala.slick.jdbc.meta.MTable 5 | 6 | abstract class AbstractRepository { 7 | val bucket: String 8 | protected val db = SlackbotDatabase.db 9 | 10 | def migrationNeeded()(implicit s: Session) = { 11 | MTable.getTables.list.exists(table => { 12 | table.name.name.contains(bucket) 13 | }) == false 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/models/SlackUser.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.models 2 | 3 | /** 4 | * Created on 28.01.15 23:11 5 | */ 6 | case class SlackUser(id: String, name: String, deleted: Boolean, isAdmin: Option[Boolean], isOwner: Option[Boolean], isPrimaryOwner: Option[Boolean], isRestricted: Option[Boolean], isUltraRestricted: Option[Boolean], hasFiles: Option[Boolean], isBot: Option[Boolean], presence: Presence) 7 | 8 | sealed trait Presence 9 | 10 | object Away extends Presence 11 | object Active extends Presence -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # scala-slack-bot-core 2 | 3 | [![Codacy Badge](https://www.codacy.com/project/badge/fa1e21dc6de24e0cb5286e7579e0698d)](https://www.codacy.com/app/pjazdzewski1990/scala-slack-bot-core) 4 | 5 | Akka based library for writing Slack bots in Scala. 6 | 7 | For examples, description and ideas take a look at our [implementation](https://github.com/ScalaConsultants/scala-slack-bot) or blog [post](http://blog.scalac.io/2015/07/16/slack.html). 8 | 9 | Developed by [Scalac](https://scalac.io/?utm_source=scalac_github&utm_campaign=scalac1&utm_medium=web) 10 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/api/ApiClient.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.api 2 | 3 | import spray.http.{HttpMethod, HttpRequest} 4 | import spray.json.JsonReader 5 | 6 | import scala.concurrent.Future 7 | 8 | /** 9 | * Created on 25.01.15 22:22 10 | */ 11 | trait ApiClient { 12 | 13 | def request[T <: ResponseObject](method: HttpMethod, 14 | endpoint: String, 15 | queryParams: Map[String, String] = Map.empty, 16 | token: Option[String])(implicit reader: JsonReader[T]): Future[T] 17 | 18 | } -------------------------------------------------------------------------------- /src/test/scala/io/scalac/slack/common/MessageCounterTest.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.common 2 | 3 | import org.scalatest.{Matchers, FunSuite} 4 | 5 | /** 6 | * Created on 08.02.15 00:57 7 | */ 8 | class MessageCounterTest extends FunSuite with Matchers { 9 | 10 | test("message counter should still increment and reset "){ 11 | MessageCounter.next should equal (1) 12 | MessageCounter.next should equal (2) 13 | MessageCounter.next should equal (3) 14 | 15 | MessageCounter.next should equal (4) 16 | MessageCounter.reset() 17 | MessageCounter.next should equal (1) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/test/scala/io/scalac/slack/common/MessageJsonProtocolTest.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.common 2 | 3 | import org.scalatest.{FunSuite, Matchers} 4 | import spray.json._ 5 | 6 | 7 | /** 8 | * Created on 10.02.15 17:37 9 | */ 10 | class MessageJsonProtocolTest extends FunSuite with Matchers { 11 | import io.scalac.slack.common.MessageJsonProtocol._ 12 | 13 | test("MessageType message") { 14 | 15 | val mes = /*language=json*/ """{ "type" : "hello"}""" 16 | val hello = mes.parseJson.convertTo[MessageType] 17 | 18 | hello.messageType should equal ("hello") 19 | hello.subType should be(None) 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/resources/log4j.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/common/JsonProtocols.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.common 2 | 3 | import spray.json._ 4 | 5 | /** 6 | * Created on 09.03.15 11:59 7 | */ 8 | object JsonProtocols extends DefaultJsonProtocol { 9 | 10 | implicit object AttachmentFormatWriter extends RootJsonWriter[Attachment] { 11 | val attachmentFormat = jsonFormat7(Attachment.apply) 12 | 13 | override def write(a: Attachment): JsValue = { 14 | JsObject(JsObject("fallback" -> JsString("wrong formatted message")).fields ++ a.toJson(attachmentFormat).asJsObject.fields) 15 | } 16 | } 17 | 18 | implicit val fieldFormat = jsonFormat3(Field) 19 | } 20 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/OutgoingRichMessageProcessor.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack 2 | 3 | import akka.actor.{Actor, ActorLogging, ActorRef} 4 | import io.scalac.slack.common._ 5 | 6 | /** 7 | * Created on 08.02.15 23:00 8 | * 9 | */ 10 | class OutgoingRichMessageProcessor(apiActor: ActorRef, eventBus: MessageEventBus) extends Actor with ActorLogging { 11 | 12 | override def receive: Receive = { 13 | 14 | case msg: RichOutboundMessage => 15 | if (msg.elements.nonEmpty) 16 | apiActor ! msg //trasport through WebAPI until RTM support begin 17 | 18 | case ignored => //nothing else 19 | 20 | } 21 | 22 | @throws[Exception](classOf[Exception]) 23 | override def preStart(): Unit = { 24 | eventBus.subscribe(self, Outgoing) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/bots/system/HelpBot.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.bots.system 2 | 3 | import io.scalac.slack.MessageEventBus 4 | import io.scalac.slack.bots.AbstractBot 5 | import io.scalac.slack.common.{Command, HelpRequest, OutboundMessage} 6 | 7 | /** 8 | * Maintainer: Patryk 9 | */ 10 | class HelpBot(override val bus: MessageEventBus) extends AbstractBot { 11 | override def act: Receive = { 12 | case Command("help", options, raw) => 13 | publish(HelpRequest(options.headOption, raw.channel)) 14 | } 15 | 16 | override def help(channel: String): OutboundMessage = OutboundMessage(channel, 17 | s"*$name* is for helping. Duh \\n" + 18 | s"`help` - display help from all bots \\n " + 19 | s"`help {botName}` - display help for certain bot module") 20 | } 21 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/MessageEventBus.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack 2 | 3 | import akka.event.{ActorEventBus, LookupClassification} 4 | import io.scalac.slack.common._ 5 | 6 | /** 7 | * Created on 08.02.15 22:16 8 | */ 9 | class MessageEventBus extends ActorEventBus with LookupClassification { 10 | override type Event = MessageEvent 11 | 12 | override type Classifier = MessageEventType 13 | 14 | override protected def mapSize(): Int = 2 15 | 16 | override protected def publish(event: Event, subscriber: Subscriber): Unit = { 17 | subscriber ! event 18 | } 19 | 20 | override protected def classify(event: Event): Classifier = { 21 | event match { 22 | case im: IncomingMessage => Incoming 23 | case om: OutgoingMessage => Outgoing 24 | case rich: RichOutboundMessage => Outgoing 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/OutgoingMessageProcessor.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack 2 | 3 | import akka.actor.{Actor, ActorLogging, ActorRef} 4 | import io.scalac.slack.common._ 5 | import io.scalac.slack.websockets.WebSocket 6 | 7 | /** 8 | * Created on 08.02.15 23:00 9 | * Outgoing message protocol should change received 10 | * protocol into string and send it to websocket 11 | */ 12 | class OutgoingMessageProcessor(wsActor: ActorRef, eventBus: MessageEventBus) extends Actor with ActorLogging { 13 | 14 | override def receive: Receive = { 15 | case Ping => 16 | wsActor ! WebSocket.Send(Ping.toJson) 17 | 18 | case msg: OutboundMessage => 19 | wsActor ! WebSocket.Send(msg.toJson) 20 | 21 | case ignored => //nothing else 22 | 23 | } 24 | 25 | @throws[Exception](classOf[Exception]) 26 | override def preStart(): Unit = { 27 | eventBus.subscribe(self, Outgoing) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/api/Message.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.api 2 | 3 | /** 4 | * Messages sended between actors 5 | */ 6 | sealed trait Message 7 | 8 | case object Start extends Message 9 | case object Stop extends Message 10 | case object RegisterModules extends Message 11 | 12 | //API CALLS 13 | case class ApiTest(param: Option[String] = None, error: Option[String] = None) extends Message 14 | case class AuthTest(token: APIKey) extends Message 15 | 16 | @deprecated("Use RtmConnect instead") 17 | case class RtmStart(token: APIKey) extends Message 18 | 19 | case class RtmConnect(token: APIKey) extends Message 20 | //API RESPONSES 21 | case object Ok extends Message 22 | case object Connected extends Message 23 | 24 | case class AuthData(url: String, team: String, user: String, teamId: String, userId: String) extends Message 25 | case class RtmData(url: String) 26 | 27 | object AuthData { 28 | def apply(atr: AuthData) = atr 29 | } 30 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/Config.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack 2 | 3 | import com.typesafe.config.ConfigFactory 4 | import io.scalac.slack.api.APIKey 5 | 6 | /** 7 | * Created on 20.01.15 22:17 8 | */ 9 | object Config { 10 | def websocketKey: String = config.getString("websocket.key") 11 | 12 | private val config = ConfigFactory.load() 13 | 14 | def apiKey: APIKey = APIKey(config.getString("api.key")) 15 | 16 | def baseUrl(endpoint: String) = config.getString("api.base.url") + endpoint 17 | 18 | def scalaLibraryPath = config.getString("scalaLibraryPath") 19 | 20 | def consumerKey: String = config.getString("twitter.consumerKey") 21 | def consumerKeySecret: String = config.getString("twitter.consumerKeySecret") 22 | def accessToken: String = config.getString("twitter.accessToken") 23 | def accessTokenSecret: String = config.getString("twitter.accessTokenSecret") 24 | def twitterGuardians: String = config.getString("twitter.guardians") 25 | } 26 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/SlackError.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack 2 | 3 | /** 4 | * Created on 21.01.15 01:23 5 | */ 6 | sealed trait SlackError 7 | 8 | object ApiTestError extends SlackError 9 | 10 | //no authentication token provided 11 | object NotAuthenticated extends SlackError 12 | 13 | //invalid auth token 14 | object InvalidAuth extends SlackError 15 | 16 | //token is for deleted user or team 17 | object AccountInactive extends SlackError 18 | 19 | //team is being migrated between servers 20 | object MigrationInProgress extends SlackError 21 | 22 | case class UnspecifiedError(msg: String) extends SlackError 23 | 24 | 25 | object SlackError { 26 | def apply(errorName: String) = { 27 | errorName match { 28 | case "not_authed" => NotAuthenticated 29 | case "invalid_auth" => InvalidAuth 30 | case "account_inactive" => AccountInactive 31 | case "migration_in_progress" => MigrationInProgress 32 | case err => new UnspecifiedError(err) 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /src/test/scala/io/scalac/slack/websockets/WSActorTest.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.websockets 2 | 3 | import akka.actor.{ActorSystem, Props} 4 | 5 | /** 6 | * Created on 28.01.15 22:03 7 | */ 8 | object WSActorTest { 9 | 10 | def main(args: Array[String]) { 11 | implicit lazy val system = ActorSystem("TestEchoServer") 12 | var wsmsg = "" 13 | val wse = system.actorOf(Props[WSActor]) 14 | //websocket echo service 15 | wse ! WebSocket.Connect("wss://echo.websocket.org/echo") 16 | // wse ! WebSocket.Connect("ms25.slack-msgs.com", 443, "/websocket/9mOAPneoOcdvGXm_C6tLAkSXRG5be8aR/fDRj3/C1a8tmUP8YAFWJM1zANmgv8pn3oX4DdRecbCgmbSvFe8gzZB1GQ2wyPDjEWDVg3s7OFc=", withSsl = true) 17 | 18 | Thread.sleep(3500L) // wait for all servers to be cleanly started 19 | println("sending message") 20 | val rock = "Rock it with WebSocket" 21 | wse ! WebSocket.Send(rock) 22 | Thread.sleep(2000L) 23 | wse ! WebSocket.Release 24 | system.terminate() 25 | Thread.sleep(1000L) 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/common/MessageJsonProtocol.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.common 2 | 3 | import spray.json._ 4 | 5 | /** 6 | * Created on 10.02.15 17:33 7 | */ 8 | object MessageJsonProtocol extends DefaultJsonProtocol { 9 | 10 | implicit object BaseMessageJsonReader extends RootJsonReader[BaseMessage] { 11 | 12 | def read(value: JsValue) = { 13 | 14 | value.asJsObject.getFields("text", "channel", "user", "ts", "edited") match { 15 | 16 | case Seq(JsString(text), JsString(channel), JsString(user), JsString(ts)) => 17 | BaseMessage(text, channel, user, ts, edited = false) 18 | 19 | case Seq(JsString(text), JsString(channel), JsString(user), JsString(ts), JsObject(edited)) => 20 | 21 | BaseMessage(text, channel, user, ts, edited = true) 22 | 23 | case _ => 24 | throw new DeserializationException("BaseMessage expected") 25 | } 26 | } 27 | } 28 | 29 | 30 | implicit val messageTypeFormat = jsonFormat(MessageType, "type", "subtype") 31 | 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/test/scala/io/scalac/slack/api/ApiActorTest.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.api 2 | 3 | import akka.actor.{ActorSystem, Props} 4 | import akka.pattern.ask 5 | import akka.testkit.{ImplicitSender, TestKit} 6 | import akka.util.Timeout 7 | import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpecLike} 8 | 9 | import scala.concurrent.Await 10 | import scala.concurrent.duration.DurationInt 11 | 12 | 13 | class ApiActorTest extends TestKit(ActorSystem("api-actor")) with WordSpecLike with Matchers with ImplicitSender with BeforeAndAfterAll { 14 | 15 | override def afterAll(): Unit = { 16 | TestKit.shutdownActorSystem(system) 17 | } 18 | 19 | "ApiActor" must { 20 | "connect after testing test api endpoint" in { 21 | implicit val timeout: Timeout = 5.second 22 | val actor = system.actorOf(Props[ApiActor]) 23 | val responseFuture = actor.ask(ApiTest()) 24 | val response = Await.result(responseFuture, timeout.duration) 25 | 26 | response shouldBe Connected 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 ScalaC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/IncomingMessageProcessor.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack 2 | 3 | import akka.actor.{Actor, ActorLogging} 4 | import io.scalac.slack.common._ 5 | import spray.json._ 6 | 7 | /** 8 | * Created on 08.02.15 23:36 9 | * Incoming message processor should parse incoming string 10 | * and change into proper protocol 11 | */ 12 | class IncomingMessageProcessor(eventBus: MessageEventBus) extends Actor with ActorLogging { 13 | 14 | import io.scalac.slack.common.MessageJsonProtocol._ 15 | 16 | override def receive: Receive = { 17 | 18 | case s: String => 19 | try { 20 | val mType = s.parseJson.convertTo[MessageType] 21 | val incomingMessage: IncomingMessage = mType match { 22 | case MessageType("hello", _) => Hello 23 | case MessageType("pong", _) => Pong 24 | case MessageType("message", None) => s.parseJson.convertTo[BaseMessage] 25 | case _ => 26 | UndefinedMessage(s) 27 | } 28 | eventBus.publish(incomingMessage) 29 | } 30 | catch { 31 | case e : Exception => 32 | eventBus.publish(UndefinedMessage(s)) 33 | } 34 | case ignored => //nothing special 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/test/scala/io/scalac/slack/SlackDateTimeTest.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack 2 | 3 | import io.scalac.slack.common.{MessageCounter, SlackDateTime} 4 | import org.joda.time.DateTime 5 | import org.scalatest.{Matchers, WordSpecLike} 6 | 7 | /** 8 | * Created on 13.02.15 11:42 9 | */ 10 | class SlackDateTimeTest extends Matchers with WordSpecLike { 11 | 12 | val baseDate = new DateTime(2015, 2, 15, 8, 23, 45, 0) 13 | 14 | "SlackDateTime" must { 15 | "properly change DateTime into timestamp" in { 16 | SlackDateTime.timeStamp(baseDate) should equal("1423985025000") 17 | } 18 | 19 | "properly change DateTime into seconds" in { 20 | SlackDateTime.seconds(baseDate) should equal("1423985025") 21 | } 22 | 23 | "properly change DateTime into unique timestamp" in { 24 | MessageCounter.reset() 25 | SlackDateTime.uniqueTimeStamp(baseDate) should equal("1423985025.000001") 26 | } 27 | 28 | "properly parse timestamp" in { 29 | SlackDateTime.parseTimeStamp("1423985025000") should equal(baseDate) 30 | } 31 | 32 | "properly parse seconds" in { 33 | SlackDateTime.parseSeconds("1423985025") should equal(baseDate) 34 | } 35 | 36 | "properly parse unique timestamp" in { 37 | SlackDateTime.parseUniqueTimestamp("1423985025.000001") should equal(baseDate) 38 | } 39 | 40 | } 41 | 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/api/ResponseObject.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.api 2 | 3 | import io.scalac.slack.models.{Channel, DirectChannel, SlackUser} 4 | 5 | sealed trait ResponseObject 6 | 7 | case class ApiTestResponse(ok: Boolean, error: Option[String], args: Option[Map[String, String]]) extends ResponseObject 8 | 9 | case class AuthTestResponse(ok: Boolean, error: Option[String], url: Option[String], team: Option[String], user: Option[String], team_id: Option[String], user_id: Option[String]) extends ResponseObject 10 | 11 | @deprecated("Please use RtmConnectResponse") 12 | case class RtmStartResponse(ok: Boolean, url: String, users: List[SlackUser], channels: List[Channel], self: BotInfo, ims: List[DirectChannel]) extends ResponseObject 13 | 14 | case class RtmConnectResponse(ok: Boolean, url: String, self: BotInfo, team: Team) extends ResponseObject 15 | object ResponseObject { 16 | implicit def authTestResponseToAuthData(atr: AuthTestResponse): AuthData = 17 | AuthData(atr.url.getOrElse("url"), atr.team.getOrElse("team"), atr.user.getOrElse("user"), atr.team_id.getOrElse("teamID"), atr.user_id.getOrElse("userID")) 18 | } 19 | 20 | case class ChatPostMessageResponse(ok: Boolean, channel: String, error: Option[String]) extends ResponseObject 21 | 22 | case class BotInfo(id: String, name: String) 23 | 24 | case class Team(domain: String, id: String, name: String) -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/bots/system/CommandsRecognizerBot.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.bots.system 2 | 3 | import io.scalac.slack.MessageEventBus 4 | import io.scalac.slack.bots.IncomingMessageListener 5 | import io.scalac.slack.common.{BaseMessage, BotInfoKeeper, Command} 6 | 7 | class CommandsRecognizerBot(override val bus: MessageEventBus) extends IncomingMessageListener { 8 | 9 | val commandChar = '$' 10 | 11 | def receive: Receive = { 12 | 13 | case bm@BaseMessage(text, channel, user, dateTime, edited) => 14 | //COMMAND links list with bot's nam jack can be called: 15 | // jack link list 16 | // jack: link list 17 | // @jack link list 18 | // @jack: link list 19 | // $link list 20 | def changeIntoCommand(pattern: String): Boolean = { 21 | if (text.trim.startsWith(pattern)) { 22 | val tokenized = text.trim.drop(pattern.length).trim.split("\\s") 23 | publish(Command(tokenized.head, tokenized.tail.toList.filter(_.nonEmpty), bm)) 24 | true 25 | } 26 | false 27 | } 28 | 29 | //call by commad character 30 | if (!changeIntoCommand(commandChar.toString)) 31 | BotInfoKeeper.current match { 32 | case Some(bi) => 33 | //call by name 34 | changeIntoCommand(bi.name + ":") || 35 | changeIntoCommand(bi.name) || 36 | //call by ID 37 | changeIntoCommand(s"<@${bi.id}>:") || 38 | changeIntoCommand(s"<@${bi.id}>") 39 | 40 | case None => //nothing to do! 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/common/SlackDateTime.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.common 2 | 3 | import org.joda.time.DateTime 4 | 5 | /** 6 | * Created on 08.02.15 01:07 7 | * Slack use two formats of timestamp 8 | * 1. "1234567890" - time in seconds 9 | * 2. "1234567890.000012" - time in seconds and unique ID 10 | */ 11 | object SlackDateTime { 12 | 13 | /** 14 | * 15 | * @return 13-digist long timestamp (miliseconds) 16 | */ 17 | def timeStamp(dt: DateTime = DateTime.now): String = { 18 | dt.getMillis.toString //timestamp in milis 19 | } 20 | 21 | /** 22 | * 23 | * @return 10-digits long timestamp (seconds) 24 | */ 25 | def seconds(dt: DateTime = DateTime.now): String = { 26 | (dt.getMillis / 1000).toString 27 | } 28 | 29 | /** 30 | * 31 | * @return 10-digits long timestamp with unique connection ID 32 | */ 33 | def uniqueTimeStamp(dt: DateTime = DateTime.now): String = { 34 | seconds(dt) + "." + f"${MessageCounter.next}%06d" 35 | } 36 | 37 | def parseTimeStamp(ts: String): DateTime = { 38 | try { 39 | new DateTime(ts.toLong) 40 | } catch { 41 | case e: NumberFormatException => 42 | DateTime.now 43 | } 44 | } 45 | 46 | def parseSeconds(seconds: String): DateTime = { 47 | try { 48 | val tsl = seconds.toLong * 1000 49 | new DateTime(tsl) 50 | } catch { 51 | case e: NumberFormatException => 52 | DateTime.now 53 | } 54 | } 55 | 56 | def parseUniqueTimestamp(uniqueTimeStamp: String): DateTime = { 57 | parseSeconds(uniqueTimeStamp.split('.').head) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/common/UsersStorage.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.common 2 | 3 | import akka.actor.{Actor, ActorLogging} 4 | import io.scalac.slack.api.Ok 5 | import io.scalac.slack.models.{DirectChannel, Presence, SlackUser} 6 | 7 | import scala.language.{implicitConversions, postfixOps} 8 | 9 | /** 10 | * Maintainer: @marioosh 11 | */ 12 | class UsersStorage extends Actor with ActorLogging { 13 | 14 | var userCatalog = List.empty[UserInfo] 15 | var channelCatalog = List.empty[DirectChannel] 16 | 17 | implicit def convertUsers(su: SlackUser): UserInfo = UserInfo(su.id.trim, su.name.trim, su.presence) 18 | 19 | override def receive: Receive = { 20 | case RegisterUsers(users@_*) => 21 | users.filterNot(u => u.deleted).foreach(addUser(_)) 22 | sender ! Ok 23 | 24 | case FindUser(key) => sender ! userCatalog.find { user => 25 | val matcher = key.trim.toLowerCase 26 | matcher == user.id || matcher == user.name 27 | } 28 | 29 | case RegisterDirectChannels(channels@_*) => 30 | channels foreach addDirectChannel 31 | sender ! Ok 32 | 33 | case FindChannel(key) => 34 | 35 | val id = userCatalog.find(u => u.name == key.trim.toLowerCase) match { 36 | case Some(user) => user.id 37 | case None => key 38 | } 39 | sender ! channelCatalog.find(c => c.id == id || c.userId == id).map(_.id) 40 | 41 | } 42 | 43 | def addDirectChannel(channel: DirectChannel): Unit = { 44 | channelCatalog = channel :: channelCatalog.filterNot(_.userId == channel.userId) 45 | } 46 | 47 | private def addUser(user: UserInfo): Unit = { 48 | userCatalog = user :: userCatalog.filterNot(_.id == user.id) 49 | } 50 | 51 | } 52 | 53 | case class UserInfo(id: String, name: String, presence: Presence) { 54 | def userLink() = s"""<@$id|name>""" 55 | } 56 | 57 | case class RegisterUsers(slackUsers: SlackUser*) 58 | 59 | case class RegisterDirectChannels(ims: DirectChannel*) 60 | 61 | case class FindUser(key: String) 62 | 63 | case class FindChannel(key: String) -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/api/SlackApiClient.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.api 2 | 3 | import akka.actor.ActorSystem 4 | import akka.event.Logging 5 | import io.scalac.slack.Config 6 | import spray.client.pipelining._ 7 | import spray.http.HttpHeaders.Authorization 8 | import spray.http._ 9 | import spray.json._ 10 | 11 | import scala.concurrent.Future 12 | import spray.httpx.SprayJsonSupport._ 13 | import spray.json.DefaultJsonProtocol._ 14 | /** 15 | * Created on 29.01.15 22:43 16 | */ 17 | object SlackApiClient extends ApiClient{ 18 | 19 | val log = Logging 20 | 21 | implicit val system = ActorSystem("SlackApiClient") 22 | 23 | import system.dispatcher 24 | 25 | //function from HttpRequest to HttpResponse 26 | val pipeline: HttpRequest => Future[HttpResponse] = sendReceive 27 | 28 | def get[T <: ResponseObject](endpoint: String, params: Map[String, String] = Map.empty[String, String], token: Option[String] = None)(implicit reader: JsonReader[T]): Future[T] = request(HttpMethods.GET, endpoint, params, token) 29 | def post[T <: ResponseObject](endpoint: String, params: Map[String, String] = Map.empty[String, String], token: Option[String] = None)(implicit reader: JsonReader[T]): Future[T] = request(HttpMethods.POST, endpoint, params, token) 30 | 31 | def request[T <: ResponseObject](method: HttpMethod, 32 | endpoint: String, 33 | queryParams: Map[String, String] = Map.empty[String,String], 34 | token: Option[String] = None)(implicit reader: JsonReader[T]): Future[T] = { 35 | 36 | val url = Uri(apiUrl(endpoint)).withQuery(queryParams) 37 | 38 | var request = HttpRequest(method, url) 39 | 40 | if (token.isDefined) { 41 | request = request.withHeaders(Authorization(OAuth2BearerToken(token.get))) 42 | } 43 | 44 | val futureResponse = pipeline(request).map(_.entity.asString) 45 | (for { 46 | responseJson <- futureResponse 47 | response = JsonParser(responseJson).convertTo[T] 48 | } yield response) recover { 49 | case cause => throw new Exception("Something went wrong", cause) 50 | } 51 | 52 | } 53 | 54 | def apiUrl(endpoint: String) = Config.baseUrl(endpoint) 55 | } 56 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/bots/MessageListener.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.bots 2 | 3 | import akka.actor.{Actor, ActorLogging, ActorRef} 4 | import akka.util.Timeout 5 | import io.scalac.slack.MessageEventBus 6 | import io.scalac.slack.common.{Incoming, MessageEvent, Outgoing, _} 7 | 8 | import scala.concurrent.ExecutionContext 9 | import scala.language.implicitConversions 10 | 11 | trait MessagePublisher { 12 | 13 | import akka.pattern._ 14 | 15 | def bus: MessageEventBus 16 | 17 | 18 | implicit def publish(event: MessageEvent): Unit = { 19 | bus.publish(event) 20 | } 21 | 22 | def publish(directMessage: DirectMessage)(implicit context: ExecutionContext, userStorage: ActorRef, timeout: Timeout): Unit = { 23 | userStorage ? FindChannel(directMessage.key) onSuccess { 24 | case Some(channel: String) => 25 | val eventToSend = directMessage.event match { 26 | case message: OutboundMessage => message.copy(channel = channel) 27 | case message: RichOutboundMessage => message.copy(channel = channel) 28 | case other => other 29 | } 30 | publish(eventToSend) 31 | } 32 | } 33 | } 34 | 35 | abstract class MessageListener extends Actor with ActorLogging with MessagePublisher 36 | 37 | /** 38 | * A raw messaging interface used to create internal system level bots. 39 | * For user facing bots use AbstractBot 40 | */ 41 | abstract class IncomingMessageListener extends MessageListener { 42 | @throws[Exception](classOf[Exception]) 43 | override def preStart(): Unit = bus.subscribe(self, Incoming) 44 | } 45 | 46 | abstract class OutgoingMessageListener extends MessageListener { 47 | @throws[Exception](classOf[Exception]) 48 | override def preStart(): Unit = bus.subscribe(self, Outgoing) 49 | } 50 | 51 | /** 52 | * The class to extend when creating a bot. 53 | */ 54 | abstract class AbstractBot extends IncomingMessageListener { 55 | log.debug(s"Starting ${self.path.name} on $bus") 56 | 57 | override val bus: MessageEventBus 58 | 59 | def name: String = self.path.name 60 | 61 | def help(channel: String): OutboundMessage 62 | 63 | def act: Actor.Receive 64 | 65 | def handleSystemCommands: Actor.Receive = { 66 | case HelpRequest(t, ch) if t.map(_ == name).getOrElse(true) => publish(help(ch)) 67 | } 68 | 69 | override final def receive: Actor.Receive = act.orElse(handleSystemCommands) 70 | } 71 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/api/Unmarshallers.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.api 2 | 3 | import io.scalac.slack.models._ 4 | import org.joda.time.DateTime 5 | import spray.json._ 6 | 7 | object Unmarshallers extends DefaultJsonProtocol { 8 | 9 | implicit object DateTimeJsonFormat extends JsonFormat[DateTime] { 10 | def write(dt: DateTime) = JsNumber(dt.getMillis) 11 | 12 | def read(value: JsValue) = { 13 | value match { 14 | case JsNumber(number) => new DateTime(number.toLong * 1000) 15 | case JsString(num) => 16 | val seconds = num.toString.split('.')(0) 17 | val nanos = num.split('.')(1).take(3) 18 | new DateTime((seconds + nanos).toLong) 19 | 20 | case _ => throw new DeserializationException("Timestamp expected") 21 | } 22 | } 23 | } 24 | 25 | implicit object PresenceJsonFormat extends JsonFormat[Presence] { 26 | def write(dt: Presence) = JsString(dt.getClass.toString.toLowerCase) 27 | 28 | def read(value: JsValue) = { 29 | 30 | value match { 31 | 32 | case JsString(presence) => 33 | presence match { 34 | case "away" => Away 35 | case "active" => Active 36 | 37 | } 38 | 39 | case _ => throw new DeserializationException("Presence expected") 40 | } 41 | } 42 | } 43 | 44 | implicit val apiTestResponseFormat = jsonFormat3(ApiTestResponse) 45 | implicit val authTestResponseFormat = jsonFormat7(AuthTestResponse) 46 | implicit val channelInfoFormat = jsonFormat3(ChannelInfo) 47 | implicit val channelFormat = jsonFormat(Channel, "name", "creator", "is_member", "is_channel", "id", "is_general", "is_archived", "created", "purpose", "topic", "unread_count", "last_read", "members") 48 | implicit val userFormat = jsonFormat(SlackUser, "id", "name", "deleted", "is_admin", "is_owner", "is_primary_owner", "is_restricted", "is_ultra_restricted", "has_files", "is_bot", "presence") 49 | implicit val botInfoFormat = jsonFormat(BotInfo, "id", "name") 50 | implicit val teamFormat = jsonFormat3(Team) 51 | implicit val imFormat = jsonFormat(DirectChannel, "id", "user") 52 | implicit val rtmStartResponseFormat = jsonFormat(RtmStartResponse, "ok", "url", "users", "channels", "self", "ims") 53 | implicit val rtmConnectResponseFormat = jsonFormat(RtmConnectResponse, "ok", "url", "self", "team") 54 | implicit val chatPostMessageResponse = jsonFormat(ChatPostMessageResponse, "ok", "channel", "error") 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/api/ApiActor.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.api 2 | 3 | import akka.actor.{Actor, ActorLogging, PoisonPill} 4 | import io.scalac.slack.api.ResponseObject._ 5 | import io.scalac.slack.common.JsonProtocols._ 6 | import io.scalac.slack.common.RichOutboundMessage 7 | import io.scalac.slack.{ApiTestError, Config, SlackError} 8 | import spray.json._ 9 | 10 | import scala.util.{Failure, Success} 11 | 12 | class ApiActor extends Actor with ActorLogging { 13 | 14 | import context.dispatcher 15 | import io.scalac.slack.api.Unmarshallers._ 16 | 17 | override def receive = { 18 | 19 | case ApiTest(param, error) => 20 | log.debug("api.test requested") 21 | val send = sender() 22 | val params = Map("param" -> param, "error" -> error).collect { case (key, Some(value)) => key -> value } 23 | 24 | SlackApiClient.get[ApiTestResponse]("api.test", params) onComplete { 25 | case Success(res) => 26 | if (res.ok) { 27 | send ! Connected 28 | } 29 | else { 30 | send ! ApiTestError 31 | } 32 | case Failure(ex) => 33 | send ! ex 34 | 35 | } 36 | 37 | case AuthTest(token) => 38 | log.debug("auth.test requested") 39 | val send = sender() 40 | 41 | SlackApiClient.post[AuthTestResponse]("auth.test", token = Some(token.key)) onComplete { 42 | case Success(res) => 43 | 44 | if (res.ok) 45 | send ! AuthData(res) 46 | else 47 | send ! SlackError(res.error.get) 48 | case Failure(ex) => 49 | send ! ex 50 | } 51 | 52 | case RtmConnect(token) => 53 | log.debug("rtm.connect requested") 54 | val send = sender() 55 | 56 | SlackApiClient.get[RtmConnectResponse]("rtm.connect", token = Some(token.key)) onComplete { 57 | 58 | case Success(res) => 59 | if (res.ok) { 60 | send ! RtmData(res.url) 61 | send ! res.self 62 | send ! res 63 | } 64 | case Failure(ex) => 65 | println(ex) 66 | send ! ex 67 | } 68 | case msg: RichOutboundMessage => 69 | log.debug("chat.postMessage requested") 70 | 71 | val attachments = msg.elements.filter(_.isValid).map(_.toJson).mkString("[", ",", "]") 72 | val params = Map("channel" -> msg.channel, "as_user" -> "true", "attachments" -> attachments) 73 | 74 | SlackApiClient.post[ChatPostMessageResponse]("chat.postMessage", params, token = Some(Config.apiKey.key)) onComplete { 75 | case Success(res) => 76 | if (res.ok) { 77 | log.info("[chat.postMessage]: message delivered: " + res.toString) 78 | } 79 | case Failure(ex) => 80 | log.error("[chat.postMessage] Error encountered - " + ex.getMessage) 81 | } 82 | 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/test/scala/io/scalac/slack/common/AttachmentTest.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.common 2 | 3 | import org.scalatest.{Matchers, FunSuite} 4 | import spray.json._ 5 | import JsonProtocols._ 6 | /** 7 | * Created on 09.03.15 10:35 8 | */ 9 | class AttachmentTest extends FunSuite with Matchers { 10 | 11 | test("parse attachment with color only") { 12 | val att1 = Attachment(Color.good) 13 | val att = Attachment(color = Some("good")) 14 | att should equal (att1) 15 | att shouldNot be ('valid) 16 | } 17 | 18 | test("parse attachment with title ") { 19 | val att1 = Attachment(Title("fine title")) 20 | val att = Attachment(title = Some("fine title")) 21 | att should equal (att1) 22 | att should be ('valid) 23 | } 24 | 25 | test("parse attachment with titleURL only ") { 26 | val att1 = Attachment(Title("", Some("title url"))) 27 | val att = Attachment() 28 | att should equal (att1) 29 | att shouldNot be ('valid) 30 | } 31 | 32 | test("parse attachment with title and titleURL ") { 33 | val att1 = Attachment(Title("title", Some("title url"))) 34 | val att = Attachment(title = Some("title"), title_link = Some("title url")) 35 | att should equal (att1) 36 | att should be ('valid) 37 | } 38 | test("parse attachment with title ,titleURL and Color") { 39 | val att1 = Attachment(Title("title", Some("title url")), Color.warning) 40 | val att = Attachment(title = Some("title"), title_link = Some("title url"), color = Some("warning")) 41 | att should equal (att1) 42 | att should be ('valid) 43 | } 44 | 45 | test("parse fields and pretext"){ 46 | val att1 = Attachment(PreText("pretext"), Field("title 1", "content 1", short = false)) 47 | val att = Attachment(pretext = Some("pretext"), fields = Some(List(Field("title 1", "content 1", short = false)))) 48 | att should equal (att1) 49 | att should be ('valid) 50 | } 51 | 52 | test("parse some fields and text"){ 53 | val att1 = Attachment(Text("sometext"), Field("title 1", "content 1", short = false), Field("title 2", "content 2", short = true)) 54 | val att = Attachment(text = Some("sometext"), fields = Some(List(Field("title 1", "content 1", short = false), Field("title 2", "content 2", short = true)))) 55 | att should equal (att1) 56 | att should be ('valid) 57 | } 58 | 59 | test("attachment to JSON"){ 60 | val att1 = Attachment(Text("sometext"), Field("title 1", "content 1", short = false), Field("title 2", "content 2", short = true), Color.danger) 61 | //language=JSON 62 | val json = """{"color":"danger","fallback":"wrong formatted message","fields":[{"short":false,"title":"title 1","value":"content 1"},{"short":true,"title":"title 2","value":"content 2"}],"text":"sometext"}""" 63 | (att1.toJson.toString()) shouldEqual json 64 | 65 | } 66 | 67 | test("field serializer to JSON"){ 68 | 69 | import io.scalac.slack.common.JsonProtocols._ 70 | val field = Field("field title", "field value", short = false) 71 | 72 | val fieldJson = field.toJson.toString() 73 | //language=JSON 74 | val json = """{"short":false,"title":"field title","value":"field value"}""" 75 | fieldJson shouldEqual json 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/websockets/WSActor.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.websockets 2 | 3 | import akka.{Done, NotUsed} 4 | import akka.actor.{Actor, ActorLogging, Props} 5 | import akka.http.scaladsl.Http 6 | import akka.http.scaladsl.model.StatusCodes 7 | import akka.http.scaladsl.model.ws.{BinaryMessage, Message, TextMessage, WebSocketRequest} 8 | import akka.stream.{ActorMaterializer, OverflowStrategy} 9 | import akka.stream.scaladsl.{Flow, Keep, Sink, Source} 10 | import io.scalac.slack._ 11 | 12 | import scala.concurrent.{ExecutionContext, Future} 13 | import scala.util.{Failure, Success} 14 | 15 | /** 16 | * Created on 28.01.15 19:45 17 | */ 18 | class WSActor(eventBus: MessageEventBus) extends Actor with ActorLogging { 19 | 20 | private implicit val system = context.system 21 | private implicit val mat = ActorMaterializer() 22 | private implicit val ec: ExecutionContext = context.dispatcher 23 | override def receive = connect() 24 | 25 | val out = context.actorOf(Props(classOf[OutgoingMessageProcessor], self, eventBus)) 26 | val in = context.actorOf(Props(classOf[IncomingMessageProcessor], eventBus)) 27 | 28 | private val (sourceQueue, source) = Source.queue[Message](100, OverflowStrategy.fail).preMaterialize() 29 | private def messageSink: Sink[Message, Future[Done]] = Sink.foreach({ 30 | case message: TextMessage.Strict => 31 | log.debug(s"Received $message from websocket") 32 | in ! message.text 33 | 34 | case message: TextMessage.Streamed => 35 | log.debug(s"Received stream message from socket") 36 | val futureString = message.textStream.runWith(Sink.fold("")(_ + _)) 37 | futureString.onComplete({ 38 | case Failure(exception) => 39 | log.error("Error consuming streamed buffer", exception) 40 | throw exception 41 | case Success(value) => 42 | in ! value 43 | }) 44 | 45 | case message: BinaryMessage => 46 | log.warning("Received binary message, ignoring") 47 | message.dataStream.runWith(Sink.ignore) // prevent memory leak by consuming buffer into ether 48 | () // ignore binary streamed messages, slack will use only json 49 | }) 50 | 51 | private def messageSource: Source[Message, NotUsed] = source 52 | private def flow: Flow[Message, Message, Future[Done]] = 53 | Flow.fromSinkAndSourceMat(messageSink, messageSource)(Keep.left) 54 | private def connect(): Receive = { 55 | case WebSocket.Connect(url) => 56 | val (upgradeResponse, closed) = Http() 57 | .singleWebSocketRequest(WebSocketRequest(url), flow) 58 | 59 | closed.onComplete(x => log.info(s"websocket closed ${x}")) 60 | upgradeResponse.map { upgrade => 61 | if (upgrade.response.status == StatusCodes.SwitchingProtocols) { 62 | Done 63 | } else { 64 | throw new RuntimeException(s"Connection failed: ${upgrade.response.status}") 65 | } 66 | }(context.dispatcher) 67 | 68 | case WebSocket.Send(msg) => 69 | log.debug(s"send : $msg") 70 | sourceQueue.offer(TextMessage(msg)) 71 | 72 | } 73 | 74 | } 75 | 76 | object WebSocket { 77 | 78 | sealed trait WebSocketMessage 79 | 80 | case class Connect(url: String) extends WebSocketMessage 81 | 82 | case class Send(msg: String) extends WebSocketMessage 83 | 84 | case object Release extends WebSocketMessage 85 | 86 | } 87 | 88 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/common/actors/SlackBotActor.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.common.actors 2 | 3 | import java.util.concurrent.TimeUnit 4 | import akka.actor.{Actor, ActorLogging, ActorRef, PoisonPill, Props} 5 | import akka.util.Timeout 6 | import io.scalac.slack.api.{ApiActor, ApiTest, AuthData, AuthTest, BotInfo, Connected, RegisterModules, RtmConnect, RtmConnectResponse, RtmData, RtmStart, RtmStartResponse, Start, Stop} 7 | import io.scalac.slack.common.{BotInfoKeeper, RegisterDirectChannels, RegisterUsers, Shutdownable} 8 | import io.scalac.slack.websockets.{WSActor, WebSocket} 9 | import io.scalac.slack.{BotModules, Config, MessageEventBus, MigrationInProgress, OutgoingRichMessageProcessor, SlackError} 10 | 11 | import scala.concurrent.duration._ 12 | 13 | class SlackBotActor(modules: BotModules, eventBus: MessageEventBus, master: Shutdownable, usersStorageOpt: Option[ActorRef] = None) extends Actor with ActorLogging { 14 | 15 | import context.{dispatcher, system} 16 | 17 | val api = context.actorOf(Props[ApiActor]) 18 | val richProcessor = context.actorOf(Props(classOf[OutgoingRichMessageProcessor], api, eventBus)) 19 | val websocketClient = system.actorOf(Props(classOf[WSActor], eventBus), "ws-actor") 20 | 21 | var errors = 0 22 | 23 | override def receive: Receive = { 24 | case Start => 25 | //test connection 26 | log.info("trying to connect to Slack's server...") 27 | api ! ApiTest() 28 | case Stop => 29 | master.shutdown() 30 | case Connected => 31 | log.info("connected successfully...") 32 | log.info("trying to auth") 33 | api ! AuthTest(Config.apiKey) 34 | case ad: AuthData => 35 | log.info("authenticated successfully") 36 | log.info("request for websocket connection...") 37 | api ! RtmConnect(Config.apiKey) 38 | case RtmData(url) => 39 | log.info("fetched WSS URL") 40 | log.info(url) 41 | log.info("trying to connect to websockets channel") 42 | val dropProtocol = url.drop(6) 43 | val host = dropProtocol.split('/')(0) 44 | val resource = dropProtocol.drop(host.length) 45 | 46 | implicit val timeout: Timeout = 5.seconds 47 | 48 | log.info(s"Connecting to host [$host] and resource [$resource]") 49 | 50 | websocketClient ! WebSocket.Connect(url) 51 | 52 | context.system.scheduler.scheduleOnce(Duration.create(5, TimeUnit.SECONDS), self, RegisterModules) 53 | 54 | case bi@BotInfo(_, _) => 55 | BotInfoKeeper.current = Some(bi) 56 | case RegisterModules => 57 | modules.registerModules(context, websocketClient) 58 | case MigrationInProgress => 59 | log.warning("MIGRATION IN PROGRESS, next try for 10 seconds") 60 | system.scheduler.scheduleOnce(10.seconds, self, Start) 61 | case se: SlackError if errors < 10 => 62 | errors += 1 63 | log.error(s"connection error [$errors], repeat for 10 seconds") 64 | log.error(s"SlackError occured [${se.toString}]") 65 | system.scheduler.scheduleOnce(10.seconds, self, Start) 66 | case se: SlackError => 67 | log.error(s"SlackError occured [${se.toString}]") 68 | master.shutdown() 69 | case res: RtmConnectResponse => 70 | // if(usersStorageOpt.isDefined) { 71 | // val userStorage = usersStorageOpt.get 72 | // 73 | // userStorage ! RegisterUsers(res.users: _*) 74 | // userStorage ! RegisterDirectChannels(res.ims: _*) 75 | // } 76 | 77 | case WebSocket.Release => 78 | websocketClient ! WebSocket.Release 79 | 80 | case ex: Exception => 81 | log.error("Received exception", ex) 82 | self ! PoisonPill 83 | 84 | case other => 85 | println("what is this?") 86 | println(other) 87 | 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /src/test/scala/io/scalac/slack/common/UsersStorageTest.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.common 2 | 3 | import akka.actor.{ActorSystem, Props} 4 | import akka.testkit.{DefaultTimeout, ImplicitSender, TestKit} 5 | import io.scalac.slack.api.Ok 6 | import io.scalac.slack.models.{Active, DirectChannel, SlackUser} 7 | import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpecLike} 8 | 9 | import scala.concurrent.duration._ 10 | import scala.language.postfixOps 11 | 12 | /** 13 | * Maintainer: @marioosh 14 | */ 15 | class UsersStorageTest(_system: ActorSystem) extends TestKit(_system) with DefaultTimeout with ImplicitSender with Matchers with WordSpecLike with BeforeAndAfterAll { 16 | 17 | def this() = this(ActorSystem("UsersStorageTestActorSystem")) 18 | 19 | override protected def afterAll(): Unit = TestKit.shutdownActorSystem(system) 20 | 21 | "UserStorage" must { 22 | 23 | "save incoming users" in { 24 | val mario = SlackUser("1234", "mario", deleted = false, Some(false), None, None, None, None, None, None, Active) 25 | val stefek = SlackUser("12345", "stefek", deleted = false, Some(false), None, None, None, None, None, None, Active) 26 | val us = system.actorOf(Props[UsersStorage]) 27 | 28 | within(2 seconds) { 29 | us ! RegisterUsers(mario) 30 | expectMsg(Ok) 31 | } 32 | 33 | } 34 | 35 | "find user from storage" in { 36 | val mario = SlackUser("1234", "mario", deleted = false, Some(false), None, None, None, None, None, None, Active) 37 | val stefek = SlackUser("12345", "stefek", deleted = false, Some(false), None, None, None, None, None, None, Active) 38 | val us = system.actorOf(Props[UsersStorage]) 39 | 40 | within(1 second) { 41 | us ! RegisterUsers(mario, stefek) 42 | expectMsg(Ok) 43 | us ! FindUser("mario") 44 | expectMsg(Some(UserInfo("1234", "mario", Active))) 45 | us ! FindUser("12345") 46 | expectMsg(Some(UserInfo("12345", "stefek", Active))) 47 | us ! FindUser("gitara") 48 | expectMsg(None) 49 | 50 | } 51 | } 52 | } 53 | 54 | 55 | "Channel Catalog" must { 56 | "be able to add new channel and find it" in { 57 | val im1 = DirectChannel("D123", "U123") 58 | val im2 = DirectChannel("D124", "U124") 59 | val im3 = DirectChannel("D125", "U124") 60 | 61 | val us = system.actorOf(Props[UsersStorage]) 62 | 63 | within(1 second) { 64 | us ! RegisterDirectChannels(im1, im2) 65 | expectMsg(Ok) 66 | us ! RegisterDirectChannels(im3) 67 | expectMsg(Ok) 68 | us ! FindChannel("D12234") 69 | expectMsg(None) 70 | us ! FindChannel("D123") 71 | expectMsg(Some("D123")) 72 | us ! FindChannel("D125") 73 | expectMsg(Some("D125")) 74 | us ! FindChannel("U124") 75 | expectMsg(Some("D125")) 76 | 77 | } 78 | } 79 | "be able to find channel by username" in { 80 | val im1 = DirectChannel("D123", "U123") 81 | val mario = SlackUser("U123", "mario", deleted = false, Some(false), None, None, None, None, None, None, Active) 82 | 83 | val us = system.actorOf(Props[UsersStorage]) 84 | 85 | within(1 second) { 86 | us ! RegisterUsers(mario) 87 | expectMsg(Ok) 88 | us ! RegisterDirectChannels(im1) 89 | expectMsg(Ok) 90 | //find 91 | us ! FindChannel("D123") 92 | expectMsg(Some("D123")) 93 | us ! FindChannel("U123") 94 | expectMsg(Some("D123")) 95 | us ! FindChannel("mario") 96 | expectMsg(Some("D123")) 97 | 98 | } 99 | 100 | } 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /src/test/scala/io/scalac/slack/IncomingMessageProcessorTest.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack 2 | 3 | import akka.actor.{Actor, ActorRef, ActorSystem, Props} 4 | import akka.testkit.{ImplicitSender, TestKit, TestProbe} 5 | import io.scalac.slack.common._ 6 | import org.joda.time.DateTime 7 | import org.joda.time.format.DateTimeFormat 8 | import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpecLike} 9 | 10 | import scala.concurrent.duration._ 11 | import scala.language.postfixOps 12 | 13 | /** 14 | * Created on 10.02.15 18:27 15 | */ 16 | class IncomingMessageProcessorTest(_system: ActorSystem) extends TestKit(_system) with ImplicitSender with Matchers with WordSpecLike with BeforeAndAfterAll { 17 | 18 | def this() = this(ActorSystem("IncomingMessageProcessorTestSystem")) 19 | 20 | def eB() = new MessageEventBus 21 | 22 | val theProbe = TestProbe() 23 | 24 | def getEchoSubscriber = { 25 | system.actorOf(Props(new Actor { 26 | def receive = { 27 | case im: IncomingMessage => 28 | theProbe.ref ! im 29 | } 30 | })) 31 | } 32 | 33 | //date helpers 34 | val baseTime = new DateTime(2015, 2, 15, 8, 23, 45, 0) 35 | val uniqueTS = SlackDateTime.uniqueTimeStamp(baseTime) 36 | 37 | /** 38 | * Why this function is named matrix? 39 | * Because I can! 40 | * @param f code to execute 41 | * @return nothing at all 42 | */ 43 | def matrix()(f: (ActorRef) => Unit) = { 44 | implicit val eventBus = eB() 45 | val echo = getEchoSubscriber 46 | val entry = system.actorOf(Props(classOf[IncomingMessageProcessor], eventBus)) 47 | eventBus.subscribe(echo, Incoming) 48 | f(entry) 49 | } 50 | 51 | override protected def afterAll(): Unit = TestKit.shutdownActorSystem(system) 52 | 53 | "IncommingMessageProcessor" must { 54 | "push to event bus undefined message" in { 55 | 56 | matrix() { entry => 57 | entry ! "just string!" 58 | theProbe.expectMsg(1 second, UndefinedMessage("just string!")) 59 | } 60 | } 61 | "push hello Object into event bus" in { 62 | matrix() { 63 | entry => 64 | entry ! """{"type":"hello"}""" 65 | theProbe.expectMsg(1 second, Hello) 66 | } 67 | } 68 | "push pong object into event bus" in { 69 | matrix() { entry => 70 | entry ! """{"type":"pong","time":1423985025000,"reply_to":1}""" 71 | 72 | theProbe.expectMsg(1 second, Pong) 73 | } 74 | } 75 | 76 | "push BaseMessage without edited date" in { 77 | matrix() { entry => 78 | entry ! s"""{ 79 | | "type": "message", 80 | | "channel": "C2147483705", 81 | | "user": "U2147483697", 82 | | "text": "Hello world", 83 | | "ts": "1405894322.002768" 84 | |}""".stripMargin 85 | theProbe.expectMsg(1 second, BaseMessage("Hello world", "C2147483705", "U2147483697", "1405894322.002768", edited = false)) 86 | } 87 | } 88 | 89 | /* 90 | "push BaseMessage with edited date" in { 91 | matrix() { entry => 92 | entry ! s"""{ 93 | | "type": "message", 94 | | "channel": "C2147483705", 95 | | "user": "U2147483697", 96 | | "text": "Hello, world!", 97 | | "ts": "$uniqueTS", 98 | | "edited": { 99 | | "user": "U2147483697", 100 | | "ts": "$uniqueTS" 101 | | } 102 | |}""".stripMargin 103 | 104 | theProbe.expectMsg(1 seconds, BaseMessage("Hello world!", "C2147483705", "U2147483697", baseTime, edited = true)) 105 | } 106 | } 107 | */ // I DON"T KNOW WHY IT FAILS 108 | 109 | 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/main/scala/io/scalac/slack/common/Messages.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack.common 2 | 3 | import scala.annotation.tailrec 4 | 5 | /** 6 | * Created on 08.02.15 22:04 7 | */ 8 | sealed trait MessageEvent 9 | 10 | /** 11 | * Incoming message types 12 | */ 13 | trait IncomingMessage extends MessageEvent 14 | 15 | case object Pong extends IncomingMessage 16 | 17 | case object Hello extends IncomingMessage 18 | 19 | /** 20 | * BaseMessage is on the top of messages hierarchy, there is 21 subtypes of BaseMessage and each of them 21 | * should has its own model 22 | * @param text message text, written by user 23 | * @param channel ID of channel 24 | * @param user ID of message author 25 | * @param ts unique timestamp 26 | */ 27 | case class BaseMessage(text: String, channel: String, user: String, ts: String, edited: Boolean = false) extends IncomingMessage 28 | 29 | //user issued command to bot 30 | case class Command(command: String, params: List[String], underlying: BaseMessage) extends IncomingMessage 31 | 32 | //last in the incoming messages hierarchy 33 | case class UndefinedMessage(body: String) extends IncomingMessage 34 | 35 | /** 36 | * User requested help for given target 37 | * @param target Some(x) bot named x should display it's help, None any bot receiving command should send help 38 | */ 39 | case class HelpRequest(target: Option[String], channel: String) extends IncomingMessage 40 | 41 | /** 42 | * Outgoing message types 43 | */ 44 | trait OutgoingMessage extends MessageEvent { 45 | def toJson: String 46 | } 47 | 48 | case object Ping extends OutgoingMessage { 49 | override def toJson = s"""{"id": ${MessageCounter.next}, "type": "ping","time": ${SlackDateTime.timeStamp()}}""" 50 | } 51 | 52 | case class OutboundMessage(channel: String, text: String) extends OutgoingMessage { 53 | override def toJson = 54 | s"""{ 55 | |"id": ${MessageCounter.next}, 56 | |"type": "message", 57 | |"channel": "$channel", 58 | |"text": "$text" 59 | |}""".stripMargin 60 | } 61 | 62 | sealed trait RichMessageElement 63 | 64 | case class Text(value: String) extends RichMessageElement 65 | 66 | case class PreText(value: String) extends RichMessageElement 67 | 68 | case class Field(title: String, value: String, short: Boolean = false) extends RichMessageElement 69 | 70 | case class Title(value: String, url: Option[String] = None) extends RichMessageElement 71 | 72 | case class Color(value: String) extends RichMessageElement 73 | 74 | object Color { 75 | val good = Color("good") 76 | val warning = Color("warning") 77 | val danger = Color("danger") 78 | } 79 | 80 | case class ImageUrl(url: String) extends RichMessageElement 81 | 82 | case class RichOutboundMessage(channel: String, elements: List[Attachment]) extends MessageEvent 83 | 84 | case class Attachment(text: Option[String] = None, pretext: Option[String] = None, fields: Option[List[Field]] = None, title: Option[String] = None, title_link: Option[String] = None, color: Option[String] = None, image_url: Option[String] = None) { 85 | def isValid = text.isDefined || pretext.isDefined || title.isDefined || (fields.isDefined && fields.get.nonEmpty) 86 | 87 | def addElement(element: RichMessageElement): Attachment = { 88 | element match { 89 | case Color(value) if value.nonEmpty => 90 | copy(color = Some(value)) 91 | case Title(value, url) if value.nonEmpty => 92 | copy(title = Some(value), title_link = url) 93 | case PreText(value) if value.nonEmpty => 94 | copy(pretext = Some(value)) 95 | case Text(value) if value.nonEmpty => 96 | copy(text = Some(value)) 97 | case ImageUrl(url) if url.nonEmpty => copy(image_url = Some(url)) 98 | case f: Field => copy(fields = Some(this.fields.getOrElse(List.empty[Field]) :+ f)) 99 | case _ => this 100 | } 101 | } 102 | } 103 | 104 | object Attachment { 105 | 106 | 107 | def apply(elements: RichMessageElement*): Attachment = { 108 | 109 | @tailrec 110 | def loopBuild(elems: List[RichMessageElement], acc: Attachment): Attachment = { 111 | elems match { 112 | case Nil => acc 113 | case head :: tail => loopBuild(tail, acc.addElement(head)) 114 | } 115 | } 116 | loopBuild(elements.toList, new Attachment()) 117 | } 118 | 119 | } 120 | 121 | /** 122 | * Classifier for message event 123 | */ 124 | sealed trait MessageEventType 125 | 126 | object Incoming extends MessageEventType 127 | 128 | object Outgoing extends MessageEventType 129 | 130 | /** 131 | * Message Type is unmarshalling helper 132 | * that show what kind of type is incomming message 133 | * it's needed because of their similiarity 134 | */ 135 | case class MessageType(messageType: String, subType: Option[String]) 136 | 137 | /** 138 | * DirectMessage is sent directly to choosen user 139 | * @param key user's name, Id or channelId 140 | * @param event message to send, channel inside event will be overwritten 141 | */ 142 | case class DirectMessage(key: String, event: MessageEvent) 143 | 144 | object DirectMessage { 145 | def apply(key: String, message: String): DirectMessage = DirectMessage(key, OutboundMessage("", message)) 146 | } -------------------------------------------------------------------------------- /src/test/scala/io/scalac/slack/UnmarshallerTest.scala: -------------------------------------------------------------------------------- 1 | package io.scalac.slack 2 | 3 | import io.scalac.slack.api.{ApiTestResponse, AuthTestResponse, RtmStartResponse} 4 | import io.scalac.slack.models._ 5 | import org.joda.time.DateTime 6 | import org.scalatest.{FunSuite, Matchers} 7 | import spray.json._ 8 | 9 | /** 10 | * Created on 27.01.15 22:39 11 | */ 12 | class UnmarshallerTest extends FunSuite with Matchers { 13 | 14 | import io.scalac.slack.api.Unmarshallers._ 15 | 16 | val url = "https://testapp.slack.com/" 17 | val team = "testteam" 18 | val username = "testuser" 19 | val teamId = "T03DN3GTN" 20 | val userId = "U03DQKG34" 21 | 22 | 23 | test("api.test empty response") { 24 | val response = /*language=JSON*/ """{"ok":true}""" 25 | 26 | val apiTestResponse = response.parseJson.convertTo[ApiTestResponse] 27 | 28 | apiTestResponse shouldBe 'ok 29 | apiTestResponse.args should be(None) 30 | apiTestResponse.error should be(None) 31 | 32 | } 33 | 34 | test("api.test with param") { 35 | val response = /*language=JSON*/ """{"ok":true,"args":{"name":"mario"}}""" 36 | val apiTestResponse = response.parseJson.convertTo[ApiTestResponse] 37 | apiTestResponse shouldBe 'ok 38 | apiTestResponse.args should be(Some(Map("name" -> "mario"))) 39 | apiTestResponse.error should be(None) 40 | 41 | } 42 | test("api.test with error") { 43 | val response = """{"ok":false,"error":"auth_error","args":{"error":"auth_error"}}""" 44 | 45 | val apiTestResponse = response.parseJson.convertTo[ApiTestResponse] 46 | 47 | apiTestResponse should not be 'ok 48 | apiTestResponse.args should be(Some(Map("error" -> "auth_error"))) 49 | apiTestResponse.error should be(Some("auth_error")) 50 | 51 | } 52 | 53 | test("api.test with error and param") { 54 | val response = """{"ok":false,"error":"auth_error","args":{"error":"auth_error","name":"mario"}}""" 55 | val apiTestResponse = response.parseJson.convertTo[ApiTestResponse] 56 | 57 | apiTestResponse should not be 'ok 58 | apiTestResponse.args should be(Some(Map("error" -> "auth_error", "name" -> "mario"))) 59 | apiTestResponse.error should be(Some("auth_error")) 60 | 61 | } 62 | 63 | test("auth.test successful") { 64 | val response = s"""{"ok":true,"url":"$url","team":"$team","user":"$username","team_id":"$teamId","user_id":"$userId"}""" 65 | val authTestResponse = response.parseJson.convertTo[AuthTestResponse] 66 | 67 | authTestResponse shouldBe 'ok 68 | 69 | authTestResponse.error should be(None) 70 | authTestResponse.url should equal(Some(url)) 71 | authTestResponse.team should equal(Some(team)) 72 | authTestResponse.user should equal(Some(username)) 73 | authTestResponse.user_id should equal(Some(userId)) 74 | authTestResponse.team_id should equal(Some(teamId)) 75 | } 76 | 77 | test("auth.test failed") { 78 | val response = """{"ok":false,"error":"not_authed"}""" 79 | 80 | val authTestResponse = response.parseJson.convertTo[AuthTestResponse] 81 | 82 | authTestResponse should not be 'ok 83 | authTestResponse.error should be(Some("not_authed")) 84 | authTestResponse.url should be(None) 85 | authTestResponse.team should equal(None) 86 | authTestResponse.user should equal(None) 87 | authTestResponse.user_id should equal(None) 88 | authTestResponse.team_id should equal(None) 89 | } 90 | 91 | test("rtm.start successful") { 92 | /* language=JSON */ 93 | val response = """{"channels": [{ 94 | | "is_channel": true, 95 | | "name": "general", 96 | | "last_read": "1421772996.000005", 97 | | "creator": "U03DN1GTQ", 98 | | "purpose": { 99 | | "value": "This channel is for team-wide communication and announcements. All team members are in this channel.", 100 | | "creator": "", 101 | | "last_set": 0 102 | | }, 103 | | "is_member": true, 104 | | "id": "C03DN1GUJ", 105 | | "unread_count": 1, 106 | | "members": ["U03DKUF05", "U03DKUMKH", "U03DKUTAZ", "U03DL3Q9M", "U03DN1GTQ", "U03DQKG14"], 107 | | "is_general": true, 108 | | "topic": { 109 | | "value": "", 110 | | "creator": "", 111 | | "last_set": 0 112 | | }, 113 | | "latest": { 114 | | "subtype": "channel_join", 115 | | "ts": "1421786647.000002", 116 | | "text": "<@U03DQKG14|secretary> has joined the channel", 117 | | "type": "message", 118 | | "user": "U03DQKG14" 119 | | }, 120 | | "is_archived": false, 121 | | "created": 1421772055 122 | | }, { 123 | | "is_channel": true, 124 | | "name": "random", 125 | | "creator": "U03DN1GTQ", 126 | | "is_member": false, 127 | | "id": "C03DN1GUN", 128 | | "is_general": false, 129 | | "is_archived": false, 130 | | "created": 1421772055 131 | | }], 132 | | "url": "wss://ms25.slack-msgs.com/websocket/_eQUaO1csLMyoe4p4rUgEIH/W/gEruHxke8x0TNSE0ltMOdO7bHsP_W9mOznr5U1DzWvW7qs6BZulFXKcg0X2giBxV8UaHtptGEK0_F_rUA=", 133 | | "bots": [{ 134 | | "id": "B03DL3Q9K", 135 | | "name": "bot", 136 | | "deleted": false, 137 | | "icons": { 138 | | "image_48": "https://slack.global.ssl.fastly.net/26133/plugins/bot/assets/bot_48.png" 139 | | } 140 | | }, { 141 | | "id": "B03DQKG0Y", 142 | | "name": "bot", 143 | | "deleted": false, 144 | | "icons": { 145 | | "image_48": "https://slack.global.ssl.fastly.net/26133/plugins/bot/assets/bot_48.png" 146 | | } 147 | | }], 148 | | "users": [{ 149 | | "is_bot": false, 150 | | "name": "benek", 151 | | "tz_offset": 3600, 152 | | "is_admin": false, 153 | | "tz": "Europe/Amsterdam", 154 | | "color": "4bbe2e", 155 | | "is_owner": false, 156 | | "has_files": false, 157 | | "id": "U03DKUF05", 158 | | "presence": "away", 159 | | "profile": { 160 | | "email": "benek@5dots.pl", 161 | | "image_72": "https://secure.gravatar.com/avatar/3d6188e64eb0f7d1156d3bda95452901.jpg?s=72&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0000-72.png", 162 | | "image_48": "https://secure.gravatar.com/avatar/3d6188e64eb0f7d1156d3bda95452901.jpg?s=48&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0000-48.png", 163 | | "image_32": "https://secure.gravatar.com/avatar/3d6188e64eb0f7d1156d3bda95452901.jpg?s=32&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0000-32.png", 164 | | "real_name_normalized": "", 165 | | "real_name": "", 166 | | "image_24": "https://secure.gravatar.com/avatar/3d6188e64eb0f7d1156d3bda95452901.jpg?s=24&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0000-24.png", 167 | | "image_192": "https://secure.gravatar.com/avatar/3d6188e64eb0f7d1156d3bda95452901.jpg?s=192&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0000.png" 168 | | }, 169 | | "tz_label": "Central European Time", 170 | | "is_ultra_restricted": false, 171 | | "status": null, 172 | | "real_name": "", 173 | | "is_restricted": false, 174 | | "deleted": false, 175 | | "is_primary_owner": false 176 | | }, { 177 | | "is_bot": true, 178 | | "name": "iwan", 179 | | "has_files": false, 180 | | "id": "U03DL3Q9M", 181 | | "presence": "away", 182 | | "profile": { 183 | | "image_original": "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2015-01-20/3462126459_e2907a3b77c466905e17_original.jpg", 184 | | "image_72": "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2015-01-20/3462126459_e2907a3b77c466905e17_72.jpg", 185 | | "image_48": "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2015-01-20/3462126459_e2907a3b77c466905e17_48.jpg", 186 | | "bot_id": "B03DL3Q9K", 187 | | "image_32": "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2015-01-20/3462126459_e2907a3b77c466905e17_32.jpg", 188 | | "real_name_normalized": "", 189 | | "real_name": "", 190 | | "image_24": "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2015-01-20/3462126459_e2907a3b77c466905e17_24.jpg", 191 | | "image_192": "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2015-01-20/3462126459_e2907a3b77c466905e17_192.jpg" 192 | | }, 193 | | "deleted": true 194 | | }, { 195 | | "is_bot": false, 196 | | "name": "marioosh", 197 | | "tz_offset": 3600, 198 | | "is_admin": true, 199 | | "tz": "Europe/Amsterdam", 200 | | "color": "9f69e7", 201 | | "is_owner": true, 202 | | "has_files": false, 203 | | "id": "U03DN1GTQ", 204 | | "presence": "active", 205 | | "profile": { 206 | | "email": "marioosh@5dots.pl", 207 | | "image_72": "https://secure.gravatar.com/avatar/ab02a07bc137cb73708602cafcd897d4.jpg?s=72&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0020-72.png", 208 | | "image_48": "https://secure.gravatar.com/avatar/ab02a07bc137cb73708602cafcd897d4.jpg?s=48&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0020-48.png", 209 | | "image_32": "https://secure.gravatar.com/avatar/ab02a07bc137cb73708602cafcd897d4.jpg?s=32&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0020-32.png", 210 | | "real_name_normalized": "", 211 | | "real_name": "", 212 | | "image_24": "https://secure.gravatar.com/avatar/ab02a07bc137cb73708602cafcd897d4.jpg?s=24&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0020-24.png", 213 | | "image_192": "https://secure.gravatar.com/avatar/ab02a07bc137cb73708602cafcd897d4.jpg?s=192&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0020.png" 214 | | }, 215 | | "tz_label": "Central European Time", 216 | | "is_ultra_restricted": false, 217 | | "status": null, 218 | | "real_name": "", 219 | | "is_restricted": false, 220 | | "deleted": false, 221 | | "is_primary_owner": true 222 | | }, { 223 | | "is_bot": true, 224 | | "name": "secretary", 225 | | "tz_offset": -28800, 226 | | "is_admin": false, 227 | | "tz": null, 228 | | "color": "e96699", 229 | | "is_owner": false, 230 | | "has_files": false, 231 | | "id": "U03DQKG14", 232 | | "presence": "away", 233 | | "profile": { 234 | | "first_name": "IVAN", 235 | | "image_original": "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2015-01-20/3466670008_0a4adf28d0f251ad032e_original.jpg", 236 | | "image_72": "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2015-01-20/3466670008_0a4adf28d0f251ad032e_48.jpg", 237 | | "image_48": "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2015-01-20/3466670008_0a4adf28d0f251ad032e_48.jpg", 238 | | "bot_id": "B03DQKG0Y", 239 | | "image_32": "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2015-01-20/3466670008_0a4adf28d0f251ad032e_32.jpg", 240 | | "real_name_normalized": "IVAN DEPLOYER", 241 | | "last_name": "DEPLOYER", 242 | | "real_name": "IVAN DEPLOYER", 243 | | "image_24": "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2015-01-20/3466670008_0a4adf28d0f251ad032e_24.jpg", 244 | | "title": "KEEP CHANNEL TIDY", 245 | | "image_192": "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2015-01-20/3466670008_0a4adf28d0f251ad032e_48.jpg" 246 | | }, 247 | | "tz_label": "Pacific Standard Time", 248 | | "is_ultra_restricted": false, 249 | | "status": null, 250 | | "real_name": "IVAN DEPLOYER", 251 | | "is_restricted": false, 252 | | "deleted": false, 253 | | "is_primary_owner": false 254 | | }, { 255 | | "is_bot": false, 256 | | "name": "stefek", 257 | | "tz_offset": 3600, 258 | | "is_admin": false, 259 | | "tz": "Europe/Amsterdam", 260 | | "color": "3c989f", 261 | | "is_owner": false, 262 | | "has_files": false, 263 | | "id": "U03DKUTAZ", 264 | | "presence": "away", 265 | | "profile": { 266 | | "email": "stefek@5dots.pl", 267 | | "image_72": "https://secure.gravatar.com/avatar/a4551e4b7d330e59acf4bdda79ac8b21.jpg?s=72&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0002-72.png", 268 | | "image_48": "https://secure.gravatar.com/avatar/a4551e4b7d330e59acf4bdda79ac8b21.jpg?s=48&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F20655%2Fimg%2Favatars%2Fava_0002-48.png", 269 | | "image_32": "https://secure.gravatar.com/avatar/a4551e4b7d330e59acf4bdda79ac8b21.jpg?s=32&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0002-32.png", 270 | | "real_name_normalized": "", 271 | | "real_name": "", 272 | | "image_24": "https://secure.gravatar.com/avatar/a4551e4b7d330e59acf4bdda79ac8b21.jpg?s=24&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0002-24.png", 273 | | "image_192": "https://secure.gravatar.com/avatar/a4551e4b7d330e59acf4bdda79ac8b21.jpg?s=192&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0002.png" 274 | | }, 275 | | "tz_label": "Central European Time", 276 | | "is_ultra_restricted": false, 277 | | "status": null, 278 | | "real_name": "", 279 | | "is_restricted": false, 280 | | "deleted": false, 281 | | "is_primary_owner": false 282 | | }, { 283 | | "is_bot": false, 284 | | "name": "ziuta", 285 | | "tz_offset": 3600, 286 | | "is_admin": false, 287 | | "tz": "Europe/Amsterdam", 288 | | "color": "e7392d", 289 | | "is_owner": false, 290 | | "has_files": false, 291 | | "id": "U03DKUMKH", 292 | | "presence": "away", 293 | | "profile": { 294 | | "email": "ziuta@5dots.pl", 295 | | "image_72": "https://secure.gravatar.com/avatar/92b61c6a2a1efea6208c7faf3ffabea4.jpg?s=72&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0016-72.png", 296 | | "image_48": "https://secure.gravatar.com/avatar/92b61c6a2a1efea6208c7faf3ffabea4.jpg?s=48&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0016-48.png", 297 | | "image_32": "https://secure.gravatar.com/avatar/92b61c6a2a1efea6208c7faf3ffabea4.jpg?s=32&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0016-32.png", 298 | | "real_name_normalized": "", 299 | | "real_name": "", 300 | | "image_24": "https://secure.gravatar.com/avatar/92b61c6a2a1efea6208c7faf3ffabea4.jpg?s=24&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0016-24.png", 301 | | "image_192": "https://secure.gravatar.com/avatar/92b61c6a2a1efea6208c7faf3ffabea4.jpg?s=192&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0016.png" 302 | | }, 303 | | "tz_label": "Central European Time", 304 | | "is_ultra_restricted": false, 305 | | "status": null, 306 | | "real_name": "", 307 | | "is_restricted": false, 308 | | "deleted": false, 309 | | "is_primary_owner": false 310 | | }, { 311 | | "is_bot": false, 312 | | "name": "slackbot", 313 | | "tz_offset": -28800, 314 | | "is_admin": false, 315 | | "tz": null, 316 | | "color": "757575", 317 | | "is_owner": false, 318 | | "id": "USLACKBOT", 319 | | "presence": "active", 320 | | "profile": { 321 | | "first_name": "Slack", 322 | | "email": null, 323 | | "image_72": "https://slack-assets2.s3-us-west-2.amazonaws.com/10068/img/slackbot_72.png", 324 | | "image_48": "https://slack-assets2.s3-us-west-2.amazonaws.com/10068/img/slackbot_48.png", 325 | | "image_32": "https://slack-assets2.s3-us-west-2.amazonaws.com/10068/img/slackbot_32.png", 326 | | "real_name_normalized": "Slack Bot", 327 | | "last_name": "Bot", 328 | | "real_name": "Slack Bot", 329 | | "image_24": "https://slack-assets2.s3-us-west-2.amazonaws.com/10068/img/slackbot_24.png", 330 | | "image_192": "https://slack-assets2.s3-us-west-2.amazonaws.com/10068/img/slackbot_192.png" 331 | | }, 332 | | "tz_label": "Pacific Standard Time", 333 | | "is_ultra_restricted": false, 334 | | "status": null, 335 | | "real_name": "Slack Bot", 336 | | "is_restricted": false, 337 | | "deleted": false, 338 | | "is_primary_owner": false 339 | | }], 340 | | "latest_event_ts": "1422397894.000000", 341 | | "self": { 342 | | "name": "secretary", 343 | | "id": "U03DQKG14", 344 | | "manual_presence": "active", 345 | | "prefs": { 346 | | "has_invited": false, 347 | | "no_created_overlays": false, 348 | | "seen_team_menu_tip_card": false, 349 | | "webapp_spellcheck": true, 350 | | "expand_snippets": false, 351 | | "color_names_in_list": true, 352 | | "no_joined_overlays": false, 353 | | "sidebar_behavior": "", 354 | | "email_alerts": "instant", 355 | | "seen_ssb_prompt": false, 356 | | "has_uploaded": false, 357 | | "show_member_presence": true, 358 | | "email_misc": true, 359 | | "time24": false, 360 | | "never_channels": "", 361 | | "push_dm_alert": true, 362 | | "user_colors": "", 363 | | "expand_inline_imgs": true, 364 | | "last_snippet_type": "", 365 | | "emoji_mode": "default", 366 | | "collapsible_by_click": true, 367 | | "load_lato_2": false, 368 | | "mac_speak_speed": 250, 369 | | "ss_emojis": true, 370 | | "no_macssb1_banner": false, 371 | | "highlight_words": "", 372 | | "seen_welcome_2": false, 373 | | "mute_sounds": false, 374 | | "muted_channels": "", 375 | | "seen_member_invite_reminder": false, 376 | | "posts_formatting_guide": true, 377 | | "seen_channels_tip_card": false, 378 | | "sidebar_theme_custom_values": "", 379 | | "at_channel_suppressed_channels": "", 380 | | "f_key_search": false, 381 | | "tz": null, 382 | | "no_text_in_notifications": false, 383 | | "has_created_channel": false, 384 | | "seen_message_input_tip_card": false, 385 | | "arrow_history": false, 386 | | "email_alerts_sleep_until": 0, 387 | | "seen_flexpane_tip_card": false, 388 | | "mac_speak_voice": "com.apple.speech.synthesis.voice.Alex", 389 | | "tab_ui_return_selects": true, 390 | | "privacy_policy_seen": true, 391 | | "mark_msgs_read_immediately": true, 392 | | "push_sound": "b2.mp3", 393 | | "comma_key_prefs": false, 394 | | "collapsible": false, 395 | | "mac_ssb_bullet": true, 396 | | "k_key_omnibox": true, 397 | | "dropbox_enabled": false, 398 | | "growls_enabled": true, 399 | | "welcome_message_hidden": false, 400 | | "all_channels_loud": true, 401 | | "email_weekly": true, 402 | | "seen_search_input_tip_card": false, 403 | | "seen_channel_menu_tip_card": false, 404 | | "search_only_my_channels": false, 405 | | "loud_channels_set": "", 406 | | "search_exclude_channels": "", 407 | | "seen_user_menu_tip_card": false, 408 | | "win_ssb_bullet": true, 409 | | "push_loud_channels_set": "", 410 | | "push_loud_channels": "", 411 | | "enter_is_special_in_tbt": false, 412 | | "full_text_extracts": false, 413 | | "fuzzy_matching": false, 414 | | "push_idle_wait": 2, 415 | | "obey_inline_img_limit": true, 416 | | "seen_domain_invite_reminder": false, 417 | | "sidebar_theme": "default", 418 | | "expand_internal_inline_imgs": true, 419 | | "push_mention_alert": true, 420 | | "new_msg_snd": "knock_brush.mp3", 421 | | "search_exclude_bots": false, 422 | | "convert_emoticons": true, 423 | | "start_scroll_at_oldest": true, 424 | | "fuller_timestamps": false, 425 | | "pagekeys_handled": true, 426 | | "require_at": false, 427 | | "push_mention_channels": "", 428 | | "ls_disabled": false, 429 | | "autoplay_chat_sounds": true, 430 | | "mac_ssb_bounce": "", 431 | | "graphic_emoticons": false, 432 | | "snippet_editor_wrap_long_lines": false, 433 | | "display_real_names_override": 0, 434 | | "push_everything": true, 435 | | "last_seen_at_channel_warning": 0, 436 | | "messages_theme": "default", 437 | | "show_typing": true, 438 | | "speak_growls": false, 439 | | "push_at_channel_suppressed_channels": "", 440 | | "loud_channels": "", 441 | | "search_sort": "timestamp", 442 | | "prompted_for_email_disabling": false, 443 | | "expand_non_media_attachments": true 444 | | }, 445 | | "created": 1421786646 446 | | }, 447 | | "groups": [], 448 | | "cache_version": "v3-dog", 449 | | "ims": [{ 450 | | "last_read": "0000000000.000000", 451 | | "is_open": true, 452 | | "id": "D03DQKG18", 453 | | "unread_count": 0, 454 | | "is_im": true, 455 | | "latest": null, 456 | | "user": "USLACKBOT", 457 | | "created": 1421786647 458 | | }, { 459 | | "last_read": "0000000000.000000", 460 | | "is_open": true, 461 | | "id": "D03DQKG24", 462 | | "unread_count": 0, 463 | | "is_im": true, 464 | | "latest": null, 465 | | "user": "U03DKUF05", 466 | | "created": 1421786647 467 | | }, { 468 | | "last_read": "0000000000.000000", 469 | | "is_open": true, 470 | | "id": "D03DQKG1L", 471 | | "unread_count": 0, 472 | | "is_im": true, 473 | | "latest": null, 474 | | "user": "U03DKUMKH", 475 | | "created": 1421786647 476 | | }, { 477 | | "last_read": "0000000000.000000", 478 | | "is_open": true, 479 | | "id": "D03DQKG1U", 480 | | "unread_count": 0, 481 | | "is_im": true, 482 | | "latest": null, 483 | | "user": "U03DKUTAZ", 484 | | "created": 1421786647 485 | | }, { 486 | | "last_read": "0000000000.000000", 487 | | "is_open": true, 488 | | "id": "D03DQKG1C", 489 | | "unread_count": 0, 490 | | "is_im": true, 491 | | "latest": null, 492 | | "user": "U03DN1GTQ", 493 | | "created": 1421786647 494 | | }], 495 | | "team": { 496 | | "name": "fivedots", 497 | | "domain": "5dots", 498 | | "icon": { 499 | | "image_132": "https://slack.global.ssl.fastly.net/28461/img/avatars-teams/ava_0018-132.png", 500 | | "image_68": "https://slack.global.ssl.fastly.net/28461/img/avatars-teams/ava_0018-68.png", 501 | | "image_88": "https://slack.global.ssl.fastly.net/28461/img/avatars-teams/ava_0018-88.png", 502 | | "image_102": "https://slack.global.ssl.fastly.net/28461/img/avatars-teams/ava_0018-102.png", 503 | | "image_44": "https://slack.global.ssl.fastly.net/28461/img/avatars-teams/ava_0018-44.png", 504 | | "image_34": "https://slack.global.ssl.fastly.net/28461/img/avatars-teams/ava_0018-34.png", 505 | | "image_default": true 506 | | }, 507 | | "over_storage_limit": false, 508 | | "email_domain": "5dots.pl", 509 | | "id": "T03DN1GTN", 510 | | "prefs": { 511 | | "who_can_kick_channels": "admin", 512 | | "warn_before_at_channel": "always", 513 | | "who_can_archive_channels": "regular", 514 | | "dm_retention_type": 0, 515 | | "retention_type": 0, 516 | | "default_channels": ["C03DN1GUJ", "C03DN1GUN"], 517 | | "who_can_post_general": "ra", 518 | | "who_can_at_everyone": "regular", 519 | | "who_can_at_channel": "ra", 520 | | "allow_message_deletion": true, 521 | | "require_at_for_mention": 0, 522 | | "display_real_names": false, 523 | | "who_can_create_channels": "regular", 524 | | "compliance_export_start": 0, 525 | | "who_can_create_groups": "ra", 526 | | "dm_retention_duration": 0, 527 | | "who_can_kick_groups": "regular", 528 | | "retention_duration": 0, 529 | | "msg_edit_window_mins": -1, 530 | | "hide_referers": true, 531 | | "group_retention_duration": 0, 532 | | "group_retention_type": 0 533 | | }, 534 | | "msg_edit_window_mins": -1 535 | | }, 536 | | "ok": true 537 | |}""".stripMargin 538 | 539 | val rtmResponse = response.parseJson.convertTo[RtmStartResponse] 540 | 541 | rtmResponse shouldBe 'ok 542 | rtmResponse.url should equal("wss://ms25.slack-msgs.com/websocket/_eQUaO1csLMyoe4p4rUgEIH/W/gEruHxke8x0TNSE0ltMOdO7bHsP_W9mOznr5U1DzWvW7qs6BZulFXKcg0X2giBxV8UaHtptGEK0_F_rUA=") 543 | rtmResponse.users shouldBe 'nonEmpty 544 | rtmResponse.channels shouldBe 'nonEmpty 545 | rtmResponse.users.size should equal(7) 546 | rtmResponse.channels.size should equal(2) 547 | rtmResponse.self.id should equal("U03DQKG14") 548 | rtmResponse.self.name should equal("secretary") 549 | rtmResponse.ims.size should equal(5) 550 | } 551 | 552 | test("long channel unmarshall") { 553 | /* language=JSON */ 554 | val channelString = """{ 555 | | "is_channel": true, 556 | | "name": "general", 557 | | "last_read": "1421772996.000005", 558 | | "creator": "U03DN1GTQ", 559 | | "purpose": { 560 | | "value": "This channel is for team-wide communication and announcements. All team members are in this channel.", 561 | | "creator": "", 562 | | "last_set": 0 563 | | }, 564 | | "is_member": true, 565 | | "id": "C03DN1GUJ", 566 | | "unread_count": 1, 567 | | "members": ["U03DKUF05", "U03DKUMKH", "U03DKUTAZ", "U03DL3Q9M", "U03DN1GTQ", "U03DQKG14"], 568 | | "is_general": true, 569 | | "topic": { 570 | | "value": "", 571 | | "creator": "", 572 | | "last_set": 0 573 | | }, 574 | | "latest": { 575 | | "subtype": "channel_join", 576 | | "ts": "1421786647.000002", 577 | | "text": "<@U03DQKG14|secretary> has joined the channel", 578 | | "type": "message", 579 | | "user": "U03DQKG14" 580 | | }, 581 | | "is_archived": false, 582 | | "created": 1421772055 583 | | }""".stripMargin 584 | val channel = channelString.parseJson.convertTo[Channel] 585 | channel shouldBe 'isChannel 586 | channel shouldBe 'isMember 587 | channel.name should equal("general") 588 | channel.creator should equal("U03DN1GTQ") 589 | channel.id should equal("C03DN1GUJ") 590 | channel shouldBe 'isGeneral 591 | channel should not be 'isArchived 592 | channel.created should equal(new DateTime(1421772055000l)) 593 | channel.purpose should be(Some(ChannelInfo("This channel is for team-wide communication and announcements. All team members are in this channel.", "", 0))) 594 | channel.topic should be(Some(ChannelInfo("", "", 0))) 595 | channel.unreadCount should be(Some(1)) 596 | channel.lastRead should be(Some(new DateTime(1421772996000l))) 597 | channel.members should be(Some(List("U03DKUF05", "U03DKUMKH", "U03DKUTAZ", "U03DL3Q9M", "U03DN1GTQ", "U03DQKG14"))) 598 | } 599 | 600 | test("short channel unmarshall") { 601 | /* language=JSON */ 602 | val channelString = """{ 603 | | "is_channel": true, 604 | | "name": "random", 605 | | "creator": "U03DN1GTQ", 606 | | "is_member": false, 607 | | "id": "C03DN1GUN", 608 | | "is_general": false, 609 | | "is_archived": false, 610 | | "created": 1421772055 611 | | }""".stripMargin 612 | 613 | 614 | val channel = channelString.parseJson.convertTo[Channel] 615 | channel shouldBe 'isChannel 616 | channel should not be 'isMember 617 | channel.name should equal("random") 618 | channel.creator should equal("U03DN1GTQ") 619 | channel.id should equal("C03DN1GUN") 620 | channel should not be 'isGeneral 621 | channel should not be 'isArchived 622 | channel.created should equal(new DateTime(1421772055000L)) 623 | channel.purpose should be(None) 624 | channel.topic should be(None) 625 | channel.unreadCount should be(None) 626 | channel.lastRead should be(None) 627 | channel.members should be(None) 628 | } 629 | 630 | test("Channel topic") { 631 | val topicString = """{ 632 | | "value": "", 633 | | "creator": "", 634 | | "last_set": 0 635 | | }""".stripMargin 636 | 637 | val topic = topicString.parseJson.convertTo[ChannelInfo] 638 | topic.value should equal("") 639 | topic.creator should equal("") 640 | topic.last_set should equal(0) 641 | } 642 | 643 | test("channel purpose") { 644 | 645 | val purposeString = """{ 646 | | "value": "This channel is for team-wide communication and announcements. All team members are in this channel.", 647 | | "creator": "", 648 | | "last_set": 0 649 | | }""".stripMargin 650 | val purpose = purposeString.parseJson.convertTo[ChannelInfo] 651 | 652 | purpose.value should equal("This channel is for team-wide communication and announcements. All team members are in this channel.") 653 | purpose.creator should equal("") 654 | purpose.last_set should equal(0) 655 | } 656 | 657 | test("user object") { 658 | /* language=JSON */ 659 | val userString = """{ 660 | | "is_bot": false, 661 | | "name": "benek", 662 | | "tz_offset": 3600, 663 | | "is_admin": false, 664 | | "tz": "Europe/Amsterdam", 665 | | "color": "4bbe2e", 666 | | "is_owner": false, 667 | | "has_files": false, 668 | | "id": "U03DKUF05", 669 | | "presence": "away", 670 | | "profile": { 671 | | "email": "benek@5dots.pl", 672 | | "image_72": "https://secure.gravatar.com/avatar/3d6188e64eb0f7d1156d3bda95452901.jpg?s=72&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0000-72.png", 673 | | "image_48": "https://secure.gravatar.com/avatar/3d6188e64eb0f7d1156d3bda95452901.jpg?s=48&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0000-48.png", 674 | | "image_32": "https://secure.gravatar.com/avatar/3d6188e64eb0f7d1156d3bda95452901.jpg?s=32&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0000-32.png", 675 | | "real_name_normalized": "", 676 | | "real_name": "", 677 | | "image_24": "https://secure.gravatar.com/avatar/3d6188e64eb0f7d1156d3bda95452901.jpg?s=24&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0000-24.png", 678 | | "image_192": "https://secure.gravatar.com/avatar/3d6188e64eb0f7d1156d3bda95452901.jpg?s=192&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F8390%2Fimg%2Favatars%2Fava_0000.png" 679 | | }, 680 | | "tz_label": "Central European Time", 681 | | "is_ultra_restricted": false, 682 | | "status": null, 683 | | "real_name": "", 684 | | "is_restricted": false, 685 | | "deleted": false, 686 | | "is_primary_owner": false 687 | | }""".stripMargin 688 | 689 | val user = userString.parseJson.convertTo[SlackUser] 690 | 691 | user.isBot should equal(Some(false)) 692 | user.name should equal("benek") 693 | user.id should equal("U03DKUF05") 694 | user should not be 'deleted 695 | user.isAdmin should equal(Some(false)) 696 | user.isOwner should equal(Some(false)) 697 | user.isPrimaryOwner should equal(Some(false)) 698 | user.isRestricted should equal(Some(false)) 699 | user.isUltraRestricted should equal(Some(false)) 700 | user.hasFiles should equal(Some(false)) 701 | user.presence should equal(Away) 702 | } 703 | 704 | test("IM object") { 705 | /*language=JSON*/ 706 | val imString = """{ 707 | | "last_read": "0000000000.000000", 708 | | "is_open": true, 709 | | "id": "D03DQKG1C", 710 | | "unread_count": 0, 711 | | "is_im": true, 712 | | "latest": null, 713 | | "user": "U03DN1GTQ", 714 | | "created": 1421786647 715 | | }""".stripMargin 716 | 717 | val im = imString.parseJson.convertTo[DirectChannel] 718 | im.id should equal("D03DQKG1C") 719 | im.userId should equal("U03DN1GTQ") 720 | } 721 | 722 | } 723 | --------------------------------------------------------------------------------