Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
39 changes: 36 additions & 3 deletions build.mill
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -176,6 +185,7 @@ object pekko extends Module {

override def mvnDeps = Task {
super.mvnDeps() ++ Seq(
library.scalaLogging,
library.pekkoActor,
library.pekkoHttp,
library.pekkoStream
Expand All @@ -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"

Expand Down
14 changes: 14 additions & 0 deletions core/src-js/com/bot4s/telegram/clients/SttpInputFile.scala
Original file line number Diff line number Diff line change
@@ -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))
}
}
25 changes: 25 additions & 0 deletions core/src-js/com/bot4s/telegram/models/InputFile.scala
Original file line number Diff line number Diff line change
@@ -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
}
}
21 changes: 21 additions & 0 deletions core/src-js/com/bot4s/telegram/util/StrictLogging.scala
Original file line number Diff line number Diff line change
@@ -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 = ()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions core/src-jvm/com/bot4s/telegram/clients/SttpInputFile.scala
Original file line number Diff line number Diff line change
@@ -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))
}
}
3 changes: 3 additions & 0 deletions core/src-jvm/com/bot4s/telegram/util/StrictLogging.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.bot4s.telegram.util

trait StrictLogging extends com.typesafe.scalalogging.StrictLogging
2 changes: 1 addition & 1 deletion core/src/com/bot4s/telegram/api/Polling.scala
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion core/src/com/bot4s/telegram/api/RequestHandler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand Down
2 changes: 1 addition & 1 deletion core/src/com/bot4s/telegram/cats/Polling.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
14 changes: 2 additions & 12 deletions core/src/com/bot4s/telegram/clients/SttpClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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._

Expand All @@ -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

Expand All @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion core/src/com/bot4s/telegram/future/Polling.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down
7 changes: 7 additions & 0 deletions examples/src-js/ExampleBackend.scala
Original file line number Diff line number Diff line change
@@ -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()
}
27 changes: 27 additions & 0 deletions examples/src-js/Launcher.scala
Original file line number Diff line number Diff line change
@@ -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, "<redacted>")}")
js.Dynamic.global.process.exitCode = 1
}
}
}
7 changes: 7 additions & 0 deletions examples/src-jvm/ExampleBackend.scala
Original file line number Diff line number Diff line change
@@ -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()
}
3 changes: 1 addition & 2 deletions examples/src/ExampleBot.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
}
4 changes: 1 addition & 3 deletions pekko/src/com/bot4s/telegram/clients/PekkoHttpClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -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/"
Expand Down
Loading
Loading