add HTTP server and basic tstickers-api

- add HTTP server backend
  - with configurable listening port
    - default is 30179
    - cannot disable it yet
  - with UI service with a 523 image
  - with simple RESTful API service
- add basic tstickers-api
  - now can only get and output binary content without file-type tagging or converting
-
This commit is contained in:
A.C.Sukazyo Eyre 2024-02-04 23:58:15 +08:00
parent ee47446900
commit 5aa63de2a9
22 changed files with 299 additions and 15 deletions

View File

@ -25,7 +25,7 @@
[![Maven metadata of snapshots][badge_snapshot_img]][badge_snapshot_target] [![Maven metadata of snapshots][badge_snapshot_img]][badge_snapshot_target]
[//]: # (**[说明书][book] | [FindInTelegram][tg-account]**) [//]: # (**[说明书][book] | [FindInTelegram][tg-account]**)
[badge_handbook_img]: https://img.shields.io/website?url=https%3A%2F%2Fbook.sukazyo.cc%2Fmorny&up_message=0.8.0.11*PUTIAN&up_color=7b68ee&down_message=%E4%B8%8D%E5%8F%AF%E7%94%A8&down_color=dc143c&label=%E8%AF%B4%E6%98%8E%E4%B9%A6 [badge_handbook_img]: https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fbook.sukazyo.cc%2Fmorny%2Fmain.json&query=%24.target_version&label=%E8%AF%B4%E6%98%8E%E4%B9%A6&color=7b68ee
[badge_handbook_target]: https://book.sukazyo.cc/morny [badge_handbook_target]: https://book.sukazyo.cc/morny
[badge_telegram_img]: https://img.shields.io/website?url=https%3A%2F%2Ft.me%2Fmorny_cono_annie_bot&up_message=%40morny_cono_annie_bot&up_color=28a8ea&down_message=unavailable&down_color=red&logo=telegram&label=Telegram [badge_telegram_img]: https://img.shields.io/website?url=https%3A%2F%2Ft.me%2Fmorny_cono_annie_bot&up_message=%40morny_cono_annie_bot&up_color=28a8ea&down_message=unavailable&down_color=red&logo=telegram&label=Telegram
[badge_telegram_target]: https://t.me/morny_cono_annie_bot [badge_telegram_target]: https://t.me/morny_cono_annie_bot

View File

@ -66,7 +66,11 @@ lazy val root = (project in file("."))
}, },
assembly / artifact := (assembly / artifact).value assembly / artifact := (assembly / artifact).value
.withClassifier(Some("fat")), .withClassifier(Some("fat")),
addArtifact(assembly / artifact, assembly), if (MornyProject.publishWithFatJar) {
addArtifact(assembly / artifact, assembly)
} else {
Nil
},
if (System.getenv("DOCKER_BUILD") != null) { if (System.getenv("DOCKER_BUILD") != null) {
assembly / assemblyJarName := { assembly / assemblyJarName := {
sLog.value info "environment DOCKER_BUILD checked" sLog.value info "environment DOCKER_BUILD checked"

View File

@ -8,13 +8,13 @@ object MornyConfiguration {
val MORNY_CODE_STORE = "https://github.com/Eyre-S/Coeur-Morny-Cono" val MORNY_CODE_STORE = "https://github.com/Eyre-S/Coeur-Morny-Cono"
val MORNY_COMMIT_PATH = "https://github.com/Eyre-S/Coeur-Morny-Cono/commit/%s" val MORNY_COMMIT_PATH = "https://github.com/Eyre-S/Coeur-Morny-Cono/commit/%s"
val VERSION = "2.0.0-alpha10" val VERSION = "2.0.0-alpha11"
val VERSION_DELTA: Option[String] = None val VERSION_DELTA: Option[String] = None
val CODENAME = "guanggu" val CODENAME = "guanggu"
val SNAPSHOT = true val SNAPSHOT = true
val dependencies = Seq( val dependencies: Seq[ModuleID] = Seq(
"com.github.spotbugs" % "spotbugs-annotations" % "4.7.3" % Compile, "com.github.spotbugs" % "spotbugs-annotations" % "4.7.3" % Compile,
@ -22,11 +22,15 @@ object MornyConfiguration {
"cc.sukazyo" % "resource-tools" % "0.2.2", "cc.sukazyo" % "resource-tools" % "0.2.2",
"com.github.pengrad" % "java-telegram-bot-api" % "6.2.0", "com.github.pengrad" % "java-telegram-bot-api" % "6.2.0",
"org.http4s" %% "http4s-dsl" % "0.23.24",
"org.http4s" %% "http4s-circe" % "0.23.24",
"org.http4s" %% "http4s-netty-server" % "0.5.11",
"com.softwaremill.sttp.client3" %% "core" % "3.9.0", "com.softwaremill.sttp.client3" %% "core" % "3.9.0",
"com.softwaremill.sttp.client3" %% "okhttp-backend" % "3.9.0", "com.softwaremill.sttp.client3" %% "okhttp-backend" % "3.9.0",
"com.squareup.okhttp3" % "okhttp" % "4.11.0" % Runtime, "com.squareup.okhttp3" % "okhttp" % "4.11.0" % Runtime,
"org.typelevel" %% "case-insensitive" % "1.4.0",
"com.google.code.gson" % "gson" % "2.10.1", "com.google.code.gson" % "gson" % "2.10.1",
"io.circe" %% "circe-core" % "0.14.6", "io.circe" %% "circe-core" % "0.14.6",
"io.circe" %% "circe-generic" % "0.14.6", "io.circe" %% "circe-generic" % "0.14.6",

View File

@ -1,15 +1,14 @@
import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.Git
import org.eclipse.jgit.revwalk.RevWalk import org.eclipse.jgit.revwalk.RevWalk
import org.eclipse.jgit.storage.file.FileRepositoryBuilder import org.eclipse.jgit.storage.file.FileRepositoryBuilder
import sbt.*
import java.io.File
//noinspection TypeAnnotation //noinspection TypeAnnotation
object MornyProject { object MornyProject {
val _git_repo = new FileRepositoryBuilder() val _git_repo = new FileRepositoryBuilder()
.setGitDir(new File(".git")) .setGitDir(file(".git"))
.setWorkTree(new File("")) .setWorkTree(file("."))
.readEnvironment() .readEnvironment()
.build() .build()
val _git = new Git(_git_repo) val _git = new Git(_git_repo)
@ -45,6 +44,7 @@ object MornyProject {
val dependencies = MornyConfiguration.dependencies val dependencies = MornyConfiguration.dependencies
val publishWithFatJar = !version_is_snapshot
def publishTo = MornyConfiguration.publishTo def publishTo = MornyConfiguration.publishTo
val publishCredentials = MornyConfiguration.publishCredentials val publishCredentials = MornyConfiguration.publishCredentials

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@ -4,6 +4,8 @@ import cc.sukazyo.cono.morny.core.Log.{exceptionLog, logger}
import cc.sukazyo.cono.morny.core.MornyCoeur.* import cc.sukazyo.cono.morny.core.MornyCoeur.*
import cc.sukazyo.cono.morny.core.bot.api.{EventListenerManager, MornyCommandManager, MornyQueryManager} import cc.sukazyo.cono.morny.core.bot.api.{EventListenerManager, MornyCommandManager, MornyQueryManager}
import cc.sukazyo.cono.morny.core.bot.event.{MornyOnInlineQuery, MornyOnTelegramCommand, MornyOnUpdateTimestampOffsetLock} import cc.sukazyo.cono.morny.core.bot.event.{MornyOnInlineQuery, MornyOnTelegramCommand, MornyOnUpdateTimestampOffsetLock}
import cc.sukazyo.cono.morny.core.http.api.{HttpServer, MornyHttpServerContext}
import cc.sukazyo.cono.morny.core.http.internal.MornyHttpServerContextImpl
import cc.sukazyo.cono.morny.reporter.MornyReport import cc.sukazyo.cono.morny.reporter.MornyReport
import cc.sukazyo.cono.morny.util.schedule.Scheduler import cc.sukazyo.cono.morny.util.schedule.Scheduler
import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis
@ -38,6 +40,7 @@ object MornyCoeur {
eventManager: EventListenerManager, eventManager: EventListenerManager,
commandManager: MornyCommandManager, commandManager: MornyCommandManager,
queryManager: MornyQueryManager, queryManager: MornyQueryManager,
httpServer: MornyHttpServerContext,
givenCxt: GivenContext givenCxt: GivenContext
) )
@ -52,6 +55,7 @@ object MornyCoeur {
eventManager: EventListenerManager, eventManager: EventListenerManager,
commandManager: MornyCommandManager, commandManager: MornyCommandManager,
queryManager: MornyQueryManager, queryManager: MornyQueryManager,
httpServer: MornyHttpServerContext,
givenCxt: GivenContext givenCxt: GivenContext
) )
@ -66,6 +70,7 @@ object MornyCoeur {
eventManager: EventListenerManager, eventManager: EventListenerManager,
commandManager: MornyCommandManager, commandManager: MornyCommandManager,
queryManager: MornyQueryManager, queryManager: MornyQueryManager,
httpServer: MornyHttpServerContext,
givenCxt: GivenContext givenCxt: GivenContext
) )
@ -166,11 +171,14 @@ class MornyCoeur (modules: List[MornyModule])(using val config: MornyConfig)(tes
val commands: MornyCommandManager = MornyCommandManager() val commands: MornyCommandManager = MornyCommandManager()
val queries: MornyQueryManager = MornyQueryManager() val queries: MornyQueryManager = MornyQueryManager()
private var _httpServerContext: MornyHttpServerContext = MornyHttpServerContextImpl()
// Coeur Initializing Pre Event // Coeur Initializing Pre Event
modules.foreach(it => it.onInitializingPre(OnInitializingPreContext( modules.foreach(it => it.onInitializingPre(OnInitializingPreContext(
externalContext, externalContext,
coeurStartTimestamp, account, username, userid, tasks, trusted, coeurStartTimestamp, account, username, userid, tasks, trusted,
eventManager, commands, queries, eventManager, commands, queries,
_httpServerContext,
initializeContext))) initializeContext)))
// register core/api events // register core/api events
@ -199,12 +207,16 @@ class MornyCoeur (modules: List[MornyModule])(using val config: MornyConfig)(tes
) )
} }
// register core http api service
import cc.sukazyo.cono.morny.core.http.services as http_srv
_httpServerContext register4API http_srv.Ping()
// Coeur Initializing Event // Coeur Initializing Event
modules.foreach(it => it.onInitializing(OnInitializingContext( modules.foreach(it => it.onInitializing(OnInitializingContext(
externalContext, externalContext,
coeurStartTimestamp, account, username, userid, tasks, trusted, coeurStartTimestamp, account, username, userid, tasks, trusted,
eventManager, commands, queries, eventManager, commands, queries,
_httpServerContext,
initializeContext))) initializeContext)))
val watchDog: WatchDog = WatchDog("watch-dog", 1000, 1500, { (consumed, _) => val watchDog: WatchDog = WatchDog("watch-dog", 1000, 1500, { (consumed, _) =>
@ -221,6 +233,7 @@ class MornyCoeur (modules: List[MornyModule])(using val config: MornyConfig)(tes
externalContext, externalContext,
coeurStartTimestamp, account, username, userid, tasks, trusted, coeurStartTimestamp, account, username, userid, tasks, trusted,
eventManager, commands, queries, eventManager, commands, queries,
_httpServerContext,
initializeContext))) initializeContext)))
///>>> BLOCK START instance configure & startup stage 2 ///>>> BLOCK START instance configure & startup stage 2
@ -237,6 +250,9 @@ class MornyCoeur (modules: List[MornyModule])(using val config: MornyConfig)(tes
modules.foreach(it => it.onStarting(OnStartingContext( modules.foreach(it => it.onStarting(OnStartingContext(
initializeContext))) initializeContext)))
logger info "start http server"
val http: HttpServer = _httpServerContext.start
_httpServerContext = null
logger info "start telegram event listening" logger info "start telegram event listening"
import com.pengrad.telegrambot.TelegramException import com.pengrad.telegrambot.TelegramException
account.setUpdatesListener(eventManager, (e: TelegramException) => { account.setUpdatesListener(eventManager, (e: TelegramException) => {

View File

@ -103,7 +103,13 @@ public class MornyConfig {
public final boolean commandLogoutClear; public final boolean commandLogoutClear;
/* ======================================= * /* ======================================= *
* system: morny report * * system: http server *
* ======================================= */
public final int httpPort;
/* ======================================= *
* function: reporter *
* ======================================= */ * ======================================= */
/** /**
@ -164,11 +170,14 @@ public class MornyConfig {
this.medicationTimerUseTimezone = prototype.medicationTimerUseTimezone; this.medicationTimerUseTimezone = prototype.medicationTimerUseTimezone;
prototype.medicationNotifyAt.forEach(i -> { if (i < 0 || i > 23) throw new CheckFailure.UnavailableTimeInMedicationNotifyAt(); }); prototype.medicationNotifyAt.forEach(i -> { if (i < 0 || i > 23) throw new CheckFailure.UnavailableTimeInMedicationNotifyAt(); });
this.medicationNotifyAt = prototype.medicationNotifyAt; this.medicationNotifyAt = prototype.medicationNotifyAt;
if (prototype.httpPort < 0 || prototype.httpPort > 65535) throw new CheckFailure.UnavailableHttpPort();
this.httpPort = prototype.httpPort;
} }
public static class CheckFailure extends RuntimeException { public static class CheckFailure extends RuntimeException {
public static class NullTelegramBotKey extends CheckFailure {} public static class NullTelegramBotKey extends CheckFailure {}
public static class UnavailableTimeInMedicationNotifyAt extends CheckFailure {} public static class UnavailableTimeInMedicationNotifyAt extends CheckFailure {}
public static class UnavailableHttpPort extends CheckFailure {}
} }
public static class Prototype { public static class Prototype {
@ -193,6 +202,7 @@ public class MornyConfig {
public long medicationNotifyToChat = -1L; public long medicationNotifyToChat = -1L;
@Nonnull public ZoneOffset medicationTimerUseTimezone = ZoneOffset.UTC; @Nonnull public ZoneOffset medicationTimerUseTimezone = ZoneOffset.UTC;
@Nonnull public final Set<Integer> medicationNotifyAt = new HashSet<>(); @Nonnull public final Set<Integer> medicationNotifyAt = new HashSet<>();
public int httpPort = 30179;
} }

View File

@ -57,6 +57,10 @@ object ServerMain {
case "--trusted-reader-dinner" | "-trsd" => i += 1; config.dinnerTrustedReaders add(args(i) toLong) case "--trusted-reader-dinner" | "-trsd" => i += 1; config.dinnerTrustedReaders add(args(i) toLong)
case "--dinner-chat" | "-chd" => i += 1; config.dinnerChatId = args(i) toLong case "--dinner-chat" | "-chd" => i += 1; config.dinnerChatId = args(i) toLong
case "--http-listen-port" | "-hp" =>
i += 1
config.httpPort = args(i) toInt
case "--medication-notify-chat" | "-medc" => i += 1; config.medicationNotifyToChat = args(i) toLong case "--medication-notify-chat" | "-medc" => i += 1; config.medicationNotifyToChat = args(i) toLong
case "--medication-notify-timezone" | "-medtz" => case "--medication-notify-timezone" | "-medtz" =>
i += 1 i += 1

View File

@ -19,7 +19,8 @@ object ServerModulesLoader {
morny.medication_timer.ModuleMedicationTimer(), morny.medication_timer.ModuleMedicationTimer(),
morny.morny_misc.ModuleMornyMisc(), morny.morny_misc.ModuleMornyMisc(),
morny.uni_meow.ModuleUniMeow(), morny.uni_meow.ModuleUniMeow(),
morny.reporter.Module() morny.reporter.Module(),
morny.stickers_get.Module(),
) )

View File

@ -0,0 +1,19 @@
package cc.sukazyo.cono.morny.core.http
import cats.effect.IO
import cc.sukazyo.cono.morny.core.http.api.HttpService4Api
import cc.sukazyo.cono.morny.data.TelegramImages
import org.http4s.{HttpRoutes, MediaType}
import org.http4s.dsl.impl./
import org.http4s.dsl.io.*
import org.http4s.headers.`Content-Type`
class ServiceUI extends HttpService4Api {
override lazy val service: HttpRoutes[IO] = HttpRoutes.of[IO] {
case GET -> Root =>
NotImplemented(TelegramImages.IMG_501.get)
.map(_.withContentType(`Content-Type`(MediaType.image.jpeg)))
}
}

View File

@ -0,0 +1,9 @@
package cc.sukazyo.cono.morny.core.http.api
import cats.effect.unsafe.IORuntime
trait HttpServer (using IORuntime) {
def stop(): Unit
}

View File

@ -0,0 +1,35 @@
package cc.sukazyo.cono.morny.core.http.api
import cats.effect.IO
import cc.sukazyo.cono.morny.core.Log.exceptionLog
import org.http4s.{HttpRoutes, Response}
trait HttpService4Api {
lazy val service: HttpRoutes[IO]
extension (response: Response[IO]) {
def setMornyInternalErrorHeader (e: Throwable): Response[IO] =
response.setMornyInternalErrorHeader(
e.getClass.getSimpleName,
e.getMessage,
exceptionLog(e)
)
def setMornyInternalErrorHeader (
`Morny-Internal-Error-Type`: String,
`Morny-Internal-Error-Message`: String,
`Morny-Internal-Error-Detail`: String
): Response[IO] =
response.withHeaders(
"Morny-Internal-Error-Type" -> `Morny-Internal-Error-Type`,
"Morny-Internal-Error-Message" -> `Morny-Internal-Error-Message`,
"Morny-Internal-Error-Detail" -> `Morny-Internal-Error-Detail`,
)
}
}
object HttpService4Api {
def apply (_service: HttpRoutes[IO]): HttpService4Api = new HttpService4Api:
override lazy val service: HttpRoutes[IO] = _service
}

View File

@ -0,0 +1,9 @@
package cc.sukazyo.cono.morny.core.http.api
trait MornyHttpServerContext {
def register4API (service: HttpService4Api*): Unit
def start: HttpServer
}

View File

@ -0,0 +1,61 @@
package cc.sukazyo.cono.morny.core.http.internal
import cc.sukazyo.cono.morny.core.MornyCoeur
import cc.sukazyo.cono.morny.core.http.api.{HttpServer, HttpService4Api, MornyHttpServerContext}
import cc.sukazyo.cono.morny.core.http.ServiceUI
import cc.sukazyo.cono.morny.core.Log.{exceptionLog, logger}
import scala.collection.mutable
class MornyHttpServerContextImpl (using coeur: MornyCoeur) extends MornyHttpServerContext {
private val services_api = mutable.Queue.empty[HttpService4Api]
private lazy val service_ui = ServiceUI()
override def register4API (services: HttpService4Api*): Unit =
services_api ++= services
override def start: HttpServer = {
import cats.data.OptionT
import cats.effect.unsafe.implicits.global
import cats.effect.IO
import cats.implicits.toSemigroupKOps
import org.http4s.server.{Router, Server}
import org.http4s.server.middleware.{ErrorAction, ErrorHandling}
val router = Router(
"/" -> service_ui.service,
"/api" -> services_api.map(_.service).reduce(_ <+> _)
)
def errorHandler (t: Throwable, message: =>String): OptionT[IO, Unit] =
OptionT.liftF(IO {
logger error
s"""Unexpected exception occurred on Morny Http Server :
|${exceptionLog(t)}""".stripMargin
})
val withErrorHandler = ErrorHandling.Recover.total(
ErrorAction.log(
router,
messageFailureLogAction = errorHandler,
serviceErrorLogAction = errorHandler
)
)
val server = org.http4s.netty.server.NettyServerBuilder[IO]
.bindHttp(coeur.config.httpPort, "0.0.0.0")
.withHttpApp(withErrorHandler.orNotFound)
.resource
val (_server, _shutdown_io) = server.allocated.unsafeRunSync() match
case (_1, _2) => (_1, _2)
logger notice s"Morny HTTP Server started at ${_server.baseUri}"
new HttpServer(using global):
val server: Server = _server
private val shutdown_io = _shutdown_io
override def stop (): Unit = shutdown_io.unsafeRunSync()
}
}

View File

@ -0,0 +1,22 @@
package cc.sukazyo.cono.morny.core.http.services
import cats.effect.IO
import cc.sukazyo.cono.morny.core.http.api.HttpService4Api
import org.http4s.{HttpRoutes, Response}
import org.http4s.circe.jsonEncoder
import org.http4s.dsl.io.*
class Ping extends HttpService4Api {
case class PingResult (
pong: Boolean = true
)
override lazy val service: HttpRoutes[IO] = HttpRoutes.of[IO] {
case GET -> Root / "ping" =>
import io.circe.generic.auto.*
import io.circe.syntax.*
Ok(PingResult().asJson)
}
}

View File

@ -31,5 +31,9 @@ object TelegramImages {
} }
val IMG_ABOUT: AssetsFileImage = AssetsFileImage("images/featured-image@0.5x.jpg") val IMG_ABOUT: AssetsFileImage = AssetsFileImage("images/featured-image@0.5x.jpg")
val IMG_404: AssetsFileImage = AssetsFileImage("images/http-sekai-404.png")
val IMG_500: AssetsFileImage = AssetsFileImage("images/http-sekai-500.png")
val IMG_501: AssetsFileImage = AssetsFileImage("images/http-sekai-501.png")
val IMG_523: AssetsFileImage = AssetsFileImage("images/http-sekai-523.png")
} }

View File

@ -0,0 +1,28 @@
package cc.sukazyo.cono.morny.stickers_get
import cc.sukazyo.cono.morny.core.internal.MornyInternalModule
import cc.sukazyo.cono.morny.core.MornyCoeur
import cc.sukazyo.cono.morny.stickers_get.http.StickerService
class Module extends MornyInternalModule {
override val id: String = "morny.stickers-get"
override val name: String = "Morny Can Get and Provide Stickers"
override val description: String | Null =
// language=markdown
"""Make Morny as a Telegram Stickers API.
|
|This module handles `/api/sticker` route that you can get a sticker
|by a sticker ID via HTTP request.
|
|original idea is: https://github.com/tjhorner/tstickers-api
|""".stripMargin
override def onInitializing (using MornyCoeur)(cxt: MornyCoeur.OnInitializingContext): Unit = {
import cxt.*
httpServer register4API StickerService()
}
}

View File

@ -0,0 +1,57 @@
package cc.sukazyo.cono.morny.stickers_get.http
import cats.effect.IO
import cc.sukazyo.cono.morny.core.http.api.HttpService4Api
import cc.sukazyo.cono.morny.core.MornyCoeur
import cc.sukazyo.cono.morny.data.TelegramImages
import com.pengrad.telegrambot.request.GetFile
import org.http4s.{HttpRoutes, MediaType}
import org.http4s.dsl.io.*
import org.http4s.headers.`Content-Type`
import java.io.IOException
class StickerService (using coeur: MornyCoeur) extends HttpService4Api {
override lazy val service: HttpRoutes[IO] = HttpRoutes.of {
case GET -> Root / "sticker" / "id" / id =>
try {
val response = coeur.account execute GetFile(id)
if response.isOk then
try {
val file = coeur.account getFileContent response.file
Ok(file)
} catch {
case e: IOException =>
ServiceUnavailable(
TelegramImages.IMG_523.get,
`Content-Type`(MediaType.image.png),
).map(_.setMornyInternalErrorHeader(e))
}
else
NotFound(
TelegramImages.IMG_404.get,
`Content-Type`(MediaType.image.png),
).map(_.setMornyInternalErrorHeader(
"_telegram_api",
response.errorCode.toString,
response.description,
))
} catch
case io: IOException =>
ServiceUnavailable(
TelegramImages.IMG_523.get,
`Content-Type`(MediaType.image.png),
).map(_.setMornyInternalErrorHeader(io))
case e: Throwable =>
InternalServerError(
TelegramImages.IMG_500.get,
`Content-Type`(MediaType.image.png),
).map(_.setMornyInternalErrorHeader(e))
case GET -> Root / "sticker" =>
NotFound("not found")
}
}

View File

@ -1,5 +1,6 @@
package cc.sukazyo.cono.morny package cc.sukazyo.cono.morny
import cc.sukazyo.cono.morny.core.ServerMain
import cc.sukazyo.cono.morny.util.UniversalCommand import cc.sukazyo.cono.morny.util.UniversalCommand
import scala.io.StdIn import scala.io.StdIn