diff --git a/build.gradle b/build.gradle index 8d9f712..a436ee8 100644 --- a/build.gradle +++ b/build.gradle @@ -83,19 +83,29 @@ dependencies { implementation group: 'cc.sukazyo', name: 'messiva', version: lib_messiva_v implementation group: 'cc.sukazyo', name: 'resource-tools', version: lib_resourcetools_v - testImplementation group: 'cc.sukazyo', name: 'resource-tools', version: lib_resourcetools_v implementation group: 'com.github.pengrad', name: 'java-telegram-bot-api', version: lib_javatelegramapi_v implementation group: 'com.softwaremill.sttp.client3', name: scala('core'), version: lib_sttp_v implementation group: 'com.softwaremill.sttp.client3', name: scala('okhttp-backend'), version: lib_sttp_v - implementation group: 'com.squareup.okhttp3', name: 'okhttp', version: lib_okhttp_v + runtimeOnly group: 'com.squareup.okhttp3', name: 'okhttp', version: lib_okhttp_v implementation group: 'com.google.code.gson', name: 'gson', version: lib_gson_v + implementation group: 'io.circe', name: scala('circe-core'), version: lib_circe_v + implementation group: 'io.circe', name: scala('circe-generic'), version: lib_circe_v + implementation group: 'io.circe', name: scala('circe-parser'), version: lib_circe_v + implementation group: 'org.jsoup', name: 'jsoup', version: '1.16.2' + implementation group: 'com.cronutils', name: 'cron-utils', version: lib_cron_utils_v + // used for disable slf4j + // due to the slf4j api have been used in the following libraries: + // - cron-utils + runtimeOnly group: 'org.slf4j', name: 'slf4j-nop', version: lib_slf4j_v + testRuntimeOnly group: 'org.slf4j', name: 'slf4j-nop', version: lib_slf4j_v + + testImplementation group: 'cc.sukazyo', name: 'resource-tools', version: lib_resourcetools_v testImplementation group: 'org.scalatest', name: scala('scalatest'), version: lib_scalatest_v testImplementation group: 'org.scalatest', name: scala('scalatest-freespec'), version: lib_scalatest_v testRuntimeOnly group: 'org.scala-lang.modules', name: scala('scala-xml'), version: lib_scalamodule_xml_v - - // for generating HTML report // required by gradle-scalatest plugin + // for generating HTML report: required by gradle-scalatest plugin testRuntimeOnly group: 'com.vladsch.flexmark', name: 'flexmark-all', version: '0.64.6' } @@ -133,6 +143,7 @@ tasks.withType(ScalaCompile).configureEach { targetCompatibility proj_java.getMajorVersion() scalaCompileOptions.additionalParameters.add "-language:postfixOps" + scalaCompileOptions.additionalParameters.addAll ("-Xmax-inlines", "256") scalaCompileOptions.encoding = proj_file_encoding.name() options.encoding = proj_file_encoding.name() diff --git a/gradle.properties b/gradle.properties index 1fbb5c4..3882b9d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,12 +5,12 @@ MORNY_ARCHIVE_NAME = morny-coeur MORNY_CODE_STORE = https://github.com/Eyre-S/Coeur-Morny-Cono MORNY_COMMIT_PATH = https://github.com/Eyre-S/Coeur-Morny-Cono/commit/%s -VERSION = 1.2.2-beta2 +VERSION = 1.3.0 USE_DELTA = false VERSION_DELTA = -CODENAME = xiongan +CODENAME = guanggu # dependencies @@ -19,11 +19,14 @@ lib_scalamodule_xml_v = 2.2.0 lib_messiva_v = 0.2.0 lib_resourcetools_v = 0.2.2 +lib_slf4j_v = 2.0.9 lib_javatelegramapi_v = 6.2.0 lib_sttp_v = 3.9.0 lib_okhttp_v = 4.11.0 lib_gson_v = 2.10.1 +lib_circe_v = 0.14.6 +lib_cron_utils_v = 9.2.0 lib_scalatest_v = 3.2.17 diff --git a/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala b/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala index e9337cf..3f3f2d4 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala @@ -7,9 +7,13 @@ import cc.sukazyo.cono.morny.MornyCoeur.THREAD_SERVER_EXIT import cc.sukazyo.cono.morny.bot.api.EventListenerManager import cc.sukazyo.cono.morny.bot.event.{MornyEventListeners, MornyOnInlineQuery, MornyOnTelegramCommand, MornyOnUpdateTimestampOffsetLock} import cc.sukazyo.cono.morny.bot.query.MornyQueries +import cc.sukazyo.cono.morny.util.schedule.Scheduler +import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis +import cc.sukazyo.cono.morny.util.time.WatchDog import com.pengrad.telegrambot.TelegramBot import com.pengrad.telegrambot.request.GetMe +import scala.annotation.unused import scala.util.boundary import scala.util.boundary.break @@ -53,7 +57,7 @@ class MornyCoeur (using val config: MornyConfig) { * * in milliseconds. */ - val coeurStartTimestamp: Long = System.currentTimeMillis + val coeurStartTimestamp: EpochMillis = System.currentTimeMillis /** [[TelegramBot]] account of this Morny */ val account: TelegramBot = __loginResult.account @@ -62,6 +66,8 @@ class MornyCoeur (using val config: MornyConfig) { /** [[account]]'s telegram user id */ val userid: Long = __loginResult.userid + /** Morny's task [[Scheduler]] */ + val tasks: Scheduler = Scheduler() /** current Morny's [[MornyTrusted]] instance */ val trusted: MornyTrusted = MornyTrusted() @@ -76,12 +82,67 @@ class MornyCoeur (using val config: MornyConfig) { eventManager register MornyOnInlineQuery(using queries) //noinspection ScalaUnusedSymbol val events: MornyEventListeners = MornyEventListeners(using eventManager) + eventManager register daemons.reporter.EventStatistics.EventInfoCatcher + @unused + val watchDog: WatchDog = WatchDog("watch-dog", 1000, 1500, { (consumed, _) => + import cc.sukazyo.cono.morny.util.CommonFormat.formatDuration as f + logger warn + s"""Can't keep up! is the server overloaded or host machine fall asleep? + | current tick takes ${f(consumed)} to complete.""".stripMargin + tasks.notifyIt() + }) ///>>> BLOCK START instance configure & startup stage 2 daemons.start() logger info "start telegram event listening" - account setUpdatesListener eventManager + import com.pengrad.telegrambot.TelegramException + account.setUpdatesListener(eventManager, (e: TelegramException) => { + + // This function intended to catch exceptions on update + // fetching controlled by Telegram API Client. So that + // it won't be directly printed to STDOUT without Morny's + // logger. And it can be reported when needed. + // TelegramException can either contains a caused that infers + // a lower level client exception (network err or others); + // nor contains a response that means API request failed. + + if (e.response != null) { + import com.google.gson.GsonBuilder + logger error + s"""Failed get updates: ${e.getMessage} + | server responses: + |${GsonBuilder().setPrettyPrinting().create.toJson(e.response) indent 4} + |""".stripMargin + this.daemons.reporter.exception(e, "Failed get updates.") + } + + if (e.getCause != null) { + import java.net.{SocketException, SocketTimeoutException} + import javax.net.ssl.SSLHandshakeException + val caused = e.getCause + caused match + case e_timeout: (SSLHandshakeException|SocketException|SocketTimeoutException) => + import cc.sukazyo.messiva.log.Message + + import scala.collection.mutable + val log = mutable.ArrayBuffer(s"Failed get updates: Network Error") + var current: Throwable = e_timeout + log += s" due to: ${current.getClass.getSimpleName}: ${current.getMessage}" + while (current.getCause != null) { + current = current.getCause + log += s" caused by: ${current.getClass.getSimpleName}: ${current.getMessage}" + } + logger error Message(log mkString "\n") + case e_other => + logger error + s"""Failed get updates: + |${exceptionLog(e_other) indent 3}""".stripMargin + this.daemons.reporter.exception(e_other, "Failed get updates.") + } + + }) + if config.commandLoginRefresh then logger info "resetting telegram command list" commands.automaticTGListUpdate() @@ -101,6 +162,8 @@ class MornyCoeur (using val config: MornyConfig) { account.shutdown() logger info "stopped bot account" daemons.stop() + tasks.waitForStop() + logger info s"morny tasks stopped: remains ${tasks.amount} tasks not be executed" if config.commandLogoutClear then commands.automaticTGListRemove() logger info "done exit cleanup" @@ -160,5 +223,5 @@ class MornyCoeur (using val config: MornyConfig) { } } - + } diff --git a/src/main/scala/cc/sukazyo/cono/morny/MornyConfig.java b/src/main/scala/cc/sukazyo/cono/morny/MornyConfig.java index 2702254..c8ed479 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/MornyConfig.java +++ b/src/main/scala/cc/sukazyo/cono/morny/MornyConfig.java @@ -6,6 +6,7 @@ import java.lang.annotation.*; import java.time.ZoneOffset; import java.util.HashSet; import java.util.Set; +import java.util.TimeZone; public class MornyConfig { @@ -109,6 +110,18 @@ public class MornyConfig { */ public final long reportToChat; + /** + * 控制 Morny Coeur 系统的报告的基准时间. + *
+ * 仅会用于 {@link cc.sukazyo.cono.morny.daemon.MornyReport} 内的时间敏感的报告, + * 不会用于 {@code /info} 命令等位置。 + *
+ * 默认使用 {@link TimeZone#getDefault()}.
+ *
+ * @since 1.3.0
+ */
+ @Nonnull public final TimeZone reportZone;
+
/* ======================================= *
* function: dinner query tool *
* ======================================= */
@@ -144,6 +157,7 @@ public class MornyConfig {
this.dinnerTrustedReaders = prototype.dinnerTrustedReaders;
this.dinnerChatId = prototype.dinnerChatId;
this.reportToChat = prototype.reportToChat;
+ this.reportZone = prototype.reportZone;
this.medicationNotifyToChat = prototype.medicationNotifyToChat;
this.medicationTimerUseTimezone = prototype.medicationTimerUseTimezone;
prototype.medicationNotifyAt.forEach(i -> { if (i < 0 || i > 23) throw new CheckFailure.UnavailableTimeInMedicationNotifyAt(); });
@@ -173,6 +187,7 @@ public class MornyConfig {
@Nonnull public final Set
"
).parseMode(ParseMode HTML).replyToMessageId(event.message.messageId)
diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/command/GetSocial.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/command/GetSocial.scala
new file mode 100644
index 0000000..f8bfc58
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/bot/command/GetSocial.scala
@@ -0,0 +1,32 @@
+package cc.sukazyo.cono.morny.bot.command
+import cc.sukazyo.cono.morny.data.TelegramStickers
+import cc.sukazyo.cono.morny.util.tgapi.InputCommand
+import cc.sukazyo.cono.morny.MornyCoeur
+import cc.sukazyo.cono.morny.bot.event.OnGetSocial
+import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
+import com.pengrad.telegrambot.model.Update
+import com.pengrad.telegrambot.request.SendSticker
+
+class GetSocial (using coeur: MornyCoeur) extends ITelegramCommand {
+
+ override val name: String = "get"
+ override val aliases: Array[ICommandAlias] | Null = null
+ override val paramRule: String = "${h(_text.text)}
${coeur.tasks.amount}
+ | - scheduler status: ${coeur.tasks.state}
+ | - current runner status: ${coeur.tasks.runnerState}
+ |""".stripMargin
+ ).parseMode(ParseMode.HTML).replyToMessageId(update.message.messageId)
+ }
+
+ private def echoEventStatistics (using update: Update): Unit = {
+ coeur.account exec SendMessage(
+ update.message.chat.id,
+ // language=html
+ s"""Event Statistics :
+ |in today
+ |${coeur.daemons.reporter.EventStatistics.eventStatisticsHTML}""".stripMargin
+ ).parseMode(ParseMode.HTML).replyToMessageId(update.message.messageId)
+ }
+
private def echo404 (using event: Update): Unit =
coeur.account exec new SendSticker(
event.message.chat.id,
diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/command/Nbnhhsh.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/command/Nbnhhsh.scala
index ea95cbe..c05714b 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/bot/command/Nbnhhsh.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/bot/command/Nbnhhsh.scala
@@ -1,7 +1,8 @@
package cc.sukazyo.cono.morny.bot.command
import cc.sukazyo.cono.morny.MornyCoeur
-import cc.sukazyo.cono.morny.data.{NbnhhshQuery, TelegramStickers}
+import cc.sukazyo.cono.morny.data.TelegramStickers
+import cc.sukazyo.cono.morny.extra.NbnhhshQuery
import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.escapeHtml as h
import cc.sukazyo.cono.morny.util.tgapi.InputCommand
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/event/MornyEventListeners.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/event/MornyEventListeners.scala
index e3a6d1f..9e2d674 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/bot/event/MornyEventListeners.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/bot/event/MornyEventListeners.scala
@@ -17,6 +17,7 @@ class MornyEventListeners (using manager: EventListenerManager) (using coeur: Mo
OnUserSlashAction(),
OnCallMe(),
OnCallMsgSend(),
+ OnGetSocial(),
OnMedicationNotifyApply(),
OnEventHackHandle()
)
diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/event/MornyOnUpdateTimestampOffsetLock.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/event/MornyOnUpdateTimestampOffsetLock.scala
index ae44432..734af34 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/bot/event/MornyOnUpdateTimestampOffsetLock.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/bot/event/MornyOnUpdateTimestampOffsetLock.scala
@@ -2,13 +2,12 @@ package cc.sukazyo.cono.morny.bot.event
import cc.sukazyo.cono.morny.bot.api.{EventEnv, EventListener}
import cc.sukazyo.cono.morny.MornyCoeur
-import com.pengrad.telegrambot.model.Update
class MornyOnUpdateTimestampOffsetLock (using coeur: MornyCoeur) extends EventListener {
private def checkOutdated (timestamp: Int)(using event: EventEnv): Unit =
if coeur.config.eventIgnoreOutdated && (timestamp < (coeur.coeurStartTimestamp/1000)) then
- event.setEventOk
+ event.setEventCanceled
override def onMessage (using event: EventEnv): Unit = checkOutdated(event.update.message.date)
override def onEditedMessage (using event: EventEnv): Unit = checkOutdated(event.update.editedMessage.date)
diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnCallMe.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnCallMe.scala
index 4f80a69..5d303d2 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnCallMe.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnCallMe.scala
@@ -5,7 +5,7 @@ import cc.sukazyo.cono.morny.bot.api.{EventEnv, EventListener}
import cc.sukazyo.cono.morny.data.TelegramStickers
import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramFormatter.*
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
-import com.pengrad.telegrambot.model.{Chat, Message, Update, User}
+import com.pengrad.telegrambot.model.{Chat, Message, User}
import com.pengrad.telegrambot.model.request.ParseMode
import com.pengrad.telegrambot.request.{ForwardMessage, GetChat, SendMessage, SendSticker}
@@ -73,13 +73,14 @@ class OnCallMe (using coeur: MornyCoeur) extends EventListener {
lastDinnerData.forwardFromMessageId
)
import cc.sukazyo.cono.morny.util.CommonFormat.{formatDate, formatDuration}
+ import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis
import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.escapeHtml as h
- def lastDinner_dateMillis: Long = lastDinnerData.forwardDate longValue;
+ def lastDinner_dateMillis: EpochMillis = EpochMillis fromEpochSeconds lastDinnerData.forwardDate
coeur.account exec SendMessage(
req.from.id,
"on %s [UTC+8]
\n- %s
before".formatted(
h(formatDate(lastDinner_dateMillis, 8)),
- h(formatDuration(lastDinner_dateMillis))
+ h(formatDuration(System.currentTimeMillis - lastDinner_dateMillis))
)
).parseMode(ParseMode HTML).replyToMessageId(sendResp.message.messageId)
isAllowed = true
diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnGetSocial.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnGetSocial.scala
new file mode 100644
index 0000000..5732d58
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnGetSocial.scala
@@ -0,0 +1,116 @@
+package cc.sukazyo.cono.morny.bot.event
+
+import cc.sukazyo.cono.morny.MornyCoeur
+import cc.sukazyo.cono.morny.bot.api.{EventEnv, EventListener}
+import cc.sukazyo.cono.morny.bot.event.OnGetSocial.tryFetchSocial
+import cc.sukazyo.cono.morny.data.TelegramStickers
+import cc.sukazyo.cono.morny.extra.{twitter, weibo}
+import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
+import cc.sukazyo.cono.morny.Log.{exceptionLog, logger}
+import cc.sukazyo.cono.morny.data.social.{SocialTwitterParser, SocialWeiboParser}
+import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Message.entitiesSafe
+import com.pengrad.telegrambot.model.Chat
+import com.pengrad.telegrambot.model.request.ParseMode
+import com.pengrad.telegrambot.request.{SendMessage, SendSticker}
+
+class OnGetSocial (using coeur: MornyCoeur) extends EventListener {
+
+ override def onMessage (using event: EventEnv): Unit = {
+ import event.update.message as messageEvent
+
+ if messageEvent.chat.`type` != Chat.Type.Private then return;
+ if messageEvent.text == null then return;
+
+ if tryFetchSocial(
+ Left((
+ messageEvent.text :: messageEvent.entitiesSafe.map(f => f.url).filterNot(f => f == null)
+ ).mkString(" "))
+ )(using messageEvent.chat.id, messageEvent.messageId) then
+ event.setEventOk
+
+ }
+
+}
+
+object OnGetSocial {
+
+ /** Try fetch from url from input and output fetched social content.
+ *
+ * @param text input text, receive either a texts contains some URLs that should
+ * pass through [[Left]], or a exactly URL that should pass through
+ * [[Right]].
+ * @param replyChat chat that should be output to.
+ * @param replyToMessage message that should be reply to.
+ * @param coeur [[MornyCoeur]] instance for executing Telegram function.
+ * @return [[true]] if fetched social content and sent something out.
+ */
+ def tryFetchSocial (text: Either[String, String])(using replyChat: Long, replyToMessage: Int)(using coeur: MornyCoeur): Boolean = {
+
+ var succeed = 0
+
+ {
+ text match
+ case Left(texts) =>
+ twitter.guessTweetUrl(texts.trim)
+ case Right(url) =>
+ twitter.parseTweetUrl(url.trim).toList
+ }.map(f => {
+ succeed += 1
+ tryFetchSocialOfTweet(f)
+ })
+
+ {
+ text match
+ case Left(texts) =>
+ weibo.guessWeiboStatusUrl(texts.trim)
+ case Right(url) =>
+ weibo.parseWeiboStatusUrl(url.trim).toList
+ }.map(f => {
+ succeed += 1
+ tryFetchSocialOfWeibo(f)
+ })
+ succeed > 0
+
+ }
+
+ def tryFetchSocialOfTweet (url: twitter.TweetUrlInformation)(using replyChat: Long, replyToMessage: Int)(using coeur: MornyCoeur) =
+ import io.circe.{DecodingFailure, ParsingFailure}
+ import sttp.client3.SttpClientException
+ import twitter.FXApi
+ try {
+ val api = FXApi.Fetch.status(Some(url.screenName), url.statusId)
+ SocialTwitterParser.parseFXTweet(api).outputToTelegram
+ } catch case e: (SttpClientException | ParsingFailure | DecodingFailure) =>
+ coeur.account exec SendSticker(
+ replyChat,
+ TelegramStickers.ID_NETWORK_ERR
+ ).replyToMessageId(replyToMessage)
+ logger error
+ "Error on requesting FixTweet API\n" + exceptionLog(e)
+ coeur.daemons.reporter.exception(e, "Error on requesting FixTweet API")
+
+ def tryFetchSocialOfWeibo (url: weibo.StatusUrlInfo)(using replyChat: Long, replyToMessage: Int)(using coeur: MornyCoeur) =
+ import io.circe.{DecodingFailure, ParsingFailure}
+ import sttp.client3.{HttpError, SttpClientException}
+ import weibo.MApi
+ try {
+ val api = MApi.Fetch.statuses_show(url.id)
+ SocialWeiboParser.parseMStatus(api).outputToTelegram
+ } catch
+ case e: HttpError[?] =>
+ coeur.account exec SendMessage(
+ replyChat,
+ // language=html
+ s"""Weibo Request Error ${e.statusCode}
+ |
""".stripMargin
+ ).replyToMessageId(replyToMessage).parseMode(ParseMode.HTML)
+ case e: (SttpClientException | ParsingFailure | DecodingFailure) =>
+ coeur.account exec SendSticker(
+ replyChat,
+ TelegramStickers.ID_NETWORK_ERR
+ ).replyToMessageId(replyToMessage)
+ logger error
+ "Error on requesting Weibo m.API\n" + exceptionLog(e)
+ coeur.daemons.reporter.exception(e, "Error on requesting Weibo m.API")
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnMedicationNotifyApply.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnMedicationNotifyApply.scala
index ed24c3b..40f20c4 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnMedicationNotifyApply.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnMedicationNotifyApply.scala
@@ -2,8 +2,7 @@ package cc.sukazyo.cono.morny.bot.event
import cc.sukazyo.cono.morny.bot.api.{EventEnv, EventListener}
import cc.sukazyo.cono.morny.MornyCoeur
-import cc.sukazyo.cono.morny.daemon.{MedicationTimer, MornyDaemons}
-import com.pengrad.telegrambot.model.{Message, Update}
+import com.pengrad.telegrambot.model.Message
class OnMedicationNotifyApply (using coeur: MornyCoeur) extends EventListener {
@@ -14,8 +13,8 @@ class OnMedicationNotifyApply (using coeur: MornyCoeur) extends EventListener {
private def editedMessageProcess (edited: Message)(using event: EventEnv): Unit = {
if edited.chat.id != coeur.config.medicationNotifyToChat then return;
- coeur.daemons.medicationTimer.refreshNotificationWrite(edited)
- event.setEventOk
+ if coeur.daemons.medicationTimer.refreshNotificationWrite(edited) then
+ event.setEventOk
}
}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnQuestionMarkReply.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnQuestionMarkReply.scala
index 516499e..2b37510 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnQuestionMarkReply.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnQuestionMarkReply.scala
@@ -4,7 +4,6 @@ import cc.sukazyo.cono.morny.bot.api.{EventEnv, EventListener}
import cc.sukazyo.cono.morny.MornyCoeur
import cc.sukazyo.cono.morny.bot.event.OnQuestionMarkReply.isAllMessageMark
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
-import com.pengrad.telegrambot.model.Update
import com.pengrad.telegrambot.request.SendMessage
import scala.language.postfixOps
@@ -33,7 +32,9 @@ class OnQuestionMarkReply (using coeur: MornyCoeur) extends EventListener {
object OnQuestionMarkReply {
- private val QUESTION_MARKS = Set('?', '?', '¿', '⁈', '⁇', '‽', '❔', '❓')
+ // todo: due to the limitation of Java char, the ⁉️ character (actually not a
+ // single character) is not supported yet.
+ private val QUESTION_MARKS = Set('?', '?', '¿', '⁈', '⁇', '‽', '⸘', '❔', '❓')
def isAllMessageMark (using text: String): Boolean = {
boundary[Boolean] {
diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnUniMeowTrigger.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnUniMeowTrigger.scala
index ccf140f..40e4fd3 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnUniMeowTrigger.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnUniMeowTrigger.scala
@@ -4,13 +4,12 @@ import cc.sukazyo.cono.morny.bot.api.{EventEnv, EventListener}
import cc.sukazyo.cono.morny.bot.command.MornyCommands
import cc.sukazyo.cono.morny.util.tgapi.InputCommand
import cc.sukazyo.cono.morny.Log.logger
-import cc.sukazyo.cono.morny.MornyCoeur
-class OnUniMeowTrigger (using commands: MornyCommands) (using coeur: MornyCoeur) extends EventListener {
+class OnUniMeowTrigger (using commands: MornyCommands) extends EventListener {
override def onMessage (using event: EventEnv): Unit = {
- event.consume (classOf[InputCommand]) { input =>
+ event.consume[InputCommand] { input =>
logger trace s"got input command {$input} from event-context"
for ((name, command_instance) <- commands.commands_uni) {
diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/query/MornyQueries.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/query/MornyQueries.scala
index 11c6a12..f0436ca 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/bot/query/MornyQueries.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/bot/query/MornyQueries.scala
@@ -12,7 +12,8 @@ class MornyQueries (using MornyCoeur) {
RawText(),
MyInformation(),
ShareToolTwitter(),
- ShareToolBilibili()
+ ShareToolBilibili(),
+ ShareToolSocialContent()
)
def query (event: Update): List[InlineQueryUnit[_]] = {
diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/query/ShareToolBilibili.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/query/ShareToolBilibili.scala
index 85128db..f7b6872 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/bot/query/ShareToolBilibili.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/bot/query/ShareToolBilibili.scala
@@ -24,7 +24,7 @@ class ShareToolBilibili (using coeur: MornyCoeur) extends ITelegramQuery {
if (event.inlineQuery.query == null) return null
if (event.inlineQuery.query isBlank) return null
- import cc.sukazyo.cono.morny.data.BilibiliForms.*
+ import cc.sukazyo.cono.morny.extra.BilibiliForms.*
val result: BiliVideoId =
try
parse_videoUrl(event.inlineQuery.query)
diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/query/ShareToolSocialContent.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/query/ShareToolSocialContent.scala
new file mode 100644
index 0000000..60912e7
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/bot/query/ShareToolSocialContent.scala
@@ -0,0 +1,43 @@
+package cc.sukazyo.cono.morny.bot.query
+import cc.sukazyo.cono.morny.data.social.{SocialTwitterParser, SocialWeiboParser}
+import cc.sukazyo.cono.morny.extra.{twitter, weibo}
+import cc.sukazyo.cono.morny.extra.twitter.{FXApi, TweetUrlInformation}
+import cc.sukazyo.cono.morny.extra.weibo.{MApi, StatusUrlInfo}
+import com.pengrad.telegrambot.model.Update
+
+class ShareToolSocialContent extends ITelegramQuery {
+
+ override def query (event: Update): List[InlineQueryUnit[_]] | Null = {
+
+ val _queryRaw = event.inlineQuery.query
+ val query =
+ _queryRaw.trim match
+ case _startsWithTag if _startsWithTag startsWith "get " =>
+ (_startsWithTag drop 4)trim
+ case _endsWithTag if _endsWithTag endsWith " get" =>
+ (_endsWithTag dropRight 4)trim
+ case _ => return null
+
+ (
+ twitter.parseTweetUrl(query) match
+ case Some(TweetUrlInformation(_, statusPath, _, statusId, _, _)) =>
+ SocialTwitterParser.parseFXTweet(FXApi.Fetch.status(Some(statusPath), statusId))
+ .genInlineQueryResults(using
+ "morny/share/tweet/content", statusId,
+ "Twitter Tweet Content"
+ )
+ case None => Nil
+ ) ::: (
+ weibo.parseWeiboStatusUrl(query) match
+ case Some(StatusUrlInfo(_, id)) =>
+ SocialWeiboParser.parseMStatus(MApi.Fetch.statuses_show(id))
+ .genInlineQueryResults(using
+ "morny/share/weibo/status/content", id,
+ "Weibo Content"
+ )
+ case None => Nil
+ ) ::: Nil
+
+ }
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/query/ShareToolTwitter.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/query/ShareToolTwitter.scala
index a550501..3105b05 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/bot/query/ShareToolTwitter.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/bot/query/ShareToolTwitter.scala
@@ -1,5 +1,7 @@
package cc.sukazyo.cono.morny.bot.query
+import cc.sukazyo.cono.morny.extra.twitter
+import cc.sukazyo.cono.morny.extra.twitter.TweetUrlInformation
import cc.sukazyo.cono.morny.util.tgapi.formatting.NamingUtils.inlineQueryId
import com.pengrad.telegrambot.model.Update
import com.pengrad.telegrambot.model.request.InlineQueryResultArticle
@@ -10,26 +12,25 @@ import scala.util.matching.Regex
class ShareToolTwitter extends ITelegramQuery {
private val TITLE_VX = "[tweet] Share as VxTwitter"
- private val TITLE_VX_COMBINED = "[tweet] Share as VxTwitter(combination)"
private val ID_PREFIX_VX = "[morny/share/twitter/vxtwi]"
- private val ID_PREFIX_VX_COMBINED = "[morny/share/twitter/vxtwi_combine]"
- private val REGEX_TWEET_LINK: Regex = "^(?:https?://)?((?:(?:c\\.)?vx|fx|www\\.)?twitter\\.com)/((\\w+)/status/(\\d+)(?:/photo/(\\d+))?)/?(\\?[\\w&=-]+)?$"r
+ private val TITLE_FX = "[tweet] Share as Fix-Tweet"
+ private val ID_PREFIX_FX = "[morny/share/twitter/fxtwi]"
override def query (event: Update): List[InlineQueryUnit[_]] | Null = {
if (event.inlineQuery.query == null) return null
- event.inlineQuery.query match
+ twitter.parseTweetUrl(event.inlineQuery.query) match
- case REGEX_TWEET_LINK(_, _path_data, _, _, _, _) =>
+ case Some(TweetUrlInformation(_, _path_data, _, _, _, _)) =>
List(
+ InlineQueryUnit(InlineQueryResultArticle(
+ inlineQueryId(ID_PREFIX_FX + event.inlineQuery.query), TITLE_FX,
+ s"https://fxtwitter.com/$_path_data"
+ )),
InlineQueryUnit(InlineQueryResultArticle(
inlineQueryId(ID_PREFIX_VX+event.inlineQuery.query), TITLE_VX,
s"https://vxtwitter.com/$_path_data"
- )),
- InlineQueryUnit(InlineQueryResultArticle(
- inlineQueryId(ID_PREFIX_VX_COMBINED+event.inlineQuery.query), TITLE_VX_COMBINED,
- s"https://c.vxtwitter.com/$_path_data"
))
)
diff --git a/src/main/scala/cc/sukazyo/cono/morny/daemon/EventHacker.scala b/src/main/scala/cc/sukazyo/cono/morny/daemon/EventHacker.scala
index ee1ab67..00596e7 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/daemon/EventHacker.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/daemon/EventHacker.scala
@@ -42,7 +42,7 @@ class EventHacker (using coeur: MornyCoeur) {
coeur.account exec SendMessage(
x.from_chat,
// language=html
- s"${e.body}
${h(GsonBuilder().setPrettyPrinting().create.toJson(update))}
"
+ s"
"
).parseMode(ParseMode HTML).replyToMessageId(x.from_message toInt)
true
}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/daemon/MedicationTimer.scala b/src/main/scala/cc/sukazyo/cono/morny/daemon/MedicationTimer.scala
index 4840418..8275614 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/daemon/MedicationTimer.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/daemon/MedicationTimer.scala
@@ -1,19 +1,24 @@
package cc.sukazyo.cono.morny.daemon
-import cc.sukazyo.cono.morny.Log.{exceptionLog, logger}
+import cc.sukazyo.cono.morny.Log.logger
import cc.sukazyo.cono.morny.MornyCoeur
import cc.sukazyo.cono.morny.daemon.MedicationTimer.calcNextRoutineTimestamp
+import cc.sukazyo.cono.morny.util.schedule.RoutineTask
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
import cc.sukazyo.cono.morny.util.CommonFormat
+import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis
+import com.cronutils.builder.CronBuilder
+import com.cronutils.model.definition.{CronDefinition, CronDefinitionBuilder}
+import com.cronutils.model.time.ExecutionTime
import com.pengrad.telegrambot.model.{Message, MessageEntity}
import com.pengrad.telegrambot.request.{EditMessageText, SendMessage}
import com.pengrad.telegrambot.response.SendResponse
-import java.time.{LocalDateTime, ZoneOffset}
+import java.time.{Instant, ZonedDateTime, ZoneOffset}
import scala.collection.mutable.ArrayBuffer
import scala.language.implicitConversions
-class MedicationTimer (using coeur: MornyCoeur) extends Thread {
+class MedicationTimer (using coeur: MornyCoeur) {
private val NOTIFY_MESSAGE = "🍥⏲"
private val DAEMON_THREAD_NAME_DEF = "MedicationTimer"
@@ -23,53 +28,49 @@ class MedicationTimer (using coeur: MornyCoeur) extends Thread {
private val notify_atHour: Set[Int] = coeur.config.medicationNotifyAt.asScala.toSet.map(_.intValue)
private val notify_toChat = coeur.config.medicationNotifyToChat
- this.setName(DAEMON_THREAD_NAME_DEF)
-
private var lastNotify_messageId: Option[Int] = None
- override def run (): Unit = {
+ private val scheduleTask: RoutineTask = new RoutineTask {
- if ((notify_toChat == -1) || (notify_atHour isEmpty)) {
- logger notice "Medication Timer disabled : related param is not complete set"
- return
- }
+ override def name: String = DAEMON_THREAD_NAME_DEF
- logger notice "Medication Timer started."
- while (!this.isInterrupted) {
- try {
- val next_time = calcNextRoutineTimestamp(System.currentTimeMillis, use_timeZone, notify_atHour)
- logger info s"medication timer will send next notify at ${CommonFormat.formatDate(next_time, use_timeZone.getTotalSeconds/60/60)} with $use_timeZone [$next_time]"
- val sleep_millis = next_time - System.currentTimeMillis
- logger debug s"medication timer will sleep ${CommonFormat.formatDuration(sleep_millis)} [$sleep_millis]"
- Thread sleep sleep_millis
- sendNotification()
- logger info "medication notify sent."
- } catch
- case _: InterruptedException =>
- interrupt()
- logger notice "MedicationTimer was interrupted, will be exit now"
- case ill: IllegalArgumentException =>
- logger warn "MedicationTimer will not work due to: " + ill.getMessage
- interrupt()
- case e =>
- logger error
- s"""unexpected error occurred on NotificationTimer
- |${exceptionLog(e)}"""
- .stripMargin
- coeur.daemons.reporter.exception(e)
+ def calcNextSendTime: EpochMillis =
+ val next_time = calcNextRoutineTimestamp(System.currentTimeMillis, use_timeZone, notify_atHour)
+ logger info s"medication timer will send next notify at ${CommonFormat.formatDate(next_time, use_timeZone.getTotalSeconds / 60 / 60)} with $use_timeZone [$next_time]"
+ next_time
+
+ override def firstRoutineTimeMillis: EpochMillis =
+ calcNextSendTime
+
+ override def nextRoutineTimeMillis (previousRoutineScheduledTimeMillis: EpochMillis): EpochMillis | Null =
+ calcNextSendTime
+
+ override def main: Unit = {
+ sendNotification()
+ logger info "medication notify sent."
}
- logger notice "Medication Timer stopped."
}
+ def start(): Unit =
+ if ((notify_toChat == -1) || (notify_atHour isEmpty))
+ logger notice "Medication Timer disabled : related param is not complete set"
+ return;
+ coeur.tasks ++ scheduleTask
+ logger notice "Medication Timer started."
+
+ def stop(): Unit =
+ coeur.tasks % scheduleTask
+ logger notice "Medication Timer stopped."
+
private def sendNotification(): Unit = {
val sendResponse: SendResponse = coeur.account exec SendMessage(notify_toChat, NOTIFY_MESSAGE)
if sendResponse isOk then lastNotify_messageId = Some(sendResponse.message.messageId)
else lastNotify_messageId = None
}
- def refreshNotificationWrite (edited: Message): Unit = {
- if (lastNotify_messageId isEmpty) || (lastNotify_messageId.get != (edited.messageId toInt)) then return
+ def refreshNotificationWrite (edited: Message): Boolean = {
+ if (lastNotify_messageId isEmpty) || (lastNotify_messageId.get != (edited.messageId toInt)) then return false
import cc.sukazyo.cono.morny.util.CommonFormat.formatDate
val editTime = formatDate(edited.editDate*1000, use_timeZone.getTotalSeconds/60/60)
val entities = ArrayBuffer.empty[MessageEntity]
@@ -81,24 +82,31 @@ class MedicationTimer (using coeur: MornyCoeur) extends Thread {
edited.text + s"\n-- $editTime --"
).entities(entities toArray:_*)
lastNotify_messageId = None
+ true
}
}
object MedicationTimer {
+ //noinspection ScalaWeakerAccess
+ val cronDef: CronDefinition = CronDefinitionBuilder.defineCron
+ .withHours.and
+ .instance
+
@throws[IllegalArgumentException]
- def calcNextRoutineTimestamp (baseTimeMillis: Long, zone: ZoneOffset, notifyAt: Set[Int]): Long = {
+ def calcNextRoutineTimestamp (baseTimeMillis: EpochMillis, zone: ZoneOffset, notifyAt: Set[Int]): EpochMillis = {
if (notifyAt isEmpty) throw new IllegalArgumentException("notify time is not set")
- var time = LocalDateTime.ofEpochSecond(
- baseTimeMillis / 1000, ((baseTimeMillis % 1000) * 1000 * 1000) toInt,
- zone
- ).withMinute(0).withSecond(0).withNano(0)
- time = time plusHours 1
- while (!(notifyAt contains(time getHour))) {
- time = time plusHours 1
- }
- (time toInstant zone) toEpochMilli
+ import com.cronutils.model.field.expression.FieldExpressionFactory.*
+ ExecutionTime.forCron(CronBuilder.cron(cronDef)
+ .withHour(and({
+ import scala.jdk.CollectionConverters.*
+ (for (i <- notifyAt) yield on(i)).toList.asJava
+ }))
+ .instance
+ ).nextExecution(
+ ZonedDateTime ofInstant (Instant ofEpochMilli baseTimeMillis, zone.normalized)
+ ).get.toInstant.toEpochMilli
}
}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyDaemons.scala b/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyDaemons.scala
index 44c0edc..e3256bc 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyDaemons.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyDaemons.scala
@@ -13,8 +13,8 @@ class MornyDaemons (using val coeur: MornyCoeur) {
logger notice "ALL Morny Daemons starting..."
- // TrackerDataManager.init();
medicationTimer.start()
+ reporter.start()
logger notice "Morny Daemons started."
@@ -24,12 +24,8 @@ class MornyDaemons (using val coeur: MornyCoeur) {
logger notice "stopping All Morny Daemons..."
- // TrackerDataManager.DAEMON.interrupt();
- medicationTimer.interrupt()
- // TrackerDataManager.trackingLock.lock();
- try { medicationTimer.join() }
- catch case e: InterruptedException =>
- e.printStackTrace(System.out)
+ medicationTimer.stop()
+ reporter.stop()
logger notice "stopped ALL Morny Daemons."
}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyReport.scala b/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyReport.scala
index d42c345..2ede970 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyReport.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyReport.scala
@@ -2,16 +2,26 @@ package cc.sukazyo.cono.morny.daemon
import cc.sukazyo.cono.morny.{MornyCoeur, MornyConfig}
import cc.sukazyo.cono.morny.Log.{exceptionLog, logger}
+import cc.sukazyo.cono.morny.bot.api.{EventEnv, EventListener}
import cc.sukazyo.cono.morny.data.MornyInformation.getVersionAllFullTagHTML
+import cc.sukazyo.cono.morny.util.statistics.NumericStatistics
import cc.sukazyo.cono.morny.util.tgapi.event.EventRuntimeException
import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramFormatter.*
import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.escapeHtml as h
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
+import cc.sukazyo.cono.morny.util.EpochDateTime.DurationMillis
+import cc.sukazyo.cono.morny.util.schedule.CronTask
+import com.cronutils.builder.CronBuilder
+import com.cronutils.model.Cron
+import com.cronutils.model.definition.CronDefinitionBuilder
import com.google.gson.GsonBuilder
import com.pengrad.telegrambot.model.request.ParseMode
import com.pengrad.telegrambot.model.User
import com.pengrad.telegrambot.request.{BaseRequest, SendMessage}
import com.pengrad.telegrambot.response.BaseResponse
+import com.pengrad.telegrambot.TelegramException
+
+import java.time.ZoneId
class MornyReport (using coeur: MornyCoeur) {
@@ -23,13 +33,20 @@ class MornyReport (using coeur: MornyCoeur) {
if !enabled then return;
try {
coeur.account exec report
- } catch case e: EventRuntimeException.ActionFailed => {
- logger warn
- s"""cannot execute report to telegram:
- |${exceptionLog(e) indent 4}
- | tg-api response:
- |${(e.response toString) indent 4}"""
- .stripMargin
+ } catch case e: EventRuntimeException => {
+ import EventRuntimeException.*
+ e match
+ case e: ActionFailed =>
+ logger warn
+ s"""cannot execute report to telegram:
+ |${exceptionLog(e) indent 4}
+ | tg-api response:
+ |${(e.response toString) indent 4}""".stripMargin
+ case e: ClientFailed =>
+ logger error
+ s"""failed when report to telegram:
+ |${exceptionLog(e.getCause) indent 4}
+ |""".stripMargin
}
}
@@ -37,15 +54,19 @@ class MornyReport (using coeur: MornyCoeur) {
def _tgErrFormat: String = e match
case api: EventRuntimeException.ActionFailed =>
// language=html
- "\n\ntg-api error:\n${h(GsonBuilder().setPrettyPrinting().create.toJson(update))}
"
+ "\n\ntg-api error:\n%s
"
.formatted(GsonBuilder().setPrettyPrinting().create.toJson(api.response))
+ case tgErr: TelegramException if tgErr.response != null =>
+ // language=html
+ "\n\ntg-api error:\n%s
"
+ .formatted(GsonBuilder().setPrettyPrinting().create.toJson(tgErr.response))
case _ => ""
executeReport(SendMessage(
coeur.config.reportToChat,
// language=html
s"""▌Coeur Unexpected Exception
|${if description ne null then h(description)+"\n" else ""}
- |%s
$_tgErrFormat"""
+ |${h(exceptionLog(e))}
$_tgErrFormat"""
.stripMargin
).parseMode(ParseMode HTML))
}
@@ -67,10 +88,12 @@ class MornyReport (using coeur: MornyCoeur) {
// language=html
s"""▌Morny Logged in
|-v $getVersionAllFullTagHTML
- |as user @${coeur.username}
+ |Logged into user: @${coeur.username}
|
|as config fields:
- |${sectionConfigFields(coeur.config)}"""
+ |${sectionConfigFields(coeur.config)}
+ |
+ |Report Daemon will use TimeZone ${h(exceptionLog(e))}
${coeur.config.reportZone.getDisplayName}
for following report."""
.stripMargin
).parseMode(ParseMode HTML))
}
@@ -120,4 +143,106 @@ class MornyReport (using coeur: MornyCoeur) {
).parseMode(ParseMode HTML))
}
+ object EventStatistics {
+
+ private var eventTotal = 0
+ private var eventCanceled = 0
+ private val runningTime: NumericStatistics[DurationMillis] = NumericStatistics()
+
+ def reset (): Unit = {
+ eventTotal = 0
+ eventCanceled = 0
+ runningTime.reset()
+ }
+
+ private def runningTimeStatisticsHTML: String =
+ runningTime.value match
+ // language=html
+ case None => "<no-statistics>"
+ case Some(value) =>
+ import cc.sukazyo.cono.morny.util.CommonFormat.formatDuration as f
+ s""" - average: ${f(value.total / value.count)}
+ | - max time: ${f(value.max)}
+ | - min time: ${f(value.min)}
+ | - total: ${f(value.total)}
""".stripMargin
+
+ def eventStatisticsHTML: String =
+ import cc.sukazyo.cono.morny.util.UseMath.percentageOf as p
+ val processed = runningTime.count
+ val canceled = eventCanceled
+ val ignored = eventTotal - processed - canceled
+ // language=html
+ s""" - total event received: $eventTotal
+ | - event ignored: (${eventTotal p ignored}%
) $ignored
+ | - event canceled: (${eventTotal p canceled}%
) $canceled
+ | - event processed: (${eventTotal p processed}%
) $processed
+ | - processed time usage:
+ |${runningTimeStatisticsHTML.indent(3)}""".stripMargin
+
+ object EventInfoCatcher extends EventListener {
+ override def executeFilter (using EventEnv): Boolean = true
+ //noinspection ScalaWeakerAccess
+ case class EventTimeUsed (it: DurationMillis)
+ override def atEventPost (using event: EventEnv): Unit = {
+ import event.State
+ eventTotal += 1
+ event.state match
+ case State.OK(from) =>
+ val timeUsed = EventTimeUsed(System.currentTimeMillis - event.timeStartup)
+ event provide timeUsed
+ logger debug
+ s"""event done with OK
+ | with time consumed ${timeUsed.it}ms
+ | by $from""".stripMargin
+ runningTime ++ timeUsed.it
+ case State.CANCELED(from) =>
+ eventCanceled += 1
+ logger debug
+ s"""event done with CANCELED"
+ | by $from""".stripMargin
+ case null =>
+ }
+ }
+
+ }
+
+ private object DailyReportTask extends CronTask {
+
+ import com.cronutils.model.field.expression.FieldExpressionFactory.*
+
+ override val name: String = "reporter#event"
+ override val cron: Cron = CronBuilder.cron(
+ CronDefinitionBuilder.defineCron
+ .withHours.and
+ .instance
+ ).withHour(on(0)).instance
+ override val zone: ZoneId = coeur.config.reportZone.toZoneId
+
+ //noinspection TypeAnnotation
+ override def main = {
+
+ executeReport(SendMessage(
+ coeur.config.reportToChat,
+ // language=html
+ s"""▌Morny Daily Report
+ |
+ |Event Statistics :
+ |${EventStatistics.eventStatisticsHTML}""".stripMargin
+ ).parseMode(ParseMode.HTML))
+
+ // daily reset
+ EventStatistics.reset()
+
+ }
+
+ }
+
+ def start (): Unit = {
+ coeur.tasks ++ DailyReportTask
+ }
+
+ def stop (): Unit = {
+ coeur.tasks % DailyReportTask
+ }
+
}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/MornyJrrp.scala b/src/main/scala/cc/sukazyo/cono/morny/data/MornyJrrp.scala
index b707576..b2e9172 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/data/MornyJrrp.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/data/MornyJrrp.scala
@@ -1,15 +1,16 @@
package cc.sukazyo.cono.morny.data
+import cc.sukazyo.cono.morny.util.EpochDateTime.{EpochDays, EpochMillis}
import com.pengrad.telegrambot.model.User
import scala.language.postfixOps
object MornyJrrp {
- def jrrp_of_telegramUser (user: User, timestamp: Long): Double =
- jrrp_v_xmomi(user.id, timestamp/(1000*60*60*24)) * 100.0
+ def jrrp_of_telegramUser (user: User, timestamp: EpochMillis): Double =
+ jrrp_v_xmomi(user.id, EpochDays fromEpochMillis timestamp) * 100.0
- private def jrrp_v_xmomi (identifier: Long, dayStamp: Long): Double =
+ private def jrrp_v_xmomi (identifier: Long, dayStamp: EpochDays): Double =
import cc.sukazyo.cono.morny.util.CommonEncrypt.MD5
import cc.sukazyo.cono.morny.util.ConvertByteHex.toHex
java.lang.Long.parseLong(MD5(s"$identifier@$dayStamp").toHex.substring(0, 4), 16) / (0xffff toDouble)
diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/social/SocialContent.scala b/src/main/scala/cc/sukazyo/cono/morny/data/social/SocialContent.scala
new file mode 100644
index 0000000..e7766bc
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/data/social/SocialContent.scala
@@ -0,0 +1,114 @@
+package cc.sukazyo.cono.morny.data.social
+
+import cc.sukazyo.cono.morny.data.social.SocialContent.{SocialMedia, SocialMediaType, SocialMediaWithUrl}
+import cc.sukazyo.cono.morny.data.social.SocialContent.SocialMediaType.{Photo, Video}
+import cc.sukazyo.cono.morny.MornyCoeur
+import cc.sukazyo.cono.morny.bot.query.InlineQueryUnit
+import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
+import cc.sukazyo.cono.morny.util.tgapi.formatting.NamingUtils.inlineQueryId
+import com.pengrad.telegrambot.model.request.*
+import com.pengrad.telegrambot.request.{SendMediaGroup, SendMessage}
+
+/** Model of social networks' status. for example twitter tweet or
+ * weibo status.
+ *
+ * Can be output to Telegram.
+ *
+ * @param text_html Formatted HTML output of the status that can be output
+ * directly to Telegram. Normally will contains metadata
+ * like status' author or like count etc.
+ * @param text_withPicPlaceholder same with [[text_html]], but contains more
+ * placeholder texts of medias. can be used
+ * when medias cannot be output.
+ * @param medias Status attachment medias.
+ * @param medias_mosaic Mosaic version of status medias. Will be used when
+ * the output API doesn't support multiple medias like
+ * Telegram inline API. This value is depends on the specific
+ * backend parser/formatter implementation.
+ * @param thumbnail Medias' thumbnail. Will be used when the output API required
+ * a thumbnail. This value is depends on the specific backend
+ * parser/formatter implementation.
+ */
+case class SocialContent (
+ text_html: String,
+ text_withPicPlaceholder: String,
+ medias: List[SocialMedia],
+ medias_mosaic: Option[SocialMedia] = None,
+ thumbnail: Option[SocialMedia] = None
+) {
+
+ def thumbnailOrElse[T] (orElse: T): String | T =
+ thumbnail match
+ case Some(x) if x.isInstanceOf[SocialMediaWithUrl] && x.t == Photo =>
+ x.asInstanceOf[SocialMediaWithUrl].url
+ case _ => orElse
+
+ def outputToTelegram (using replyChat: Long, replyToMessage: Int)(using coeur: MornyCoeur): Unit = {
+ if medias isEmpty then
+ coeur.account exec
+ SendMessage(replyChat, text_html)
+ .parseMode(ParseMode.HTML)
+ .replyToMessageId(replyToMessage)
+ else
+ val mediaGroup = medias.map(f => f.genTelegramInputMedia)
+ mediaGroup.head.caption(text_html)
+ mediaGroup.head.parseMode(ParseMode.HTML)
+ coeur.account exec
+ SendMediaGroup(replyChat, mediaGroup: _*)
+ .replyToMessageId(replyToMessage)
+ }
+
+ def genInlineQueryResults (using id_head: String, id_param: Any, name: String): List[InlineQueryUnit[?]] = {
+ (
+ if (medias_mosaic nonEmpty) && (medias_mosaic.get.t == Photo) && medias_mosaic.get.isInstanceOf[SocialMediaWithUrl] then
+ InlineQueryUnit(InlineQueryResultPhoto(
+ inlineQueryId(s"[$id_head/photo/mosaic]$id_param"),
+ medias_mosaic.get.asInstanceOf[SocialMediaWithUrl].url,
+ thumbnailOrElse(medias_mosaic.get.asInstanceOf[SocialMediaWithUrl].url)
+ ).title(s"$name").caption(text_html).parseMode(ParseMode.HTML)) :: Nil
+ else if (medias nonEmpty) && (medias.head.t == Photo) then
+ val media = medias.head
+ media match
+ case media_url: SocialMediaWithUrl =>
+ InlineQueryUnit(InlineQueryResultPhoto(
+ inlineQueryId(s"[$id_head/photo/0]$id_param"),
+ media_url.url,
+ thumbnailOrElse(media_url.url)
+ ).title(s"$name").caption(text_html).parseMode(ParseMode.HTML)) :: Nil
+ case _ =>
+ InlineQueryUnit(InlineQueryResultArticle(
+ inlineQueryId(s"[$id_head/text_only]$id_param"), s"$name (text only)",
+ InputTextMessageContent(text_withPicPlaceholder).parseMode(ParseMode.HTML)
+ )) :: Nil
+ else
+ InlineQueryUnit(InlineQueryResultArticle(
+ inlineQueryId(s"[$id_head/text]$id_param"), s"$name",
+ InputTextMessageContent(text_html).parseMode(ParseMode.HTML)
+ )) :: Nil
+ ) ::: Nil
+ }
+
+}
+
+object SocialContent {
+
+ enum SocialMediaType:
+ case Photo
+ case Video
+ sealed trait SocialMedia(val t: SocialMediaType) {
+ def genTelegramInputMedia: InputMedia[?]
+ }
+ case class SocialMediaWithUrl (url: String)(t: SocialMediaType) extends SocialMedia(t) {
+ override def genTelegramInputMedia: InputMedia[_] =
+ t match
+ case Photo => InputMediaPhoto(url)
+ case Video => InputMediaVideo(url)
+ }
+ case class SocialMediaWithBytesData (data: Array[Byte])(t: SocialMediaType) extends SocialMedia(t) {
+ override def genTelegramInputMedia: InputMedia[_] =
+ t match
+ case Photo => InputMediaPhoto(data)
+ case Video => InputMediaVideo(data)
+ }
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/social/SocialTwitterParser.scala b/src/main/scala/cc/sukazyo/cono/morny/data/social/SocialTwitterParser.scala
new file mode 100644
index 0000000..b8cef11
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/data/social/SocialTwitterParser.scala
@@ -0,0 +1,66 @@
+package cc.sukazyo.cono.morny.data.social
+
+import cc.sukazyo.cono.morny.data.social.SocialContent.{SocialMedia, SocialMediaWithUrl}
+import cc.sukazyo.cono.morny.data.social.SocialContent.SocialMediaType.{Photo, Video}
+import cc.sukazyo.cono.morny.extra.twitter.{FXApi, FXTweet}
+import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.escapeHtml as h
+
+object SocialTwitterParser {
+
+ def parseFXTweet_forMediaPlaceholderInContent (tweet: FXTweet): String =
+ tweet.media match
+ case None => ""
+ case Some(media) =>
+ "\n" + (media.photos.getOrElse(Nil).map(* => "🖼️") ::: media.videos.getOrElse(Nil).map(* => "🎞️"))
+ .mkString(" ")
+
+ def parseFXTweet (api: FXApi): SocialContent = {
+ api.tweet match
+ case None =>
+ val content =
+ // language=html
+ s"""❌ Fix-Tweet ${api.code}
+ |${h(api.message)}""".stripMargin
+ SocialContent(content, content, Nil)
+ case Some(tweet) =>
+ val content: String =
+ // language=html
+ s"""⚪️ ${h(tweet.author.name)} @${h(tweet.author.screen_name)}
+ |
+ |${h(tweet.text)}
+ |
+ |💬${tweet.replies} 🔗${tweet.retweets} ❤️${tweet.likes}
+ |${h(tweet.created_at)}""".stripMargin
+ val content_withMediasPlaceholder: String =
+ // language=html
+ s"""⚪️ ${h(tweet.author.name)} @${h(tweet.author.screen_name)}
+ |
+ |${h(tweet.text)}${parseFXTweet_forMediaPlaceholderInContent(tweet)}
+ |
+ |💬${tweet.replies} 🔗${tweet.retweets} ❤️${tweet.likes}
+ |${h(tweet.created_at)}""".stripMargin
+ tweet.media match
+ case None =>
+ SocialContent(content, content_withMediasPlaceholder, Nil)
+ case Some(media) =>
+ val mediaGroup: List[SocialMedia] =
+ (
+ media.photos match
+ case None => List.empty
+ case Some(photos) => for i <- photos yield SocialMediaWithUrl(i.url)(Photo)
+ ) ::: (
+ media.videos match
+ case None => List.empty
+ case Some(videos) => for i <- videos yield SocialMediaWithUrl(i.url)(Video)
+ )
+ val thumbnail =
+ if media.videos.nonEmpty then
+ Some(SocialMediaWithUrl(media.videos.get.head.thumbnail_url)(Photo))
+ else None
+ val mediaMosaic = media.mosaic match
+ case Some(mosaic) => Some(SocialMediaWithUrl(mosaic.formats.jpeg)(Photo))
+ case None => None
+ SocialContent(content, content_withMediasPlaceholder, mediaGroup, mediaMosaic, thumbnail)
+ }
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/social/SocialWeiboParser.scala b/src/main/scala/cc/sukazyo/cono/morny/data/social/SocialWeiboParser.scala
new file mode 100644
index 0000000..7acaa08
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/data/social/SocialWeiboParser.scala
@@ -0,0 +1,50 @@
+package cc.sukazyo.cono.morny.data.social
+
+import cc.sukazyo.cono.morny.data.social.SocialContent.SocialMediaType.Photo
+import cc.sukazyo.cono.morny.data.social.SocialContent.SocialMediaWithBytesData
+import cc.sukazyo.cono.morny.extra.weibo.{genWeiboStatusUrl, MApi, MStatus, StatusUrlInfo}
+import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.{cleanupHtml as ch, escapeHtml as h}
+import io.circe.{DecodingFailure, ParsingFailure}
+import sttp.client3.{HttpError, SttpClientException}
+
+object SocialWeiboParser {
+
+ def parseMStatus_forPicPreview (status: MStatus): String =
+ if status.pic_ids.isEmpty then "" else
+ "\n" + (for (pic <- status.pic_ids) yield "🖼️").mkString(" ")
+
+ def parseMStatus_forRetweeted (originalStatus: MStatus): String =
+ originalStatus.retweeted_status match
+ case Some(status) =>
+ // language=html
+ s"""
+ |//${h(status.user.screen_name)}:
+ |${ch(status.text)}${parseMStatus_forPicPreview(status)}
+ |""".stripMargin
+ case None => ""
+
+ @throws[HttpError[?] | SttpClientException | ParsingFailure | DecodingFailure]
+ def parseMStatus (api: MApi[MStatus]): SocialContent = {
+ val content =
+ // language=html
+ s"""🔸${h(api.data.user.screen_name)}
+ |
+ |${ch(api.data.text)}
+ |${parseMStatus_forRetweeted(api.data)}
+ |${h(api.data.created_at)}""".stripMargin
+ val content_withPicPlaceholder =
+ // language=html
+ s"""🔸${h(api.data.user.screen_name)}
+ |
+ |${ch(api.data.text)}${parseMStatus_forPicPreview(api.data)}
+ |${parseMStatus_forRetweeted(api.data)}
+ |${h(api.data.created_at)}""".stripMargin
+ api.data.pics match
+ case None =>
+ SocialContent(content, content_withPicPlaceholder, Nil)
+ case Some(pics) =>
+ val mediaGroup = pics.map(f => SocialMediaWithBytesData(MApi.Fetch.pic(f.large.url))(Photo))
+ SocialContent(content, content_withPicPlaceholder, mediaGroup)
+ }
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/BilibiliForms.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/BilibiliForms.scala
similarity index 94%
rename from src/main/scala/cc/sukazyo/cono/morny/data/BilibiliForms.scala
rename to src/main/scala/cc/sukazyo/cono/morny/extra/BilibiliForms.scala
index c2bee8e..3dc55f3 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/data/BilibiliForms.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/extra/BilibiliForms.scala
@@ -1,9 +1,9 @@
-package cc.sukazyo.cono.morny.data
+package cc.sukazyo.cono.morny.extra
import cc.sukazyo.cono.morny.util.BiliTool
-import cc.sukazyo.cono.morny.util.SttpPublic.Schemes
+import cc.sukazyo.cono.morny.util.SttpPublic.{mornyBasicRequest, Schemes}
import cc.sukazyo.cono.morny.util.UseSelect.select
-import sttp.client3.{basicRequest, ignore, HttpError, SttpClientException}
+import sttp.client3.{HttpError, SttpClientException}
import sttp.client3.okhttp.OkHttpSyncBackend
import sttp.model.Uri
@@ -77,7 +77,8 @@ object BilibiliForms {
throw IllegalArgumentException(s"is a b23 video link: $uri . (use parse_videoUrl instead)")
try {
- val response = basicRequest
+ import sttp.client3.ignore
+ val response = mornyBasicRequest
.get(uri)
.followRedirects(false)
.response(ignore)
diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/NbnhhshQuery.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/NbnhhshQuery.scala
similarity index 80%
rename from src/main/scala/cc/sukazyo/cono/morny/data/NbnhhshQuery.scala
rename to src/main/scala/cc/sukazyo/cono/morny/extra/NbnhhshQuery.scala
index 4322739..99a8170 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/data/NbnhhshQuery.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/extra/NbnhhshQuery.scala
@@ -1,7 +1,8 @@
-package cc.sukazyo.cono.morny.data
+package cc.sukazyo.cono.morny.extra
+import cc.sukazyo.cono.morny.util.SttpPublic.mornyBasicRequest
import com.google.gson.Gson
-import sttp.client3.{asString, basicRequest, HttpError, SttpClientException, UriContext}
+import sttp.client3.{asString, HttpError, SttpClientException, UriContext}
import sttp.client3.okhttp.OkHttpSyncBackend
import sttp.model.MediaType
@@ -22,7 +23,7 @@ object NbnhhshQuery {
@throws[HttpError[_]|SttpClientException]
def sendGuess (text: String): GuessResult = {
case class GuessRequest (text: String)
- val http = basicRequest
+ val http = mornyBasicRequest
.body(Gson().toJson(GuessRequest(text))).contentType(MediaType.ApplicationJson)
.post(API_GUESS_METHOD)
.response(asString.getRight)
diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/ip186/IP186QueryHandler.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/ip186/IP186QueryHandler.scala
similarity index 88%
rename from src/main/scala/cc/sukazyo/cono/morny/data/ip186/IP186QueryHandler.scala
rename to src/main/scala/cc/sukazyo/cono/morny/extra/ip186/IP186QueryHandler.scala
index 178e21d..630bf29 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/data/ip186/IP186QueryHandler.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/extra/ip186/IP186QueryHandler.scala
@@ -1,7 +1,7 @@
-package cc.sukazyo.cono.morny.data.ip186
+package cc.sukazyo.cono.morny.extra.ip186
-import cc.sukazyo.cono.morny.util.SttpPublic.Schemes
-import sttp.client3.{asString, basicRequest, HttpError, SttpClientException, UriContext}
+import cc.sukazyo.cono.morny.util.SttpPublic.{mornyBasicRequest, Schemes}
+import sttp.client3.{asString, HttpError, SttpClientException, UriContext}
import sttp.client3.okhttp.OkHttpSyncBackend
import sttp.model.Uri
@@ -36,7 +36,7 @@ object IP186QueryHandler {
val uri = requestPath.scheme(Schemes.HTTPS).host(SITE_HOST)
IP186Response(
uri.toString,
- basicRequest
+ mornyBasicRequest
.get(uri)
.response(asString.getRight)
.send(httpClient)
diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/ip186/IP186Response.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/ip186/IP186Response.scala
similarity index 56%
rename from src/main/scala/cc/sukazyo/cono/morny/data/ip186/IP186Response.scala
rename to src/main/scala/cc/sukazyo/cono/morny/extra/ip186/IP186Response.scala
index 6fcad79..6673044 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/data/ip186/IP186Response.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/extra/ip186/IP186Response.scala
@@ -1,3 +1,3 @@
-package cc.sukazyo.cono.morny.data.ip186
+package cc.sukazyo.cono.morny.extra.ip186
case class IP186Response (url: String, body: String)
diff --git a/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXApi.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXApi.scala
new file mode 100644
index 0000000..11a4af8
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXApi.scala
@@ -0,0 +1,107 @@
+package cc.sukazyo.cono.morny.extra.twitter
+
+import cc.sukazyo.cono.morny.util.SttpPublic
+import cc.sukazyo.cono.morny.util.SttpPublic.mornyBasicRequest
+import io.circe.{DecodingFailure, ParsingFailure}
+
+/** The struct of FixTweet Status-Fetch-API response.
+ *
+ * It may have some issues due to the API reference from FixTweet
+ * project is very outdated and inaccurate.
+ *
+ * @see [[https://github.com/FixTweet/FixTweet/wiki/Status-Fetch-API]]
+ *
+ * @param code Status code, normally be [[200]], but can be 401
+ * or [[404]] or [[500]] due to different reasons.
+ *
+ * Related to [[message]]
+ * @param message Status message.
+ *
+ * - When [[code]] is [[200]], it should be `OK`
+ * - When [[code]] is [[401]], it should be `PRIVATE_TWEET`,
+ * while in practice, it seems PRIVATE_TWEET will
+ * just return [[404]].
+ * - When [[code]] is [[404]], it should be `NOT_FOUND`
+ * - When [[code]] is [[500]], it should be `API_FILE`
+ * @param tweet [[FXTweet]] content.
+ * @since 1.3.0
+ * @version 2023.11.21
+ */
+case class FXApi (
+ code: Int,
+ message: String,
+ tweet: Option[FXTweet]
+)
+
+object FXApi {
+
+ object CirceADTs {
+ import io.circe.Decoder
+ import io.circe.generic.semiauto.deriveDecoder
+ implicit val decoderForAny: Decoder[Any] = _ => Right(None)
+ implicit val decoder_FXAuthor_website: Decoder[FXAuthor.websiteType] = deriveDecoder
+ implicit val decoder_FXAuthor: Decoder[FXAuthor] = deriveDecoder
+ implicit val decoder_FXExternalMedia: Decoder[FXExternalMedia] = deriveDecoder
+ implicit val decoder_FXMosaicPhoto_formats: Decoder[FXMosaicPhoto.formatsType] = deriveDecoder
+ implicit val decoder_FXMosaicPhoto: Decoder[FXMosaicPhoto] = deriveDecoder
+ implicit val decoder_FXPhoto: Decoder[FXPhoto] = deriveDecoder
+ implicit val decoder_FXVideo: Decoder[FXVideo] = deriveDecoder
+ implicit val decoder_FXPoolChoice: Decoder[FXPoolChoice] = deriveDecoder
+ implicit val decoder_FXPool: Decoder[FXPool] = deriveDecoder
+ implicit val decoder_FXTranslate: Decoder[FXTranslate] = deriveDecoder
+ implicit val decoder_FXTweet_media: Decoder[FXTweet.mediaType] = deriveDecoder
+ implicit val decoder_FXTweet: Decoder[FXTweet] = deriveDecoder
+ implicit val decoder_FXApi: Decoder[FXApi] = deriveDecoder
+ }
+
+ object Fetch {
+
+ import io.circe.parser
+ import CirceADTs.*
+ import sttp.client3.*
+ import sttp.client3.okhttp.OkHttpSyncBackend
+
+ val uri_base = uri"https://api.fxtwitter.com/"
+ /** Endpoint URI of [[https://github.com/FixTweet/FixTweet/wiki/Status-Fetch-API FixTweet Status Fetch API]]. */
+ val uri_status =
+ (screen_name: Option[String], id: String, translate_to: Option[String]) =>
+ uri"$uri_base/$screen_name/status/$id/$translate_to"
+
+ private val httpClient = OkHttpSyncBackend()
+
+ /** Get tweet data from [[uri_status FixTweet Status Fetch API]].
+ *
+ * This method uses [[SttpPublic.Headers.UserAgent.MORNY_CURRENT Morny HTTP User-Agent]]
+ *
+ * @param screen_name The screen name (@ handle) (aka. user id) of the
+ * tweet, which is ignored.
+ * @param id The ID of the status (tweet)
+ * @param translate_to 2 letter ISO language code of the language you
+ * want to translate the tweet into.
+ * @throws SttpClientException When HTTP Request fails due to network
+ * or else HTTP client related problem.
+ * @throws ParsingFailure When the response from API is not a regular JSON
+ * so cannot be parsed. It mostly due to some problem
+ * or breaking changes from the API serverside.
+ * @throws DecodingFailure When cannot decode the API response to a [[FXApi]]
+ * object. It might be some wrong with the [[FXApi]]
+ * model, or the remote API spec changes.
+ * @return a [[FXApi]] response object, with [[200]] or any other response code.
+ */
+ @throws[SttpClientException|ParsingFailure|DecodingFailure]
+ def status (screen_name: Option[String], id: String, translate_to: Option[String] = None): FXApi =
+ val get = mornyBasicRequest
+ .get(uri_status(screen_name, id, translate_to))
+ .response(asString)
+ .send(httpClient)
+ val body = get.body match
+ case Left(error) => error
+ case Right(success) => success
+ parser.parse(body)
+ .toTry.get
+ .as[FXApi]
+ .toTry.get
+
+ }
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXAuthor.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXAuthor.scala
new file mode 100644
index 0000000..1aacb81
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXAuthor.scala
@@ -0,0 +1,33 @@
+package cc.sukazyo.cono.morny.extra.twitter
+
+/** Information about the author of a tweet.
+ *
+ * @param name Name of the user, set on their profile
+ * @param screen_name Screen name or @ handle of the user.
+ * @param avatar_url URL for the user's avatar (profile picture)
+ * @param avatar_color Palette color corresponding to the user's avatar (profile picture). Value is a hex, including `#`.
+ * @param banner_url URL for the banner of the user
+ */
+case class FXAuthor (
+ name: String,
+ url: String,
+ screen_name: String,
+ avatar_url: Option[String],
+ avatar_color: Option[String],
+ banner_url: Option[String],
+ description: String, // todo
+ location: String, // todo
+ website: Option[FXAuthor.websiteType], // todo
+ followers: Int, // todo
+ following: Int, // todo
+ joined: String, // todo
+ likes: Int, // todo
+ tweets: Int // todo
+)
+
+object FXAuthor {
+ case class websiteType (
+ url: String,
+ display_url: String
+ )
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXExternalMedia.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXExternalMedia.scala
new file mode 100644
index 0000000..202c55e
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXExternalMedia.scala
@@ -0,0 +1,17 @@
+package cc.sukazyo.cono.morny.extra.twitter
+
+/** Data for external media, currently only video.
+ *
+ * @param `type` Embed type, currently always `video`
+ * @param url Video URL
+ * @param height Video height in pixels
+ * @param width Video width in pixels
+ * @param duration Video duration in seconds
+ */
+case class FXExternalMedia (
+ `type`: String,
+ url: String,
+ height: Int,
+ width: Int,
+ duration: Int
+)
diff --git a/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXMosaicPhoto.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXMosaicPhoto.scala
new file mode 100644
index 0000000..4fd6453
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXMosaicPhoto.scala
@@ -0,0 +1,24 @@
+package cc.sukazyo.cono.morny.extra.twitter
+
+import cc.sukazyo.cono.morny.extra.twitter.FXMosaicPhoto.formatsType
+
+/** Data for the mosaic service, which stitches photos together
+ *
+ * @param `type` This can help compare items in a pool of media
+ * @param formats Pool of formats, only `jpeg` and `webp` are returned currently
+ */
+case class FXMosaicPhoto (
+ `type`: "mosaic_photo",
+ formats: formatsType
+)
+
+object FXMosaicPhoto {
+ /** Pool of formats, only `jpeg` and `webp` are returned currently.
+ * @param webp URL for webp resource
+ * @param jpeg URL for jpeg resource
+ */
+ case class formatsType (
+ webp: String,
+ jpeg: String
+ )
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXPhoto.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXPhoto.scala
new file mode 100644
index 0000000..d759110
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXPhoto.scala
@@ -0,0 +1,16 @@
+package cc.sukazyo.cono.morny.extra.twitter
+
+/** This can help compare items in a pool of media
+ *
+ * @param `type` This can help compare items in a pool of media
+ * @param url URL of the photo
+ * @param width Width of the photo, in pixels
+ * @param height Height of the photo, in pixels
+ */
+case class FXPhoto (
+ `type`: "photo",
+ url: String,
+ width: Int,
+ height: Int,
+ altText: String // todo
+)
diff --git a/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXPool.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXPool.scala
new file mode 100644
index 0000000..bfc7f80
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXPool.scala
@@ -0,0 +1,15 @@
+package cc.sukazyo.cono.morny.extra.twitter
+
+/** Data for a poll on a given Tweet.
+ *
+ * @param choices Array of the poll choices
+ * @param total_votes Total votes in poll
+ * @param ends_at Date of which the poll ends
+ * @param time_left_en Time remaining counter in English (i.e. **9 hours left**)
+ */
+case class FXPool (
+ choices: List[FXPoolChoice],
+ total_votes: Int,
+ ends_at: String,
+ time_left_en: String
+)
diff --git a/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXPoolChoice.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXPoolChoice.scala
new file mode 100644
index 0000000..1dfe0bd
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXPoolChoice.scala
@@ -0,0 +1,13 @@
+package cc.sukazyo.cono.morny.extra.twitter
+
+/** Data for a single choice in a poll
+ *
+ * @param label What this choice in the poll is called
+ * @param count How many people voted in this poll
+ * @param percentage Percentage of total people who voted for this option (0 - 100, rounded to nearest tenth)
+ */
+case class FXPoolChoice (
+ label: String,
+ count: Int,
+ percentage: Int
+)
diff --git a/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXTranslate.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXTranslate.scala
new file mode 100644
index 0000000..0f6a08b
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXTranslate.scala
@@ -0,0 +1,13 @@
+package cc.sukazyo.cono.morny.extra.twitter
+
+/** Information about a requested translation for a Tweet, when asked.
+ *
+ * @param text Translated Tweet text
+ * @param source_lang 2-letter ISO language code of source language
+ * @param target_lang 2-letter ISO language code of target language
+ */
+case class FXTranslate (
+ text: String,
+ source_lang: String,
+ target_lang: String
+)
diff --git a/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXTweet.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXTweet.scala
new file mode 100644
index 0000000..b4b2b57
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXTweet.scala
@@ -0,0 +1,90 @@
+package cc.sukazyo.cono.morny.extra.twitter
+
+import cc.sukazyo.cono.morny.extra.twitter.FXTweet.mediaType
+import cc.sukazyo.cono.morny.util.EpochDateTime.EpochSeconds
+
+/** The container of all the information for a Tweet.
+ *
+ * @param id Status (Tweet) ID
+ * @param url Link to original Tweet
+ * @param text Text of Tweet
+ * @param created_at Date/Time in UTC when the Tweet was created
+ * @param created_timestamp Date/Time in UTC when the Tweet was created
+ * @param color Dominant color pulled from either Tweet media or from the author's profile picture.
+ * @param lang Language that Twitter detects a Tweet is. May be null is unknown.
+ * @param replying_to Screen name of person being replied to, or null
+ * @param replying_to_status Tweet ID snowflake being replied to, or null
+ * @param twitter_card Corresponds to proper embed container for Tweet, which is used by
+ * FixTweet for our official embeds.
+ * Notice that this should be of type [["tweet"|"summary"|"summary_large_image"|"player"]]
+ * but due to circe parser does not support it well so alternative
+ * [[String]] type is used.
+ * @param author Author of the tweet
+ * @param source Tweet source (i.e. Twitter for iPhone)
+ * @param likes Like count
+ * @param retweets Retweet count
+ * @param replies Reply count
+ * @param views View count, returns null if view count is not available (i.e. older Tweets)
+ * @param quote Nested Tweet corresponding to the tweet which this tweet is quoting, if applicable
+ * @param pool Poll attached to Tweet
+ * @param translation Translation results, only provided if explicitly asked
+ * @param media Containing object containing references to photos, videos, or external media
+ */
+case class FXTweet (
+
+ ///====================
+ /// Core
+ ///====================
+
+ id: String,
+ url: String,
+ text: String,
+ created_at: String,
+ created_timestamp: EpochSeconds,
+ is_note_tweet: Boolean, // todo
+ possibly_sensitive: Option[Boolean], // todo
+ color: Option[String],
+ lang: Option[String],
+ replying_to: Option[String],
+ replying_to_status: Option[String],
+// twitter_card: "tweet"|"summary"|"summary_large_image"|"player",
+ twitter_card: Option[String],
+ author: FXAuthor,
+ source: String,
+
+ ///====================
+ /// Interaction counts
+ ///====================
+
+ likes: Int,
+ retweets: Int,
+ replies: Int,
+ views: Option[Int],
+
+ ///====================
+ /// Embeds
+ ///====================
+
+ quote: Option[FXTweet],
+ pool: Option[FXPool],
+ translation: Option[FXTranslate],
+ media: Option[mediaType]
+
+)
+
+object FXTweet {
+ /** Containing object containing references to photos, videos, or external media.
+ *
+ * @param external Refers to external media, such as YouTube embeds
+ * @param photos An Array of photos from a Tweet
+ * @param videos An Array of videos from a Tweet
+ * @param mosaic Corresponding Mosaic information for a Tweet
+ */
+ case class mediaType (
+ all: Option[List[Any]], // todo
+ external: Option[FXExternalMedia],
+ photos: Option[List[FXPhoto]],
+ videos: Option[List[FXVideo]],
+ mosaic: Option[FXMosaicPhoto]
+ )
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXVideo.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXVideo.scala
new file mode 100644
index 0000000..b58f133
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXVideo.scala
@@ -0,0 +1,21 @@
+package cc.sukazyo.cono.morny.extra.twitter
+
+/** Data for a Tweet's video
+ *
+ * @param `type` Returns video if video, or gif if gif. Note that on Twitter, all GIFs are MP4s.
+ * @param url URL corresponding to the video file
+ * @param thumbnail_url URL corresponding to the thumbnail for the video
+ * @param width Width of the video, in pixels
+ * @param height Height of the video, in pixels
+ * @param format Video format, usually `video/mp4`
+ */
+case class FXVideo (
+// `type`: "video"|"gif",
+ `type`: String,
+ url: String,
+ thumbnail_url: String,
+ width: Int,
+ height: Int,
+ duration: Float, // todo
+ format: String
+)
diff --git a/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/package.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/package.scala
new file mode 100644
index 0000000..ee07551
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/package.scala
@@ -0,0 +1,81 @@
+package cc.sukazyo.cono.morny.extra
+
+import scala.util.matching.Regex
+
+package object twitter {
+
+ private val REGEX_TWEET_URL: Regex = "(?:https?://)?((?:(?:(?:c\\.)?vx|fx|www\\.)?twitter|(?:www\\.|fixup|fixv)?x)\\.com)/((\\w+)/status/(\\d+)(?:/photo/(\\d+))?)/?(?:\\?(\\S+))?"r
+
+ /** Messages that can contains on a tweet url.
+ *
+ * A tweet url is like `https://twitter.com/pj_sekai/status/1726526899982352557?s=20`
+ * which can be found in address bar of tweet page or tweet's share link.
+ *
+ * @param domain Domain of the tweet url. Normally `twitter.com` or `x.com`
+ * (can be with `www.` or without). But [[parseTweetUrl]] also
+ * supports to parse some third-party tweet share url domain
+ * includes `fx.twitter.com`, `vxtwitter.com`(or `c.vxtwitter.com`
+ * though it have been deprecated), or `fixupx.com`.
+ * @param statusPath Full path of the status. It should be like
+ * `$screenName/status/$statusId`, with or without photo param
+ * like `/photo/$subPhotoId`. It does not contains tracking
+ * or any else params.
+ * @param screenName Screen name of the tweet author, aka. author's user id.
+ * For most case this section is useless in processing at
+ * the backend (because [[statusId]] along is accurate enough)
+ * so it may not be right, but it should always exists.
+ * @param statusId Unique ID of the status. It is unique in whole Twitter globe.
+ * Should be a number.
+ * @param subPhotoId photo id or serial number in the status. Unique in the status
+ * globe, only exists when specific a photo in the status. It should
+ * be a number of 0~3 (because twitter supports 4 image at most in
+ * one tweet).
+ * @param trackingParam All of encoded url params. Normally no data here is something
+ * important.
+ */
+ case class TweetUrlInformation (
+ domain: String,
+ statusPath: String,
+ screenName: String,
+ statusId: String,
+ subPhotoId: Option[String],
+ trackingParam: Option[String]
+ )
+
+ /** Parse a url to [[TweetUrlInformation]] for future processing.
+ *
+ * Supports following url:
+ *
+ * - `twitter.com` or `www.twitter.com`
+ * - `x.com` or `www.x.com`
+ * - `fxtwitter.com` or `fixupx.com`
+ * - `vxtwitter.com` or `c.vxtwitter.com` or `fixvx.com`
+ * - should be the path of `/:screenName/status/:id`
+ * - can contains `./photo/:photoId`
+ * - url param non-sensitive
+ * - http or https non-sensitive
+ *
+ * @param url a supported tweet URL or not.
+ * @return [[Option]] with [[TweetUrlInformation]] if the input url is a supported
+ * tweet url, or [[None]] if it's not.
+ */
+ def parseTweetUrl (url: String): Option[TweetUrlInformation] =
+ url match
+ case REGEX_TWEET_URL(_1, _2, _3, _4, _5, _6) =>
+ Some(TweetUrlInformation(
+ _1, _2, _3, _4,
+ Option(_5),
+ Option(_6)
+ ))
+ case _ => None
+
+ def guessTweetUrl (text: String): List[TweetUrlInformation] =
+ REGEX_TWEET_URL.findAllMatchIn(text).map(f => {
+ TweetUrlInformation(
+ f.group(1), f.group(2), f.group(3), f.group(4),
+ Option(f.group(5)),
+ Option(f.group(6))
+ )
+ }).toList
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MApi.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MApi.scala
new file mode 100644
index 0000000..c87236f
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MApi.scala
@@ -0,0 +1,66 @@
+package cc.sukazyo.cono.morny.extra.weibo
+
+case class MApi [D] (
+ ok: Int,
+ data: D
+)
+
+object MApi {
+
+ object CirceADTs {
+ import io.circe.Decoder
+ import io.circe.generic.semiauto.deriveDecoder
+ given Decoder[MUser] = deriveDecoder
+ given given_Decoder_largeType_getType: Decoder[MPic.largeType.geoType] = deriveDecoder
+ given Decoder[MPic.largeType] = deriveDecoder
+ given Decoder[MPic.geoType] = deriveDecoder
+ given Decoder[MPic] = deriveDecoder
+ given Decoder[MStatus] = deriveDecoder
+ given Decoder[MApi[MStatus]] = deriveDecoder
+ }
+
+ object Fetch {
+
+ import cc.sukazyo.cono.morny.util.SttpPublic
+ import cc.sukazyo.cono.morny.util.SttpPublic.mornyBasicRequest
+ import io.circe.{parser, DecodingFailure, ParsingFailure}
+ import sttp.client3.{HttpError, SttpClientException, UriContext}
+ import sttp.client3.okhttp.OkHttpSyncBackend
+
+ val uri_base = uri"https://m.weibo.cn/"
+ val uri_statuses_show =
+ (id: String) => uri"$uri_base/statuses/show?id=$id"
+
+ private val httpClient = OkHttpSyncBackend()
+
+ @throws[HttpError[_]|SttpClientException|ParsingFailure|DecodingFailure]
+ def statuses_show (id: String): MApi[MStatus] =
+ import sttp.client3.asString
+ import MApi.CirceADTs.given
+ val response = mornyBasicRequest
+ .get(uri_statuses_show(id))
+ .response(asString.getRight)
+ .send(httpClient)
+ parser.parse(response.body)
+ .toTry.get
+ .as[MApi[MStatus]]
+ .toTry.get
+
+ @throws[HttpError[_] | SttpClientException | ParsingFailure | DecodingFailure]
+ def pic (picUrl: String): Array[Byte] =
+ import sttp.client3.*
+ import sttp.model.{MediaType, Uri}
+ mornyBasicRequest
+ .acceptEncoding(MediaType.ImageJpeg.toString)
+ .get(Uri.unsafeParse(picUrl))
+ .response(asByteArray.getRight)
+ .send(httpClient)
+ .body
+
+// @throws[HttpError[_] | SttpClientException | ParsingFailure | DecodingFailure]
+// def pic (info: PicUrl): Array[Byte] =
+// pic(info.toUrl)
+
+ }
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MPic.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MPic.scala
new file mode 100644
index 0000000..d9d2167
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MPic.scala
@@ -0,0 +1,33 @@
+package cc.sukazyo.cono.morny.extra.weibo
+
+case class MPic (
+ pid: String,
+ url: String,
+ size: String,
+ geo: MPic.geoType,
+ large: MPic.largeType
+)
+
+object MPic {
+
+ case class geoType (
+// width: Int,
+// height: Int,
+ croped: Boolean
+ )
+
+ case class largeType (
+ size: String,
+ url: String,
+ geo: largeType.geoType
+ )
+
+ object largeType {
+ case class geoType (
+// width: String,
+// height: String,
+ croped: Boolean
+ )
+ }
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MStatus.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MStatus.scala
new file mode 100644
index 0000000..8d52be6
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MStatus.scala
@@ -0,0 +1,92 @@
+package cc.sukazyo.cono.morny.extra.weibo
+
+case class MStatus (
+
+ id: String,
+ mid: String,
+ bid: String,
+
+ created_at: String,
+ text: String,
+ raw_text: Option[String],
+
+ user: MUser,
+
+ retweeted_status: Option[MStatus],
+
+ pic_ids: List[String],
+ pics: Option[List[MPic]],
+ thumbnail_pic: Option[String],
+ bmiddle_pic: Option[String],
+ original_pic: Option[String],
+
+// visible: Nothing,
+// created_at: String,
+// id: String,
+// mid: String,
+// bid: String,
+// can_edit: Boolean,
+// show_additional_indication: Int,
+// text: String,
+// textLength: Option[Int],
+// source: String,
+// favorited: Boolean,
+// pic_ids: List[String],
+// pic_focus_point: Option[List[Nothing]],
+// falls_pic_focus_point: Option[List[Nothing]],
+// pic_rectangle_object: Option[List[Nothing]],
+// pic_flag: Option[Int],
+// thumbnail_pic: Option[String],
+// bmiddle_pic: Option[String],
+// original_pic: Option[String],
+// is_paid: Boolean,
+// mblog_vip_type: Int,
+// user: Nothing,
+// picStatus: Option[String],
+// retweeted_status: Option[Nothing],
+// reposts_count: Int,
+// comments_count: Int,
+// reprint_cmt_count: Int,
+// attitudes_count: Int,
+// pending_approval_count: Int,
+// isLongText: Boolean,
+// show_mlevel: Int,
+// topic_id: Option[String],
+// sync_mblog: Option[Boolean],
+// is_imported_topic: Option[Boolean],
+// darwin_tags: List[Nothing],
+// ad_marked: Boolean,
+// mblogtype: Int,
+// item_category: String,
+// rid: String,
+// number_display_strategy: Nothing,
+// content_auth: Int,
+// safe_tags: Option[Int],
+// comment_manage_info: Nothing,
+// repost_type: Option[Int],
+// pic_num: Int,
+// jump_type: Option[Int],
+// hot_page: Nothing,
+// new_comment_style: Int,
+// ab_switcher: Int,
+// mlevel: Int,
+// region_name: String,
+// region_opt: 1,
+// page_info: Option[Nothing],
+// pics: Option[List[Nothing]],
+// raw_text: Option[String],
+// buttons: List[Nothing],
+// status_title: Option[String],
+// ok: Int,
+
+
+// pid: Long,
+// pidstr: String,
+// pic_types: String,
+// alchemy_params: Nothing,
+// ad_state: Int,
+// cardid: String,
+// hide_flag: Int,
+// mark: String,
+// more_info_type: Int,
+)
diff --git a/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MUser.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MUser.scala
new file mode 100644
index 0000000..f01fff5
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MUser.scala
@@ -0,0 +1,13 @@
+package cc.sukazyo.cono.morny.extra.weibo
+
+case class MUser (
+
+ id: Long,
+ screen_name: String,
+ profile_url: String,
+ profile_image_url: Option[String],
+ avatar_hd: Option[String],
+ description: Option[String],
+ cover_image_phone: Option[String],
+
+)
diff --git a/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/package.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/package.scala
new file mode 100644
index 0000000..c57e5dc
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/package.scala
@@ -0,0 +1,45 @@
+package cc.sukazyo.cono.morny.extra
+
+package object weibo {
+
+ /** Information in weibo status url.
+ *
+ * @param uid Status owner's user id. should be a number.
+ * @param id Status id. Should be unique in the whole weibo.com
+ * globe. Maybe a number format mid, or a base58-like
+ * bid.
+ */
+ case class StatusUrlInfo (
+ uid: String,
+ id: String
+ )
+
+// case class PicUrl (
+// cdn: String,
+// mode: String,
+// pid: String
+// ) {
+// def toUrl: String =
+// s"https://$cdn.singimg.cn/$mode/$pid.jpg"
+// }
+
+ private val REGEX_WEIBO_STATUS_URL = "(?:https?://)?((?:www\\.|m.)?weibo\\.(?:com|cn))/(\\d+)/([0-9a-zA-Z]+)/?(?:\\?(\\S+))?"r
+
+ def parseWeiboStatusUrl (url: String): Option[StatusUrlInfo] =
+ url match
+ case REGEX_WEIBO_STATUS_URL(_, uid, id, _) => Some(StatusUrlInfo(uid, id))
+ case _ => None
+
+ def guessWeiboStatusUrl (text: String): List[StatusUrlInfo] =
+ REGEX_WEIBO_STATUS_URL.findAllMatchIn(text).map(matches => {
+ StatusUrlInfo(matches.group(2), matches.group(3))
+ }).toList
+
+ def genWeiboStatusUrl (url: StatusUrlInfo): String =
+ s"https://weibo.com/${url.uid}/${url.id}"
+
+// def randomPicCdn: String =
+// import scala.util.Random
+// s"wx${Random.nextInt(4)+1}"
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/EpochDateTime.scala b/src/main/scala/cc/sukazyo/cono/morny/util/EpochDateTime.scala
index 0bc623f..5f3d7a2 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/util/EpochDateTime.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/EpochDateTime.scala
@@ -5,9 +5,11 @@ import java.time.format.DateTimeFormatter
object EpochDateTime {
+ /** The UNIX Epoch Time in milliseconds.
+ *
+ * aka. Milliseconds since 00:00:00 UTC on Thursday, 1 January 1970.
+ */
type EpochMillis = Long
- type DurationMillis = Long
-
object EpochMillis:
/** convert a localtime with timezone to epoch milliseconds
*
@@ -26,5 +28,41 @@ object EpochDateTime {
def apply (time_zone: (String, String)): EpochMillis =
time_zone match
case (time, zone) => apply(time, zone)
+
+ /** Convert from [[EpochSeconds]].
+ *
+ * Due to the missing accuracy, the converted EpochMillis will
+ * be always in 0ms aligned.
+ */
+ def fromEpochSeconds (epochSeconds: EpochSeconds): EpochMillis =
+ epochSeconds.longValue * 1000L
+
+ /** The UNIX Epoch Time in seconds.
+ *
+ * aka. Seconds since 00:00:00 UTC on Thursday, 1 January 1970.
+ *
+ * Normally is the epochSeconds = (epochMillis / 1000)
+ *
+ * Notice that, currently, it stores using [[Int]] (also the implementation
+ * method of Telegram), which will only store times before 2038-01-19 03:14:07.
+ */
+ type EpochSeconds = Int
+
+ /** The UNIX Epoch Time in day.
+ *
+ * aka. days since 00:00:00 UTC on Thursday, 1 January 1970.
+ *
+ * Normally is the epochDays = (epochMillis / 1000 / 60 / 60 / 24)
+ *
+ * Notice that, currently, it stores using [[Short]] (also the implementation
+ * method of Telegram), which will only store times before 2059-09-18.
+ */
+ type EpochDays = Short
+ object EpochDays:
+ def fromEpochMillis (epochMillis: EpochMillis): EpochDays =
+ (epochMillis / (1000*60*60*24)).toShort
+
+ /** Time duration/interval in milliseconds. */
+ type DurationMillis = Long
}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/SttpPublic.scala b/src/main/scala/cc/sukazyo/cono/morny/util/SttpPublic.scala
index 241a394..1f338b9 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/util/SttpPublic.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/SttpPublic.scala
@@ -1,5 +1,9 @@
package cc.sukazyo.cono.morny.util
+import cc.sukazyo.cono.morny.MornySystem
+import sttp.client3.basicRequest
+import sttp.model.Header
+
object SttpPublic {
object Schemes {
@@ -7,4 +11,20 @@ object SttpPublic {
val HTTPS = "https"
}
+ object Headers {
+
+ object UserAgent {
+
+ private val key = "User-Agent"
+
+ val MORNY_CURRENT: Header = Header(key, s"MornyCoeur / ${MornySystem.VERSION}")
+
+ }
+
+ }
+
+ val mornyBasicRequest =
+ basicRequest
+ .header(Headers.UserAgent.MORNY_CURRENT, true)
+
}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/UseMath.scala b/src/main/scala/cc/sukazyo/cono/morny/util/UseMath.scala
index 5f11c38..fdbfd1b 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/util/UseMath.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/UseMath.scala
@@ -16,4 +16,9 @@ object UseMath {
def ** (other: Int): Double = Math.pow(self, other)
}
+ extension (base: Int) {
+ def percentageOf (another: Int): Int =
+ Math.round((another.toDouble/base)*100).toInt
+ }
+
}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/CronTask.scala b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/CronTask.scala
new file mode 100644
index 0000000..c6c9607
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/CronTask.scala
@@ -0,0 +1,46 @@
+package cc.sukazyo.cono.morny.util.schedule
+
+import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis
+import com.cronutils.model.time.ExecutionTime
+import com.cronutils.model.Cron
+
+import java.time.{Instant, ZonedDateTime, ZoneId}
+import scala.jdk.OptionConverters.*
+
+trait CronTask extends RoutineTask {
+
+ private lazy val cronCalc = ExecutionTime.forCron(cron)
+
+ def cron: Cron
+
+ def zone: ZoneId
+
+ override def firstRoutineTimeMillis: EpochMillis =
+ cronCalc.nextExecution(
+ ZonedDateTime.ofInstant(
+ Instant.now, zone
+ )
+ ).get.toInstant.toEpochMilli
+
+ override def nextRoutineTimeMillis (previousRoutineScheduledTimeMillis: EpochMillis): EpochMillis | Null =
+ cronCalc.nextExecution(
+ ZonedDateTime.ofInstant(
+ Instant.ofEpochMilli(previousRoutineScheduledTimeMillis),
+ zone
+ )
+ ).toScala match
+ case Some(time) => time.toInstant.toEpochMilli
+ case None => null
+
+}
+
+object CronTask {
+
+ def apply (_name: String, _cron: Cron, _zone: ZoneId, _main: =>Unit): CronTask =
+ new CronTask:
+ override def name: String = _name
+ override def cron: Cron = _cron
+ override def zone: ZoneId = _zone
+ override def main: Unit = _main
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/DelayedTask.scala b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/DelayedTask.scala
new file mode 100644
index 0000000..130c4e3
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/DelayedTask.scala
@@ -0,0 +1,20 @@
+package cc.sukazyo.cono.morny.util.schedule
+
+import cc.sukazyo.cono.morny.util.EpochDateTime.{DurationMillis, EpochMillis}
+
+trait DelayedTask (
+ val delayedMillis: DurationMillis
+) extends Task {
+
+ override val scheduledTimeMillis: EpochMillis = System.currentTimeMillis + delayedMillis
+
+}
+
+object DelayedTask {
+
+ def apply (_name: String, delayedMillis: DurationMillis, task: =>Unit): DelayedTask =
+ new DelayedTask (delayedMillis):
+ override val name: String = _name
+ override def main: Unit = task
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/IntervalTask.scala b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/IntervalTask.scala
new file mode 100644
index 0000000..37667ba
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/IntervalTask.scala
@@ -0,0 +1,27 @@
+package cc.sukazyo.cono.morny.util.schedule
+
+import cc.sukazyo.cono.morny.util.EpochDateTime.{DurationMillis, EpochMillis}
+
+trait IntervalTask extends RoutineTask {
+
+ def intervalMillis: DurationMillis
+
+ override def firstRoutineTimeMillis: EpochMillis =
+ System.currentTimeMillis() + intervalMillis
+
+ override def nextRoutineTimeMillis (
+ previousScheduledRoutineTimeMillis: EpochMillis
+ ): EpochMillis|Null =
+ previousScheduledRoutineTimeMillis + intervalMillis
+
+}
+
+object IntervalTask {
+
+ def apply (_name: String, _intervalMillis: DurationMillis, task: =>Unit): IntervalTask =
+ new IntervalTask:
+ override def intervalMillis: DurationMillis = _intervalMillis
+ override def name: String = _name
+ override def main: Unit = task
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/IntervalWithTimesTask.scala b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/IntervalWithTimesTask.scala
new file mode 100644
index 0000000..5e55a8a
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/IntervalWithTimesTask.scala
@@ -0,0 +1,28 @@
+package cc.sukazyo.cono.morny.util.schedule
+
+import cc.sukazyo.cono.morny.util.EpochDateTime.{DurationMillis, EpochMillis}
+
+trait IntervalWithTimesTask extends IntervalTask {
+
+ def times: Int
+ private var currentExecutedTimes = 1
+
+ override def nextRoutineTimeMillis (previousScheduledRoutineTimeMillis: EpochMillis): EpochMillis | Null =
+ if currentExecutedTimes >= times then
+ null
+ else
+ currentExecutedTimes = currentExecutedTimes + 1
+ super.nextRoutineTimeMillis(previousScheduledRoutineTimeMillis)
+
+}
+
+object IntervalWithTimesTask {
+
+ def apply (_name: String, _intervalMillis: DurationMillis, _times: Int, task: =>Unit): IntervalWithTimesTask =
+ new IntervalWithTimesTask:
+ override def name: String = _name
+ override def times: Int = _times
+ override def intervalMillis: DurationMillis = _intervalMillis
+ override def main: Unit = task
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/RoutineTask.scala b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/RoutineTask.scala
new file mode 100644
index 0000000..d7904ab
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/RoutineTask.scala
@@ -0,0 +1,51 @@
+package cc.sukazyo.cono.morny.util.schedule
+
+import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis
+
+/** The task that can execute multiple times with custom routine function.
+ *
+ * When creating a Routine Task, the task's [[firstRoutineTimeMillis]] function
+ * will be called and the result value will be the first task scheduled time.
+ *
+ * After every execution complete and enter the post effect, the [[nextRoutineTimeMillis]]
+ * function will be called, then its value will be stored as the new task's
+ * scheduled time and re-scheduled by its scheduler.
+ */
+trait RoutineTask extends Task {
+
+ private[schedule] var currentScheduledTimeMillis: Option[EpochMillis] = None
+
+ /** Next running time of this task.
+ *
+ * Should be auto generated from [[firstRoutineTimeMillis]] when this method
+ * is called at first time, and then from [[nextRoutineTimeMillis]] for following
+ * routines controlled by [[Scheduler]].
+ */
+ override def scheduledTimeMillis: EpochMillis =
+ currentScheduledTimeMillis match
+ case Some(time) => time
+ case None =>
+ currentScheduledTimeMillis = Some(firstRoutineTimeMillis)
+ currentScheduledTimeMillis.get
+
+ /** The task scheduled time at initial.
+ *
+ * In the default environment, this function will only be called once
+ * when the task object is just created.
+ */
+ def firstRoutineTimeMillis: EpochMillis
+
+ /** The function to calculate the next scheduled time after previous task
+ * routine complete.
+ *
+ * This function will be called every time the task is done once, in the
+ * task runner thread and the post effect scope.
+ *
+ * @param previousRoutineScheduledTimeMillis The previous task routine's
+ * scheduled time.
+ * @return The next task routine's scheduled time, or [[null]] means end
+ * of the task.
+ */
+ def nextRoutineTimeMillis (previousRoutineScheduledTimeMillis: EpochMillis): EpochMillis|Null
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Scheduler.scala b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Scheduler.scala
new file mode 100644
index 0000000..4a07a24
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Scheduler.scala
@@ -0,0 +1,280 @@
+package cc.sukazyo.cono.morny.util.schedule
+
+import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis
+
+import scala.annotation.targetName
+import scala.collection.mutable
+
+/** Stores some [[Task tasks]] and execute them at time defined in task.
+ *
+ * == Usage ==
+ *
+ * Start a new scheduler instance by create a new Scheduler object, and
+ * the scheduler runner will automatic start to run.
+ *
+ * Using [[Scheduler.++]] or [[Scheduler.schedule]] to add a [[Task]] to
+ * a Scheduler instance.
+ *
+ * If you want to remove a task, use [[Scheduler.%]] or [[Scheduler.cancel]].
+ * Removal task should be the same task object, but not just the same name.
+ *
+ * The scheduler will not automatic stop when the tasks is all done and the
+ * main thread is stopped. You can/should use [[stop]], [[waitForStop]],
+ * [[tagStopAtAllDone]], [[waitForStopAtAllDone]] to async or syncing stop
+ * the scheduler.
+ *
+ * == Implementation details ==
+ *
+ * Inside the Scheduler, the runner's implementation is very similar to
+ * java's [[java.util.Timer]]: There's a task queue sorted by [[Task.scheduledTimeMillis]]
+ * (which is the default order method implemented in [[Task]]), and a
+ * runner getting the most previous task in the queue, and sleep to that
+ * task's execution time.
+ *
+ * Every time the runner is executing a task, it will firstly set its thread name
+ * to [[Task.name]]. After running a task, if the task have some post-process
+ * method (like [[RoutineTask]] will do prepare for next routine), the runner's
+ * thread name will be set to [[Task.name]]#post
. After all of
+ * that, the task is fully complete, and the runner's thread name will be
+ * reset to [[runnerName]].
+ */
+class Scheduler {
+
+ /** Status tag of this scheduler. */
+ //noinspection ScalaWeakerAccess
+ enum State:
+ /** The scheduler is on init stage, have not prepared for running tasks. */
+ case INIT
+ /** The scheduler is managing the task queue, processing the exit signal,
+ * and looking for the next running task. */
+ case PREPARE_RUN
+ /** The scheduler is infinitely waiting due to there's nothing in the task
+ * queue. */
+ case WAITING_EMPTY
+ /** The scheduler is waiting until the next task's running time. */
+ case WAITING
+ /** The scheduler is running a task in the runner. */
+ case RUNNING
+ /** The scheduler is executing a task's post effect. */
+ case RUNNING_POST
+ /** The scheduler have been stopped, will not process any more tasks. */
+ case END
+
+ private val taskList: mutable.TreeSet[Task] = mutable.TreeSet.empty
+ private var exitAtNextRoutine = false
+ private var waitForDone = false
+// private var currentRunning: Task|Null = _
+ private var runtimeStatus = State.INIT
+ private val runtime: Thread = new Thread {
+
+ override def run (): Unit = {
+ def willExit: Boolean =
+ if exitAtNextRoutine then true
+ else if waitForDone then
+ taskList.synchronized:
+ if taskList.isEmpty then true
+ else false
+ else false
+ taskList.synchronized { while (!willExit) {
+
+ runtimeStatus = State.PREPARE_RUN
+
+ val nextMove: Task|EpochMillis|"None" =
+ taskList.headOption match
+ case Some(_readyToRun) if System.currentTimeMillis >= _readyToRun.scheduledTimeMillis =>
+ taskList -= _readyToRun
+// currentRunning = _readyToRun
+ _readyToRun
+ case Some(_notReady) =>
+ _notReady.scheduledTimeMillis - System.currentTimeMillis
+ case None => "None"
+
+ nextMove match
+ case readyToRun: Task =>
+
+ runtimeStatus = State.RUNNING
+ this setName readyToRun.name
+
+ try {
+ readyToRun.main
+ } catch case _: (Exception | Error) => {}
+
+ runtimeStatus = State.RUNNING_POST
+ this setName s"${readyToRun.name}#post"
+
+ // this if is used for check if post effect need to be
+ // run. It is useless since the wait/notify changes.
+ if false then {}
+ else {
+ readyToRun match
+ case routine: RoutineTask =>
+ routine.nextRoutineTimeMillis(routine.currentScheduledTimeMillis.get) match
+ case next: EpochMillis =>
+ routine.currentScheduledTimeMillis = Some(next)
+ schedule(routine)
+ case _ =>
+ case _ =>
+ }
+
+// currentRunning = null
+ this setName runnerName
+
+ case needToWaitMillis: EpochMillis =>
+ runtimeStatus = State.WAITING
+ try taskList.wait(needToWaitMillis)
+ catch case _: (InterruptedException|IllegalArgumentException) => {}
+ case _: "None" =>
+ runtimeStatus = State.WAITING_EMPTY
+ try taskList.wait()
+ catch case _: InterruptedException => {}
+
+ }}
+ runtimeStatus = State.END
+ }
+
+ }
+ runtime setName runnerName
+ runtime.start()
+
+ /** Name of the scheduler runner.
+ * Currently, same with the scheduler [[toString]]
+ */
+ //noinspection ScalaWeakerAccess
+ def runnerName: String =
+ s"${this.getClass.getSimpleName}@${this.hashCode.toHexString}"
+
+ /** Add one task to scheduler task queue.
+ * @return this scheduler for chained call.
+ */
+ @targetName("scheduleIt")
+ def ++ (task: Task): this.type =
+ schedule(task)
+ this
+ /** Add one task to scheduler task queue.
+ * @return [[true]] if the task is added.
+ */
+ def schedule (task: Task): Boolean =
+ taskList.synchronized:
+ try taskList add task
+ finally taskList.notifyAll()
+
+ /** Remove the task from scheduler task queue.
+ *
+ * If the removal task is running, the current run will be done, but will
+ * not do the post effect of the task (like schedule the next routine
+ * of [[RoutineTask]]).
+ *
+ * @return this scheduler for chained call.
+ */
+ @targetName("cancelIt")
+ def % (task: Task): this.type =
+ cancel(task)
+ this
+ /** Remove the task from scheduler task queue.
+ *
+ * If the removal task is running, the method will wait for the current run
+ * complete (and current run post effect complete), then do remove.
+ *
+ * @return [[true]] if the task is in task queue or is running, and have been
+ * succeed removed from task queue.
+ */
+ def cancel (task: Task): Boolean =
+ taskList synchronized:
+ try taskList remove task
+ finally taskList.notifyAll()
+
+ /** Count of tasks in the task queue.
+ *
+ * Do not contains the running task.
+ */
+ def amount: Int =
+ taskList.size
+
+ /** Current [[State status]] */
+ def state: this.State =
+ runtimeStatus
+
+ /** This scheduler's runner thread state */
+ def runnerState: Thread.State =
+ runtime.getState
+
+ /** Manually update the task scheduler.
+ *
+ * If the inner state of the scheduler somehow changed and cannot automatically
+ * update schedule states to schedule the new state, you can call this method
+ * to manually let the task scheduler reschedule it.
+ *
+ * You can also use it with some tick-guard like [[cc.sukazyo.cono.morny.util.time.WatchDog]]
+ * to make the scheduler avoid fails when machine fall asleep or some else conditions.
+ */
+ def notifyIt(): Unit =
+ taskList synchronized:
+ taskList.notifyAll()
+
+ /** Stop the scheduler's runner, no matter how much task is not run yet.
+ *
+ * After call this, it will immediately give a signal to the runner for
+ * stopping it. If the runner is not running any task, it will stop immediately;
+ * If there's one task running, the runner will continue executing until
+ * the current task is done and the current task's post effect is done, then
+ * stop.
+ *
+ * This method is async, means complete this method does not means the
+ * runner is stopped. If you want a sync version, see [[waitForStop]].
+ */
+ def stop (): Unit =
+ taskList synchronized:
+ exitAtNextRoutine = true
+ taskList.notifyAll()
+
+ /** Stop the scheduler's runner, no matter how much task is not run yet,
+ * and wait for the runner stopped.
+ *
+ * It do the same job with [[stop]], the only different is this method
+ * will join the runner thread to wait it stopped.
+ *
+ * @throws InterruptedException if any thread has interrupted the current
+ * thread. The interrupted status of the current
+ * thread is cleared when this exception is thrown.
+ */
+ @throws[InterruptedException]
+ def waitForStop (): Unit =
+ stop()
+ runtime.join()
+
+ /** Tag this scheduler runner stop when all of the scheduler's task in task
+ * queue have been stopped.
+ *
+ * After called this method, the runner will exit when all tasks executed done
+ * and there's no more task can be found in task queue.
+ *
+ * Notice that if there's [[RoutineTask]] in task queue, due to the routine
+ * task will re-enter the task queue in task's post effect stage after executed,
+ * it will cause the task queue will never be empty. You may need to remove all
+ * routine tasks before calling this.
+ *
+ * This method is async, means complete this method does not means the
+ * runner is stopped. If you want a sync version, see [[waitForStopAtAllDone]].
+ */
+ //noinspection ScalaWeakerAccess
+ def tagStopAtAllDone (): Unit =
+ taskList synchronized:
+ waitForDone = true
+ taskList.notifyAll()
+
+ /** Tag this scheduler runner stop when all of the scheduler's task in task
+ * queue have been stopped, and wait for the runner stopped.
+ *
+ * It do the same job with [[tagStopAtAllDone]], the only different is this method
+ * will join the runner thread to wait it stopped.
+ *
+ * @throws InterruptedException if any thread has interrupted the current
+ * thread. The interrupted status of the current
+ * thread is cleared when this exception is thrown.
+ */
+ @throws[InterruptedException]
+ def waitForStopAtAllDone(): Unit =
+ tagStopAtAllDone()
+ runtime.join()
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Task.scala b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Task.scala
new file mode 100644
index 0000000..6250d9a
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Task.scala
@@ -0,0 +1,67 @@
+package cc.sukazyo.cono.morny.util.schedule
+
+import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis
+
+/** A schedule task that can be added to [[Scheduler]].
+ *
+ * Contains some basic task information: [[name]], [[scheduledTimeMillis]],
+ * and [[main]] as the method which will be called.
+ *
+ * Tasks are ordered by time, and makes sure that two different task instance
+ * is NOT THE SAME.
+ *
+ * When comparing two tasks, it will firstly compare the [[scheduledTimeMillis]]:
+ * If the result is the not the same, return it; If the result is the same, then
+ * using [[Object]]'s compare method to compare it.
+ *
+ */
+trait Task extends Ordered[Task] {
+
+ /** Task name. Also the executor thread name when task is executing.
+ *
+ * Will be used in [[Scheduler]] to change the running thread's name.
+ */
+ def name: String
+ /** Next running time.
+ *
+ * If it is smaller than current time, the task should be executed immediately.
+ */
+ def scheduledTimeMillis: EpochMillis
+
+ //noinspection UnitMethodIsParameterless
+ def main: Unit
+
+ override def compare (that: Task): Int =
+ scheduledTimeMillis.compareTo(that.scheduledTimeMillis) match
+ case 0 => this.hashCode - that.hashCode
+ case n => n
+
+ /** Returns this task's object name and the task name.
+ *
+ * for example:
+ * {{{
+ * scala> val task = new Task {
+ * val name = "example-task"
+ * val scheduledTimeMillis = 0
+ * def main = println("example")
+ * }
+ * val task: cc.sukazyo.cono.morny.util.schedule.Task = anon$1@26d8908e{"example-task": 0}
+ *
+ * scala> task.toString
+ * val res0: String = anon$1@26d8908e{"example-task": 0}
+ * }}}
+ */
+ override def toString: String =
+ s"""${super.toString}{"$name": $scheduledTimeMillis}"""
+
+}
+
+object Task {
+
+ def apply (_name: String, _scheduledTime: EpochMillis, _main: =>Unit): Task =
+ new Task:
+ override def name: String = _name
+ override def scheduledTimeMillis: EpochMillis = _scheduledTime
+ override def main: Unit = _main
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/statistics/NumericStatistics.scala b/src/main/scala/cc/sukazyo/cono/morny/util/statistics/NumericStatistics.scala
new file mode 100644
index 0000000..0d1df83
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/statistics/NumericStatistics.scala
@@ -0,0 +1,101 @@
+package cc.sukazyo.cono.morny.util.statistics
+
+import scala.annotation.targetName
+
+/** Statistics for numbers.
+ *
+ * Gives a easy way to get amount of numbers min/max/sum value.
+ *
+ * Use [[++]] to collect a value to statistics, use [[value]] to
+ * get the statistic results.
+ *
+ * @param role The [[Numeric]] implementation of the given number type,
+ * required for numeric calculation.
+ * @tparam T The exactly number type
+ */
+class NumericStatistics [T] (using role: Numeric[T]) {
+
+ /** Statistic state values.
+ *
+ * This class instance should only be used in the statistics manager.
+ * You need to converted it to [[State.Immutable]] version when expose
+ * it (use its [[readonly]] method).
+ *
+ * @param total The sum of all data collected.
+ * @param min The minimal value in the collected data.
+ * @param max The maximize value in the collected data.
+ * @param count total collected data count.
+ */
+ class State (
+ var min: T,
+ var max: T,
+ var total: T,
+ var count: Int
+ ) {
+ /** Generate the [[State.Immutable]] readonly copy for this. */
+ def readonly: State.Immutable = State.Immutable(this)
+ }
+ object State:
+ /** The immutable (readonly) version [[State]]. */
+ class Immutable (source: State):
+ /** @see [[State.min]] */
+ val min: T = source.min
+ /** @see [[State.max]] */
+ val max: T = source.max
+ /** @see [[State.total]] */
+ val total: T = source.total
+ /** @see [[State.count]] */
+ val count: Int = source.count
+
+ private var state: Option[State] = None
+
+ /** Collect a new data to the statistic.
+ * @return The [[NumericStatistics]] itself for chained call.
+ */
+ @targetName("collect")
+ def ++ (newOne: T): this.type =
+ state match
+ case Some(current) =>
+ if (role.lt(newOne, current.min)) current.min = newOne
+ if (role.gt(newOne, current.max)) current.max = newOne
+ current.total = role.plus(current.total, newOne)
+ current.count = current.count + 1
+ case None =>
+ state = Some(new State (
+ min = newOne,
+ max = newOne,
+ total = newOne,
+ count = 1
+ ))
+ this
+
+ /** Reset the statistics to the initial state.
+ *
+ * All the collected data will be drop.
+ */
+ def reset (): Unit =
+ state = None
+
+ /** Get the statistic values.
+ *
+ * @return An [[Option]] contains one [[State.Immutable]] object
+ * which refers the statistic state when call this method.
+ * If the statistic have no data recorded, then it will
+ * be [[None]]
+ */
+ def value: Option[State.Immutable] =
+ state match
+ case Some(v) => Some(v.readonly)
+ case None => None
+
+ /** The number counts in the statistics.
+ *
+ * It will always returns a [[Int]] value, regardless if the
+ * statistic is collected some data.
+ */
+ def count: Int =
+ state match
+ case Some(value) => value.count
+ case None => 0
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/tgapi/TelegramExtensions.scala b/src/main/scala/cc/sukazyo/cono/morny/util/tgapi/TelegramExtensions.scala
index 5e38eeb..d8811b0 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/util/tgapi/TelegramExtensions.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/tgapi/TelegramExtensions.scala
@@ -2,7 +2,7 @@ package cc.sukazyo.cono.morny.util.tgapi
import cc.sukazyo.cono.morny.util.tgapi.event.EventRuntimeException
import com.pengrad.telegrambot.TelegramBot
-import com.pengrad.telegrambot.model.{Chat, ChatMember, User}
+import com.pengrad.telegrambot.model.*
import com.pengrad.telegrambot.request.{BaseRequest, GetChatMember}
import com.pengrad.telegrambot.response.BaseResponse
@@ -12,13 +12,19 @@ object TelegramExtensions {
object Bot { extension (bot: TelegramBot) {
+ @throws[EventRuntimeException]
def exec [T <: BaseRequest[T, R], R <: BaseResponse] (request: T, onError_message: String = ""): R = {
- val response = bot execute request
- if response isOk then return response
- throw EventRuntimeException.ActionFailed(
- if onError_message isEmpty then response.errorCode toString else onError_message,
- response
- )
+ try {
+ val response = bot execute request
+ if response isOk then return response
+ throw EventRuntimeException.ActionFailed(
+ if onError_message isEmpty then response.errorCode toString else onError_message,
+ response
+ )
+ } catch
+ case e: EventRuntimeException.ActionFailed => throw e
+ case e: RuntimeException =>
+ throw EventRuntimeException.ClientFailed(e)
}
}}
@@ -60,6 +66,14 @@ object TelegramExtensions {
}}
+ object Message { extension (self: Message) {
+
+ def entitiesSafe: List[MessageEntity] =
+ if self.entities == null then Nil else
+ self.entities.toList
+
+ }}
+
class LimboUser (id: Long) extends User(id)
class LimboChat (val _id: Long) extends Chat() {
override val id: java.lang.Long = _id
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/tgapi/event/EventRuntimeException.scala b/src/main/scala/cc/sukazyo/cono/morny/util/tgapi/event/EventRuntimeException.scala
index b44e7be..dfc3e86 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/util/tgapi/event/EventRuntimeException.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/tgapi/event/EventRuntimeException.scala
@@ -2,8 +2,26 @@ package cc.sukazyo.cono.morny.util.tgapi.event
import com.pengrad.telegrambot.response.BaseResponse
-class EventRuntimeException (message: String) extends RuntimeException(message)
+/** All possible exception when do Telegram Request.
+ *
+ * Contains following detailed exceptions:
+ * - [[EventRuntimeException.ClientFailed]]
+ * - [[EventRuntimeException.ActionFailed]]
+ */
+abstract class EventRuntimeException (message: String) extends RuntimeException(message)
object EventRuntimeException {
+ /** Telegram API request failed due to the response code is not 200 OK.
+ * @param response Raw API response object.
+ */
class ActionFailed (message: String, val response: BaseResponse) extends EventRuntimeException(message)
+ /** Client exception occurred when sending request.
+ *
+ * It may be some network exception, or parsing API response exception.
+ *
+ * The client exception is stored in [[getCause]].
+ */
+ class ClientFailed (caused: Exception) extends EventRuntimeException("API client failed.") {
+ this.initCause(caused)
+ }
}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/tgapi/formatting/TelegramParseEscape.scala b/src/main/scala/cc/sukazyo/cono/morny/util/tgapi/formatting/TelegramParseEscape.scala
index 4b27795..208646a 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/util/tgapi/formatting/TelegramParseEscape.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/tgapi/formatting/TelegramParseEscape.scala
@@ -1,5 +1,11 @@
package cc.sukazyo.cono.morny.util.tgapi.formatting
+import org.jsoup.Jsoup
+import org.jsoup.nodes.Node
+
+import scala.collection.mutable
+import scala.jdk.CollectionConverters.*
+
object TelegramParseEscape {
def escapeHtml (input: String): String =
@@ -9,4 +15,55 @@ object TelegramParseEscape {
process = process.replaceAll(">", ">")
process
+ def cleanupHtml (input: String): String =
+ import org.jsoup.nodes.*
+ val source = Jsoup.parse(input)
+ val x = cleanupHtml(source.body.childNodes.asScala.toSeq)
+ val doc = Document("")
+ doc.outputSettings
+ .prettyPrint(false)
+ x.map(f => doc.appendChild(f))
+ x.mkString("")
+
+// def toHtmlRaw (input: Node): String =
+// import org.jsoup.nodes.*
+// input match
+// case text: TextNode => text.getWholeText
+// case _: (DataNode | XmlDeclaration | DocumentType | Comment) => ""
+// case elem: Element => elem.childNodes.asScala.map(f => toHtmlRaw(f)).mkString("")
+
+ def cleanupHtml (input: Seq[Node]): List[Node] =
+ val result = mutable.ListBuffer.empty[Node]
+ for (i <- input) {
+ import org.jsoup.nodes.*
+ def produceChildNodes (curr: Element): Element =
+ val newOne = Element(curr.tagName)
+ curr.attributes.forEach(attr => newOne.attr(attr.getKey, attr.getValue))
+ for (i <- cleanupHtml(curr.childNodes.asScala.toSeq)) newOne.appendChild(i)
+ newOne
+ i match
+ case text_cdata: CDataNode => result += CDataNode(text_cdata.text)
+ case text: TextNode => result += TextNode(text.getWholeText)
+ case _: (DataNode | XmlDeclaration | DocumentType | Comment) =>
+ case elem: Element => elem match
+ case _: Document => // should not exists here
+ case _: FormElement => // ignored due to Telegram do not support form
+ case elem => elem.tagName match
+ case "a"|"b"|"strong"|"i"|"em"|"u"|"ins"|"s"|"strike"|"del"|"tg-spoiler"|"code"|"pre" =>
+ result += produceChildNodes(elem)
+ case "br" =>
+ result += TextNode("\n")
+ case "tg-emoji" =>
+ if elem.attributes.hasKey("emoji-id") then
+ result += produceChildNodes(elem)
+ else
+ result += TextNode(elem.text)
+ case "img" =>
+ if elem.attributes hasKey "alt" then
+ result += TextNode(s"[${elem attr "alt"}]")
+ case _ =>
+ for (i <- cleanupHtml(elem.childNodes.asScala.toSeq)) result += i
+ }
+ result.toList
+
}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/tgapi/formatting/TelegramUserInformation.scala b/src/main/scala/cc/sukazyo/cono/morny/util/tgapi/formatting/TelegramUserInformation.scala
index c02c84a..cd8a896 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/util/tgapi/formatting/TelegramUserInformation.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/tgapi/formatting/TelegramUserInformation.scala
@@ -1,7 +1,8 @@
package cc.sukazyo.cono.morny.util.tgapi.formatting
+import cc.sukazyo.cono.morny.util.SttpPublic.mornyBasicRequest
import com.pengrad.telegrambot.model.User
-import sttp.client3.{asString, basicRequest, HttpError, SttpClientException, UriContext}
+import sttp.client3.{asString, HttpError, SttpClientException, UriContext}
import sttp.client3.okhttp.OkHttpSyncBackend
import java.io.IOException
@@ -17,7 +18,7 @@ object TelegramUserInformation {
def getDataCenterFromUser (username: String): String = {
try
- val body = basicRequest
+ val body = mornyBasicRequest
.get(uri"https://t.me/$username")
.response(asString.getRight)
.send(httpClient)
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/time/WatchDog.scala b/src/main/scala/cc/sukazyo/cono/morny/util/time/WatchDog.scala
new file mode 100644
index 0000000..ffa996e
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/time/WatchDog.scala
@@ -0,0 +1,67 @@
+package cc.sukazyo.cono.morny.util.time
+
+import cc.sukazyo.cono.morny.util.EpochDateTime.{DurationMillis, EpochMillis}
+
+trait WatchDog (val isDaemonIt: Boolean = true) extends Thread {
+
+ val threadName: String = "watch-dog"
+ val tickSpeedMillis: DurationMillis = 1000
+ val overloadMillis: DurationMillis = tickSpeedMillis + (tickSpeedMillis/2)
+ private var previousTickTimeMillis: Option[EpochMillis] = None
+
+ this setName threadName
+ this setDaemon isDaemonIt
+
+ this.start()
+
+ override def run(): Unit = {
+ while (!this.isInterrupted) {
+ val currentMillis = System.currentTimeMillis()
+ previousTickTimeMillis match
+ case Some(_previousMillis) =>
+ val consumedMillis = currentMillis - _previousMillis
+ if consumedMillis > overloadMillis then
+ this.overloaded(consumedMillis, consumedMillis - _previousMillis)
+ previousTickTimeMillis = Some(currentMillis)
+ case _ =>
+ previousTickTimeMillis = Some(currentMillis)
+ try Thread.sleep(tickSpeedMillis)
+ catch case _: InterruptedException =>
+ this.interrupt()
+ }
+ }
+
+ def overloaded(consumed: DurationMillis, delayed: DurationMillis): Unit
+
+}
+
+object WatchDog {
+
+ def apply (
+ _threadName: String, _tickSpeedMillis: DurationMillis, _overloadMillis: DurationMillis,
+ overloadedCallback: (DurationMillis, DurationMillis) => Unit
+ ): WatchDog =
+ new WatchDog:
+ override val threadName: String = _threadName
+ override val tickSpeedMillis: DurationMillis = _tickSpeedMillis
+ override val overloadMillis: DurationMillis = _overloadMillis
+ override def overloaded (consumed: DurationMillis, delayed: DurationMillis): Unit = overloadedCallback(consumed, delayed)
+
+ def apply (
+ _threadName: String, _tickSpeedMillis: DurationMillis,
+ overloadedCallback: (DurationMillis, DurationMillis) => Unit
+ ): WatchDog =
+ new WatchDog:
+ override val threadName: String = _threadName
+ override val tickSpeedMillis: DurationMillis = _tickSpeedMillis
+ override def overloaded (consumed: DurationMillis, delayed: DurationMillis): Unit = overloadedCallback(consumed, delayed)
+
+ def apply (
+ _threadName: String,
+ overloadedCallback: (DurationMillis, DurationMillis) => Unit
+ ): WatchDog =
+ new WatchDog:
+ override val threadName: String = _threadName
+ override def overloaded (consumed: DurationMillis, delayed: DurationMillis): Unit = overloadedCallback(consumed, delayed)
+
+}
diff --git a/src/test/scala/cc/sukazyo/cono/morny/test/bot/event/OnQuestionMarkReplyTest.scala b/src/test/scala/cc/sukazyo/cono/morny/test/bot/event/OnQuestionMarkReplyTest.scala
index 1c8de88..38ee81d 100644
--- a/src/test/scala/cc/sukazyo/cono/morny/test/bot/event/OnQuestionMarkReplyTest.scala
+++ b/src/test/scala/cc/sukazyo/cono/morny/test/bot/event/OnQuestionMarkReplyTest.scala
@@ -16,10 +16,19 @@ class OnQuestionMarkReplyTest extends MornyTests with TableDrivenPropertyChecks
("为什么?", false),
("?这不合理", false),
("??尊嘟假嘟", false),
+ (":¿", false),
("?????", true),
+ ("¿", true),
+ ("⁈??", true),
+ ("?!??", false),
+ ("⁇", true),
+ ("‽", true),
+ ("?⸘?", true),
("?", true),
- ("?", true),
- ("??❔", true),
+ ("??", true),
+ ("❔", true),
+ ("❓❓❓", true),
+// ("⁉️", true)
)
forAll(examples) { (text, is) =>
diff --git a/src/test/scala/cc/sukazyo/cono/morny/test/data/BilibiliFormsTest.scala b/src/test/scala/cc/sukazyo/cono/morny/test/extra/BilibiliFormsTest.scala
similarity index 68%
rename from src/test/scala/cc/sukazyo/cono/morny/test/data/BilibiliFormsTest.scala
rename to src/test/scala/cc/sukazyo/cono/morny/test/extra/BilibiliFormsTest.scala
index c3dde75..fd2d1fd 100644
--- a/src/test/scala/cc/sukazyo/cono/morny/test/data/BilibiliFormsTest.scala
+++ b/src/test/scala/cc/sukazyo/cono/morny/test/extra/BilibiliFormsTest.scala
@@ -1,9 +1,8 @@
-package cc.sukazyo.cono.morny.test.data
+package cc.sukazyo.cono.morny.test.extra
-import cc.sukazyo.cono.morny.data.BilibiliForms.*
+import cc.sukazyo.cono.morny.extra.BilibiliForms.*
import cc.sukazyo.cono.morny.test.MornyTests
import org.scalatest.prop.TableDrivenPropertyChecks
-import org.scalatest.tagobjects.{Network, Slow}
class BilibiliFormsTest extends MornyTests with TableDrivenPropertyChecks {
@@ -89,29 +88,34 @@ class BilibiliFormsTest extends MornyTests with TableDrivenPropertyChecks {
}
- "while destruct b23.tv share link :" - {
-
- val examples = Table(
- ("b23_link", "bilibili_video_link"),
- ("https://b23.tv/iiCldvZ", "https://www.bilibili.com/video/BV1Gh411P7Sh?buvid=XY6F25B69BE9CF469FF5B917D012C93E95E72&is_story_h5=false&mid=wD6DQnYivIG5pfA3sAGL6A%3D%3D&p=1&plat_id=114&share_from=ugc&share_medium=android&share_plat=android&share_session_id=8081015b-1210-4dea-a665-6746b4850fcd&share_source=COPY&share_tag=s_i×tamp=1689605644&unique_k=iiCldvZ&up_id=19977489"),
- ("http://b23.tv/3ymowwx", "https://www.bilibili.com/video/BV15Y411n754?p=1&share_medium=android_i&share_plat=android&share_source=COPY&share_tag=s_i×tamp=1650293889&unique_k=3ymowwx")
- )
-
- "not b23.tv link is not supported" in:
- an[IllegalArgumentException] should be thrownBy destructB23Url("sukazyo.cc/2xhUHO2e")
- an[IllegalArgumentException] should be thrownBy destructB23Url("https://sukazyo.cc/2xhUHO2e")
- an[IllegalArgumentException] should be thrownBy destructB23Url("长月烬明澹台烬心理分析向解析(一)因果之锁,渡魔之路")
- an[IllegalArgumentException] should be thrownBy destructB23Url("https://b23.tvb/JDo2eaD")
- an[IllegalArgumentException] should be thrownBy destructB23Url("https://ab23.tv/JDo2eaD")
- "b23.tv/avXXX video link is not supported" in:
- an[IllegalArgumentException] should be thrownBy destructB23Url("https://b23.tv/av123456")
- an[IllegalArgumentException] should be thrownBy destructB23Url("https://b23.tv/BV1Q541167Qg")
-
- forAll (examples) { (origin, result) =>
- s"b23 link $origin should be destructed to $result" taggedAs (Slow, Network) in:
- destructB23Url(origin) shouldEqual result
- }
-
- }
+ // Due to this url is expirable, I have no energy to update links in time.
+ // So I decide to deprecate the tests.
+// "while destruct b23.tv share link :" - {
+//
+// val examples = Table(
+// ("b23_link", "bilibili_video_link"),
+// ("https://b23.tv/iiCldvZ", "https://www.bilibili.com/video/BV1Gh411P7Sh?buvid=XY6F25B69BE9CF469FF5B917D012C93E95E72&is_story_h5=false&mid=wD6DQnYivIG5pfA3sAGL6A%3D%3D&p=1&plat_id=114&share_from=ugc&share_medium=android&share_plat=android&share_session_id=8081015b-1210-4dea-a665-6746b4850fcd&share_source=COPY&share_tag=s_i×tamp=1689605644&unique_k=iiCldvZ&up_id=19977489"),
+// ("https://b23.tv/xWiWFl9", "https://www.bilibili.com/video/BV1N54y1c7us?buvid=XY705C970C2ADBB710C1801E1F45BDC3B9210&is_story_h5=false&mid=w%2B1u1wpibjYsW4pP%2FIo7Ww%3D%3D&p=1&plat_id=116&share_from=ugc&share_medium=android&share_plat=android&share_session_id=6da09711-d601-4da4-bba1-46a4edbb1c60&share_source=COPY&share_tag=s_i×tamp=1680280016&unique_k=xWiWFl9&up_id=275354674"),
+// ("http://b23.tv/uJPIvhv", "https://www.bilibili.com/video/BV1E84y1C7in?is_story_h5=false&p=1&share_from=ugc&share_medium=android&share_plat=android&share_session_id=4a077fa1-5ee2-40d4-ac37-bf9a2bf567e3&share_source=COPY&share_tag=s_i×tamp=1669044671&unique_k=uJPIvhv")
+// // this link have been expired
+//// ("http://b23.tv/3ymowwx", "https://www.bilibili.com/video/BV15Y411n754?p=1&share_medium=android_i&share_plat=android&share_source=COPY&share_tag=s_i×tamp=1650293889&unique_k=3ymowwx")
+// )
+//
+// "not b23.tv link is not supported" in:
+// an[IllegalArgumentException] should be thrownBy destructB23Url("sukazyo.cc/2xhUHO2e")
+// an[IllegalArgumentException] should be thrownBy destructB23Url("https://sukazyo.cc/2xhUHO2e")
+// an[IllegalArgumentException] should be thrownBy destructB23Url("长月烬明澹台烬心理分析向解析(一)因果之锁,渡魔之路")
+// an[IllegalArgumentException] should be thrownBy destructB23Url("https://b23.tvb/JDo2eaD")
+// an[IllegalArgumentException] should be thrownBy destructB23Url("https://ab23.tv/JDo2eaD")
+// "b23.tv/avXXX video link is not supported" in:
+// an[IllegalArgumentException] should be thrownBy destructB23Url("https://b23.tv/av123456")
+// an[IllegalArgumentException] should be thrownBy destructB23Url("https://b23.tv/BV1Q541167Qg")
+//
+// forAll (examples) { (origin, result) =>
+// s"b23 link $origin should be destructed to $result" taggedAs (Slow, Network) in:
+// destructB23Url(origin) shouldEqual result
+// }
+//
+// }
}
diff --git a/src/test/scala/cc/sukazyo/cono/morny/test/extra/twitter/FXApiTest.scala b/src/test/scala/cc/sukazyo/cono/morny/test/extra/twitter/FXApiTest.scala
new file mode 100644
index 0000000..7a84054
--- /dev/null
+++ b/src/test/scala/cc/sukazyo/cono/morny/test/extra/twitter/FXApiTest.scala
@@ -0,0 +1,82 @@
+package cc.sukazyo.cono.morny.test.extra.twitter
+
+import cc.sukazyo.cono.morny.extra.twitter.FXApi
+import cc.sukazyo.cono.morny.extra.twitter.FXApi.Fetch
+import cc.sukazyo.cono.morny.test.MornyTests
+import org.scalatest.prop.TableDrivenPropertyChecks
+import org.scalatest.tagobjects.{Network, Slow}
+
+//noinspection ScalaUnusedExpression
+class FXApiTest extends MornyTests with TableDrivenPropertyChecks {
+
+ "while fetch status (tweet) :" - {
+
+ "non exists tweet id should return 404" taggedAs (Slow, Network) in {
+ val api = Fetch.status(Some("some_non_exists"), "-1")
+ api.code shouldEqual 404
+ api.message shouldEqual "NOT_FOUND"
+ api.tweet shouldBe empty
+ }
+
+ /** It should return 401, but in practice it seems will only
+ * return 404.
+ */
+ "private tweet should return 410 or 404" taggedAs (Slow, Network) in {
+ val api = Fetch.status(Some("_takiChan"), "1586671758999924736")
+ api.code should (equal (404) or equal (401))
+ api.code match
+ case 401 =>
+ api.message shouldEqual "PRIVATE_TWEET"
+ note("from private tweet got 401 PRIVATE_TWEET")
+ case 404 =>
+ api.message shouldEqual "NOT_FOUND"
+ note("from private tweet got 404 NOT_FOUND")
+ api.tweet shouldBe empty
+ }
+
+ val examples = Table[(Option[String], String), FXApi =>Unit](
+ ("id", "checking"),
+ ((Some("_Eyre_S"), "1669362743332438019"), api => {
+ api.tweet shouldBe defined
+ api.tweet.get.text shouldEqual "猫头猫头鹰头猫头鹰头猫头鹰"
+ api.tweet.get.quote shouldBe defined
+ api.tweet.get.quote.get.id shouldEqual "1669302279386828800"
+ }),
+ ((None, "1669362743332438019"), api => {
+ api.tweet shouldBe defined
+ api.tweet.get.text shouldEqual "猫头猫头鹰头猫头鹰头猫头鹰"
+ api.tweet.get.quote shouldBe defined
+ api.tweet.get.quote.get.id shouldEqual "1669302279386828800"
+ }),
+ ((None, "1654080016802807809"), api => {
+ api.tweet shouldBe defined
+ api.tweet.get.media shouldBe defined
+ api.tweet.get.media.get.videos shouldBe empty
+ api.tweet.get.media.get.photos shouldBe defined
+ api.tweet.get.media.get.photos.get.length shouldBe 1
+ api.tweet.get.media.get.photos.get.head.width shouldBe 2048
+ api.tweet.get.media.get.photos.get.head.height shouldBe 1536
+ api.tweet.get.media.get.mosaic shouldBe empty
+ }),
+ ((None, "1538536152093044736"), api => {
+ api.tweet shouldBe defined
+ api.tweet.get.media shouldBe defined
+ api.tweet.get.media.get.videos shouldBe empty
+ api.tweet.get.media.get.photos shouldBe defined
+ api.tweet.get.media.get.photos.get.length shouldBe 2
+ api.tweet.get.media.get.photos.get.head.width shouldBe 2894
+ api.tweet.get.media.get.photos.get.head.height shouldBe 4093
+ api.tweet.get.media.get.photos.get(1).width shouldBe 2894
+ api.tweet.get.media.get.photos.get(1).height shouldBe 4093
+ api.tweet.get.media.get.mosaic shouldBe defined
+ })
+ )
+ forAll(examples) { (data, assertion) =>
+ s"tweet $data should be fetched successful" taggedAs (Slow, Network) in {
+ assertion(Fetch.status(data._1, data._2))
+ }
+ }
+
+ }
+
+}
diff --git a/src/test/scala/cc/sukazyo/cono/morny/test/extra/twitter/PackageTest.scala b/src/test/scala/cc/sukazyo/cono/morny/test/extra/twitter/PackageTest.scala
new file mode 100644
index 0000000..e65189e
--- /dev/null
+++ b/src/test/scala/cc/sukazyo/cono/morny/test/extra/twitter/PackageTest.scala
@@ -0,0 +1,123 @@
+package cc.sukazyo.cono.morny.test.extra.twitter
+
+import cc.sukazyo.cono.morny.extra.twitter.{parseTweetUrl, TweetUrlInformation}
+import cc.sukazyo.cono.morny.test.MornyTests
+
+class PackageTest extends MornyTests {
+
+ "while parsing tweet url :" - {
+
+ "normal twitter tweet share url should be parsed" in {
+ parseTweetUrl("https://twitter.com/ps_urine/status/1727614825755505032?s=20")
+ .shouldEqual(Some(TweetUrlInformation(
+ "twitter.com", "ps_urine/status/1727614825755505032",
+ "ps_urine", "1727614825755505032",
+ None, Some("s=20")
+ )))
+ }
+
+ "normal X.com tweet share url should be parsed" in {
+ parseTweetUrl("https://x.com/ps_urine/status/1727614825755505032?s=20")
+ .shouldBe(defined)
+ }
+
+ "X.com or twitter tweet share url should not www.sensitive" in {
+ parseTweetUrl("https://www.twitter.com/ps_urine/status/1727614825755505032?s=20")
+ .shouldBe(defined)
+ parseTweetUrl("https://www.x.com/ps_urine/status/1727614825755505032?s=20")
+ .shouldBe(defined)
+ }
+
+ "fxtwitter and fixupx url should be parsed" in {
+ parseTweetUrl("https://fxtwitter.com/ps_urine/status/1727614825755505032?s=20")
+ .shouldBe(defined)
+ parseTweetUrl("https://fixupx.com/ps_urine/status/1727614825755505032?s=20")
+ .shouldBe(defined)
+ }
+ "vxtwitter should be parsed and can be with c." in {
+ parseTweetUrl("https://vxtwitter.com/ps_urine/status/1727614825755505032?s=20")
+ .shouldBe(defined)
+ parseTweetUrl("https://c.vxtwitter.com/ps_urine/status/1727614825755505032?s=20")
+ .shouldBe(defined)
+ }
+ "fixvx should be parsed and cannot be with c." in {
+ parseTweetUrl("https://fixvx.com/ps_urine/status/1727614825755505032?s=20")
+ .shouldBe(defined)
+ parseTweetUrl("https://c.fixvx.com/ps_urine/status/1727614825755505032?s=20")
+ .shouldBe(empty)
+ }
+
+ "fxtwitter and vxtwitter should not contains www." in {
+ parseTweetUrl("https://www.fxtwitter.com/ps_urine/status/1727614825755505032?s=20")
+ .shouldBe(empty)
+ parseTweetUrl("https://www.fixupx.com/ps_urine/status/1727614825755505032?s=20")
+ .shouldBe(empty)
+ parseTweetUrl("https://www.vxtwitter.com/ps_urine/status/1727614825755505032?s=20")
+ .shouldBe(empty)
+ parseTweetUrl("https://www.fixvx.com/ps_urine/status/1727614825755505032?s=20")
+ .shouldBe(empty)
+ }
+
+ "url should be http/s non-sensitive" in {
+ parseTweetUrl("twitter.com/ps_urine/status/1727614825755505032?s=20")
+ .shouldBe(defined)
+ parseTweetUrl("http://x.com/ps_urine/status/1727614825755505032?s=20")
+ .shouldBe(defined)
+ parseTweetUrl("http://fxtwitter.com/ps_urine/status/1727614825755505032?s=20")
+ .shouldBe(defined)
+ parseTweetUrl("http://fixupx.com/ps_urine/status/1727614825755505032?s=20")
+ .shouldBe(defined)
+ parseTweetUrl("vxtwitter.com/ps_urine/status/1727614825755505032?s=20")
+ .shouldBe(defined)
+ parseTweetUrl("fixvx.com/ps_urine/status/1727614825755505032?s=20")
+ .shouldBe(defined)
+ }
+
+ "url param should be non-sensitive" in {
+ parseTweetUrl("twitter.com/ps_urine/status/1727614825755505032")
+ .shouldBe(defined)
+ parseTweetUrl("http://x.com/ps_urine/status/1727614825755505032/?q=ajisdl&form=ANNNB1&refig=5883b79c966b4881b79b50cb6f1c6c6a")
+ .shouldBe(defined)
+ parseTweetUrl("http://fxtwitter.com/ps_urine/status/1727614825755505032/?s=20")
+ .shouldBe(defined)
+ parseTweetUrl("http://fixupx.com/ps_urine/status/1727614825755505032?s=20")
+ .shouldBe(defined)
+ parseTweetUrl("vxtwitter.com/ps_urine/status/1727614825755505032")
+ .shouldBe(defined)
+ parseTweetUrl("fixvx.com/ps_urine/status/1727614825755505032?q=ajisdl&form=ANNNB1&refig=5883b79c966b4881b79b50cb6f1c6c6a")
+ .shouldBe(defined)
+ }
+
+ "screen name should not be non-exists" in {
+ parseTweetUrl("twitter.com/status/1727614825755505032")
+ .shouldBe(empty)
+ parseTweetUrl("http://x.com/status/1727614825755505032/?q=ajisdl&form=ANNNB1&refig=5883b79c966b4881b79b50cb6f1c6c6a")
+ .shouldBe(empty)
+ parseTweetUrl("http://fxtwitter.com/status/1727614825755505032/?s=20")
+ .shouldBe(empty)
+ parseTweetUrl("http://fixupx.com/status/1727614825755505032?s=20")
+ .shouldBe(empty)
+ parseTweetUrl("vxtwitter.com/status/1727614825755505032")
+ .shouldBe(empty)
+ parseTweetUrl("fixvx.com/status/1727614825755505032?q=ajisdl&form=ANNNB1&refig=5883b79c966b4881b79b50cb6f1c6c6a")
+ .shouldBe(empty)
+ }
+
+ "url with photo id should be parsed" in {
+ parseTweetUrl("twitter.com/ps_urine/status/1727614825755505032/photo/2")
+ .should(matchPattern { case Some(TweetUrlInformation(_, _, _, _, Some("2"), _)) => })
+ parseTweetUrl("http://x.com/ps_urine/status/1727614825755505032/photo/1/?q=ajisdl&form=ANNNB1&refig=5883b79c966b4881b79b50cb6f1c6c6a")
+ .should(matchPattern { case Some(TweetUrlInformation(_, _, _, _, Some("1"), _)) => })
+ parseTweetUrl("http://fxtwitter.com/ps_urine/status/1727614825755505032/photo/4/?s=20")
+ .should(matchPattern { case Some(TweetUrlInformation(_, _, _, _, Some("4"), _)) => })
+ parseTweetUrl("http://fixupx.com/ps_urine/status/1727614825755505032/photo/7?s=20")
+ .should(matchPattern { case Some(TweetUrlInformation(_, _, _, _, Some("7"), _)) => })
+ parseTweetUrl("vxtwitter.com/ps_urine/status/1727614825755505032/photo/114514")
+ .should(matchPattern { case Some(TweetUrlInformation(_, _, _, _, Some("114514"), _)) => })
+ parseTweetUrl("fixvx.com/ps_urine/status/1727614825755505032/photo/unavailable-id?q=ajisdl&form=ANNNB1&refig=5883b79c966b4881b79b50cb6f1c6c6a")
+ .shouldBe(empty)
+ }
+
+ }
+
+}
diff --git a/src/test/scala/cc/sukazyo/cono/morny/test/utils/EpochDateTimeTest.scala b/src/test/scala/cc/sukazyo/cono/morny/test/utils/EpochDateTimeTest.scala
index a0d218a..a766428 100644
--- a/src/test/scala/cc/sukazyo/cono/morny/test/utils/EpochDateTimeTest.scala
+++ b/src/test/scala/cc/sukazyo/cono/morny/test/utils/EpochDateTimeTest.scala
@@ -1,11 +1,55 @@
package cc.sukazyo.cono.morny.test.utils
import cc.sukazyo.cono.morny.test.MornyTests
-import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis
+import cc.sukazyo.cono.morny.util.EpochDateTime.{EpochDays, EpochMillis, EpochSeconds}
import org.scalatest.prop.TableDrivenPropertyChecks
class EpochDateTimeTest extends MornyTests with TableDrivenPropertyChecks {
+ "while converting to EpochMillis :" - {
+
+ "from EpochSeconds :" - {
+
+ val examples = Table[EpochSeconds, EpochMillis](
+ ("EpochSeconds", "EpochMillis"),
+ (1699176068, 1699176068000L),
+ (1699176000, 1699176000000L),
+ (1, 1000L),
+ )
+
+ forAll(examples) { (epochSeconds, epochMillis) =>
+ s"EpochSeconds($epochSeconds) should be converted to EpochMillis($epochMillis)" in {
+ (EpochMillis fromEpochSeconds epochSeconds) shouldEqual epochMillis
+ }
+ }
+
+ }
+
+ }
+
+ "while converting to EpochDays :" - {
+
+ "from EpochMillis :" - {
+
+ val examples = Table(
+ ("EpochMillis", "EpochDays"),
+ (0L, 0),
+ (1000L, 0),
+ (80000000L, 0),
+ (90000000L, 1),
+ (1699176549059L, 19666)
+ )
+
+ forAll(examples) { (epochMillis, epochDays) =>
+ s"EpochMillis($epochMillis) should be converted to EpochDays($epochDays)" in {
+ (EpochDays fromEpochMillis epochMillis) shouldEqual epochDays
+ }
+ }
+
+ }
+
+ }
+
"while converting date-time string to time-millis : " - {
"while using ISO-Offset-Date-Time : " - {
diff --git a/src/test/scala/cc/sukazyo/cono/morny/test/utils/schedule/CronTaskTest.scala b/src/test/scala/cc/sukazyo/cono/morny/test/utils/schedule/CronTaskTest.scala
new file mode 100644
index 0000000..4ace71c
--- /dev/null
+++ b/src/test/scala/cc/sukazyo/cono/morny/test/utils/schedule/CronTaskTest.scala
@@ -0,0 +1,51 @@
+package cc.sukazyo.cono.morny.test.utils.schedule
+
+import cc.sukazyo.cono.morny.test.MornyTests
+import cc.sukazyo.cono.morny.util.schedule.{CronTask, Scheduler}
+import cc.sukazyo.cono.morny.util.CommonFormat.formatDate
+import com.cronutils.builder.CronBuilder
+import com.cronutils.model.definition.CronDefinitionBuilder
+import com.cronutils.model.field.expression.FieldExpressionFactory as C
+import com.cronutils.model.time.ExecutionTime
+import org.scalatest.tagobjects.Slow
+
+import java.lang.System.currentTimeMillis
+import java.time.{ZonedDateTime, ZoneOffset}
+import java.time.temporal.ChronoUnit
+
+class CronTaskTest extends MornyTests {
+
+ "cron task works fine" taggedAs Slow in {
+
+ val scheduler = Scheduler()
+ val cronSecondly =
+ CronBuilder.cron(
+ CronDefinitionBuilder.defineCron
+ .withSeconds.and
+ .instance
+ ).withSecond(C.every(1)).instance
+ Thread.sleep(
+ ExecutionTime.forCron(cronSecondly)
+ .timeToNextExecution(ZonedDateTime.now)
+ .get.get(ChronoUnit.NANOS)/1000
+ ) // aligned current time to millisecond 000
+ note(s"CronTask test time aligned to ${formatDate(currentTimeMillis, 0)}")
+
+ var times = 0
+ val task = CronTask("cron-task", cronSecondly, ZoneOffset.ofHours(0).normalized, {
+ times = times + 1
+ note(s"CronTask executed at ${formatDate(currentTimeMillis, 0)}")
+ })
+ scheduler ++ task
+ Thread.sleep(10300)
+
+ // it should be at 300ms position to 10 seconds
+
+ scheduler % task
+ scheduler.stop()
+ note(s"CronTasks done at ${formatDate(currentTimeMillis, 0)}")
+ times shouldEqual 10
+
+ }
+
+}
diff --git a/src/test/scala/cc/sukazyo/cono/morny/test/utils/schedule/IntervalsTest.scala b/src/test/scala/cc/sukazyo/cono/morny/test/utils/schedule/IntervalsTest.scala
new file mode 100644
index 0000000..da49874
--- /dev/null
+++ b/src/test/scala/cc/sukazyo/cono/morny/test/utils/schedule/IntervalsTest.scala
@@ -0,0 +1,23 @@
+package cc.sukazyo.cono.morny.test.utils.schedule
+
+import cc.sukazyo.cono.morny.test.MornyTests
+import cc.sukazyo.cono.morny.util.schedule.{IntervalWithTimesTask, Scheduler}
+import org.scalatest.tagobjects.Slow
+
+class IntervalsTest extends MornyTests {
+
+ "IntervalWithTimesTest should work even scheduler is scheduled to stop" taggedAs Slow in {
+ val scheduler = Scheduler()
+ var times = 0
+ scheduler ++ IntervalWithTimesTask("intervals-10", 200, 10, {
+ times = times + 1
+ })
+ val startTime = System.currentTimeMillis()
+ scheduler.waitForStopAtAllDone()
+ val timeUsed = System.currentTimeMillis() - startTime
+ times shouldEqual 10
+ timeUsed should (be <= 2100L and be >= 1900L)
+ info(s"Interval Task with interval 200ms for 10 times used time ${timeUsed}ms")
+ }
+
+}
diff --git a/src/test/scala/cc/sukazyo/cono/morny/test/utils/schedule/SchedulerTest.scala b/src/test/scala/cc/sukazyo/cono/morny/test/utils/schedule/SchedulerTest.scala
new file mode 100644
index 0000000..c0383b0
--- /dev/null
+++ b/src/test/scala/cc/sukazyo/cono/morny/test/utils/schedule/SchedulerTest.scala
@@ -0,0 +1,49 @@
+package cc.sukazyo.cono.morny.test.utils.schedule
+
+import cc.sukazyo.cono.morny.test.MornyTests
+import cc.sukazyo.cono.morny.util.schedule.{DelayedTask, Scheduler, Task}
+import org.scalatest.tagobjects.Slow
+
+import scala.collection.mutable
+
+class SchedulerTest extends MornyTests {
+
+ "While executing tasks using scheduler :" - {
+
+ "Task with scheduleTime smaller than current time should be executed immediately" in {
+ val scheduler = Scheduler()
+ val time = System.currentTimeMillis
+ var doneTime: Option[Long] = None
+ scheduler ++ Task("task", 0, {
+ doneTime = Some(System.currentTimeMillis)
+ })
+ Thread.sleep(10)
+ scheduler.stop()
+ doneTime shouldBe defined
+ info(s"Immediately Task done in ${doneTime.get - time}ms")
+ }
+
+ "Task's running thread name should be task name" in {
+ val scheduler = Scheduler()
+ var executedThread: Option[String] = None
+ scheduler ++ Task("task", 0, {
+ executedThread = Some(Thread.currentThread.getName)
+ })
+ scheduler.waitForStopAtAllDone()
+ executedThread shouldEqual Some("task")
+ }
+
+ "Task's execution order should be ordered by task Ordering but not insert order" taggedAs Slow in {
+ val scheduler = Scheduler()
+ val result = mutable.ArrayBuffer.empty[String]
+ scheduler
+ ++ DelayedTask("task-later", 400L, { result += Thread.currentThread.getName })
+ ++ DelayedTask("task-very-late", 800L, { result += Thread.currentThread.getName })
+ ++ DelayedTask("task-early", 100L, { result += Thread.currentThread.getName })
+ scheduler.waitForStopAtAllDone()
+ result.toArray shouldEqual Array("task-early", "task-later", "task-very-late")
+ }
+
+ }
+
+}
diff --git a/src/test/scala/cc/sukazyo/cono/morny/test/utils/schedule/TaskBasicTest.scala b/src/test/scala/cc/sukazyo/cono/morny/test/utils/schedule/TaskBasicTest.scala
new file mode 100644
index 0000000..0883040
--- /dev/null
+++ b/src/test/scala/cc/sukazyo/cono/morny/test/utils/schedule/TaskBasicTest.scala
@@ -0,0 +1,46 @@
+package cc.sukazyo.cono.morny.test.utils.schedule
+
+import cc.sukazyo.cono.morny.test.MornyTests
+import cc.sukazyo.cono.morny.util.schedule.Task
+import org.scalatest.tagobjects.Slow
+
+class TaskBasicTest extends MornyTests {
+
+ "while comparing tasks :" - {
+
+ "tasks with different scheduleTime should be compared using scheduledTime" in {
+ Task("task-a", 21747013400912L, {}) should be > Task("task-b", 21747013400138L, {})
+ Task("task-a", 100L, {}) should be > Task("task-b", 99L, {})
+ Task("task-a", 100L, {}) should be < Task("task-b", 101, {})
+ Task("task-a", -19943L, {}) should be < Task("task-b", 0L, {})
+ }
+
+ "task with the same scheduledTime should not be equal" in {
+ Task("same-task?", 0L, {}) should not equal Task("same-task?", 0L, {})
+ }
+
+ "tasks which is only the same object should be equal" in {
+ def createNewTask = Task("same-task?", 0L, {})
+ val task1 = createNewTask
+ val task2 = createNewTask
+ val task1_copy = task1
+ task1 shouldEqual task1_copy
+ task1 should not equal task2
+ }
+
+ }
+
+ "task can be sync executed by calling its main method." taggedAs Slow in {
+
+ Thread.currentThread setName "parent-thread"
+ val data = StringBuilder("")
+ val task = Task("some-task", 0L, {
+ Thread.sleep(100)
+ data ++= Thread.currentThread.getName ++= " // " ++= "task-complete"
+ })
+ task.main
+ data.toString shouldEqual "parent-thread // task-complete"
+
+ }
+
+}