From fe9da51830f939dc3f4c1ce9fab8e74575653bde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Mickevi=C4=8Dius?= Date: Tue, 10 Aug 2021 21:33:43 +0300 Subject: [PATCH] Upgrade to citywasp-api 2.0 --- .scalafix.conf | 8 ++ .scalafmt.conf | 13 ++- .travis.yml | 33 -------- build.sbt | 21 +++-- project/build.properties | 2 +- project/plugins.sbt | 14 ++-- src/main/resources/application.conf | 8 +- src/main/scala/Kabrioletas.scala | 125 ++++++++++++++++++---------- src/main/scala/package.scala | 24 ------ src/main/scala/tapir.scala | 45 ++++++++++ 10 files changed, 170 insertions(+), 123 deletions(-) create mode 100644 .scalafix.conf delete mode 100644 .travis.yml delete mode 100644 src/main/scala/package.scala create mode 100644 src/main/scala/tapir.scala diff --git a/.scalafix.conf b/.scalafix.conf new file mode 100644 index 0000000..b70ecd7 --- /dev/null +++ b/.scalafix.conf @@ -0,0 +1,8 @@ +rule = SortImports +SortImports.blocks = [ + "java.", + "scala.", + "akka.", + "*", + "lt.dvim.citywasp.", +] diff --git a/.scalafmt.conf b/.scalafmt.conf index d4b7be4..f86e741 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,4 +1,11 @@ -version = "2.7.5" - -align = some +version = "3.0.0-RC6" +align = more maxColumn = 120 + +align.tokens."+" = [ + {code = "%", owner = "Term.ApplyInfix"}, + {code = "%%", owner = "Term.ApplyInfix"}, +] + +rewrite.rules = [RedundantBraces, RedundantParens, SortImports] +unindentTopLevelOperators = true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 3dde735..0000000 --- a/.travis.yml +++ /dev/null @@ -1,33 +0,0 @@ -language: scala -scala: 2.12.13 -jdk: openjdk8 - -jobs: - include: - - stage: test - script: sbt headerCheck scalafmtSbtCheck scalafmtCheck test - - stage: publish - script: sbt publish - -stages: - - name: test # runs on master commits and PRs - if: NOT tag =~ ^v - - name: publish # runs on main repo master commits or version-tagged commits - if: repo = 2m/kabrioletas AND ( ( branch = master AND type = push ) OR tag =~ ^v ) - -before_cache: - - find $HOME/.ivy2 -name "ivydata-*.properties" -print -delete - - find $HOME/.sbt -name "*.lock" -print -delete - -cache: - directories: - - $HOME/.coursier - - $HOME/.ivy2/cache - - $HOME/.sbt/boot - - $HOME/.sbt/launchers - -env: - global: - - BINTRAY_USER=2m - # travis encrypt BINTRAY_PASS=... - - secure: "Qcpoc09QjK9MnQj4TOLGy3nDMnnBQ6bYsUqCHZWLTh63m3iB2e6HuJMl0E2R1M1qs/nE+hDaeRZlo8qgH1m97qY180sLRgAcpSRzj4rkrrrsxi6pUHBxAK7oUtVt+1ZrPbSeBeYEOarDzd2Vphm5ZQELKG8WhayWXUnDYgcyI1ykFE6TZfNNN4mlNkBLeT2+Vxd8MCS9zY6ygSPYbiJN1l/NYbZAyTMSDofQ0JHdwWr93sE2R4chf6FJumsFTInxVLtFXIVcR8wC6OpsPJ+hu4eyQ/c8DRlZ0ifjxILABoMsfuoGl3WCnR/8noiOpgbV94/l+Aqq46i3U469pxz3YuyBUBsBKUSOzGRqI8T98VIPuZijhzcfWINkk1NxKmQazlzd7DrP97YFOmsU80xNB9fz1z9KUkV59RYnv8k2W8iOXqbwdj/xhTWdt931rNN/mvhUSyLywKtOdCFFxKNatqM4B4wwPNq1KqXX4P9i5PcjJbjbqAahiPyQCwcRuM028vPwTdPY4ZWPzQ5vNwiz3RNSCboCwbH0g+e3Lt/YQtI/vkBG1SeMEIVIH1/2t4L3VOdCB90QlYimL6Huigo9eQe0HCTNlLky08QuYoTADtnndddADkJ8/HB5fD9b5Lm0LX1DF809uvm+cvhj/+D9UzLot1jx6cuNGkYZVA4OEC8=" diff --git a/build.sbt b/build.sbt index 08a7101..e809512 100644 --- a/build.sbt +++ b/build.sbt @@ -22,14 +22,15 @@ micrositePalette := Map( "white-color" -> "#FFFFFF" ) -resolvers += Resolver.bintrayRepo("2m", "maven") +scalaVersion := "2.13.6" libraryDependencies ++= Seq( - "citywasp" %% "citywasp-api" % "1.2", - "com.typesafe.akka" %% "akka-stream" % "2.6.14", - "com.typesafe.akka" %% "akka-http" % "10.2.4", - "de.heikoseeberger" %% "akka-http-circe" % "1.36.0", - "com.danielasfregola" %% "twitter4s" % "7.0", - "org.scala-lang.modules" %% "scala-java8-compat" % "0.9.1" + "lt.dvim.citywasp" %% "citywasp-api" % "2.0.2", + "com.typesafe.akka" %% "akka-stream" % "2.6.15", + "com.typesafe.akka" %% "akka-http" % "10.2.6", + "de.heikoseeberger" %% "akka-http-circe" % "1.37.0", + "com.softwaremill.sttp.client3" %% "akka-http-backend" % "3.3.13", + "com.danielasfregola" %% "twitter4s" % "7.0", + "org.scala-lang.modules" %% "scala-java8-compat" % "0.9.0" ) licenses += ("Apache-2.0", url("http://www.apache.org/licenses/LICENSE-2.0")) @@ -42,10 +43,12 @@ developers += Developer( "https://gitter.im/2m/kabrioletas", url("https://github.com/2m/kabrioletas/graphs/contributors") ) -bintrayOrganization := Some("2m") -bintrayRepository := (if (isSnapshot.value) "snapshots" else "maven") organizationName := "https://github.com/2m/kabrioletas/graphs/contributors" scalafmtOnCompile := true +scalafixOnCompile := true +ThisBuild / scalafixDependencies ++= Seq( + "com.nequissimus" %% "sort-imports" % "0.5.5" +) enablePlugins(AutomateHeaderPlugin, JavaAppPackaging, MicrositesPlugin) diff --git a/project/build.properties b/project/build.properties index 0b2e09c..10fd9ee 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.4.7 +sbt.version=1.5.5 diff --git a/project/plugins.sbt b/project/plugins.sbt index 2ca5c93..db3d49e 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,7 +1,7 @@ -addSbtPlugin("com.dwijnand" % "sbt-dynver" % "4.1.1") -addSbtPlugin("com.dwijnand" % "sbt-travisci" % "1.2.0") -addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.2") -addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.6.0") -addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.8.1") -addSbtPlugin("com.47deg" % "sbt-microsites" % "1.3.4") -addSbtPlugin("org.foundweekends" % "sbt-bintray" % "0.6.1") +addSbtPlugin("com.dwijnand" % "sbt-dynver" % "4.1.1") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.2") +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.29") +addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.6.0") +addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.1.20") +addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.8.1") +addSbtPlugin("com.47deg" % "sbt-microsites" % "1.3.4") diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index 84e4c7c..35dc7cb 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -1,14 +1,16 @@ include "reference" +dry-run = yes + citywasp { - email = "" - password = "" + app-version = "9.9.9" + uris = [] } opencagedata.key = "" kabrioletas { - car-id = 719 + car-id = 96 poll-interval = 1 minute } diff --git a/src/main/scala/Kabrioletas.scala b/src/main/scala/Kabrioletas.scala index db94754..680d1da 100644 --- a/src/main/scala/Kabrioletas.scala +++ b/src/main/scala/Kabrioletas.scala @@ -18,6 +18,12 @@ package lt.dvim.citywasp.kabrioletas import java.time.{Duration, Instant} +import scala.compat.java8.DurationConverters._ +import scala.concurrent.Future +import scala.concurrent.duration.{Duration => _, _} +import scala.jdk.CollectionConverters._ +import scala.util.{Failure, Random} + import akka.actor.SupervisorStrategy.Resume import akka.actor.{Actor, ActorLogging, ActorSystem, OneForOneStrategy, Props} import akka.http.scaladsl.Http @@ -25,20 +31,26 @@ import akka.http.scaladsl.model.Uri.Query import akka.http.scaladsl.model.{HttpRequest, Uri} import akka.http.scaladsl.unmarshalling.Unmarshal import akka.pattern.pipe -import akka.stream.ActorMaterializer -import citywasp.api._ + +import cats.implicits._ import com.danielasfregola.twitter4s.TwitterRestClient -import com.danielasfregola.twitter4s.entities.{RatedData, Tweet} +import com.danielasfregola.twitter4s.entities.Tweet import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport +import eu.timepit.refined._ +import eu.timepit.refined.auto._ import io.circe._ +import sttp.client3.akkahttp.AkkaHttpBackend +import sttp.model.{Uri => SttpUri} +import sttp.tapir.client.sttp.SttpClientInterpreter -import scala.compat.java8.DurationConverters._ -import scala.concurrent.duration.{Duration => _, _} -import scala.util.{Failure, Random} +import lt.dvim.citywasp.api.Model._ +import lt.dvim.citywasp.api._ +import lt.dvim.citywasp.kabrioletas.ResponseOps._ object CabrioCheck { case object DoTheCheck - case class ParkedCars(cars: Seq[Car]) + case object Tweeted + case class ParkedCars(cars: List[Car]) case class LastTweetAndCar(tweets: List[Tweet], car: Option[Car]) case class CarWithLocation(car: Car, location: OpenCageData.Location) } @@ -48,43 +60,50 @@ class CabrioCheck extends Actor with ActorLogging { import context.dispatcher implicit val sys = context.system - implicit val mat = ActorMaterializer() - - val config = context.system.settings.config.getConfig("citywasp") - implicit val cw = RemoteCityWasp(config) + val backend = AkkaHttpBackend.usingActorSystem(sys) val twitter = TwitterRestClient() + + final val DryRun = context.system.settings.config.getBoolean("dry-run") + final val CitywaspAppVersion = { + val str = context.system.settings.config.getString("citywasp.app-version") + val refined: Either[String, AppVersion] = refineV(str) + refined.getOrElse(throw new Exception(s"unable to parse [$str] to a version")) + } + final val CitywaspUris = + context.system.settings.config.getStringList("citywasp.uris").asScala.toList.map(SttpUri.unsafeParse) final val OpenCageDataKey = context.system.settings.config.getString("opencagedata.key") - final val CardIdToSearch = context.system.settings.config.getInt("kabrioletas.car-id") + final val ServiceIdToSearch = context.system.settings.config.getInt("kabrioletas.service-id") final val PollInterval = context.system.settings.config.getDuration("kabrioletas.poll-interval").toScala var lastTweetAt: Instant = _ - context.system.scheduler.schedule(0.seconds, PollInterval, self, DoTheCheck) + context.system.scheduler.scheduleAtFixedRate(0.seconds, PollInterval, self, DoTheCheck) - override def preStart() = { + override def preStart() = resetLastTweetTimer() - } def receive = { case DoTheCheck => log.info("Starting to look for a wanted car.") - cw.login.pipeTo(self) - case login: LoggedIn => - log.info("Successfully logged in!") - login.availableCars.map(ParkedCars).pipeTo(self) + allCars().pipeTo(self) + () case ParkedCars(cars) => - val car = cars.find(_.id == CardIdToSearch) + log.info(s"Currently total ${cars.size} cars available.") + val car = cars.find(_.serviceId == ServiceIdToSearch) log.info(s"Car search resulted in $car") twitter.homeTimeline(count = 1).map(timeline => LastTweetAndCar(timeline.data.toList, car)).pipeTo(self) + () case LastTweetAndCar(Nil, None) => log.info(s"No tweets and no car. Keep on searching...") if (Duration.between(Instant.now, lastTweetAt).toDays.abs >= 1) { tweetAboutSearch().pipeTo(self) } + () case LastTweetAndCar(Nil, Some(car)) => log.info(s"Found a car. It is gonna be a great first tweet!") reverseGeocodeCarLocation(car).map(CarWithLocation(car, _)).pipeTo(self) + () case LastTweetAndCar(tweet :: _, Some(car)) => if (!tweet.text.contains("ready")) { log.info( @@ -92,8 +111,10 @@ class CabrioCheck extends Actor with ActorLogging { ) reverseGeocodeCarLocation(car).map(CarWithLocation(car, _)).pipeTo(self) } else if ( - tweet.coordinates.isDefined && math.abs(tweet.coordinates.get.coordinates.head - car.lon) > 0.001 && math - .abs(tweet.coordinates.get.coordinates.tail.head - car.lat) > 0.001 + tweet.coordinates.isDefined && math.abs( + tweet.coordinates.get.coordinates.head - car.long.toDouble + ) > 0.001 && math + .abs(tweet.coordinates.get.coordinates.tail.head - car.lat.toDouble) > 0.001 ) { log.info( s"Found a car [$car] and last tweet was about a parked car [${tweet.text}], but it was on a different place. Let tell the world about the car we just found!" @@ -102,6 +123,7 @@ class CabrioCheck extends Actor with ActorLogging { } else { log.info(s"Found a car [$car] and last tweet was about a parked car [${tweet.text}] at the same place.") } + () case LastTweetAndCar(tweet :: _, None) => if (tweet.text.contains("ready")) { log.info(s"No car and last tweet was about a parked car [${tweet.text}]. Tweet about taken car.") @@ -112,10 +134,12 @@ class CabrioCheck extends Actor with ActorLogging { tweetAboutSearch().pipeTo(self) } } - case cwl @ CarWithLocation(car, location) => + () + case cwl @ CarWithLocation(_, location) => log.info(s"Reverse geocoded car location to $location") tweetAbout(cwl).pipeTo(self) - case t: Tweet => + () + case Tweeted => log.info("Tweet success.") resetLastTweetTimer() case m => @@ -127,9 +151,19 @@ class CabrioCheck extends Actor with ActorLogging { case akka.actor.Status.Failure(ex) => log.error(ex.getMessage) ex.printStackTrace() + case _ => log.error(m.toString) } } + def allCars() = + CitywaspUris.map(cars(CitywaspAppVersion)).combineAll.map(ParkedCars.apply) + + def cars(appVersion: AppVersion)(uri: SttpUri) = { + val params = Params.default.copy(appVersion = appVersion, country = Country.fromUri(uri)) + val carsRequest = SttpClientInterpreter().toRequest(Api.CarsLive.GetAvailableCars, Some(uri)).apply(params) + carsRequest.send(backend).flatMap(_.toFuture) + } + def reverseGeocodeCarLocation(car: Car)(implicit sys: ActorSystem) = { import OpenCageData._ @@ -137,7 +171,7 @@ class CabrioCheck extends Actor with ActorLogging { .singleRequest( HttpRequest( uri = Uri("http://api.opencagedata.com/geocode/v1/json") - .withQuery(Query("q" -> s"${car.lat},${car.lon}", "key" -> OpenCageDataKey)) + .withQuery(Query("q" -> s"${car.lat},${car.long}", "key" -> OpenCageDataKey)) ) ) .flatMap(resp => Unmarshal(resp.entity).to[Location]) @@ -152,29 +186,33 @@ class CabrioCheck extends Actor with ActorLogging { } yield s"$suburb$city" val locationDescription = cityDescription.orElse(location.town).map(location => s" in $location").getOrElse("") - - twitter.createTweet( - status = - f"\uD83D\uDE95\uD83D\uDE95\uD83D\uDE95 Parked and ready for a new adventure$locationDescription. Pick me up! https://www.google.com/maps?q=${car.lat}%.6f,${car.lon}%.6f ($randomMarker)", - latitude = Some(car.lat.toLong), - longitude = Some(car.lon.toLong), - display_coordinates = true - ) + val tweet = + f"\uD83D\uDE95\uD83D\uDE95\uD83D\uDE95 Parked and ready for a new adventure$locationDescription. Pick me up! https://www.google.com/maps?q=${car.lat}%.6f,${car.long}%.6f ($randomMarker)" + + if (DryRun) { + log.info(s"Would tweet: $tweet") + } else { + twitter.createTweet( + status = tweet, + latitude = Some(car.lat.toLong), + longitude = Some(car.long.toLong), + display_coordinates = true + ) + } + Future.successful(Tweeted) } - def tweetAboutNoCar() = { + def tweetAboutNoCar() = twitter.createTweet( status = s"\uD83D\uDD1C\uD83D\uDD1C\uD83D\uDD1C I am on a ride right now. Will let you know when I am free! ($randomMarker)" ) - } - def tweetAboutSearch() = { + def tweetAboutSearch() = twitter.createTweet( status = s"🔎🔎🔎 There has been no available car for quite some time now. Nevertheless, I keep on searching. Stay tuned! ($randomMarker)" ) - } def randomMarker = Random.alphanumeric.take(6).mkString @@ -185,12 +223,13 @@ class CabrioCheck extends Actor with ActorLogging { class Supervisor extends Actor { override def supervisorStrategy = - OneForOneStrategy(loggingEnabled = true) { case t => + OneForOneStrategy(loggingEnabled = true) { case _ => Resume } override def preStart() = { - context.actorOf(Props[CabrioCheck], "cabrioCheck") + context.actorOf(Props[CabrioCheck](), "cabrioCheck") + () } def receive = { case _ => @@ -199,7 +238,7 @@ class Supervisor extends Actor { object Kabrioletas extends App { val sys = ActorSystem("Kabrioletas") - sys.actorOf(Props[Supervisor], "supervisor") + sys.actorOf(Props[Supervisor](), "supervisor") } object OpenCageData extends FailFastCirceSupport { @@ -208,9 +247,9 @@ object OpenCageData extends FailFastCirceSupport { implicit val decodeLocation: Decoder[Location] = new Decoder[Location] { final def apply(c: HCursor): Decoder.Result[Location] = for { - suburb <- c.downField("results").downArray.first.downField("components").downField("suburb").as[Option[String]] - city <- c.downField("results").downArray.first.downField("components").downField("city").as[Option[String]] - town <- c.downField("results").downArray.first.downField("components").downField("town").as[Option[String]] + suburb <- c.downField("results").downArray.downField("components").downField("suburb").as[Option[String]] + city <- c.downField("results").downArray.downField("components").downField("city").as[Option[String]] + town <- c.downField("results").downArray.downField("components").downField("town").as[Option[String]] } yield Location(suburb, city, town) } } diff --git a/src/main/scala/package.scala b/src/main/scala/package.scala deleted file mode 100644 index 68233bf..0000000 --- a/src/main/scala/package.scala +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright 2017 https://github.com/2m/kabrioletas/graphs/contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package lt.dvim.citywasp - -import scala.collection.immutable - -package object kabrioletas { - type Seq[A] = immutable.Seq[A] - val Seq = immutable.Seq -} diff --git a/src/main/scala/tapir.scala b/src/main/scala/tapir.scala new file mode 100644 index 0000000..9f7fd35 --- /dev/null +++ b/src/main/scala/tapir.scala @@ -0,0 +1,45 @@ +/* + * Copyright 2017 https://github.com/2m/kabrioletas/graphs/contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lt.dvim.citywasp.kabrioletas + +import scala.concurrent.Future +import scala.util.Failure +import scala.util.Success + +import sttp.client3.Response +import sttp.model.StatusCode +import sttp.tapir.DecodeResult + +object ResponseOps { + implicit def responseToResponseOps[Resp]( + response: Response[DecodeResult[Either[String, Resp]]] + ) = new ResponseOps(response) +} + +class ResponseOps[Resp](response: Response[DecodeResult[Either[String, Resp]]]) { + def toFutureOption: Future[Option[Resp]] = response.code match { + case StatusCode.NotFound => Future.successful(None) + case _ => Future.fromTry(extractDecoded().map(Some.apply)) + } + + def toFuture: Future[Resp] = Future.fromTry(extractDecoded()) + + private def extractDecoded() = response.body.flatMap(r => DecodeResult.fromOption(r.toOption)) match { + case DecodeResult.Value(v) => Success(v) + case f: DecodeResult.Failure => Failure(new Exception(s"TapirFailure: ${f.toString()} when parsing $response")) + } +}