diff --git a/.idea/modules/delivery-checker-bot-build.iml b/.idea/modules/delivery-checker-bot-build.iml new file mode 100644 index 0000000..19ecb3a --- /dev/null +++ b/.idea/modules/delivery-checker-bot-build.iml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules/delivery-checker-bot.iml b/.idea/modules/delivery-checker-bot.iml new file mode 100644 index 0000000..899bff0 --- /dev/null +++ b/.idea/modules/delivery-checker-bot.iml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules/root-build.iml b/.idea/modules/root-build.iml deleted file mode 100644 index 4c662cd..0000000 --- a/.idea/modules/root-build.iml +++ /dev/null @@ -1,124 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/modules/root.iml b/.idea/modules/root.iml deleted file mode 100644 index a232ccc..0000000 --- a/.idea/modules/root.iml +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/modules/telegram-bot.iml b/.idea/modules/telegram-bot.iml new file mode 100644 index 0000000..747efe4 --- /dev/null +++ b/.idea/modules/telegram-bot.iml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules/telegram-bot1.iml b/.idea/modules/telegram-bot1.iml new file mode 100644 index 0000000..5c2e88b --- /dev/null +++ b/.idea/modules/telegram-bot1.iml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/build.sbt b/build.sbt index 5d77978..1c35b5a 100644 --- a/build.sbt +++ b/build.sbt @@ -1,21 +1,37 @@ import Dependencies._ -lazy val root = (project in file(".")). - settings( - inThisBuild(List( - organization := "com.example", - scalaVersion := "2.12.6", - version := "0.1.0-SNAPSHOT", - mainClass := Some("eu.xeppaka.bot1.TelegramBotServer") - )), - name := "telegram-bot1", +lazy val commonSettings = Seq( + organization := "com.example", + scalaVersion := "2.12.7", + version := "0.1.0-SNAPSHOT", + mainClass := Some("eu.xeppaka.bot.TelegramBotServer") +) + +inThisBuild(commonSettings) + +lazy val `telegram-bot` = (project in file("telegram-bot")) + .settings( + libraryDependencies ++= Seq( + scalaTest % Test, + akka, + akkaTyped, + akkaHttp, + akkaStream, + circleCore, + circleGeneric, + circleParser, + circeAkkaHttp + ) + ) + +lazy val `delivery-checker-bot` = (project in file("delivery-checker-bot")) + .settings( libraryDependencies ++= Seq( scalaTest % Test, akka, akkaTyped, akkaHttp, akkaStream, - vkapi, circleCore, circleGeneric, circleParser diff --git a/project/Dependencies.scala b/project/Dependencies.scala index c741389..ab1c894 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -1,13 +1,14 @@ import sbt._ object Dependencies { + lazy val akka = "com.typesafe.akka" %% "akka-actor" % "2.5.17" + lazy val akkaTyped = "com.typesafe.akka" %% "akka-actor-typed" % "2.5.17" + lazy val akkaStream = "com.typesafe.akka" %% "akka-stream" % "2.5.17" + lazy val akkaHttp = "com.typesafe.akka" %% "akka-http" % "10.1.5" + //lazy val vkapi = "com.vk.api" % "sdk" % "0.5.12" + lazy val circleCore = "io.circe" %% "circe-core" % "0.10.0" + lazy val circleGeneric = "io.circe" %% "circe-generic" % "0.10.0" + lazy val circleParser = "io.circe" %% "circe-parser" % "0.10.0" + lazy val circeAkkaHttp = "de.heikoseeberger" %% "akka-http-circe" % "1.22.0" lazy val scalaTest = "org.scalatest" %% "scalatest" % "3.0.5" - lazy val akka = "com.typesafe.akka" %% "akka-actor" % "2.5.12" - lazy val akkaTyped = "com.typesafe.akka" %% "akka-actor-typed" % "2.5.12" - lazy val akkaHttp = "com.typesafe.akka" %% "akka-http" % "10.1.1" - lazy val akkaStream = "com.typesafe.akka" %% "akka-stream" % "2.5.12" - lazy val vkapi = "com.vk.api" % "sdk" % "0.5.12" - lazy val circleCore = "io.circe" %% "circe-core" % "0.9.3" - lazy val circleGeneric = "io.circe" %% "circe-generic" % "0.9.3" - lazy val circleParser = "io.circe" %% "circe-parser" % "0.9.3" } diff --git a/project/build.properties b/project/build.properties index 64cf32f..7c58a83 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.1.4 +sbt.version=1.2.6 diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf deleted file mode 100644 index 8ea5900..0000000 --- a/src/main/resources/application.conf +++ /dev/null @@ -1,3 +0,0 @@ -akka { - loglevel = "INFO" -} \ No newline at end of file diff --git a/src/main/resources/telegram-bot.p12 b/src/main/resources/telegram-bot.p12 deleted file mode 100644 index 8147227..0000000 Binary files a/src/main/resources/telegram-bot.p12 and /dev/null differ diff --git a/src/main/resources/telegram-bot.pem b/src/main/resources/telegram-bot.pem deleted file mode 100644 index 0fe289b..0000000 --- a/src/main/resources/telegram-bot.pem +++ /dev/null @@ -1,21 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDcTCCAlmgAwIBAgIJAKLMfxW4KRHuMA0GCSqGSIb3DQEBCwUAME8xCzAJBgNV -BAYTAkNaMQ8wDQYDVQQHDAZQcmFndWUxGjAYBgNVBAoMEVBhdmVsIEthY2hhbG91 -c2tpMRMwEQYDVQQDDAp4ZXBwYWthLmV1MB4XDTE4MDUxMTE4MjEzOVoXDTI4MDUw -ODE4MjEzOVowTzELMAkGA1UEBhMCQ1oxDzANBgNVBAcMBlByYWd1ZTEaMBgGA1UE -CgwRUGF2ZWwgS2FjaGFsb3Vza2kxEzARBgNVBAMMCnhlcHBha2EuZXUwggEiMA0G -CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDWwewslKtWJ7yeYrxDuoh5PS7y5/C/ -0NT6tsAAsh3ZVCqdeNvYj56n8jVob/jJ9EYMqKj7dXLAMopDhtuwdDN/KhW9QVkK -zATE1wNxuV3aBVUTJuHHadUYQa7pVevvssAIa1XQ6NvU0pkwdDApylOj1TkA9MFl -ZWHWlF0dgrVyGjFxDoWdjm2aLCdRpZCr0giTOfZ5E+OJNALTHcuJO+PRKdEreO1Y -VAlT2Sk26f8/iG63C2/t7xWTyJKOjFPxwq3+dkNfJ1AXZ4I7aFDgP7BKogvooYuC -BItqog+IRUOoK9Yj24KCUxD+gaI5+tv0j1ov5d0ZAqqaiSql96s2/jyZAgMBAAGj -UDBOMB0GA1UdDgQWBBRkOXFj0c0jNdM1nJMRGr0EvfeMuTAfBgNVHSMEGDAWgBRk -OXFj0c0jNdM1nJMRGr0EvfeMuTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUA -A4IBAQBtXUOfnKdp1GY5gATTrPdr2s7FyiJvVfx/yeNNNR8ZnZcLjBMulEvXsfNi -AL1cEctnSDyT5z2el75nTdAgTFcBZQLsVk9/4ZwBRnfunFqfU5e5X9X9z//yt4Gy -Zq9BrMddQE+qwnOclcTDCc0GnyqKbaPiyYFcaXKhdrsflvoJI9tyLwPgjfXADLDF -JtjC0gGdbgefDweBUMTF0cpZED9q/J2fKXHurub+3QySvUOvphVFP4dBz2WhdoTe -v3lkEVp3I/IUv9qegO0B0o6X+Nnml4/b7HV1PArNceWOA6f57fSL2m6eN6xs4ULJ -kfUMloAr25yvmN/tPwm+8Op5ovot ------END CERTIFICATE----- diff --git a/src/main/scala/eu/xeppaka/bot1/CircleSupport.scala b/src/main/scala/eu/xeppaka/bot1/CircleSupport.scala deleted file mode 100644 index 35237b2..0000000 --- a/src/main/scala/eu/xeppaka/bot1/CircleSupport.scala +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright 2015 Heiko Seeberger - * - * 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 eu.xeppaka.bot1 - -import akka.http.scaladsl.marshalling.{ Marshaller, ToEntityMarshaller } -import akka.http.scaladsl.model.{ ContentType, ContentTypeRange, HttpEntity } -import akka.http.scaladsl.model.MediaType -import akka.http.scaladsl.model.MediaTypes.`application/json` -import akka.http.scaladsl.unmarshalling.{ FromEntityUnmarshaller, Unmarshaller } -import akka.util.ByteString -import cats.data.NonEmptyList -import cats.syntax.show.toShow -import io.circe.{ Decoder, DecodingFailure, Encoder, Json, Printer, jawn } -import scala.collection.immutable.Seq - -/** - * Automatic to and from JSON marshalling/unmarshalling using an in-scope circe protocol. - * The unmarshaller fails fast, throwing the first `Error` encountered. - * - * To use automatic codec derivation, user needs to import `io.circe.generic.auto._`. - */ -object FailFastCirceSupport extends FailFastCirceSupport - -/** - * Automatic to and from JSON marshalling/unmarshalling using an in-scope circe protocol. - * The unmarshaller fails fast, throwing the first `Error` encountered. - * - * To use automatic codec derivation import `io.circe.generic.auto._`. - */ -trait FailFastCirceSupport extends BaseCirceSupport with FailFastUnmarshaller - -/** - * Automatic to and from JSON marshalling/unmarshalling using an in-scope circe protocol. - * The unmarshaller accumulates all errors in the exception `Errors`. - * - * To use automatic codec derivation, user needs to import `io.circe.generic.auto._`. - */ -object ErrorAccumulatingCirceSupport extends ErrorAccumulatingCirceSupport { - final case class DecodingFailures(failures: NonEmptyList[DecodingFailure]) extends Exception { - override def getMessage = failures.toList.map(_.show).mkString("\n") - } -} - -/** - * Automatic to and from JSON marshalling/unmarshalling using an in-scope circe protocol. - * The unmarshaller accumulates all errors in the exception `Errors`. - * - * To use automatic codec derivation import `io.circe.generic.auto._`. - */ -trait ErrorAccumulatingCirceSupport extends BaseCirceSupport with ErrorAccumulatingUnmarshaller - -/** - * Automatic to and from JSON marshalling/unmarshalling using an in-scope circe protocol. - */ -trait BaseCirceSupport { - - def unmarshallerContentTypes: Seq[ContentTypeRange] = - mediaTypes.map(ContentTypeRange.apply) - - def mediaTypes: Seq[MediaType.WithFixedCharset] = - List(`application/json`) - - /** - * `Json` => HTTP entity - * - * @return marshaller for JSON value - */ - implicit final def jsonMarshaller( - implicit printer: Printer = Printer.noSpaces - ): ToEntityMarshaller[Json] = - Marshaller.oneOf(mediaTypes: _*) { mediaType => - Marshaller.withFixedContentType(ContentType(mediaType)) { json => - HttpEntity(mediaType, printer.pretty(json)) - } - } - - /** - * `A` => HTTP entity - * - * @tparam A type to encode - * @return marshaller for any `A` value - */ - implicit final def marshaller[A: Encoder]( - implicit printer: Printer = Printer.noSpaces - ): ToEntityMarshaller[A] = - jsonMarshaller(printer).compose(Encoder[A].apply) - - /** - * HTTP entity => `Json` - * - * @return unmarshaller for `Json` - */ - implicit final val jsonUnmarshaller: FromEntityUnmarshaller[Json] = - Unmarshaller.byteStringUnmarshaller - .forContentTypes(unmarshallerContentTypes: _*) - .map { - case ByteString.empty => throw Unmarshaller.NoContentException - case data => jawn.parseByteBuffer(data.asByteBuffer).fold(throw _, identity) - } - - /** - * HTTP entity => `A` - * - * @tparam A type to decode - * @return unmarshaller for `A` - */ - implicit def unmarshaller[A: Decoder]: FromEntityUnmarshaller[A] -} - -/** - * Mix-in this trait to fail on the first error during unmarshalling. - */ -trait FailFastUnmarshaller { this: BaseCirceSupport => - - override implicit final def unmarshaller[A: Decoder]: FromEntityUnmarshaller[A] = { - def decode(json: Json) = Decoder[A].decodeJson(json).fold(throw _, identity) - jsonUnmarshaller.map(decode) - } -} - -/** - * Mix-in this trait to accumulate all errors during unmarshalling. - */ -trait ErrorAccumulatingUnmarshaller { this: BaseCirceSupport => - - override implicit final def unmarshaller[A: Decoder]: FromEntityUnmarshaller[A] = { - def decode(json: Json) = - Decoder[A] - .accumulating(json.hcursor) - .fold(failures => throw ErrorAccumulatingCirceSupport.DecodingFailures(failures), identity) - jsonUnmarshaller.map(decode) - } -} diff --git a/src/main/scala/eu/xeppaka/bot1/TelegramBotServer.scala b/src/main/scala/eu/xeppaka/bot1/TelegramBotServer.scala deleted file mode 100644 index 4a51dd2..0000000 --- a/src/main/scala/eu/xeppaka/bot1/TelegramBotServer.scala +++ /dev/null @@ -1,175 +0,0 @@ -package eu.xeppaka.bot1 - -import java.io.InputStream -import java.security.{KeyStore, SecureRandom} -import java.util.UUID - -import akka.actor -import akka.actor.Scheduler -import akka.actor.typed.scaladsl.adapter._ -import akka.actor.typed.{ActorSystem, DispatcherSelector} -import akka.http.scaladsl.marshalling.Marshal -import akka.http.scaladsl.model._ -import akka.http.scaladsl.server.Directives._ -import akka.http.scaladsl.server.{Route, RouteResult} -import akka.http.scaladsl.unmarshalling.Unmarshal -import akka.http.scaladsl.{ConnectionContext, Http, HttpExt, HttpsConnectionContext} -import akka.stream.ActorMaterializer -import akka.util.{ByteString, Timeout} -import eu.xeppaka.bot1.actors.UpdateActor -import eu.xeppaka.bot1.actors.UpdateActor.UpdateResponse -import javax.net.ssl.{KeyManagerFactory, SSLContext, TrustManagerFactory} - -import scala.collection.immutable -import scala.concurrent.duration._ -import scala.concurrent.{ExecutionContextExecutor, Future} -import scala.io.{Source, StdIn} -import scala.util.{Failure, Success} - -class TelegramBotServer(botId: String, port: Int, httpsContext: Option[HttpsConnectionContext]) { - - import FailFastCirceSupport._ - import eu.xeppaka.bot1.TelegramEntities._ - import io.circe.generic.auto._ - - private val botUri = BotUri(botId) - private implicit val updateSystem: ActorSystem[UpdateActor.UpdateCommand] = ActorSystem(UpdateActor.behavior, "telegram-bot") - private implicit val actorSystem: actor.ActorSystem = updateSystem.toUntyped - private implicit val materializer: ActorMaterializer = ActorMaterializer() - private implicit val executionContext: ExecutionContextExecutor = updateSystem.dispatchers.lookup(DispatcherSelector.default()) - - private val http: HttpExt = Http() - private val hookId = UUID.randomUUID().toString - private val webhookUri = Uri(s"https://xeppaka.eu:88/$hookId") - private val bindingFuture = http.bindAndHandle(botRoutes(hookId), - "lenovo", - port, - connectionContext = httpsContext.getOrElse(http.defaultClientHttpsContext)) - - println(s"Webhook path: $webhookUri") - setWebhook() - - def stop(): Unit = { - bindingFuture - .flatMap(binding => deleteWebhook().map(_ => binding)) - .flatMap(binding => http.shutdownAllConnectionPools().map(_ => binding)) - .flatMap(binding => binding.unbind()) - .onComplete(_ => updateSystem.terminate()) - } - - def printRequestMethodAndResponseStatus(req: HttpRequest)(res: RouteResult): Unit = { - println(req) - println(res) - } - - def botRoutes(hookId: String): Route = { - path(hookId) { - post { - entity(as[Update]) { _ => - handleWith(receivedUpdate) - } - } - } - } - - private def receivedUpdate(update: Update): Future[HttpResponse] = { - import akka.actor.typed.scaladsl.AskPattern._ - - implicit val timeout: Timeout = 3.seconds - implicit val scheduler: Scheduler = updateSystem.scheduler - - val result: Future[UpdateActor.UpdateResponse] = updateSystem ? (ref => UpdateActor.UpdateReceived(update, ref)) - - result.andThen { - case Success(response) => sendResponse(response.chatId, response.text) - case Failure(ex) => println("Failed to process message...") - } - - result.map(res => HttpResponse()).fallbackTo(Future.successful(HttpResponse())) - } - - private def sendResponse(chatId: Long, text: String) = { - import io.circe._, io.circe.generic.auto._, io.circe.syntax._ - - val sendMessage = SendMessage(chatId, text) - val printer = Printer.noSpaces.copy(dropNullValues = true) - val json = printer.pretty(sendMessage.asJson) - val request = HttpRequest(HttpMethods.POST, uri = botUri.sendMessage, entity = HttpEntity.Strict(ContentTypes.`application/json`, ByteString(json))) - http.singleRequest(request) - } - - def getBotInfo: Future[Response[GetMe]] = { - http.singleRequest(HttpRequest(uri = botUri.getMe)).flatMap(Unmarshal(_).to[Response[GetMe]]) - } - - private def setWebhook(): Future[HttpResponse] = { - print("Setting webhook...") - val urlEntity = HttpEntity.Strict(ContentTypes.`text/plain(UTF-8)`, ByteString(webhookUri.toString())) - val urlPart = Multipart.FormData.BodyPart.Strict("url", urlEntity) - - val certificate = ByteString(Source.fromResource("telegram-bot.pem").mkString) - val certificateEntity = HttpEntity.Strict(ContentTypes.`application/octet-stream`, certificate) - val certificatePart = Multipart.FormData.BodyPart.Strict("certificate", certificateEntity, Map("filename" -> "telegram-bot.pem")) - - val setWebhookFormData = Multipart.FormData.Strict(immutable.Seq(urlPart, certificatePart)) - - Marshal(setWebhookFormData) - .to[RequestEntity] - .flatMap(requestEntity => http.singleRequest(HttpRequest(uri = botUri.setWebhook, method = HttpMethods.POST, entity = requestEntity))) - .andThen { - case Success(response) => println(s" ${response.status.value}") - case Failure(exception) => println(s" failed with exception: ${exception.getMessage}") - } - } - - private def deleteWebhook(): Future[HttpResponse] = { - print("Deleting webhook...") - http - .singleRequest(HttpRequest(uri = botUri.deleteWebhook, method = HttpMethods.POST)) - .andThen { - case Success(response) => println(s" ${response.status.value}") - case Failure(exception) => println(s" failed with exception: ${exception.getMessage}") - } - } - - def getWebhookInfo(): Future[Response[WebhookInfo]] = { - http - .singleRequest(HttpRequest(uri = botUri.getWebhookInfo, method = HttpMethods.GET)) - .flatMap(Unmarshal(_).to[Response[WebhookInfo]]) - } -} - -object TelegramBotServer { - private val botId = "570855144:AAEv7b817cuq2JJI9f2kG5B9G3zW1x-btz4" - - def apply(port: Int, httpsContext: Option[HttpsConnectionContext]): TelegramBotServer = new TelegramBotServer(botId, port, httpsContext) - - def main(args: Array[String]): Unit = { - val httpsContext = createHttpsConnectionContext - val tbs = TelegramBotServer(8443, Some(createHttpsConnectionContext)) - - StdIn.readLine() - tbs.stop() - } - - def createHttpsConnectionContext: HttpsConnectionContext = { - val password: Array[Char] = "".toCharArray // do not store passwords in code, read them from somewhere safe! - - val ks: KeyStore = KeyStore.getInstance("PKCS12") - val keystore: InputStream = getClass.getResourceAsStream("/telegram-bot.p12") - - require(keystore != null, "Keystore required!") - ks.load(keystore, password) - - val keyManagerFactory: KeyManagerFactory = KeyManagerFactory.getInstance("SunX509") - keyManagerFactory.init(ks, password) - - val tmf: TrustManagerFactory = TrustManagerFactory.getInstance("SunX509") - tmf.init(ks) - - val sslContext: SSLContext = SSLContext.getInstance("TLS") - sslContext.init(keyManagerFactory.getKeyManagers, tmf.getTrustManagers, new SecureRandom) - - ConnectionContext.https(sslContext) - } -} diff --git a/src/main/scala/eu/xeppaka/bot1/TelegramEntities.scala b/src/main/scala/eu/xeppaka/bot1/TelegramEntities.scala deleted file mode 100644 index 7ab6908..0000000 --- a/src/main/scala/eu/xeppaka/bot1/TelegramEntities.scala +++ /dev/null @@ -1,234 +0,0 @@ -package eu.xeppaka.bot1 - -import com.vk.api.sdk.objects.base.Sticker -import com.vk.api.sdk.objects.video.Video - -object TelegramEntities { - - case class Response[T](ok: Boolean, - description: Option[String] = None, - error_code: Option[Int] = None, - result: T) - - case class GetMe(id: Int, is_bot: Boolean, first_name: String, username: String) - - case class InlineQuery(id: String, - from: User, - location: Location, - query: String, - offset: String) - - case class Location(longitude: Float, - latitude: Float) - - case class Update(update_id: Int, - message: Option[Message] = None, - edited_message: Option[Message] = None, - channel_post: Option[Message] = None, - edited_channel_post: Option[Message] = None, - inline_query: Option[InlineQuery] = None, - chosen_inline_result: Option[ChosenInlineResult] = None, - callback_query: Option[CallbackQuery] = None, - shipping_query: Option[ShippingQuery] = None, - pre_checkout_query: Option[PreCheckoutQuery] = None) - - case class ChosenInlineResult(result_id: String, - from: User, - location: Option[Location] = None, - inline_message_id: Option[String] = None, - query: String) - - case class CallbackQuery(id: String, - from: User, - message: Option[Message] = None, - inline_message_id: Option[String] = None, - chat_instance: String, - data: Option[String] = None, - game_short_name: Option[String] = None) - - case class ShippingQuery(id: String, - from: User, - invoice_payload: String, - shipping_address: ShippingAddress) - - case class ShippingAddress(country_code: String, - state: String, - city: String, - street_line1: String, - street_line2: String, - post_code: String) - - case class PreCheckoutQuery(id: String, - from: User, - currency: String, - total_amount: Int, - invoice_payload: String, - shipping_option_id: Option[String] = None, - order_info: Option[OrderInfo] = None) - - case class OrderInfo(name: Option[String] = None, - phone_number: Option[String] = None, - email: Option[String] = None, - shipping_address: Option[ShippingAddress] = None) - - case class User(id: Int, - is_bot: Boolean, - first_name: String, - last_name: Option[String] = None, - username: Option[String] = None, - language_code: Option[String] = None) - - case class SendMessage(chat_id: Long, - text: String, - parse_mode: Option[String] = None, - disable_web_page_preview: Option[Boolean] = None, - disable_notification: Option[Boolean] = None, - reply_to_message_id: Option[Int] = None, - reply_markup: Option[String] = None) - - case class Message(message_id: Int, - from: Option[User] = None, - date: Int, - chat: Chat, - forward_from: Option[User] = None, - forward_from_chat: Option[User] = None, - forward_from_message_id: Option[Int] = None, - forward_signature: Option[String] = None, - forward_date: Option[Int] = None, - reply_to_message: Option[Message] = None, - edit_date: Option[Int] = None, - media_group_id: Option[String] = None, - author_signature: Option[String] = None, - text: Option[String] = None, - entities: Option[Seq[MessageEntity]] = None, - caption_entities: Option[Seq[MessageEntity]] = None, - audio: Option[Audio] = None, - document: Option[Document] = None, - game: Option[Game] = None, - photo: Option[Seq[PhotoSize]] = None, - sticker: Option[Sticker] = None, - video: Option[Video] = None, - voice: Option[Voice] = None, - video_note: Option[VideoNote] = None, - caption: Option[String] = None, - contact: Option[Contact] = None, - location: Option[Location] = None, - venue: Option[Venue] = None, - new_chat_members: Option[Seq[User]] = None, - left_chat_member: Option[Seq[User]] = None, - new_chat_title: Option[String] = None, - new_chat_photo: Option[Seq[PhotoSize]] = None, - delete_chat_photo: Option[Boolean] = None, - group_chat_created: Option[Boolean] = None, - supergroup_chat_created: Option[Boolean] = None, - channel_chat_created: Option[Boolean] = None, - migrate_to_chat_id: Option[Int] = None, - migrate_from_chat_id: Option[Int] = None, - pinned_message: Option[Message] = None, - invoice: Option[Invoice] = None, - successful_payment: Option[SuccessfulPayment] = None, - connected_website: Option[String] = None) - - case class MessageEntity(`type`: String, - offset: Int, - length: Int, - url: Option[String] = None, - user: Option[User] = None) - - case class Contact(phone_number: String, - first_name: String, - last_name: Option[String] = None, - user_id: Option[Int] = None) - - case class Audio(file_id: String, - duration: Int, - performer: Option[String] = None, - title: Option[String] = None, - mime_type: Option[String] = None, - file_size: Option[Int] = None) - - case class Document(file_id: String, - thumb: Option[PhotoSize] = None, - file_name: Option[String] = None, - mime_type: Option[String] = None, - file_size: Option[Int] = None) - - case class PhotoSize(file_id: String, - width: Int, - height: Int, - file_size: Option[Int] = None) - - case class Voice(file_id: String, - duration: Int, - mime_type: Option[String] = None, - file_size: Option[Int] = None) - - case class VideoNote(file_id: String, - length: Int, - duration: Int, - thumb: Option[PhotoSize] = None, - file_size: Option[Int] = None) - - case class ChatPhoto(small_file_id: String, big_file_id: String) - - case class Chat(id: Long, - `type`: String, - title: Option[String] = None, - username: Option[String] = None, - first_name: Option[String] = None, - last_name: Option[String] = None, - all_members_are_administrators: Option[Boolean] = None, - photo: Option[ChatPhoto] = None, - description: Option[String] = None, - invite_link: Option[String] = None, - pinned_message: Option[Message] = None, - sticker_set_name: Option[String] = None, - can_set_sticker_set: Option[Boolean] = None) - - case class Game(title: String, - description: String, - photo: Seq[PhotoSize], - text: Option[String] = None, - text_entities: Option[Seq[MessageEntity]] = None, - animation: Option[Animation] = None) - - case class Animation(file_id: String, - thumb: Option[PhotoSize] = None, - file_name: Option[String] = None, - mime_type: Option[String] = None, - file_size: Option[Int] = None) - - case class InputFile() - - case class Venue(location: Location, - title: String, - address: String, - foursquare_id: Option[String] = None) - - case class Invoice(title: String, - description: String, - start_parameter: String, - currency: String, - total_amount: Int) - - case class SuccessfulPayment(currency: String, - total_amount: Int, - invoice_payload: String, - shipping_option_id: Option[String] = None, - order_info: Option[OrderInfo] = None, - telegram_payment_charge_id: String, - provider_payment_charge_id: String) - - case class Webhook(url: String, - certificate: Option[InputFile] = None, - max_connections: Option[Int] = None, - allowed_updates: Option[Seq[String]] = None) - - case class WebhookInfo(url: String, - has_custom_certificate: Boolean, - pending_update_count: Int, - last_error_date: Option[Int] = None, - last_error_message: Option[String] = None, - max_connections: Option[Int] = None, - allowed_updates: Option[Seq[String]] = None) -} diff --git a/src/main/scala/eu/xeppaka/bot1/VkApiTests.scala b/src/main/scala/eu/xeppaka/bot1/VkApiTests.scala deleted file mode 100644 index 70e6c3a..0000000 --- a/src/main/scala/eu/xeppaka/bot1/VkApiTests.scala +++ /dev/null @@ -1,31 +0,0 @@ -package eu.xeppaka.bot1 - -import akka.actor.ActorSystem -import com.vk.api.sdk.client.VkApiClient -import com.vk.api.sdk.client.actors.UserActor -import com.vk.api.sdk.httpclient.HttpTransportClient - -object VkApiTests { - private val APP_ID = 6472591 - private val APP_SECRET = "iGClqTncCZpzEqD8m5Wt" - private val USER_SECRET_CODE = "4180052ce419f0470d" - - private val vkTransportClient = HttpTransportClient.getInstance() - private val vkApiClient = new VkApiClient(vkTransportClient) - - // private val actorSystem = ActorSystem("vk") - - def main(args: Array[String]): Unit = { - // val accessTokenActor = actorSystem.actorOf(AccessTokenActor.props) - // accessTokenActor ! GetToken(APP_ID) - -// val authResponse = vkApiClient.oauth() -// .userAuthorizationCodeFlow(APP_ID, APP_SECRET, "https://oauth.vk.com/blank.html", USER_SECRET_CODE) -// .execute() - - val userActor = new UserActor(6242549, "e7acb4be42aa11ab692b567ae272f16ec3c49d20f51851bb507126ce9ffc0f4aea013d34c0a26e285202d") - - val resp = vkApiClient.messages().get(userActor).execute() - println(resp) - } -} diff --git a/src/main/scala/eu/xeppaka/bot1/actors/AccessTokenActor.scala b/src/main/scala/eu/xeppaka/bot1/actors/AccessTokenActor.scala deleted file mode 100644 index 4f90355..0000000 --- a/src/main/scala/eu/xeppaka/bot1/actors/AccessTokenActor.scala +++ /dev/null @@ -1,40 +0,0 @@ -package eu.xeppaka.bot1.actors - -import akka.actor.{Actor, Props} -import akka.http.scaladsl.Http -import akka.http.scaladsl.model.{HttpEntity, HttpRequest, HttpResponse, StatusCodes} -import akka.stream.{ActorMaterializer, ActorMaterializerSettings} -import eu.xeppaka.bot1.actors.AccessTokenActor.GetToken - -import scala.concurrent.duration._ - -class AccessTokenActor extends Actor { - import akka.pattern.pipe - import context.dispatcher - - private val OAUTH_URL_TEMPLATE = "https://oauth.vk.com/authorize?client_id=%d&scope=friends&redirect_uri=https://oauth.vk.com/blank.html&display=page&v=5.74&response_type=token" - private implicit val materializer: ActorMaterializer = ActorMaterializer(ActorMaterializerSettings(context.system)) - private val http = Http(context.system) - - override def receive: Receive = { - case GetToken(clientId) => - val uri = OAUTH_URL_TEMPLATE.format(clientId) - val request = HttpRequest(uri = uri) - - http.singleRequest(request).pipeTo(self) - case HttpResponse(StatusCodes.OK, headers, entity, _) => - println("OK") - entity.withoutSizeLimit().toStrict(1 second).pipeTo(self) - case resp @ HttpResponse(code, _, _, _) => - println(s"Failed with HTTP code $code") - case HttpEntity.Strict(contentType, data) => - println(data.utf8String) - } -} - -object AccessTokenActor { - - case class GetToken(clientId: Int) - - def props: Props = Props[AccessTokenActor] -} diff --git a/src/main/scala/eu/xeppaka/bot1/actors/ChatActor.scala b/src/main/scala/eu/xeppaka/bot1/actors/ChatActor.scala deleted file mode 100644 index f034925..0000000 --- a/src/main/scala/eu/xeppaka/bot1/actors/ChatActor.scala +++ /dev/null @@ -1,49 +0,0 @@ -package eu.xeppaka.bot1.actors - -import akka.actor.typed.scaladsl.Behaviors -import akka.actor.typed.{ActorRef, Behavior} - -object ChatActor { - sealed trait ChatCommand { - def replyTo: ActorRef[Response] - } - - case class Response(text: String) - case class Help(replyTo: ActorRef[Response]) extends ChatCommand - case class Start(replyTo: ActorRef[Response]) extends ChatCommand - case class MessageReceived(text: String, replyTo: ActorRef[Response]) extends ChatCommand - case class Stop(replyTo: ActorRef[Response]) extends ChatCommand - - private val started: Behavior[ChatCommand] = Behaviors.receive { (ctx, msg) => - msg match { - case MessageReceived(text, replyTo) => - replyTo ! Response(s"Ok, you said: $text") - Behaviors.same - case Help(replyTo) => - replyHelp(replyTo) - Behaviors.same - case Stop(replyTo) => - replyTo ! Response("Bye, bye!") - initial - case _ => - Behaviors.unhandled - } - } - - private val initial: Behavior[ChatCommand] = Behaviors.receive { (ctx, msg) => - msg match { - case Start(replyTo) => - replyTo ! Response("You started. Try /help, motherfucker...") - started - case c: ChatCommand => - c.replyTo ! Response("Only /start command is supported. Try it...") - Behaviors.same - } - } - - private def replyHelp(replyTo: ActorRef[Response]): Unit = { - replyTo ! Response("No help is provided for such motherfuckers like you! But... ok, send /stop and we are free.") - } - - val behavior: Behavior[ChatCommand] = initial -} diff --git a/src/main/scala/eu/xeppaka/bot1/actors/UpdateActor.scala b/src/main/scala/eu/xeppaka/bot1/actors/UpdateActor.scala deleted file mode 100644 index 5b603c2..0000000 --- a/src/main/scala/eu/xeppaka/bot1/actors/UpdateActor.scala +++ /dev/null @@ -1,78 +0,0 @@ -package eu.xeppaka.bot1.actors - -import akka.NotUsed -import akka.actor.typed.receptionist.Receptionist.Find -import akka.actor.typed.receptionist.{Receptionist, ServiceKey} -import akka.actor.typed.scaladsl.{ActorContext, Behaviors} -import akka.actor.typed.{ActorRef, Behavior} -import eu.xeppaka.bot1.TelegramEntities -import eu.xeppaka.bot1.actors.ChatActor.{Help, MessageReceived, Start, Stop} - -object UpdateActor { - object BotMessages { - val start = "/start" - val stop = "/stop" - val help = "/help" - } - - sealed trait UpdateCommand - case class UpdateReceived(update: TelegramEntities.Update, replyTo: ActorRef[UpdateResponse]) extends UpdateCommand - case class UpdateResponse(chatId: Long, text: String) - - val behavior: Behavior[UpdateCommand] = Behaviors.receive[UpdateCommand] { (ctx, msg) => - msg match { - case UpdateReceived(receivedUpdate, replyTo) => - if (receivedUpdate.message.isDefined) { - ctx.spawn(processMessage(ctx, receivedUpdate.message.get, replyTo), s"process-update-${receivedUpdate.update_id}") - } - - Behaviors.same - } - } - - private def processMessage(parentContext: ActorContext[UpdateCommand], message: TelegramEntities.Message, replyTo: ActorRef[UpdateResponse]): Behavior[NotUsed] = { - Behaviors.setup[AnyRef] { ctx => - val chatId = message.chat.id - val chatKey = ServiceKey[ChatActor.ChatCommand](chatId.toString) - - println(s"Sending Find to receptionist to find actor with id: $chatId") - - ctx.system.receptionist ! Find(chatKey, ctx.self.narrow[Receptionist.Listing]) - - Behaviors.receive[AnyRef] { (ctx, msg) => - msg match { - case chatKey.Listing(listing) => - if (listing.isEmpty) { - println(s"Actor with id: $chatId not found") - } else { - println(s"Actor with id: $chatId is found") - } - - val chat = listing.headOption - .getOrElse({ - val chatActor = parentContext.spawn(ChatActor.behavior, chatId.toString) - ctx.system.receptionist ! Receptionist.Register(chatKey, chatActor) - chatActor - }) - - chat ! getChatMessage(message, ctx.self.narrow[ChatActor.Response]) - Behaviors.same - case ChatActor.Response(text) => - replyTo ! UpdateResponse(chatId, text) - Behaviors.stopped - } - } - } - }.narrow[NotUsed] - - private def getChatMessage(message: TelegramEntities.Message, replyTo: ActorRef[ChatActor.Response]): ChatActor.ChatCommand = { - import BotMessages._ - - message.text.getOrElse(help) match { - case `start` => Start(replyTo) - case `stop` => Stop(replyTo) - case `help` => Help(replyTo) - case msgText@_ => MessageReceived(msgText, replyTo) - } - } -} diff --git a/src/main/scala/eu/xeppaka/bot1/BotUri.scala b/telegram-bot/src/main/scala/eu/xeppaka/bot/BotUri.scala similarity index 94% rename from src/main/scala/eu/xeppaka/bot1/BotUri.scala rename to telegram-bot/src/main/scala/eu/xeppaka/bot/BotUri.scala index 0b704d5..82040c0 100644 --- a/src/main/scala/eu/xeppaka/bot1/BotUri.scala +++ b/telegram-bot/src/main/scala/eu/xeppaka/bot/BotUri.scala @@ -1,4 +1,4 @@ -package eu.xeppaka.bot1 +package eu.xeppaka.bot import akka.http.scaladsl.model.Uri @@ -6,14 +6,9 @@ case class BotUri(botId: String) { private val baseUri = Uri(s"https://api.telegram.org/bot$botId") val botUri: Uri = baseUri - val getMe: Uri = baseUri.withPath(baseUri.path / "getMe") - val setWebhook: Uri = baseUri.withPath(baseUri.path / "setWebhook") - val deleteWebhook: Uri = baseUri.withPath(baseUri.path / "deleteWebhook") - val getWebhookInfo: Uri = baseUri.withPath(baseUri.path / "getWebhookInfo") - val sendMessage: Uri = baseUri.withPath(baseUri.path / "sendMessage") } diff --git a/telegram-bot/src/main/scala/eu/xeppaka/bot/Main.scala b/telegram-bot/src/main/scala/eu/xeppaka/bot/Main.scala new file mode 100644 index 0000000..814529a --- /dev/null +++ b/telegram-bot/src/main/scala/eu/xeppaka/bot/Main.scala @@ -0,0 +1,30 @@ +package eu.xeppaka.bot + +import akka.actor.Scheduler +import akka.actor.typed.scaladsl.AskPattern._ +import akka.actor.typed.{ActorSystem, DispatcherSelector} +import eu.xeppaka.bot.TelegramBot._ + +import scala.concurrent.duration._ +import scala.concurrent.{Await, ExecutionContextExecutor, Future} +import scala.io.StdIn + +object Main { + def main(args: Array[String]): Unit = { + val botId = "570855144:AAEv7b817cuq2JJI9f2kG5B9G3zW1x-btz4" + val telegramBot = ActorSystem(TelegramBot.behavior(botId, "lenovo", 8443), "telegram-bot") + implicit val executionContext: ExecutionContextExecutor = telegramBot.dispatchers.lookup(DispatcherSelector.default()) + implicit val scheduler: Scheduler = telegramBot.scheduler + + val startFuture: Future[StartResult] = (telegramBot ? (ref => TelegramBot.Start(ref))) (10.seconds, scheduler) + + println("Press enter to finish bot...") + StdIn.readLine() + + val terminateFuture = startFuture + .flatMap { _ => (telegramBot ? (ref => TelegramBot.Stop(ref))) (10.seconds, scheduler) } + .andThen { case _ => telegramBot.terminate() } + + Await.ready(terminateFuture, 5.seconds) + } +} diff --git a/telegram-bot/src/main/scala/eu/xeppaka/bot/TelegramBot.scala b/telegram-bot/src/main/scala/eu/xeppaka/bot/TelegramBot.scala new file mode 100644 index 0000000..818cb46 --- /dev/null +++ b/telegram-bot/src/main/scala/eu/xeppaka/bot/TelegramBot.scala @@ -0,0 +1,276 @@ +package eu.xeppaka.bot + +import java.io.InputStream +import java.security.{KeyStore, SecureRandom} +import java.util.UUID + +import akka.Done +import akka.actor.ActorSystem +import akka.actor.typed.scaladsl.adapter._ +import akka.actor.typed.scaladsl.{Behaviors, StashBuffer} +import akka.actor.typed.{ActorRef, Behavior, DispatcherSelector} +import akka.http.scaladsl.marshalling.Marshal +import akka.http.scaladsl.model._ +import akka.http.scaladsl.server.Directives.{as, complete, entity, path, post} +import akka.http.scaladsl.server.Route +import akka.http.scaladsl.{ConnectionContext, Http, HttpExt, HttpsConnectionContext} +import akka.stream.ActorMaterializer +import akka.util.ByteString +import eu.xeppaka.bot.TelegramEntities._ +import javax.net.ssl.{KeyManagerFactory, SSLContext, TrustManagerFactory} + +import scala.collection.immutable +import scala.concurrent.ExecutionContextExecutor +import scala.io.Source +import scala.util.{Failure, Success} + +object TelegramBot { + sealed trait Command + sealed trait CommandResult + sealed trait StartResult extends CommandResult + sealed trait StopResult extends CommandResult + + case object StartSuccess extends StartResult + case class StartFailure(exception: Throwable) extends StartResult + case object StopSuccess extends StopResult + case class StopFailure(exception: Throwable) extends StopResult + + case class Start(replyTo: ActorRef[StartResult]) extends Command + case class Stop(replyTo: ActorRef[StopResult]) extends Command + case object GetBotInfo + case object GetWebhookInfo + + def behavior(botId: String, interface: String, port: Int): Behavior[Command] = Behaviors.setup[Command] { ctx => + implicit val untypedSystem: ActorSystem = ctx.system.toUntyped + implicit val actorMaterializer: ActorMaterializer = ActorMaterializer() + implicit val executionContextExecutor: ExecutionContextExecutor = ctx.system.dispatchers.lookup(DispatcherSelector.default()) + + val botUri = BotUri(botId) + val http: HttpExt = Http() + val hookId = UUID.randomUUID().toString + val webhookUri = Uri(s"https://xeppaka.eu:88/$hookId") + val httpsContext = createHttpsConnectionContext + val stashBuffer = StashBuffer[Command](10) + val updatesProcessor = ctx.spawn(UpdatesProcessor.behavior, "updatesProcessor") + val routes = botRoutes(hookId, updatesProcessor) + + def reply(command: Command, exceptions: Seq[Throwable]): Unit = { + command match { + case Start(replyTo) => + if (exceptions.isEmpty) { + ctx.log.info("action=start_bot result=success") + replyTo ! StartSuccess + } else { + ctx.log.error("action=start_bot result=failure", exceptions.head) + replyTo ! StartFailure(exceptions.head) + } + case Stop(replyTo) => + if (exceptions.isEmpty) { + ctx.log.info("action=stop_bot result=success") + replyTo ! StopSuccess + } else { + ctx.log.error("action=stop_bot result=failure", exceptions.head) + replyTo ! StopFailure(exceptions.head) + } + case _ => throw new IllegalArgumentException(s"Unsupported command to reply: $command.") + } + } + + def stopped(replyOnCommand: Option[Command] = None, exceptions: Seq[Throwable] = Seq.empty): Behavior[Command] = Behaviors.setup { ctx => + replyOnCommand.foreach(reply(_, exceptions)) + + Behaviors.receiveMessage[Command] { + case startCommand@Start(_) => + ctx.log.info("action=start_bot") + bindingServer(startCommand) + case stopCommand@Stop(_) => + ctx.log.info("action=stop_bot") + reply(stopCommand, Seq.empty) + Behaviors.same + case _ => + Behaviors.unhandled + } + } + + def bindingServer(replyOnCommand: Command): Behavior[Command] = Behaviors.setup[Command] { ctx => + case class BindingSuccess(binding: Http.ServerBinding) extends Command + case class BindingFailure(exception: Throwable) extends Command + + ctx.log.info("action=bind_server interface={} port={}", interface, port) + + http + .bindAndHandle(routes, interface, port, httpsContext) + .onComplete { + case Success(binding) => ctx.self ! BindingSuccess(binding) + case Failure(exception) => ctx.self ! BindingFailure(exception) + } + + Behaviors.receiveMessage[Command] { + case BindingSuccess(binding) => + ctx.log.info("action=bind_server result=success") + stashBuffer.unstashAll(ctx, settingWebhook(binding, replyOnCommand, Seq.empty)) + case BindingFailure(exception) => + ctx.log.error("action=bind_server result=failure", exception) + stashBuffer.unstashAll(ctx, stopped(Some(replyOnCommand), Seq(exception))) + case otherCommand: Command => + stashBuffer.stash(otherCommand) + Behaviors.same + } + } + + def unbindingServer(binding: Http.ServerBinding, replyOnCommand: Command, exceptions: Seq[Throwable]): Behavior[Command] = Behaviors.setup[Command] { ctx => + case object UnbindingSuccess extends Command + case class UnbindingFailure(exception: Throwable) extends Command + + ctx.log.info("action=unbind_server interface={} port={}", interface, port) + + binding + .unbind() + .onComplete { + case Success(Done) => ctx.self ! UnbindingSuccess + case Failure(exception) => ctx.self ! UnbindingFailure(exception) + } + + Behaviors.receiveMessage[Command] { + case UnbindingSuccess => + ctx.log.info("action=unbind_server result=success") + stashBuffer.unstashAll(ctx, stopped(Some(replyOnCommand), exceptions)) + case UnbindingFailure(exception) => + ctx.log.error("action=unbind_server result=failure", exception) + stashBuffer.unstashAll(ctx, stopped(Some(replyOnCommand), exceptions :+ exception)) + case otherCommand: Command => + stashBuffer.stash(otherCommand) + Behaviors.same + } + } + + def settingWebhook(binding: Http.ServerBinding, replyOnCommand: Command, exceptions: Seq[Throwable]): Behavior[Command] = Behaviors.setup[Command] { ctx => + case object SetWebhookSuccess extends Command + case class SetWebhookFailure(exception: Throwable) extends Command + + ctx.log.info("action=set_webhook url={} webhook={}", botUri.setWebhook, webhookUri) + + implicit val executionContextExecutor: ExecutionContextExecutor = ctx.system.dispatchers.lookup(DispatcherSelector.default()) + + val urlEntity = HttpEntity.Strict(ContentTypes.`text/plain(UTF-8)`, ByteString(webhookUri.toString())) + val urlPart = Multipart.FormData.BodyPart.Strict("url", urlEntity) + + val certificate = ByteString(Source.fromResource("telegram-bot.pem").mkString) + val certificateEntity = HttpEntity.Strict(ContentTypes.`application/octet-stream`, certificate) + val certificatePart = Multipart.FormData.BodyPart.Strict("certificate", certificateEntity, Map("filename" -> "telegram-bot.pem")) + + val setWebhookFormData = Multipart.FormData.Strict(immutable.Seq(urlPart, certificatePart)) + + Marshal(setWebhookFormData) + .to[RequestEntity] + .flatMap(requestEntity => http.singleRequest(HttpRequest(uri = botUri.setWebhook, method = HttpMethods.POST, entity = requestEntity))) + .onComplete { + case Success(response) => + if (response.status.isSuccess()) + ctx.self ! SetWebhookSuccess + else + ctx.self ! SetWebhookFailure(new RuntimeException(s"Set webhook HTTP response status: ${response.status.value}.")) + case Failure(exception) => + ctx.self ! SetWebhookFailure(exception) + } + + Behaviors.receiveMessage { + case SetWebhookSuccess => + ctx.log.info("action=set_webhook result=success") + stashBuffer.unstashAll(ctx, started(binding, replyOnCommand, exceptions)) + case SetWebhookFailure(exception) => + ctx.log.error("action=set_webhook result=failure", exception) + stashBuffer.unstashAll(ctx, unbindingServer(binding, replyOnCommand, exceptions :+ exception)) + case otherCommand: Command => + stashBuffer.stash(otherCommand) + Behaviors.same + } + } + + def deletingWebhook(binding: Http.ServerBinding, replyOnCommand: Command, exceptions: Seq[Throwable]): Behavior[Command] = Behaviors.setup[Command] { ctx => + case object DeleteWebhookSuccess extends Command + case class DeleteWebhookFailure(exception: Throwable) extends Command + + ctx.log.info("action=delete_webhook url={} webhook={}", botUri.deleteWebhook, webhookUri) + + implicit val executionContextExecutor: ExecutionContextExecutor = ctx.system.dispatchers.lookup(DispatcherSelector.default()) + + http + .singleRequest(HttpRequest(uri = botUri.deleteWebhook, method = HttpMethods.POST)) + .onComplete { + case Success(response) => + if (response.status.isSuccess()) + ctx.self ! DeleteWebhookSuccess + else + ctx.self ! DeleteWebhookFailure(new RuntimeException(s"Delete webhook HTTP response status: ${response.status.value}")) + case Failure(exception) => + ctx.self ! DeleteWebhookFailure(exception) + } + + Behaviors.receiveMessage { + case DeleteWebhookSuccess => + ctx.log.info("action=delete_webhook result=success") + stashBuffer.unstashAll(ctx, unbindingServer(binding, replyOnCommand, exceptions)) + case DeleteWebhookFailure(exception) => + ctx.log.error("action=delete_webhook result=failure", exception) + stashBuffer.unstashAll(ctx, unbindingServer(binding, replyOnCommand, exceptions :+ exception)) + case otherCommand: Command => + stashBuffer.stash(otherCommand) + Behaviors.same + } + } + + def started(binding: Http.ServerBinding, replyOnCommand: Command, exceptions: Seq[Throwable]): Behavior[Command] = Behaviors.setup[Command] { ctx => + reply(replyOnCommand, exceptions) + + Behaviors.receiveMessage[Command] { + case startCommand@Start(_) => + ctx.log.info("action=start_bot") + reply(startCommand, Seq.empty) + Behaviors.same + case stopCommand@Stop(_) => + ctx.log.info("action=stop_bot") + deletingWebhook(binding, stopCommand, exceptions) + case _ => + Behaviors.unhandled + } + } + + stopped() + } + + private def botRoutes(hookId: String, updatesProcessor: ActorRef[UpdatesProcessor.Command]): Route = { + import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport._ + import io.circe.generic.auto._ + + path(hookId) { + post { + entity(as[Update]) { update => + updatesProcessor ! UpdatesProcessor.UpdateReceived(update) + complete(HttpResponse()) + } + } + } + } + + private def createHttpsConnectionContext: HttpsConnectionContext = { + val password: Array[Char] = "".toCharArray // do not store passwords in code, read them from somewhere safe! + + val ks: KeyStore = KeyStore.getInstance("PKCS12") + val keystore: InputStream = getClass.getResourceAsStream("/telegram-bot.p12") + + require(keystore != null, "Keystore required!") + ks.load(keystore, password) + + val keyManagerFactory: KeyManagerFactory = KeyManagerFactory.getInstance("SunX509") + keyManagerFactory.init(ks, password) + + val tmf: TrustManagerFactory = TrustManagerFactory.getInstance("SunX509") + tmf.init(ks) + + val sslContext: SSLContext = SSLContext.getInstance("TLS") + sslContext.init(keyManagerFactory.getKeyManagers, tmf.getTrustManagers, new SecureRandom) + + ConnectionContext.https(sslContext) + } +} diff --git a/telegram-bot/src/main/scala/eu/xeppaka/bot/TelegramEntities.scala b/telegram-bot/src/main/scala/eu/xeppaka/bot/TelegramEntities.scala new file mode 100644 index 0000000..ab85743 --- /dev/null +++ b/telegram-bot/src/main/scala/eu/xeppaka/bot/TelegramEntities.scala @@ -0,0 +1,278 @@ +package eu.xeppaka.bot + +object TelegramEntities { + + case class Response[T](ok: Boolean, + description: Option[String] = None, + error_code: Option[Int] = None, + result: T + ) + + case class GetMe(id: Int, is_bot: Boolean, first_name: String, username: String) + + case class InlineQuery(id: String, + from: User, + location: Location, + query: String, + offset: String + ) + + case class Location(longitude: Float, + latitude: Float + ) + + case class Update(update_id: Int, + message: Option[Message] = None, + edited_message: Option[Message] = None, + channel_post: Option[Message] = None, + edited_channel_post: Option[Message] = None, + inline_query: Option[InlineQuery] = None, + chosen_inline_result: Option[ChosenInlineResult] = None, + callback_query: Option[CallbackQuery] = None, + shipping_query: Option[ShippingQuery] = None, + pre_checkout_query: Option[PreCheckoutQuery] = None + ) + + case class ChosenInlineResult(result_id: String, + from: User, + location: Option[Location] = None, + inline_message_id: Option[String] = None, + query: String + ) + + case class CallbackQuery(id: String, + from: User, + message: Option[Message] = None, + inline_message_id: Option[String] = None, + chat_instance: String, + data: Option[String] = None, + game_short_name: Option[String] = None + ) + + case class ShippingQuery(id: String, + from: User, + invoice_payload: String, + shipping_address: ShippingAddress + ) + + case class ShippingAddress(country_code: String, + state: String, + city: String, + street_line1: String, + street_line2: String, + post_code: String + ) + + case class PreCheckoutQuery(id: String, + from: User, + currency: String, + total_amount: Int, + invoice_payload: String, + shipping_option_id: Option[String] = None, + order_info: Option[OrderInfo] = None + ) + + case class OrderInfo(name: Option[String] = None, + phone_number: Option[String] = None, + email: Option[String] = None, + shipping_address: Option[ShippingAddress] = None + ) + + case class User(id: Int, + is_bot: Boolean, + first_name: String, + last_name: Option[String] = None, + username: Option[String] = None, + language_code: Option[String] = None + ) + + case class SendMessage(chat_id: Long, + text: String, + parse_mode: Option[String] = None, + disable_web_page_preview: Option[Boolean] = None, + disable_notification: Option[Boolean] = None, + reply_to_message_id: Option[Int] = None, + reply_markup: Option[String] = None + ) + + case class Message(message_id: Int, + from: Option[User] = None, + date: Int, + chat: Chat, + forward_from: Option[User] = None, + forward_from_chat: Option[User] = None, + forward_from_message_id: Option[Int] = None, + forward_signature: Option[String] = None, + forward_date: Option[Int] = None, + reply_to_message: Option[Message] = None, + edit_date: Option[Int] = None, + media_group_id: Option[String] = None, + author_signature: Option[String] = None, + text: Option[String] = None, + entities: Option[Seq[MessageEntity]] = None, + caption_entities: Option[Seq[MessageEntity]] = None, + audio: Option[Audio] = None, + document: Option[Document] = None, + game: Option[Game] = None, + photo: Option[Seq[PhotoSize]] = None, + sticker: Option[Sticker] = None, + video: Option[Video] = None, + voice: Option[Voice] = None, + video_note: Option[VideoNote] = None, + caption: Option[String] = None, + contact: Option[Contact] = None, + location: Option[Location] = None, + venue: Option[Venue] = None, + new_chat_members: Option[Seq[User]] = None, + left_chat_member: Option[Seq[User]] = None, + new_chat_title: Option[String] = None, + new_chat_photo: Option[Seq[PhotoSize]] = None, + delete_chat_photo: Option[Boolean] = None, + group_chat_created: Option[Boolean] = None, + supergroup_chat_created: Option[Boolean] = None, + channel_chat_created: Option[Boolean] = None, + migrate_to_chat_id: Option[Int] = None, + migrate_from_chat_id: Option[Int] = None, + pinned_message: Option[Message] = None, + invoice: Option[Invoice] = None, + successful_payment: Option[SuccessfulPayment] = None, + connected_website: Option[String] = None + ) + + case class MessageEntity(`type`: String, + offset: Int, + length: Int, + url: Option[String] = None, + user: Option[User] = None + ) + + case class Contact(phone_number: String, + first_name: String, + last_name: Option[String] = None, + user_id: Option[Int] = None + ) + + case class Sticker(file_id: String, + width: Int, + height: Int, + thumb: Option[PhotoSize] = None, + emoji: Option[String] = None, + set_name: Option[String] = None, + mask_position: Option[String] = None, + file_size: Option[Int] = None + ) + + case class Video(file_id: String, + width: Int, + height: Int, + duration: Int, + thumb: Option[PhotoSize] = None, + mime_type: Option[String] = None, + file_size: Option[Int] = None + ) + + case class Audio(file_id: String, + duration: Int, + performer: Option[String] = None, + title: Option[String] = None, + mime_type: Option[String] = None, + file_size: Option[Int] = None + ) + + case class Document(file_id: String, + thumb: Option[PhotoSize] = None, + file_name: Option[String] = None, + mime_type: Option[String] = None, + file_size: Option[Int] = None + ) + + case class PhotoSize(file_id: String, + width: Int, + height: Int, + file_size: Option[Int] = None + ) + + case class Voice(file_id: String, + duration: Int, + mime_type: Option[String] = None, + file_size: Option[Int] = None + ) + + case class VideoNote(file_id: String, + length: Int, + duration: Int, + thumb: Option[PhotoSize] = None, + file_size: Option[Int] = None + ) + + case class ChatPhoto(small_file_id: String, big_file_id: String) + + case class Chat(id: Long, + `type`: String, + title: Option[String] = None, + username: Option[String] = None, + first_name: Option[String] = None, + last_name: Option[String] = None, + all_members_are_administrators: Option[Boolean] = None, + photo: Option[ChatPhoto] = None, + description: Option[String] = None, + invite_link: Option[String] = None, + pinned_message: Option[Message] = None, + sticker_set_name: Option[String] = None, + can_set_sticker_set: Option[Boolean] = None + ) + + case class Game(title: String, + description: String, + photo: Seq[PhotoSize], + text: Option[String] = None, + text_entities: Option[Seq[MessageEntity]] = None, + animation: Option[Animation] = None + ) + + case class Animation(file_id: String, + thumb: Option[PhotoSize] = None, + file_name: Option[String] = None, + mime_type: Option[String] = None, + file_size: Option[Int] = None + ) + + case class InputFile() + + case class Venue(location: Location, + title: String, + address: String, + foursquare_id: Option[String] = None + ) + + case class Invoice(title: String, + description: String, + start_parameter: String, + currency: String, + total_amount: Int + ) + + case class SuccessfulPayment(currency: String, + total_amount: Int, + invoice_payload: String, + shipping_option_id: Option[String] = None, + order_info: Option[OrderInfo] = None, + telegram_payment_charge_id: String, + provider_payment_charge_id: String + ) + + case class Webhook(url: String, + certificate: Option[InputFile] = None, + max_connections: Option[Int] = None, + allowed_updates: Option[Seq[String]] = None + ) + + case class WebhookInfo(url: String, + has_custom_certificate: Boolean, + pending_update_count: Int, + last_error_date: Option[Int] = None, + last_error_message: Option[String] = None, + max_connections: Option[Int] = None, + allowed_updates: Option[Seq[String]] = None + ) +} diff --git a/telegram-bot/src/main/scala/eu/xeppaka/bot/UpdatesProcessor.scala b/telegram-bot/src/main/scala/eu/xeppaka/bot/UpdatesProcessor.scala new file mode 100644 index 0000000..8a52bef --- /dev/null +++ b/telegram-bot/src/main/scala/eu/xeppaka/bot/UpdatesProcessor.scala @@ -0,0 +1,20 @@ +package eu.xeppaka.bot + +import akka.actor.typed.Behavior +import akka.actor.typed.scaladsl.Behaviors +import eu.xeppaka.bot.TelegramEntities.Update + +object UpdatesProcessor { + sealed trait Command + sealed trait CommandResult + + case class UpdateReceived(update: Update) extends Command + + def behavior: Behavior[Command] = Behaviors.receive[Command] { (ctx, msg) => + msg match { + case UpdateReceived(update) => + ctx.log.info("action=update_received update={}", update) + Behaviors.same + } + } +}