diff --git a/.gitignore b/.gitignore index 31ff66c..4f9918f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ target project/target .bsp +secret diff --git a/bot/src/main/scala/com/github/mmvpm/bot/Main.scala b/bot/src/main/scala/com/github/mmvpm/bot/Main.scala new file mode 100644 index 0000000..aabf3ad --- /dev/null +++ b/bot/src/main/scala/com/github/mmvpm/bot/Main.scala @@ -0,0 +1,27 @@ +package com.github.mmvpm.bot + +import cats.effect.{IO, IOApp} +import com.github.mmvpm.bot.model.MessageID +import com.github.mmvpm.bot.render.RendererImpl +import com.github.mmvpm.bot.state.{State, StateManagerImpl, StorageImpl} +import com.github.mmvpm.bot.util.ResourceUtils +import org.asynchttpclient.Dsl.asyncHttpClient +import sttp.client3.asynchttpclient.cats.AsyncHttpClientCatsBackend + +object Main extends IOApp.Simple { + + override def run: IO[Unit] = + for { + _ <- IO.println("Starting telegram bot...") + + token = ResourceUtils.readTelegramToken() + sttpBackend = AsyncHttpClientCatsBackend.usingClient[IO](asyncHttpClient) + renderer = new RendererImpl + manager = new StateManagerImpl[IO] + stateStorage = new StorageImpl[State](State.Started) + lastMessageStorage = new StorageImpl[Option[MessageID]](None) + bot = new OfferServiceBot[IO](token, sttpBackend, renderer, manager, stateStorage, lastMessageStorage) + + _ <- bot.startPolling() + } yield () +} diff --git a/bot/src/main/scala/com/github/mmvpm/bot/OfferServiceBot.scala b/bot/src/main/scala/com/github/mmvpm/bot/OfferServiceBot.scala new file mode 100644 index 0000000..42b7e05 --- /dev/null +++ b/bot/src/main/scala/com/github/mmvpm/bot/OfferServiceBot.scala @@ -0,0 +1,87 @@ +package com.github.mmvpm.bot + +import cats.effect.Concurrent +import cats.implicits.toFlatMapOps +import cats.syntax.functor._ +import com.bot4s.telegram.api.declarative.{Callbacks, Command, Commands} +import com.bot4s.telegram.cats.{Polling, TelegramBot} +import com.bot4s.telegram.methods.{EditMessageText, SendDice, SendMessage} +import com.bot4s.telegram.models._ +import com.github.mmvpm.bot.model.MessageID +import com.github.mmvpm.bot.render.Renderer +import com.github.mmvpm.bot.state.{State, StateManager, Storage} +import com.github.mmvpm.bot.state.State._ +import sttp.client3.SttpBackend + +class OfferServiceBot[F[_]: Concurrent]( + token: String, + sttpBackend: SttpBackend[F, Any], + renderer: Renderer, + manager: StateManager[F], + stateStorage: Storage[State], + lastMessageStorage: Storage[Option[MessageID]] +) extends TelegramBot[F](token, sttpBackend) + with Polling[F] + with Commands[F] + with Callbacks[F] { + + // user sent a message (text, image, etc) to the chat + onMessage { implicit message => + command(message) match { + case Some(Command("roll", _)) => roll + case Some(Command("start", _)) => start + case None => + getNextStateTag(stateStorage.get) match { + case UnknownTag => fail + case nextTag => replyResolved(nextTag) + } + } + } + + // user pressed the button + onCallbackQuery { implicit cq => + replyResolved(cq.data.get)(cq.message.get) + } + + // scenarios + + private def roll(implicit message: Message): F[Unit] = + request(SendDice(message.chat.id)).void + + private def start(implicit message: Message): F[Unit] = + requestLogged(renderer.render(stateStorage.get, lastMessageStorage.get)).void + + private def replyResolved(tag: String)(implicit message: Message): F[Unit] = + for { + nextState <- manager.getNextState(tag, stateStorage.get) + _ = stateStorage.set(withoutError(nextState)) + reply = renderer.render(nextState, lastMessageStorage.get) + _ <- requestLogged(reply) + } yield () + + private def fail(implicit message: Message): F[Unit] = + reply("Не понял вас :(").void + + // internal + + private def withoutError(state: State): State = + state match { + case Error(returnTo, _) => returnTo + case _ => state + } + + private def requestLogged(req: Either[EditMessageText, SendMessage]): F[Unit] = + req match { + case Left(toEdit) => + for { + sent <- request(toEdit) + _ = println(s"Edit $sent") + } yield () + case Right(toSend) => + for { + sent <- request(toSend) + _ = lastMessageStorage.set(Some(sent.messageId))(sent) + _ = println(s"Sent $sent") + } yield () + } +} diff --git a/bot/src/main/scala/com/github/mmvpm/bot/model/Draft.scala b/bot/src/main/scala/com/github/mmvpm/bot/model/Draft.scala new file mode 100644 index 0000000..e08e219 --- /dev/null +++ b/bot/src/main/scala/com/github/mmvpm/bot/model/Draft.scala @@ -0,0 +1,8 @@ +package com.github.mmvpm.bot.model + +case class Draft( + name: Option[String] = None, + price: Option[Long] = None, + description: Option[String] = None, + photos: Seq[String] = Seq.empty +) diff --git a/bot/src/main/scala/com/github/mmvpm/bot/model/package.scala b/bot/src/main/scala/com/github/mmvpm/bot/model/package.scala new file mode 100644 index 0000000..bfb3eaa --- /dev/null +++ b/bot/src/main/scala/com/github/mmvpm/bot/model/package.scala @@ -0,0 +1,8 @@ +package com.github.mmvpm.bot + +package object model { + type ChatID = Long + type MessageID = Int + type Tag = String + type Button = String +} diff --git a/bot/src/main/scala/com/github/mmvpm/bot/render/Renderer.scala b/bot/src/main/scala/com/github/mmvpm/bot/render/Renderer.scala new file mode 100644 index 0000000..abb86d4 --- /dev/null +++ b/bot/src/main/scala/com/github/mmvpm/bot/render/Renderer.scala @@ -0,0 +1,14 @@ +package com.github.mmvpm.bot.render + +import com.bot4s.telegram.methods.{EditMessageText, SendMessage} +import com.bot4s.telegram.models.Message +import com.github.mmvpm.bot.model.MessageID +import com.github.mmvpm.bot.state.State + +trait Renderer { + + def render( + state: State, + editMessage: Option[MessageID] + )(implicit message: Message): Either[EditMessageText, SendMessage] +} diff --git a/bot/src/main/scala/com/github/mmvpm/bot/render/RendererImpl.scala b/bot/src/main/scala/com/github/mmvpm/bot/render/RendererImpl.scala new file mode 100644 index 0000000..9b3fa75 --- /dev/null +++ b/bot/src/main/scala/com/github/mmvpm/bot/render/RendererImpl.scala @@ -0,0 +1,28 @@ +package com.github.mmvpm.bot.render + +import com.bot4s.telegram.methods.{EditMessageText, SendMessage} +import com.bot4s.telegram.models.{InlineKeyboardButton, InlineKeyboardMarkup, Message} +import com.github.mmvpm.bot.model.MessageID +import com.github.mmvpm.bot.state.State +import com.github.mmvpm.bot.state.State._ + +class RendererImpl extends Renderer { + + override def render( + state: State, + editMessage: Option[MessageID] = None + )(implicit message: Message): Either[EditMessageText, SendMessage] = { + val buttons = state.next.map { tag => + InlineKeyboardButton.callbackData(buttonBy(tag), tag) + } + val markup = Some(InlineKeyboardMarkup.singleColumn(buttons)) + + lazy val send = SendMessage(message.source, state.text, replyMarkup = markup) + lazy val edit = EditMessageText(Some(message.source), editMessage, text = state.text, replyMarkup = markup) + + if (editMessage.contains(message.messageId)) // the last message was sent by the bot + Left(edit) + else + Right(send) + } +} diff --git a/bot/src/main/scala/com/github/mmvpm/bot/state/State.scala b/bot/src/main/scala/com/github/mmvpm/bot/state/State.scala new file mode 100644 index 0000000..4d376af --- /dev/null +++ b/bot/src/main/scala/com/github/mmvpm/bot/state/State.scala @@ -0,0 +1,255 @@ +package com.github.mmvpm.bot.state + +import com.github.mmvpm.bot.model.{Button, Draft, Tag} +import com.github.mmvpm.model.Stub + +sealed trait State { + def tag: Tag + def next: Seq[Tag] + def optPrevious: Option[State] + def text: String +} + +object State { + + // tags + + val SearchTag: Tag = "search" + val ListingTag: Tag = "listing" + val OneOfferTag: Tag = "one-offer" + val CreateOfferNameTag: Tag = "create-name" + val CreateOfferPriceTag: Tag = "create-price" + val CreateOfferDescriptionTag: Tag = "create-description" + val CreateOfferPhotoTag: Tag = "create-photo" + val CreatedOfferTag: Tag = "created" + val MyOffersTag: Tag = "my-offers" + val MyOfferTag: Tag = "my-offer" + val EditOfferTag: Tag = "edit" + val EditOfferNameTag: Tag = "edit-name" + val EditOfferPriceTag: Tag = "edit-price" + val EditOfferDescriptionTag: Tag = "edit-description" + val AddOfferPhotoTag: Tag = "edit-add-photo" + val DeleteOfferPhotosTag: Tag = "edit-delete-photo" // without related state (UpdatedOffer instead of it) + val UpdatedOfferTag: Tag = "updated" + val DeletedOfferTag: Tag = "delete" + val BackTag: Tag = "back" + val StartedTag: Tag = "started" + val ErrorTag: Tag = "error" + val UnknownTag: Tag = "unknown" + + def buttonBy(tag: Tag): Button = + tag match { + case SearchTag => "Найти товар" + case ListingTag => "На следующую страницу" + case CreateOfferNameTag => "Разместить объявление" + case CreatedOfferTag => "Опубликовать объявление" + case MyOffersTag => "Посмотреть мои объявления" + case EditOfferTag => "Изменить это объявление" + case EditOfferNameTag => "Название" + case EditOfferPriceTag => "Цену" + case EditOfferDescriptionTag => "Описание" + case AddOfferPhotoTag => "Добавить фото" + case DeleteOfferPhotosTag => "Удалить все фото" + case DeletedOfferTag => "Удалить это объявление" + case BackTag => "Назад" + case StartedTag => "Вернуться в начало" + case UnknownTag => sys.error(s"buttonBy($tag)") + } + + def getNextStateTag(current: State): Tag = + current match { + case Search(_) => ListingTag + case Listing(_, _, _) => OneOfferTag + case MyOffers(_, _) => MyOfferTag + case CreateOfferName(_) => CreateOfferPriceTag + case CreateOfferPrice(_, _) => CreateOfferDescriptionTag + case CreateOfferDescription(_, _) => CreateOfferPhotoTag + case CreateOfferPhoto(_, _) => CreateOfferPhotoTag // upload another photo + case EditOfferName(_) => UpdatedOfferTag + case EditOfferPrice(_) => UpdatedOfferTag + case EditOfferDescription(_) => UpdatedOfferTag + case AddOfferPhoto(_) => UpdatedOfferTag + case _ => UnknownTag + } + + // previous state + + trait NoPrevious extends State { + val optPrevious: Option[State] = None + } + + trait WithPrevious extends State { self: { val previous: State } => + val optPrevious: Option[State] = Some(self.previous) + } + + // beginning + + case object Started extends State with NoPrevious { + val tag: Tag = StartedTag + val next: Seq[Tag] = Seq(SearchTag, CreateOfferNameTag, MyOffersTag) + val text: String = "Как я могу вам помочь?" + } + + // search + + case class Search(previous: State) extends State with WithPrevious { + val tag: Tag = SearchTag + val next: Seq[Tag] = Seq(BackTag) + val text: String = "Что хотите найти?" + } + + case class Listing(previous: State, offers: Seq[Stub], from: Int) extends State with WithPrevious { + + val tag: Tag = ListingTag + + val next: Seq[Tag] = nextPageTag ++ Seq(BackTag, StartedTag) + + val text: String = + s""" + |Вот что нашлось по вашему запросу: + | + |${offers.slice(from, from + Listing.StepSize).mkString("- ", "\n- ", "")} + | + |Если хотите посмотреть одно подробнее, напишите мне его id + |""".stripMargin + + private lazy val nextPageTag = + if (from + Listing.StepSize < offers.length) + Seq(ListingTag) + else + Seq() + } + + object Listing { + + val StepSize = 5 + + def start(previous: State, offers: Seq[Stub]): Listing = + Listing(previous: State, offers: Seq[Stub], from = 0) + } + + case class OneOffer(previous: State, offer: Stub) extends State with WithPrevious { + val tag: Tag = OneOfferTag + val next: Seq[Tag] = Seq(BackTag) + val text: String = + s""" + |Выбранное объявление: + | + |- id: ${offer.id} + |- data: ${offer.data} + |""".stripMargin + } + + // create offer + + case class CreateOfferName(previous: State) extends State with WithPrevious { + val tag: Tag = CreateOfferNameTag + val next: Seq[Tag] = Seq(BackTag) + val text: String = "Как будет называться ваше объявление?" + } + + case class CreateOfferPrice(previous: State, draft: Draft) extends State with WithPrevious { + val tag: Tag = CreateOfferPriceTag + val next: Seq[Tag] = Seq(BackTag) + val text: String = "Введите цену, за которую вы готовы продать" + } + + case class CreateOfferDescription(previous: State, draft: Draft) extends State with WithPrevious { + val tag: Tag = CreateOfferDescriptionTag + val next: Seq[Tag] = Seq(BackTag) + val text: String = "Добавьте описание к вашему объявлению" + } + + case class CreateOfferPhoto(previous: State, draft: Draft) extends State with WithPrevious { + val tag: Tag = CreateOfferPhotoTag + val next: Seq[Tag] = Seq(CreatedOfferTag, BackTag) + val text: String = "Добавьте одну или несколько фотографий" + } + + case class CreatedOffer(previous: State, draft: Draft) extends State with WithPrevious { + val tag: Tag = CreatedOfferTag + val next: Seq[Tag] = Seq(StartedTag) + val text: String = s"Объявление размещено. Вы можете посмотреть его в разделе \"Мои объявления\"\n\n$draft" + } + + // my offers + + case class MyOffers(previous: State, offers: Seq[Stub]) extends State with WithPrevious { + val tag: Tag = MyOffersTag + val next: Seq[Tag] = Seq(BackTag) + val text: String = + s""" + |Все ваши объявления: + | + |${offers.map(_.id).mkString("-`", "`\n-`", "`")} + | + |Если хотите посмотреть одно подробнее, напишите мне его id + |""".stripMargin + } + + case class MyOffer(previous: State, offer: Stub) extends State with WithPrevious { + val tag: Tag = MyOfferTag + val next: Seq[Tag] = Seq(EditOfferTag, DeletedOfferTag, BackTag) + val text: String = + s""" + |Ваше объявление: + | + |- id: ${offer.id} + |- data: ${offer.data} + |""".stripMargin + } + + // edit my offer + + case class EditOffer(previous: State) extends State with WithPrevious { + val tag: Tag = EditOfferTag + val next: Seq[Tag] = + Seq(EditOfferNameTag, EditOfferPriceTag, EditOfferDescriptionTag, AddOfferPhotoTag, DeleteOfferPhotosTag, BackTag) + val text: String = "Что хотите поменять?" + } + + case class EditOfferName(previous: State) extends State with WithPrevious { + val tag: Tag = EditOfferNameTag + val next: Seq[Tag] = Seq(BackTag) + val text: String = "Введите новое название объявления" + } + + case class EditOfferPrice(previous: State) extends State with WithPrevious { + val tag: Tag = EditOfferPriceTag + val next: Seq[Tag] = Seq(BackTag) + val text: String = "Введите новую цену" + } + + case class EditOfferDescription(previous: State) extends State with WithPrevious { + val tag: Tag = EditOfferDescriptionTag + val next: Seq[Tag] = Seq(BackTag) + val text: String = "Введите новое описание к объявлению" + } + + case class AddOfferPhoto(previous: State) extends State with WithPrevious { + val tag: Tag = AddOfferPhotoTag + val next: Seq[Tag] = Seq(BackTag) + val text: String = "Загрузите фотографию" + } + + case class UpdatedOffer(previous: State, text: String) extends State with WithPrevious { + val tag: Tag = UpdatedOfferTag + val next: Seq[Tag] = Seq(BackTag) + } + + // delete my offer + + case class DeletedOffer(previous: State) extends State with WithPrevious { + val tag: Tag = DeletedOfferTag + val next: Seq[Tag] = Seq(StartedTag) + val text: String = "Объявление удалено" + } + + // error + + case class Error(returnTo: State, message: String) extends State with NoPrevious { + val tag: Tag = ErrorTag + val next: Seq[Tag] = Seq() + val text: String = message + } +} diff --git a/bot/src/main/scala/com/github/mmvpm/bot/state/StateManager.scala b/bot/src/main/scala/com/github/mmvpm/bot/state/StateManager.scala new file mode 100644 index 0000000..c370385 --- /dev/null +++ b/bot/src/main/scala/com/github/mmvpm/bot/state/StateManager.scala @@ -0,0 +1,7 @@ +package com.github.mmvpm.bot.state + +import com.bot4s.telegram.models.Message + +trait StateManager[F[_]] { + def getNextState(tag: String, current: State)(implicit message: Message): F[State] +} diff --git a/bot/src/main/scala/com/github/mmvpm/bot/state/StateManagerImpl.scala b/bot/src/main/scala/com/github/mmvpm/bot/state/StateManagerImpl.scala new file mode 100644 index 0000000..870aa8f --- /dev/null +++ b/bot/src/main/scala/com/github/mmvpm/bot/state/StateManagerImpl.scala @@ -0,0 +1,214 @@ +package com.github.mmvpm.bot.state + +import cats.Monad +import com.bot4s.telegram.models.Message +import com.github.mmvpm.bot.model.Draft +import com.github.mmvpm.bot.state.State.{Listing, _} +import com.github.mmvpm.bot.util.StateUtils.StateSyntax +import com.github.mmvpm.model.Stub + +import java.util.UUID +import scala.util.Random + +class StateManagerImpl[F[_]: Monad] extends StateManager[F] { + + override def getNextState(tag: String, current: State)(implicit message: Message): F[State] = + tag match { + case SearchTag => toSearch(current) + case ListingTag => toListing(current) + case OneOfferTag => toOneOffer(current) + case CreateOfferNameTag => toCreateOfferName(current) + case CreateOfferPriceTag => toCreateOfferPrice(current) + case CreateOfferDescriptionTag => toCreateOfferDescription(current) + case CreateOfferPhotoTag => toCreateOfferPhoto(current) + case CreatedOfferTag => toCreatedOffer(current) + case MyOffersTag => toMyOffers(current) + case MyOfferTag => toMyOffer(current) + case EditOfferTag => toEditOffer(current) + case EditOfferNameTag => toEditOfferName(current) + case EditOfferPriceTag => toEditOfferPrice(current) + case EditOfferDescriptionTag => toEditOfferDescription(current) + case AddOfferPhotoTag => toAddOfferPhoto(current) + case DeleteOfferPhotosTag => toDeleteOfferPhotos(current) + case UpdatedOfferTag => toUpdatedOffer(current) + case DeletedOfferTag => toDeleteOffer(current) + case BackTag => toBack(current) + case StartedTag => toStarted(current) + } + + // transitions + + private def toSearch(current: State)(implicit message: Message): F[State] = + Search(current).pure + + private def toListing(current: State)(implicit message: Message): F[State] = + current match { + case Listing(_, offers, from) => + Listing(current, offers, from + Listing.StepSize).pure + case _ => + Listing.start(current, getOffers(20)).pure + } + + private def toOneOffer(current: State)(implicit message: Message): F[State] = { + val newState = for { + offerId <- message.text + offers <- current match { + case Listing(_, offers, _) => Some(offers) + case _ => None + } + offer <- offers.find(_.id.toString == offerId) + } yield OneOffer(current, offer) + + newState.getOrElse(Error(current, "К сожалению, такого id не существует! Попробуйте ещё раз")).pure + } + + private def toCreateOfferName(current: State)(implicit message: Message): F[State] = + CreateOfferName(current).pure + + private def toCreateOfferPrice(current: State)(implicit message: Message): F[State] = + message.text match { + case Some(name) => CreateOfferPrice(current, Draft(name = Some(name))).pure + case _ => Error(current, "Пожалуйста, введите название объявления").pure + } + + private def toCreateOfferDescription(current: State)(implicit message: Message): F[State] = { + val newState = for { + priceRaw <- message.text + price <- priceRaw.toLongOption + draft <- current match { + case CreateOfferPrice(_, draft) => Some(draft) + case _ => None + } + updatedDraft = draft.copy(price = Some(price)) + } yield CreateOfferDescription(current, updatedDraft) + + newState.getOrElse(Error(current, "Пожалуйста, введите цену (целое число рублей)")).pure + } + + private def toCreateOfferPhoto(current: State)(implicit message: Message): F[State] = + current match { + case CreateOfferDescription(_, draft) => // description has been uploaded + val newState = for { + description <- message.text + updatedDraft = draft.copy(description = Some(description)) + } yield CreateOfferPhoto(current, updatedDraft) + + newState.getOrElse(Error(current, "Пожалуйста, введите описание к объявлению")).pure + + case CreateOfferPhoto(_, draft) => // another photo has been uploaded + val newState = for { + photoWithSizes <- message.photo + photo <- photoWithSizes.lastOption + updatedDraft = draft.copy(photos = draft.photos ++ Seq(photo.fileId)) + } yield CreateOfferPhoto(current, updatedDraft) + + newState.getOrElse(Error(current, "Пожалуйста, загрузите фото")).pure + + case _ => + Error(current, "Произошла ошибка! Попробуйте ещё раз").pure + } + + private def toCreatedOffer(current: State)(implicit message: Message): F[State] = + current match { + case CreateOfferPhoto(_, draft) => CreatedOffer(current, draft).pure + case _ => Error(current, "Произошла ошибка! Попробуйте ещё раз").pure + } + + private def toMyOffers(current: State)(implicit message: Message): F[State] = + MyOffers(current, getOffers(5)).pure + + private def getOffers(maxLength: Int): Seq[Stub] = + (0 until Random.nextInt(maxLength)).map { index => + Stub(UUID.randomUUID(), s"${Random.nextString(Random.nextInt(30))} ($index)") + } + + private def toMyOffer(current: State)(implicit message: Message): F[State] = { + val optOffer = for { + offerId <- message.text + offers <- current match { + case MyOffers(_, offers) => Some(offers) + case _ => None + } + offer <- offers.find(_.id == UUID.fromString(offerId)) + } yield offer + + optOffer match { + case Some(offer) => MyOffer(current, offer).pure + case None => Error(current, "К сожалению, такого id не существует! Попробуйте ещё раз").pure + } + } + + private def toEditOffer(current: State)(implicit message: Message): F[State] = + EditOffer(current).pure + + private def toEditOfferName(current: State)(implicit message: Message): F[State] = + EditOfferName(current).pure + + private def toEditOfferPrice(current: State)(implicit message: Message): F[State] = + EditOfferPrice(current).pure + + private def toEditOfferDescription(current: State)(implicit message: Message): F[State] = + EditOfferDescription(current).pure + + private def toAddOfferPhoto(current: State)(implicit message: Message): F[State] = + AddOfferPhoto(current).pure + + private def toDeleteOfferPhotos(current: State)(implicit message: Message): F[State] = + UpdatedOffer(current, "Все фотографии были удалены из объявления").pure + + private def toUpdatedOffer(current: State)(implicit message: Message): F[State] = + current match { + case EditOfferName(_) => + message.text match { + case Some(newName) => + UpdatedOffer(current, s"Название было изменено на \"$newName\"").pure + case None => + Error(current, "Пожалуйста, введите новое название объявления").pure + } + case EditOfferPrice(_) => + message.text.flatMap(_.toIntOption) match { + case Some(newPrice) => + UpdatedOffer(current, s"Цена была изменена на $newPrice").pure + case None => + Error(current, "Пожалуйста, введите новую цену (целое число рублей)").pure + } + case EditOfferDescription(_) => + message.text match { + case Some(newDescription) => + UpdatedOffer(current, s"Описание было изменено").pure + case None => + Error(current, "Пожалуйста, введите новое описание объявления").pure + } + case AddOfferPhoto(_) => + message.photo.flatMap(_.lastOption) match { + case Some(newPhoto) => + UpdatedOffer(current, s"Фотография была добавлена к объявлению").pure + case None => + Error(current, "Пожалуйста, загрузите фото").pure + } + case _ => + Error(current, "Произошла ошибка! Попробуйте ещё раз").pure + } + + private def toDeleteOffer(current: State)(implicit message: Message): F[State] = + DeletedOffer(current).pure + + private def toBack(current: State)(implicit message: Message): F[State] = + current match { + case DeletedOffer(previous) => + previous.optPrevious.getOrElse(Started).pure + case UpdatedOffer(previous, _) => + // returns the nearest EditOffer + previous match { + case EditOffer(_) => previous.pure + case _ => previous.optPrevious.getOrElse(Started).pure + } + case _ => + current.optPrevious.getOrElse(Started).pure + } + + // internal + + private def toStarted(current: State)(implicit message: Message): F[State] = + Started.pure +} diff --git a/bot/src/main/scala/com/github/mmvpm/bot/state/Storage.scala b/bot/src/main/scala/com/github/mmvpm/bot/state/Storage.scala new file mode 100644 index 0000000..c14e6de --- /dev/null +++ b/bot/src/main/scala/com/github/mmvpm/bot/state/Storage.scala @@ -0,0 +1,8 @@ +package com.github.mmvpm.bot.state + +import com.bot4s.telegram.models.Message + +trait Storage[State] { + def get(implicit message: Message): State + def set(value: State)(implicit message: Message): State +} diff --git a/bot/src/main/scala/com/github/mmvpm/bot/state/StorageImpl.scala b/bot/src/main/scala/com/github/mmvpm/bot/state/StorageImpl.scala new file mode 100644 index 0000000..0ad566e --- /dev/null +++ b/bot/src/main/scala/com/github/mmvpm/bot/state/StorageImpl.scala @@ -0,0 +1,17 @@ +package com.github.mmvpm.bot.state + +import com.bot4s.telegram.models.Message +import com.github.mmvpm.bot.model.ChatID + +import java.util.concurrent.ConcurrentHashMap + +class StorageImpl[State](default: State) extends Storage[State] { + + private val storage = new ConcurrentHashMap[ChatID, State] + + def get(implicit message: Message): State = + storage.getOrDefault(message.chat.id, default) + + def set(value: State)(implicit message: Message): State = + storage.put(message.chat.id, value) +} diff --git a/bot/src/main/scala/com/github/mmvpm/bot/util/ResourceUtils.scala b/bot/src/main/scala/com/github/mmvpm/bot/util/ResourceUtils.scala new file mode 100644 index 0000000..bc9c785 --- /dev/null +++ b/bot/src/main/scala/com/github/mmvpm/bot/util/ResourceUtils.scala @@ -0,0 +1,13 @@ +package com.github.mmvpm.bot.util + +import scala.io.Source +import scala.util.Using + +object ResourceUtils { + + private def readFile(filename: String): String = + Using(Source.fromFile(filename))(_.mkString).get + + def readTelegramToken(): String = + readFile("secret/telegram-token.txt") +} diff --git a/bot/src/main/scala/com/github/mmvpm/bot/util/StateUtils.scala b/bot/src/main/scala/com/github/mmvpm/bot/util/StateUtils.scala new file mode 100644 index 0000000..0e517a4 --- /dev/null +++ b/bot/src/main/scala/com/github/mmvpm/bot/util/StateUtils.scala @@ -0,0 +1,11 @@ +package com.github.mmvpm.bot.util + +import cats.Monad +import com.github.mmvpm.bot.state.State + +object StateUtils { + + implicit class StateSyntax(state: State) { + def pure[F[_]: Monad]: F[State] = Monad[F].pure(state) + } +} diff --git a/build.sbt b/build.sbt index 10ec6c4..44727ad 100644 --- a/build.sbt +++ b/build.sbt @@ -4,6 +4,8 @@ ThisBuild / version := "0.1.0-SNAPSHOT" ThisBuild / scalaVersion := "2.13.12" +// versions + val catsVersion = "2.9.0" val catsEffect3 = "3.4.8" val circeVersion = "0.14.6" @@ -21,6 +23,7 @@ val scrapperVersion = "3.0.0" val sttpClientVersion = "3.9.0" val catsRetryVersion = "3.1.0" val catsBackendVersion = "3.8.13" +val bot4sVersion = "5.7.1" val testVersion = "1.4.0" val scalatestVersion = "3.2.17" @@ -31,6 +34,8 @@ val testcontainersVersion = "0.40.15" val testcontainersRedis = "1.3.2" val testcontainersPostgresqlVersion = "0.40.12" +// main dependencies + val cats = Seq( "org.typelevel" %% "cats-core" % catsVersion, "org.typelevel" %% "cats-effect" % catsEffect3 @@ -89,6 +94,12 @@ val scrapper = Seq( "net.ruippeixotog" %% "scala-scraper" % scrapperVersion ) +val bot4s = Seq( + "com.bot4s" %% "telegram-core" % bot4sVersion +) + +// test dependencies + val testcontainers = Seq( "com.redislabs.testcontainers" % "testcontainers-redis" % testcontainersRedis, "com.dimafeng" %% "testcontainers-scala-scalatest" % testcontainersVersion, @@ -111,6 +122,8 @@ val tapirStubServer = Seq( "com.softwaremill.sttp.tapir" %% "tapir-sttp-stub-server" % tapirVersion % Test ) +// projects + lazy val common = (project in file("common")) .settings( name := "common" @@ -130,8 +143,19 @@ lazy val stub = (project in file("stub")) ).flatten ) +lazy val bot = (project in file("bot")) + .dependsOn(common) + .settings( + name := "bot", + libraryDependencies ++= Seq( + cats, + bot4s, + sttpClient + ).flatten + ) + lazy val root = (project in file(".")) .settings( name := "OffersService" ) - .aggregate(common, stub) + .aggregate(common, stub, bot)