├── project └── build.properties ├── .gitignore ├── src └── main │ └── scala │ └── ru │ └── pavkin │ └── telegram │ ├── todolist │ ├── package.scala │ ├── App.scala │ ├── BotCommand.scala │ ├── TodoListStorage.scala │ ├── TodoListBotProcess.scala │ └── TodoListBot.scala │ └── api │ ├── dto │ ├── BotResponse.scala │ ├── BotUpdate.scala │ ├── Chat.scala │ └── BotMessage.scala │ ├── package.scala │ └── BotAPI.scala └── README.md /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.5.1 2 | 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.idea 2 | 3 | target/ 4 | .DS_Store 5 | export_bot_token.sh 6 | -------------------------------------------------------------------------------- /src/main/scala/ru/pavkin/telegram/todolist/package.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.telegram 2 | 3 | package object todolist { 4 | type Item = String 5 | } 6 | -------------------------------------------------------------------------------- /src/main/scala/ru/pavkin/telegram/api/dto/BotResponse.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.telegram.api.dto 2 | 3 | case class BotResponse[T](ok: Boolean, result: T) 4 | -------------------------------------------------------------------------------- /src/main/scala/ru/pavkin/telegram/api/package.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.telegram 2 | 3 | package object api { 4 | type ChatId = Long 5 | type Offset = Long 6 | } 7 | -------------------------------------------------------------------------------- /src/main/scala/ru/pavkin/telegram/api/dto/BotUpdate.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.telegram.api.dto 2 | 3 | case class BotUpdate(update_id: Long, message: Option[BotMessage]) 4 | -------------------------------------------------------------------------------- /src/main/scala/ru/pavkin/telegram/api/dto/Chat.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.telegram.api.dto 2 | 3 | import ru.pavkin.telegram.api.ChatId 4 | 5 | case class Chat(id: ChatId) 6 | -------------------------------------------------------------------------------- /src/main/scala/ru/pavkin/telegram/api/dto/BotMessage.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.telegram.api.dto 2 | 3 | case class BotMessage( 4 | message_id: Long, 5 | chat: Chat, 6 | text: Option[String]) 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Telegram Bot FS2 2 | 3 | Example purely functional telegram bot implementation with FS2 and http4s client. 4 | 5 | ## Launching the bot 6 | 7 | 1. Create a new bot for yourself with @BotFather: https://core.telegram.org/bots#6-botfather 8 | 2. Export the token to env variables: `export TODOLIST_BOT_TOKEN=` 9 | 3. `sbt run` 10 | 4. Just chat with the bot using your telegram app! 11 | -------------------------------------------------------------------------------- /src/main/scala/ru/pavkin/telegram/todolist/App.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.telegram.todolist 2 | 3 | import cats.effect.{ExitCode, IO, IOApp} 4 | import fs2.Stream 5 | 6 | object App extends IOApp { 7 | 8 | def stream: Stream[IO, ExitCode] = 9 | for { 10 | token <- Stream.eval(IO(System.getenv("TODOLIST_BOT_TOKEN"))) 11 | exitCode <- 12 | new TodoListBotProcess[IO](token).run.last.map(_ => ExitCode.Success) 13 | } yield exitCode 14 | 15 | override def run(args: List[String]): IO[ExitCode] = 16 | stream.compile.drain.as(ExitCode.Success) 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/main/scala/ru/pavkin/telegram/todolist/BotCommand.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.telegram.todolist 2 | 3 | import ru.pavkin.telegram.api.ChatId 4 | 5 | sealed trait BotCommand 6 | 7 | object BotCommand { 8 | 9 | case class ShowHelp(chatId: ChatId) extends BotCommand 10 | case class ClearTodoList(chatId: ChatId) extends BotCommand 11 | case class ShowTodoList(chatId: ChatId) extends BotCommand 12 | case class AddEntry(chatId: ChatId, content: String) extends BotCommand 13 | 14 | def fromRawMessage(chatId: ChatId, message: String): BotCommand = message match { 15 | case `help` | "/start" => ShowHelp(chatId) 16 | case `show` => ShowTodoList(chatId) 17 | case `clear` => ClearTodoList(chatId) 18 | case _ => AddEntry(chatId, message) 19 | } 20 | 21 | val help = "?" 22 | val show = "/show" 23 | val clear = "/clear" 24 | } 25 | -------------------------------------------------------------------------------- /src/main/scala/ru/pavkin/telegram/todolist/TodoListStorage.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.telegram.todolist 2 | 3 | import cats.Functor 4 | import cats.effect.concurrent.Ref 5 | import cats.implicits._ 6 | import ru.pavkin.telegram.api.ChatId 7 | 8 | /** 9 | * Algebra for managing storage of todo-list items 10 | */ 11 | trait TodoListStorage[F[_]] { 12 | def addItem(chatId: ChatId, item: Item): F[Unit] 13 | def getItems(chatId: ChatId): F[List[Item]] 14 | def clearList(chatId: ChatId): F[Unit] 15 | } 16 | 17 | /** 18 | * Simple in-memory implementation of [[TodoListStorage]] algebra, using [[Ref]]. 19 | * In real world this would go to some database of sort. 20 | */ 21 | class InMemoryTodoListStorage[F[_]: Functor]( 22 | private val ref: Ref[F, Map[ChatId, List[Item]]] 23 | ) extends TodoListStorage[F] { 24 | 25 | def addItem(chatId: ChatId, item: Item): F[Unit] = 26 | ref.update(m => m.updated(chatId, item :: m.getOrElse(chatId, Nil))).void 27 | 28 | def getItems(chatId: ChatId): F[List[Item]] = 29 | ref.get.map(_.getOrElse(chatId, Nil)) 30 | 31 | def clearList(chatId: ChatId): F[Unit] = 32 | ref.update(_ - chatId).void 33 | } 34 | -------------------------------------------------------------------------------- /src/main/scala/ru/pavkin/telegram/todolist/TodoListBotProcess.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.telegram.todolist 2 | 3 | import cats.effect.ConcurrentEffect 4 | import cats.effect.concurrent.Ref 5 | import cats.implicits._ 6 | import fs2.Stream 7 | import io.circe.generic.auto._ 8 | import org.http4s._ 9 | import org.http4s.circe._ 10 | import org.http4s.client.blaze.BlazeClientBuilder 11 | import org.typelevel.log4cats.slf4j.Slf4jLogger 12 | import ru.pavkin.telegram.api.dto.{BotResponse, BotUpdate} 13 | import ru.pavkin.telegram.api.{ChatId, Http4SBotAPI} 14 | 15 | import scala.concurrent.ExecutionContext 16 | 17 | /** 18 | * Creates and wires up everything that is needed to launch a [[TodoListBot]] and launches it. 19 | * 20 | * @param token telegram bot token 21 | */ 22 | class TodoListBotProcess[F[_]](token: String)(implicit F: ConcurrentEffect[F]) { 23 | 24 | implicit val decoder: EntityDecoder[F, BotResponse[List[BotUpdate]]] = 25 | jsonOf[F, BotResponse[List[BotUpdate]]] 26 | 27 | def run: Stream[F, Unit] = 28 | BlazeClientBuilder[F](ExecutionContext.global).stream.flatMap { client => 29 | val streamF: F[Stream[F, Unit]] = for { 30 | logger <- Slf4jLogger.create[F] 31 | storage <- 32 | Ref 33 | .of(Map.empty[ChatId, List[Item]]) 34 | .map(new InMemoryTodoListStorage(_)) 35 | botAPI <- F.delay(new Http4SBotAPI(token, client, logger)) 36 | todoListBot <- F.delay(new TodoListBot(botAPI, storage, logger)) 37 | } yield todoListBot.launch 38 | 39 | Stream.force(streamF) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/scala/ru/pavkin/telegram/todolist/TodoListBot.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.telegram.todolist 2 | 3 | import cats.effect.Sync 4 | import cats.implicits._ 5 | import fs2._ 6 | import org.typelevel.log4cats._ 7 | import ru.pavkin.telegram.api._ 8 | import ru.pavkin.telegram.todolist.BotCommand._ 9 | 10 | import scala.util.Random 11 | 12 | /** 13 | * Todo list telegram bot 14 | * When launched, polls incoming commands and processes them using todo-list storage algebra. 15 | * 16 | * @param api telegram bot api 17 | * @param storage storage algebra for todo-list items 18 | * @param logger logger algebra 19 | */ 20 | class TodoListBot[F[_]]( 21 | api: StreamingBotAPI[F], 22 | storage: TodoListStorage[F], 23 | logger: Logger[F])( 24 | implicit F: Sync[F]) { 25 | 26 | /** 27 | * Launches the bot process 28 | */ 29 | def launch: Stream[F, Unit] = pollCommands.evalMap(handleCommand) 30 | 31 | private def pollCommands: Stream[F, BotCommand] = for { 32 | update <- api.pollUpdates(0) 33 | chatIdAndMessage <- Stream.emits(update.message.flatMap(a => a.text.map(a.chat.id -> _)).toSeq) 34 | } yield BotCommand.fromRawMessage(chatIdAndMessage._1, chatIdAndMessage._2) 35 | 36 | private def handleCommand(command: BotCommand): F[Unit] = command match { 37 | case c: ClearTodoList => clearTodoList(c.chatId) 38 | case c: ShowTodoList => showTodoList(c.chatId) 39 | case c: AddEntry => addItem(c.chatId, c.content) 40 | case c: ShowHelp => api.sendMessage(c.chatId, List( 41 | "This bot stores your todo-list. Just write a task and the bot will store it! Other commands:", 42 | s"`$help` - show this help message", 43 | s"`$show` - show current todo-list", 44 | s"`$clear` - clear current list (vacation!)", 45 | ).mkString("\n")) 46 | } 47 | 48 | private def clearTodoList(chatId: ChatId): F[Unit] = for { 49 | _ <- storage.clearList(chatId) 50 | _ <- logger.info(s"todo list cleared for chat $chatId") *> api.sendMessage(chatId, "Your todo-list was cleared!") 51 | } yield () 52 | 53 | private def showTodoList(chatId: ChatId): F[Unit] = for { 54 | items <- storage.getItems(chatId) 55 | _ <- logger.info(s"todo list queried for chat $chatId") *> api.sendMessage(chatId, 56 | if (items.isEmpty) "You have no tasks planned!" 57 | else ("Your todo-list:" :: "" :: items.map(" - " + _)).mkString("\n")) 58 | } yield () 59 | 60 | private def addItem(chatId: ChatId, item: Item): F[Unit] = for { 61 | _ <- storage.addItem(chatId, item) 62 | response <- F.defer(F.catchNonFatal(Random.shuffle(List("Ok!", "Sure!", "Noted", "Certainly!")).head)) 63 | _ <- logger.info(s"entry added for chat $chatId") *> api.sendMessage(chatId, response) 64 | } yield () 65 | } 66 | -------------------------------------------------------------------------------- /src/main/scala/ru/pavkin/telegram/api/BotAPI.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.telegram.api 2 | 3 | import cats.effect.Sync 4 | import cats.implicits._ 5 | import fs2.Stream 6 | import org.http4s.client.Client 7 | import org.http4s.{EntityDecoder, Uri} 8 | import org.typelevel.log4cats.Logger 9 | import ru.pavkin.telegram.api.dto.{BotResponse, BotUpdate} 10 | 11 | /** 12 | * Simplified bot api algebra that exposes only APIs required for this project 13 | * 14 | * S is the streaming effect, see https://typelevel.org/blog/2018/05/09/tagless-final-streaming.html 15 | * 16 | * For the full API reference see https://core.telegram.org/bots/api 17 | */ 18 | trait BotAPI[F[_], S[_]] { 19 | /** 20 | * Send a message to specified chat 21 | */ 22 | def sendMessage(chatId: ChatId, message: String): F[Unit] 23 | 24 | /** 25 | * Stream all updated for this bot using long polling. `S[_]` is the streaming effect. 26 | * 27 | * @param fromOffset offset of the fist message to start polling from 28 | */ 29 | def pollUpdates(fromOffset: Offset): S[BotUpdate] 30 | } 31 | 32 | trait StreamingBotAPI[F[_]] extends BotAPI[F, Stream[F, *]] 33 | 34 | /** 35 | * Single bot API instance with http4s client. 36 | * Requires an implicit decoder for incoming bot updates. 37 | * 38 | * @param token bot api token 39 | * @param client http client algebra 40 | * @param logger logger algebra 41 | */ 42 | class Http4SBotAPI[F[_]]( 43 | token: String, 44 | client: Client[F], 45 | logger: Logger[F])( 46 | implicit 47 | F: Sync[F], 48 | D: EntityDecoder[F, BotResponse[List[BotUpdate]]]) extends StreamingBotAPI[F] { 49 | 50 | private val botApiUri: Uri = Uri.uri("https://api.telegram.org") / s"bot$token" 51 | 52 | def sendMessage(chatId: ChatId, message: String): F[Unit] = { 53 | 54 | // safely build a uri to query 55 | val uri = botApiUri / "sendMessage" =? Map( 56 | "chat_id" -> List(chatId.toString), 57 | "parse_mode" -> List("Markdown"), 58 | "text" -> List(message) 59 | ) 60 | 61 | client.expect[Unit](uri) // run the http request and ignore the result body. 62 | } 63 | 64 | def pollUpdates(fromOffset: Offset): Stream[F, BotUpdate] = 65 | Stream(()).repeat.covary[F] 66 | .evalMapAccumulate(fromOffset) { case (offset, _) => requestUpdates(offset) } 67 | .flatMap { case (_, response) => Stream.emits(response.result) } 68 | 69 | private def requestUpdates(offset: Offset): F[(Offset, BotResponse[List[BotUpdate]])] = { 70 | 71 | val uri = botApiUri / "getUpdates" =? Map( 72 | "offset" -> List((offset + 1).toString), 73 | "timeout" -> List("0.5"), // timeout to throttle the polling 74 | "allowed_updates" -> List("""["message"]""") 75 | ) 76 | 77 | client.expect[BotResponse[List[BotUpdate]]](uri) 78 | .map(response => (lastOffset(response).getOrElse(offset), response)) 79 | .recoverWith { 80 | case ex => logger.error(ex)("Failed to poll updates").as(offset -> BotResponse(ok = true, Nil)) 81 | } 82 | } 83 | 84 | // just get the maximum id out of all received updates 85 | private def lastOffset(response: BotResponse[List[BotUpdate]]): Option[Offset] = 86 | response.result match { 87 | case Nil => None 88 | case nonEmpty => Some(nonEmpty.maxBy(_.update_id).update_id) 89 | } 90 | } 91 | --------------------------------------------------------------------------------