diff --git a/README.md b/README.md index 313a89cc..2c349822 100644 --- a/README.md +++ b/README.md @@ -287,7 +287,7 @@ Distribution/deployment is outside the scope of the library, but all platforms w supported should be compatible. You may find [sbt-assembly](https://github.com/sbt/sbt-assembly) and [sbt-docker](https://github.com/marcuslonnberg/sbt-docker) very handy. -Scala.js is also supported, bots can run on the browser via the SttpClient. NodeJs is not supported yet. +Scala.js is also supported via the SttpClient and FetchBackend, including Node.js (see below). ## Running the examples @@ -303,6 +303,30 @@ scala> new RandomBot("BOT_TOKEN").run() Change `RandomBot` to whatever bot you find interesting [here](https://github.com/bot4s/telegram/tree/main/examples). +### Node.js + +With Node.js 22 or newer on your PATH, run from the repository root: + +```sh + export BOT_TOKEN='YOUR_BOT_TOKEN' +./mill --no-daemon examples.js.2_13_18.run +``` + +This launches `EchoBot`, which replies with your message reversed. To run `RandomBot` instead: + +```sh +BOT_NAME=RandomBot ./mill --no-daemon examples.js.2_13_18.run +``` + +Send `/coin` to try RandomBot. Stop either bot with Ctrl+C. + +Use `--no-daemon` to avoid Mill 1.1.7's Scala.js stdin forwarding failure +(`missing keys in dictionary: bytes`) in daemon mode. + +Both platforms compile the same `EchoBot.scala`, `RandomBot.scala`, and `ExampleBot.scala` sources. +Only the HTTP backend differs: OkHttp on JVM and FetchBackend on JS, using Node's built-in Fetch API. +The JS target currently includes these two examples; other examples may require JVM-only APIs. + ## A note on implicits A few implicits are provided to reduce boilerplate, but are discouraged because unexpected side-effects. diff --git a/build.mill b/build.mill index edeb2299..e891a789 100644 --- a/build.mill +++ b/build.mill @@ -1,6 +1,7 @@ import mill._ import mill.scalalib._ import mill.scalalib.publish._ +import mill.scalajslib._ import mill.javalib.api.JvmWorkerUtil val ScalaVersions = Seq("2.12.21", "2.13.18", "3.3.8") @@ -19,6 +20,7 @@ object library { val sttp = "4.0.27" val scalaTest = "3.2.20" val scalaMockScalaTest = "7.5.5" + val scalaJS = "1.22.0" val scalaLogging = "3.9.6" val logback = "1.6.4" val scalajHttp = "2.4.2" @@ -95,8 +97,7 @@ trait Bot4sTelegramModule extends CrossScalaModule { library.circeLiteral, library.catsCore, library.catsFree, - library.sttpCore, - library.scalaLogging + library.sttpCore ) trait Tests extends ScalaTests with TestModule.ScalaTest { @@ -158,12 +159,20 @@ object core extends Module { } override def mvnDeps = Task { - super.mvnDeps() ++ versionDependencies() + super.mvnDeps() ++ Seq(library.scalaLogging) ++ versionDependencies() } object test extends Tests } + object js extends Cross[CoreJsModule](ScalaVersions) + trait CoreJsModule extends Bot4sTelegramCrossPlatform with ScalaJSModule with Publishable { + override val platformSegment: String = "js" + override val location: String = "core" + + override def scalaJSVersion = library.Version.scalaJS + } + } object pekko extends Module { @@ -176,6 +185,7 @@ object pekko extends Module { override def mvnDeps = Task { super.mvnDeps() ++ Seq( + library.scalaLogging, library.pekkoActor, library.pekkoHttp, library.pekkoStream @@ -198,6 +208,29 @@ object pekko extends Module { } object examples extends Module { + object js extends Cross[ExamplesJsModule](ScalaVersions) + trait ExamplesJsModule extends Bot4sTelegramCrossPlatform with ScalaJSModule { + override val platformSegment: String = "js" + override val location: String = "examples" + + override def scalaJSVersion = library.Version.scalaJS + override def moduleDeps = Seq(core.js()) + override def mainClass = Some("Launcher") + // Command matching uses Unicode regex character classes (\p{L}, \p{S}, \p{N}). + override def esFeatures = Task { + super.esFeatures().withESVersion(mill.scalajslib.api.ESVersion.ES2018) + } + + // Reuse the same bot implementations as the JVM examples. Other examples + // require JVM-only APIs (filesystem, processes, timers, or java.time). + override def regularSources = Task.Sources( + moduleDir / "src" / "ExampleBot.scala", + moduleDir / "src" / "EchoBot.scala", + moduleDir / "src" / "RandomBot.scala", + moduleDir / "src-js" + ) + } + trait ExamplesJvmCommon extends Bot4sTelegramCrossPlatform { override val platformSegment: String = "jvm" diff --git a/core/src-js/com/bot4s/telegram/clients/SttpInputFile.scala b/core/src-js/com/bot4s/telegram/clients/SttpInputFile.scala new file mode 100644 index 00000000..366d208c --- /dev/null +++ b/core/src-js/com/bot4s/telegram/clients/SttpInputFile.scala @@ -0,0 +1,14 @@ +package com.bot4s.telegram.clients + +import com.bot4s.telegram.models.InputFile +import sttp.client4._ +import sttp.model.Part + +private[clients] object SttpInputFile { + def multipartBodyPart(key: String, inputFile: InputFile): Option[Part[BasicBodyPart]] = + inputFile match { + // FileId is submitted through the JSON fields, see `inputFileEncoder`. + case InputFile.FileId(_) => None + case InputFile.Contents(filename, contents) => Some(multipart(key, contents).fileName(filename)) + } +} diff --git a/core/src-js/com/bot4s/telegram/models/InputFile.scala b/core/src-js/com/bot4s/telegram/models/InputFile.scala new file mode 100644 index 00000000..96de9e73 --- /dev/null +++ b/core/src-js/com/bot4s/telegram/models/InputFile.scala @@ -0,0 +1,25 @@ +package com.bot4s.telegram.models + +import io.circe.Encoder +import io.circe.syntax.EncoderOps + +/** + * This object represents the contents of a file to be uploaded. + * + * Scala.js builds support file_id reuse and in-memory contents values in the model. + * Filesystem paths are JVM-only. + */ +trait InputFile + +object InputFile { + final case class FileId(fileId: String) extends InputFile + final case class Contents(filename: String, contents: Array[Byte]) extends InputFile + + def apply(fileId: String): InputFile = FileId(fileId) + def apply(filename: String, contents: Array[Byte]): InputFile = Contents(filename, contents) + + implicit val circeEncoder: Encoder[InputFile] = Encoder.instance { + case InputFile.FileId(fileId) => fileId.asJson + case _ => io.circe.Json.Null + } +} diff --git a/core/src-js/com/bot4s/telegram/util/StrictLogging.scala b/core/src-js/com/bot4s/telegram/util/StrictLogging.scala new file mode 100644 index 00000000..e06e81ae --- /dev/null +++ b/core/src-js/com/bot4s/telegram/util/StrictLogging.scala @@ -0,0 +1,21 @@ +package com.bot4s.telegram.util + +trait StrictLogging { + protected lazy val logger: BotLogger = NoopBotLogger +} + +trait BotLogger { + def trace(message: String, args: Any*): Unit + def debug(message: String, args: Any*): Unit + def info(message: String, args: Any*): Unit + def warn(message: String, args: Any*): Unit + def error(message: String, args: Any*): Unit +} + +private[telegram] object NoopBotLogger extends BotLogger { + override def trace(message: String, args: Any*): Unit = () + override def debug(message: String, args: Any*): Unit = () + override def info(message: String, args: Any*): Unit = () + override def warn(message: String, args: Any*): Unit = () + override def error(message: String, args: Any*): Unit = () +} diff --git a/core/src-jvm-2/com/bot4s/telegram/clients/ScalajHttpClient.scala b/core/src-jvm-2/com/bot4s/telegram/clients/ScalajHttpClient.scala index e39da46c..7498c685 100644 --- a/core/src-jvm-2/com/bot4s/telegram/clients/ScalajHttpClient.scala +++ b/core/src-jvm-2/com/bot4s/telegram/clients/ScalajHttpClient.scala @@ -11,7 +11,6 @@ import com.bot4s.telegram.marshalling import io.circe.parser.parse import io.circe.{ Decoder, Encoder } import scalaj.http.{ Http, MultiPart } -import com.typesafe.scalalogging.StrictLogging import scala.concurrent.{ blocking, ExecutionContext, Future } @@ -29,8 +28,7 @@ import scala.concurrent.{ blocking, ExecutionContext, Future } */ class ScalajHttpClient(token: String, proxy: Proxy = Proxy.NO_PROXY, telegramHost: String = "api.telegram.org")(implicit ec: ExecutionContext -) extends RequestHandler[Future] - with StrictLogging { +) extends RequestHandler[Future] { val connectionTimeoutMs = 10000 val readTimeoutMs = 50000 diff --git a/core/src-jvm/com/bot4s/telegram/clients/SttpInputFile.scala b/core/src-jvm/com/bot4s/telegram/clients/SttpInputFile.scala new file mode 100644 index 00000000..4391780e --- /dev/null +++ b/core/src-jvm/com/bot4s/telegram/clients/SttpInputFile.scala @@ -0,0 +1,15 @@ +package com.bot4s.telegram.clients + +import com.bot4s.telegram.models.InputFile +import sttp.client4._ +import sttp.model.Part + +private[clients] object SttpInputFile { + def multipartBodyPart(key: String, inputFile: InputFile): Option[Part[BasicBodyPart]] = + inputFile match { + // FileId is submitted through the JSON fields, see `inputFileEncoder`. + case InputFile.FileId(_) => None + case InputFile.Contents(filename, contents) => Some(multipart(key, contents).fileName(filename)) + case InputFile.Path(path) => Some(multipartFile(key, path)) + } +} diff --git a/core/src/com/bot4s/telegram/models/InputFile.scala b/core/src-jvm/com/bot4s/telegram/models/InputFile.scala similarity index 100% rename from core/src/com/bot4s/telegram/models/InputFile.scala rename to core/src-jvm/com/bot4s/telegram/models/InputFile.scala diff --git a/core/src-jvm/com/bot4s/telegram/util/StrictLogging.scala b/core/src-jvm/com/bot4s/telegram/util/StrictLogging.scala new file mode 100644 index 00000000..423926ed --- /dev/null +++ b/core/src-jvm/com/bot4s/telegram/util/StrictLogging.scala @@ -0,0 +1,3 @@ +package com.bot4s.telegram.util + +trait StrictLogging extends com.typesafe.scalalogging.StrictLogging diff --git a/core/src/com/bot4s/telegram/api/Polling.scala b/core/src/com/bot4s/telegram/api/Polling.scala index 86e3edc6..e1ccb21a 100644 --- a/core/src/com/bot4s/telegram/api/Polling.scala +++ b/core/src/com/bot4s/telegram/api/Polling.scala @@ -1,7 +1,7 @@ package com.bot4s.telegram.api import com.bot4s.telegram.methods.GetUpdates -import com.typesafe.scalalogging.StrictLogging +import com.bot4s.telegram.util.StrictLogging import scala.concurrent.duration.{ Duration, _ } import com.bot4s.telegram.models.ParsedUpdate diff --git a/core/src/com/bot4s/telegram/api/RequestHandler.scala b/core/src/com/bot4s/telegram/api/RequestHandler.scala index 4a65518e..c4068fd4 100644 --- a/core/src/com/bot4s/telegram/api/RequestHandler.scala +++ b/core/src/com/bot4s/telegram/api/RequestHandler.scala @@ -8,7 +8,7 @@ import cats.syntax.functor._ import cats.syntax.monadError._ import com.bot4s.telegram.methods._ import io.circe.{ Decoder, Encoder } -import com.typesafe.scalalogging.StrictLogging +import com.bot4s.telegram.util.StrictLogging import io.circe.Decoder._ import com.bot4s.telegram.marshalling._ diff --git a/core/src/com/bot4s/telegram/cats/Polling.scala b/core/src/com/bot4s/telegram/cats/Polling.scala index c917a327..1d9118a4 100644 --- a/core/src/com/bot4s/telegram/cats/Polling.scala +++ b/core/src/com/bot4s/telegram/cats/Polling.scala @@ -8,7 +8,7 @@ import cats.syntax.traverse._ import com.bot4s.telegram.api.{ Polling => BasePolling } import com.bot4s.telegram.methods.{ DeleteWebhook, GetMe } import com.bot4s.telegram.models.User -import com.typesafe.scalalogging.StrictLogging +import com.bot4s.telegram.util.StrictLogging import com.bot4s.telegram.models.ParsedUpdate case class PollingState(botUser: User, offset: Option[Long]) diff --git a/core/src/com/bot4s/telegram/clients/SttpClient.scala b/core/src/com/bot4s/telegram/clients/SttpClient.scala index fbdfe3eb..0ff8d3bb 100644 --- a/core/src/com/bot4s/telegram/clients/SttpClient.scala +++ b/core/src/com/bot4s/telegram/clients/SttpClient.scala @@ -7,11 +7,8 @@ import com.bot4s.telegram.api.RequestHandler import com.bot4s.telegram.marshalling.CaseConversions import com.bot4s.telegram.methods.{ JsonRequest, MultipartRequest, Request => BotRequest, Response } import com.bot4s.telegram.marshalling -import com.bot4s.telegram.models.InputFile import io.circe.parser.parse import io.circe.{ Decoder, Encoder } -import com.typesafe.scalalogging.StrictLogging - import scala.concurrent.duration._ import sttp.client4._ @@ -29,8 +26,7 @@ import sttp.client4.{ Request => SttpRequest } class SttpClient[F[_]](token: String, telegramHost: String = "api.telegram.org")(implicit backend: Backend[F], monadError: MonadError[F, Throwable] -) extends RequestHandler[F]()(using monadError) - with StrictLogging { +) extends RequestHandler[F]()(using monadError) { val readTimeout: Duration = 50.seconds @@ -54,13 +50,7 @@ class SttpClient[F[_]](token: String, telegramHost: String = "api.telegram.org") case r: MultipartRequest => val parts = r.getFiles.flatMap { case (camelKey, inputFile) => val key = CaseConversions.snakenize(camelKey) - inputFile match { - // FileId must be submitted through the JSON query, see `inputFileEncoder` - case InputFile.FileId(id) => None - case InputFile.Contents(filename, contents) => Some(multipart(key, contents).fileName(filename)) - case InputFile.Path(path) => Some(multipartFile(key, path)) - case other => throw new RuntimeException(s"InputFile $other not supported") - } + SttpInputFile.multipartBodyPart(key, inputFile) } val fields = parse(marshalling.toJson(request)) diff --git a/core/src/com/bot4s/telegram/future/Polling.scala b/core/src/com/bot4s/telegram/future/Polling.scala index 11faedd7..56e513f3 100644 --- a/core/src/com/bot4s/telegram/future/Polling.scala +++ b/core/src/com/bot4s/telegram/future/Polling.scala @@ -3,7 +3,7 @@ package com.bot4s.telegram.future import com.bot4s.telegram.api.{ Polling => BasePolling } import com.bot4s.telegram.methods.{ DeleteWebhook, GetMe } import com.bot4s.telegram.models.{ ParsedUpdate, User } -import com.typesafe.scalalogging.StrictLogging +import com.bot4s.telegram.util.StrictLogging import scala.concurrent.Future import scala.util.control.NonFatal diff --git a/core/src/com/bot4s/telegram/marshalling/CirceDecoders.scala b/core/src/com/bot4s/telegram/marshalling/CirceDecoders.scala index 2c3ea9ef..eb24401e 100644 --- a/core/src/com/bot4s/telegram/marshalling/CirceDecoders.scala +++ b/core/src/com/bot4s/telegram/marshalling/CirceDecoders.scala @@ -19,7 +19,7 @@ import com.bot4s.telegram.models.StickerFormat.StickerFormat import UpdateType.UpdateType import io.circe.Decoder import io.circe.generic.semiauto.deriveDecoder -import com.typesafe.scalalogging.StrictLogging +import com.bot4s.telegram.util.StrictLogging /** * Circe marshalling borrowed/inspired from [[https://github.com/nikdon/telepooz]] diff --git a/examples/src-js/ExampleBackend.scala b/examples/src-js/ExampleBackend.scala new file mode 100644 index 00000000..e8c68ebe --- /dev/null +++ b/examples/src-js/ExampleBackend.scala @@ -0,0 +1,7 @@ +import scala.concurrent.{ ExecutionContext, Future } +import sttp.client4.Backend +import sttp.client4.fetch.FetchBackend + +object ExampleBackend { + def apply()(implicit ec: ExecutionContext): Backend[Future] = FetchBackend() +} diff --git a/examples/src-js/Launcher.scala b/examples/src-js/Launcher.scala new file mode 100644 index 00000000..aa4e0d93 --- /dev/null +++ b/examples/src-js/Launcher.scala @@ -0,0 +1,27 @@ +import scala.scalajs.js + +object Launcher { + def main(args: Array[String]): Unit = { + val env = js.Dynamic.global.process.env + val token = env.BOT_TOKEN + .asInstanceOf[js.UndefOr[String]] + .toOption + .filter(_.nonEmpty) + .getOrElse(throw new IllegalArgumentException("Set BOT_TOKEN to your Telegram bot token")) + val name = env.BOT_NAME.asInstanceOf[js.UndefOr[String]].getOrElse("EchoBot") + val bot = name match { + case "EchoBot" => new EchoBot(token) + case "RandomBot" => new RandomBot(token) + case _ => throw new IllegalArgumentException("BOT_NAME must be EchoBot or RandomBot") + } + + import bot.executionContext + + println(s"Starting $name on Node.js; press Ctrl+C to stop") + bot.run().failed.foreach { error => + // HTTP errors may contain the request URL, including the token. + Console.err.println(s"Bot failed: ${error.toString.replace(token, "")}") + js.Dynamic.global.process.exitCode = 1 + } + } +} diff --git a/examples/src-jvm/ExampleBackend.scala b/examples/src-jvm/ExampleBackend.scala new file mode 100644 index 00000000..769fdecb --- /dev/null +++ b/examples/src-jvm/ExampleBackend.scala @@ -0,0 +1,7 @@ +import scala.concurrent.{ ExecutionContext, Future } +import sttp.client4.Backend +import sttp.client4.okhttp.OkHttpFutureBackend + +object ExampleBackend { + def apply()(implicit ec: ExecutionContext): Backend[Future] = OkHttpFutureBackend() +} diff --git a/examples/src/ExampleBot.scala b/examples/src/ExampleBot.scala index 827ed2e0..7a5e6a68 100644 --- a/examples/src/ExampleBot.scala +++ b/examples/src/ExampleBot.scala @@ -4,7 +4,6 @@ import com.bot4s.telegram.future.TelegramBot import scala.concurrent.Future import sttp.client4.Backend -import sttp.client4.okhttp.OkHttpFutureBackend /** * Quick helper to spawn example bots. @@ -18,6 +17,6 @@ import sttp.client4.okhttp.OkHttpFutureBackend */ abstract class ExampleBot(val token: String) extends TelegramBot { - implicit val backend: Backend[Future] = OkHttpFutureBackend() + implicit val backend: Backend[Future] = ExampleBackend() override val client: RequestHandler[Future] = new FutureSttpClient(token) } diff --git a/pekko/src/com/bot4s/telegram/clients/PekkoHttpClient.scala b/pekko/src/com/bot4s/telegram/clients/PekkoHttpClient.scala index ae2173fd..e03aec43 100644 --- a/pekko/src/com/bot4s/telegram/clients/PekkoHttpClient.scala +++ b/pekko/src/com/bot4s/telegram/clients/PekkoHttpClient.scala @@ -12,7 +12,6 @@ import com.bot4s.telegram.marshalling.PekkoHttpMarshalling import com.bot4s.telegram.marshalling._ import com.bot4s.telegram.methods.{ Request, Response } import io.circe.{ Decoder, Encoder } -import com.typesafe.scalalogging.StrictLogging import scala.concurrent.{ ExecutionContext, Future } @@ -26,8 +25,7 @@ class PekkoHttpClient(token: String, telegramHost: String = "api.telegram.org")( system: ActorSystem, materializer: Materializer, ec: ExecutionContext -) extends RequestHandler[Future] - with StrictLogging { +) extends RequestHandler[Future] { import PekkoHttpMarshalling._ private val apiBaseUrl = s"https://$telegramHost/bot$token/" diff --git a/pekko/src/com/bot4s/telegram/clients/YetAnotherPekkoClient.scala b/pekko/src/com/bot4s/telegram/clients/YetAnotherPekkoClient.scala index d3401379..6a8155fd 100644 --- a/pekko/src/com/bot4s/telegram/clients/YetAnotherPekkoClient.scala +++ b/pekko/src/com/bot4s/telegram/clients/YetAnotherPekkoClient.scala @@ -12,7 +12,6 @@ import cats.instances.future._ import com.bot4s.telegram.api.RequestHandler import com.bot4s.telegram.methods.{ Request, Response } import io.circe.{ Decoder, Encoder } -import com.typesafe.scalalogging.StrictLogging import com.bot4s.telegram.marshalling.responseDecoder import scala.concurrent.{ ExecutionContext, Future } @@ -21,8 +20,7 @@ class YetAnotherPekkoClient(token: String, telegramHost: String = "api.telegram. system: ActorSystem, materializer: Materializer, ec: ExecutionContext -) extends RequestHandler[Future] - with StrictLogging { +) extends RequestHandler[Future] { private val flow = Http().outgoingConnectionHttps(telegramHost)