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 dinnerTrustedReaders = new HashSet<>(); public long dinnerChatId = -1L; public long reportToChat = -1L; + @Nonnull public TimeZone reportZone = TimeZone.getDefault(); public long medicationNotifyToChat = -1L; @Nonnull public ZoneOffset medicationTimerUseTimezone = ZoneOffset.UTC; @Nonnull public final Set medicationNotifyAt = new HashSet<>(); diff --git a/src/main/scala/cc/sukazyo/cono/morny/ServerMain.scala b/src/main/scala/cc/sukazyo/cono/morny/ServerMain.scala index e4f7870..e067cba 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/ServerMain.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/ServerMain.scala @@ -51,6 +51,7 @@ object ServerMain { case "--master" | "-mm" => i+=1 ; config.trustedMaster = args(i)toLong case "--trusted-chat" | "-trs" => i+=1 ; config.trustedChat = args(i)toLong case "--report-to" => i+=1; config.reportToChat = args(i)toLong + case "--report-zone" => i+=1; config.reportZone = TimeZone.getTimeZone(args(i)) case "--trusted-reader-dinner" | "-trsd" => i+=1 ; config.dinnerTrustedReaders add (args(i)toLong) case "--dinner-chat" | "-chd" => i+=1 ; config.dinnerChatId = args(i)toLong diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventEnv.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventEnv.scala index 6cc7dea..9061a1f 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventEnv.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventEnv.scala @@ -1,8 +1,11 @@ package cc.sukazyo.cono.morny.bot.api +import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis +import cc.sukazyo.messiva.utils.StackUtils import com.pengrad.telegrambot.model.Update import scala.collection.mutable +import scala.reflect.{classTag, ClassTag} class EventEnv ( @@ -10,14 +13,34 @@ class EventEnv ( ) { - private var _isOk: Int = 0 - private val variables: mutable.HashMap[Class[?], Any] = mutable.HashMap.empty + trait StateSource (val from: StackTraceElement) + enum State: + case OK (_from: StackTraceElement) extends State with StateSource(_from) + case CANCELED (_from: StackTraceElement) extends State with StateSource(_from) - def isEventOk: Boolean = _isOk > 0 + private val _status: mutable.ListBuffer[State] = mutable.ListBuffer.empty + private val variables: mutable.HashMap[Class[?], Any] = mutable.HashMap.empty + val timeStartup: EpochMillis = System.currentTimeMillis + + def isEventOk: Boolean = _status.lastOption match + case Some(x) if x == State.OK => true + case _ => false //noinspection UnitMethodIsParameterless def setEventOk: Unit = - _isOk = _isOk + 1 + _status += State.OK(StackUtils.getStackTrace(1)(1)) + + //noinspection UnitMethodIsParameterless + def setEventCanceled: Unit = + _status += State.CANCELED(StackUtils.getStackTrace(1)(1)) + + def state: State|Null = + _status.lastOption match + case Some(x) => x + case None => null + + def status: List[State] = + _status.toList def provide (i: Any): Unit = variables += (i.getClass -> i) @@ -28,6 +51,11 @@ class EventEnv ( case None => ConsumeResult(false) } + def consume [T: ClassTag] (consumer: T => Unit): ConsumeResult = + variables get classTag[T].runtimeClass match + case Some(i) => consumer(i.asInstanceOf[T]); ConsumeResult(true) + case None => ConsumeResult(false) + class ConsumeResult (success: Boolean) { def onfail (processor: => Unit): Unit = { if !success then processor diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventListener.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventListener.scala index 3c2fbb9..fa7c490 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventListener.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventListener.scala @@ -2,6 +2,32 @@ package cc.sukazyo.cono.morny.bot.api trait EventListener () { + /** Determine if this event listener should be processed. + * + * Default implementation is it only be [[true]] when the event + * is not ok yet (when [[EventEnv.isEventOk]] is false). + * + * Notice that: You should not override this method to filter some + * affair level conditions (such as if this update contains a text + * message), you should write them to the listener function! This + * method is just for event low-level controls. + * + * @param env The [[EventEnv event variable]]. + * @return [[true]] if this event listener should run; [[false]] + * if it should not run. + */ + def executeFilter (using env: EventEnv): Boolean = + if env.state == null then true else false + + /** Run at all event listeners' listen methods done. + * + * Listen methods is the methods defined in [[EventListener this]] + * trait starts with `on`. + * + * This method will always run no matter the result of [[executeFilter]] + */ + def atEventPost (using EventEnv): Unit = {} + def onMessage (using EventEnv): Unit = {} def onEditedMessage (using EventEnv): Unit = {} def onChannelPost (using EventEnv): Unit = {} diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventListenerManager.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventListenerManager.scala index de846ab..7a810f4 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventListenerManager.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventListenerManager.scala @@ -9,7 +9,6 @@ import com.pengrad.telegrambot.UpdatesListener import scala.collection.mutable import scala.language.postfixOps -import scala.util.boundary /** Contains a [[mutable.Queue]] of [[EventListener]], and delivery telegram [[Update]]. * @@ -30,53 +29,66 @@ class EventListenerManager (using coeur: MornyCoeur) extends UpdatesListener { this setName s"upd-${update.updateId()}-$t" override def run (): Unit = { + given env: EventEnv = EventEnv(update) - boundary { for (i <- listeners) { - try { - updateThreadName("message") - if update.message ne null then i.onMessage - updateThreadName("edited-message") - if update.editedMessage ne null then i.onEditedMessage - updateThreadName("channel-post") - if update.channelPost ne null then i.onChannelPost - updateThreadName("edited-channel-post") - if update.editedChannelPost ne null then i.onEditedChannelPost - updateThreadName("inline-query") - if update.inlineQuery ne null then i.onInlineQuery - updateThreadName("chosen-inline-result") - if update.chosenInlineResult ne null then i.onChosenInlineResult - updateThreadName("callback-query") - if update.callbackQuery ne null then i.onCallbackQuery - updateThreadName("shipping-query") - if update.shippingQuery ne null then i.onShippingQuery - updateThreadName("pre-checkout-query") - if update.preCheckoutQuery ne null then i.onPreCheckoutQuery - updateThreadName("poll") - if update.poll ne null then i.onPoll - updateThreadName("poll-answer") - if update.pollAnswer ne null then i.onPollAnswer - updateThreadName("my-chat-member") - if update.myChatMember ne null then i.onMyChatMemberUpdated - updateThreadName("chat-member") - if update.chatMember ne null then i.onChatMemberUpdated - updateThreadName("chat-join-request") - if update.chatJoinRequest ne null then i.onChatJoinRequest - } catch case e => { - val errorMessage = StringBuilder() - errorMessage ++= "Event throws unexpected exception:\n" - errorMessage ++= (exceptionLog(e) indent 4) - e match - case actionFailed: EventRuntimeException.ActionFailed => - errorMessage ++= "\ntg-api action: response track: " - errorMessage ++= (GsonBuilder().setPrettyPrinting().create().toJson( - actionFailed.response - ) indent 4) ++= "\n" - case _ => - logger error errorMessage.toString - coeur.daemons.reporter.exception(e, "on event running") - } - if env.isEventOk then boundary.break() - }} + + for (i <- listeners) + if (i.executeFilter) + runEventListener(i) + for (i <- listeners) + runEventPost(i) + + } + + private def runEventPost (i: EventListener)(using EventEnv): Unit = { + updateThreadName("#post") + i.atEventPost + } + + private def runEventListener (i: EventListener)(using EventEnv): Unit = { + try { + updateThreadName("message") + if update.message ne null then i.onMessage + updateThreadName("edited-message") + if update.editedMessage ne null then i.onEditedMessage + updateThreadName("channel-post") + if update.channelPost ne null then i.onChannelPost + updateThreadName("edited-channel-post") + if update.editedChannelPost ne null then i.onEditedChannelPost + updateThreadName("inline-query") + if update.inlineQuery ne null then i.onInlineQuery + updateThreadName("chosen-inline-result") + if update.chosenInlineResult ne null then i.onChosenInlineResult + updateThreadName("callback-query") + if update.callbackQuery ne null then i.onCallbackQuery + updateThreadName("shipping-query") + if update.shippingQuery ne null then i.onShippingQuery + updateThreadName("pre-checkout-query") + if update.preCheckoutQuery ne null then i.onPreCheckoutQuery + updateThreadName("poll") + if update.poll ne null then i.onPoll + updateThreadName("poll-answer") + if update.pollAnswer ne null then i.onPollAnswer + updateThreadName("my-chat-member") + if update.myChatMember ne null then i.onMyChatMemberUpdated + updateThreadName("chat-member") + if update.chatMember ne null then i.onChatMemberUpdated + updateThreadName("chat-join-request") + if update.chatJoinRequest ne null then i.onChatJoinRequest + } catch case e => { + val errorMessage = StringBuilder() + errorMessage ++= "Event throws unexpected exception:\n" + errorMessage ++= (exceptionLog(e) indent 4) + e match + case actionFailed: EventRuntimeException.ActionFailed => + errorMessage ++= "\ntg-api action: response track: " + errorMessage ++= (GsonBuilder().setPrettyPrinting().create().toJson( + actionFailed.response + ) indent 4) ++= "\n" + case _ => + logger error errorMessage.toString + coeur.daemons.reporter.exception(e, "on event running") + } } } diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/command/Encryptor.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/command/Encryptor.scala index b63dde4..080466e 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/bot/command/Encryptor.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/bot/command/Encryptor.scala @@ -196,6 +196,7 @@ class Encryptor (using coeur: MornyCoeur) extends ITelegramCommand { import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.escapeHtml as h coeur.account exec SendMessage( event.message.chat.id, + // language=html s"

${h(_text.text)}
" ).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 = "" + override val description: String = "从社交媒体分享链接获取其内容" + + override def execute (using command: InputCommand, event: Update): Unit = { + + def do404 (): Unit = + coeur.account exec SendSticker( + event.message.chat.id, + TelegramStickers.ID_404 + ).replyToMessageId(event.message.messageId()) + + if command.args.length < 1 then { do404(); return } + + if !OnGetSocial.tryFetchSocial(Right(command.args(0)))(using event.message.chat.id, event.message.messageId) then + do404() + + } + +} diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/command/IP186Query.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/command/IP186Query.scala index 60d53a7..2b5795d 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/bot/command/IP186Query.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/bot/command/IP186Query.scala @@ -1,7 +1,7 @@ package cc.sukazyo.cono.morny.bot.command import cc.sukazyo.cono.morny.MornyCoeur -import cc.sukazyo.cono.morny.data.ip186.IP186QueryHandler +import cc.sukazyo.cono.morny.extra.ip186.IP186QueryHandler import cc.sukazyo.cono.morny.util.tgapi.InputCommand import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec import com.pengrad.telegrambot.model.Update diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/command/MornyCommands.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/command/MornyCommands.scala index e924d7e..d46b48a 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/bot/command/MornyCommands.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/bot/command/MornyCommands.scala @@ -43,11 +43,13 @@ class MornyCommands (using coeur: MornyCoeur) { $IP186Query.IP, $IP186Query.Whois, Encryptor(), + MornyOldJrrp(), + GetSocial(), + $MornyManagers.SaveData, $MornyInformation, $MornyInformationOlds.Version, $MornyInformationOlds.Runtime, - MornyOldJrrp(), $MornyManagers.Exit, Testing(), diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/command/MornyInformation.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/command/MornyInformation.scala index 36222eb..00de370 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/bot/command/MornyInformation.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/bot/command/MornyInformation.scala @@ -22,11 +22,13 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand { val RUNTIME = "runtime" val VERSION = "version" val VERSION_2 = "v" + val TASKS = "tasks" + val EVENTS = "event" } override val name: String = "info" override val aliases: Array[ICommandAlias]|Null = null - override val paramRule: String = "[(version|runtime|stickers[.IDs])]" + override val paramRule: String = "[(version|runtime|stickers[.IDs]|tasks|event)]" override val description: String = "输出当前 Morny 的各种信息" override def execute (using command: InputCommand, event: Update): Unit = { @@ -42,6 +44,8 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand { case s if s startsWith Subs.STICKERS => echoStickers case Subs.RUNTIME => echoRuntime case Subs.VERSION | Subs.VERSION_2 => echoVersion + case Subs.TASKS => echoTasksStatus + case Subs.EVENTS => echoEventStatistics case _ => echo404 } @@ -144,6 +148,29 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand { ).parseMode(ParseMode HTML).replyToMessageId(event.message.messageId) } + private def echoTasksStatus (using update: Update): Unit = { +// if !coeur.trusted.isTrusted(update.message.from.id) then return; + coeur.account exec SendMessage( + update.message.chat.id, + // language=html + s"""Coeur Task Scheduler: + | - scheduled tasks: ${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} + |
${e.body}
""".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"${h(GsonBuilder().setPrettyPrinting().create.toJson(update))}" + s"
${h(GsonBuilder().setPrettyPrinting().create.toJson(update))}
" ).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
%s
" + "\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 ""} - |
${h(exceptionLog(e))}
$_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 ${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" + + } + +}