From b57d87dece08da6f103dd9d88c8faf489a23af7f Mon Sep 17 00:00:00 2001 From: Eyre_S Date: Fri, 3 Nov 2023 20:23:32 +0800 Subject: [PATCH 01/18] add basic Scheduler and interval tasks - MedicationTimer refactor using new scheduler - add `/info tasks` for monitoring morny global tasks --- gradle.properties | 2 +- .../cc/sukazyo/cono/morny/MornyCoeur.scala | 7 +- .../morny/bot/command/MornyInformation.scala | 16 ++- .../cono/morny/daemon/MedicationTimer.scala | 61 +++++---- .../cono/morny/daemon/MornyDaemons.scala | 5 +- .../morny/internal/schedule/DelayedTask.scala | 18 +++ .../internal/schedule/IntervalTask.scala | 25 ++++ .../schedule/IntervalWithTimesTask.scala | 26 ++++ .../morny/internal/schedule/RoutineTask.scala | 12 ++ .../morny/internal/schedule/Scheduler.scala | 119 ++++++++++++++++++ .../cono/morny/internal/schedule/Task.scala | 21 ++++ .../morny/test/data/BilibiliFormsTest.scala | 5 +- 12 files changed, 277 insertions(+), 40 deletions(-) create mode 100644 src/main/scala/cc/sukazyo/cono/morny/internal/schedule/DelayedTask.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/internal/schedule/IntervalTask.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/internal/schedule/IntervalWithTimesTask.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/internal/schedule/RoutineTask.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/internal/schedule/Scheduler.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/internal/schedule/Task.scala diff --git a/gradle.properties b/gradle.properties index d12c207..1266f2e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ 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.1 +VERSION = 1.3.0-feat/scheduler USE_DELTA = false VERSION_DELTA = diff --git a/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala b/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala index e9337cf..af4bb17 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala @@ -7,6 +7,7 @@ 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.internal.schedule.Scheduler import com.pengrad.telegrambot.TelegramBot import com.pengrad.telegrambot.request.GetMe @@ -64,6 +65,8 @@ class MornyCoeur (using val config: MornyConfig) { /** current Morny's [[MornyTrusted]] instance */ val trusted: MornyTrusted = MornyTrusted() + /** Morny's task [[Scheduler]] */ + val tasks: Scheduler = Scheduler() val daemons: MornyDaemons = MornyDaemons() //noinspection ScalaWeakerAccess @@ -101,6 +104,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 +165,5 @@ class MornyCoeur (using val config: MornyConfig) { } } - + } 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..c877082 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,12 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand { val RUNTIME = "runtime" val VERSION = "version" val VERSION_2 = "v" + val TASKS = "tasks" } 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)]" override val description: String = "输出当前 Morny 的各种信息" override def execute (using command: InputCommand, event: Update): Unit = { @@ -42,6 +43,7 @@ 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 _ => echo404 } @@ -144,6 +146,18 @@ 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} + | - current runner status: ${coeur.tasks.state} + |""".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/daemon/MedicationTimer.scala b/src/main/scala/cc/sukazyo/cono/morny/daemon/MedicationTimer.scala index 4840418..f90a54d 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/daemon/MedicationTimer.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/daemon/MedicationTimer.scala @@ -3,6 +3,7 @@ package cc.sukazyo.cono.morny.daemon import cc.sukazyo.cono.morny.Log.{exceptionLog, logger} import cc.sukazyo.cono.morny.MornyCoeur import cc.sukazyo.cono.morny.daemon.MedicationTimer.calcNextRoutineTimestamp +import cc.sukazyo.cono.morny.internal.schedule.RoutineTask import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec import cc.sukazyo.cono.morny.util.CommonFormat import com.pengrad.telegrambot.model.{Message, MessageEntity} @@ -13,7 +14,7 @@ import java.time.{LocalDateTime, 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,45 +24,41 @@ 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: Long = + 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: Long = + calcNextSendTime + + override def nextRoutineTimeMillis (previousRoutineScheduledTimeMillis: Long): Long | 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) 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..0a60e1a 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyDaemons.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyDaemons.scala @@ -25,11 +25,8 @@ class MornyDaemons (using val coeur: MornyCoeur) { logger notice "stopping All Morny Daemons..." // TrackerDataManager.DAEMON.interrupt(); - medicationTimer.interrupt() + medicationTimer.stop() // TrackerDataManager.trackingLock.lock(); - try { medicationTimer.join() } - catch case e: InterruptedException => - e.printStackTrace(System.out) logger notice "stopped ALL Morny Daemons." } diff --git a/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/DelayedTask.scala b/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/DelayedTask.scala new file mode 100644 index 0000000..582e4f6 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/DelayedTask.scala @@ -0,0 +1,18 @@ +package cc.sukazyo.cono.morny.internal.schedule + +trait DelayedTask ( + val delayedMillis: Long +) extends Task { + + override val scheduledTimeMillis: Long = System.currentTimeMillis + delayedMillis + +} + +object DelayedTask { + + def apply (_name: String, delayedMillis: Long, 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/internal/schedule/IntervalTask.scala b/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/IntervalTask.scala new file mode 100644 index 0000000..5b255de --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/IntervalTask.scala @@ -0,0 +1,25 @@ +package cc.sukazyo.cono.morny.internal.schedule + +trait IntervalTask extends RoutineTask { + + def intervalMillis: Long + + override def firstRoutineTimeMillis: Long = + System.currentTimeMillis() + intervalMillis + + override def nextRoutineTimeMillis ( + previousScheduledRoutineTimeMillis: Long + ): Long|Null = + previousScheduledRoutineTimeMillis + intervalMillis + +} + +object IntervalTask { + + def apply (_name: String, _intervalMillis: Long, task: =>Unit): IntervalTask = + new IntervalTask: + override def intervalMillis: Long = _intervalMillis + override def name: String = _name + override def main: Unit = task + +} diff --git a/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/IntervalWithTimesTask.scala b/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/IntervalWithTimesTask.scala new file mode 100644 index 0000000..249a95e --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/IntervalWithTimesTask.scala @@ -0,0 +1,26 @@ +package cc.sukazyo.cono.morny.internal.schedule + +trait IntervalWithTimesTask extends IntervalTask { + + def times: Int + private var currentExecutedTimes = 1 + + override def nextRoutineTimeMillis (previousScheduledRoutineTimeMillis: Long): Long | Null = + if currentExecutedTimes >= times then + null + else + currentExecutedTimes = currentExecutedTimes + 1 + super.nextRoutineTimeMillis(previousScheduledRoutineTimeMillis) + +} + +object IntervalWithTimesTask { + + def apply (_name: String, _intervalMillis: Long, _times: Int, task: =>Unit): IntervalWithTimesTask = + new IntervalWithTimesTask: + override def name: String = _name + override def times: Int = _times + override def intervalMillis: Long = _intervalMillis + override def main: Unit = task + +} diff --git a/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/RoutineTask.scala b/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/RoutineTask.scala new file mode 100644 index 0000000..40f47ef --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/RoutineTask.scala @@ -0,0 +1,12 @@ +package cc.sukazyo.cono.morny.internal.schedule + +trait RoutineTask extends Task { + + private[schedule] var currentScheduledTimeMillis: Long = firstRoutineTimeMillis + override def scheduledTimeMillis: Long = currentScheduledTimeMillis + + def firstRoutineTimeMillis: Long + + def nextRoutineTimeMillis (previousRoutineScheduledTimeMillis: Long): Long|Null + +} diff --git a/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/Scheduler.scala b/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/Scheduler.scala new file mode 100644 index 0000000..9b71b29 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/Scheduler.scala @@ -0,0 +1,119 @@ +package cc.sukazyo.cono.morny.internal.schedule + +import scala.collection.mutable + +class Scheduler { + +// val taskList: util.TreeSet[Task] = +// Collections.synchronizedSortedSet(util.TreeSet[Task]()) + private val taskList: mutable.TreeSet[Task] = mutable.TreeSet.empty + private var exitAtNextRoutine = false + private var waitForDone = false + private var currentRunning: Task|Null = _ + private var currentRunning_isScheduledCancel = false + 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 + while (!willExit) { + + val nextMove: Task|Long = taskList.synchronized { + 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 => + Long.MaxValue + } + + nextMove match + case readyToRun: Task => + + this setName readyToRun.name + + try { + readyToRun.main + } catch case _: (Exception | Error) => {} + + this setName s"${readyToRun.name}#post" + + currentRunning match + case routine: RoutineTask => + routine.nextRoutineTimeMillis(routine.currentScheduledTimeMillis) match + case next: Long => + routine.currentScheduledTimeMillis = next + if (!currentRunning_isScheduledCancel) schedule(routine) + case _ => + case _ => + + this setName runnerName + currentRunning = null + + case needToWaitMillis: Long => + try Thread.sleep(needToWaitMillis) + catch case _: InterruptedException => {} + + } + } + + } + runtime.start() + + //noinspection ScalaWeakerAccess + def runnerName: String = + this.toString + + def ++ (task: Task): this.type = + schedule(task) + this + def schedule (task: Task): Boolean = + try taskList.synchronized: + taskList add task + finally runtime.interrupt() + + def % (task: Task): this.type = + cancel(task) + this + def cancel (task: Task): Boolean = + try { + val succeed = taskList.synchronized { taskList remove task } + if succeed then succeed + else if task == currentRunning then + currentRunning_isScheduledCancel = true + true + else false + } + finally runtime.interrupt() + + def amount: Int = + taskList.size + + def state: Thread.State = + runtime.getState + + def stop (): Unit = + exitAtNextRoutine = true + runtime.interrupt() + + def waitForStop (): Unit = + stop() + runtime.join() + + //noinspection ScalaWeakerAccess + def tagStopAtAllDone (): Unit = + waitForDone = true + + def waitForStopAtAllDone(): Unit = + tagStopAtAllDone() + runtime.join() + +} diff --git a/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/Task.scala b/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/Task.scala new file mode 100644 index 0000000..ec1840f --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/Task.scala @@ -0,0 +1,21 @@ +package cc.sukazyo.cono.morny.internal.schedule + +trait Task extends Ordered[Task] { + + def name: String + def scheduledTimeMillis: Long + + //noinspection UnitMethodIsParameterless + def main: Unit + + override def compare (that: Task): Int = + if this.scheduledTimeMillis == that.scheduledTimeMillis then + this.hashCode - that.hashCode + else if this.scheduledTimeMillis > that.scheduledTimeMillis then + 1 + else -1 + + override def toString: String = + s"""${super.toString}{"$name": $scheduledTimeMillis}""" + +} diff --git a/src/test/scala/cc/sukazyo/cono/morny/test/data/BilibiliFormsTest.scala b/src/test/scala/cc/sukazyo/cono/morny/test/data/BilibiliFormsTest.scala index c3dde75..6461e37 100644 --- a/src/test/scala/cc/sukazyo/cono/morny/test/data/BilibiliFormsTest.scala +++ b/src/test/scala/cc/sukazyo/cono/morny/test/data/BilibiliFormsTest.scala @@ -94,7 +94,10 @@ class BilibiliFormsTest extends MornyTests with TableDrivenPropertyChecks { 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") + ("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: From f0d4471646e5b566181766f4b46a529d0768e792 Mon Sep 17 00:00:00 2001 From: Eyre_S Date: Sat, 4 Nov 2023 19:38:40 +0800 Subject: [PATCH 02/18] add some scaladoc for scheduler --- .../cc/sukazyo/cono/morny/MornyCoeur.scala | 2 +- .../morny/bot/command/MornyInformation.scala | 3 +- .../cono/morny/daemon/MedicationTimer.scala | 4 +- .../morny/internal/schedule/Scheduler.scala | 119 -------- .../cono/morny/internal/schedule/Task.scala | 21 -- .../cono/morny/util/EpochDateTime.scala | 5 + .../schedule/DelayedTask.scala | 2 +- .../schedule/IntervalTask.scala | 2 +- .../schedule/IntervalWithTimesTask.scala | 2 +- .../schedule/RoutineTask.scala | 2 +- .../cono/morny/util/schedule/Scheduler.scala | 269 ++++++++++++++++++ .../cono/morny/util/schedule/Task.scala | 57 ++++ 12 files changed, 340 insertions(+), 148 deletions(-) delete mode 100644 src/main/scala/cc/sukazyo/cono/morny/internal/schedule/Scheduler.scala delete mode 100644 src/main/scala/cc/sukazyo/cono/morny/internal/schedule/Task.scala rename src/main/scala/cc/sukazyo/cono/morny/{internal => util}/schedule/DelayedTask.scala (88%) rename src/main/scala/cc/sukazyo/cono/morny/{internal => util}/schedule/IntervalTask.scala (92%) rename src/main/scala/cc/sukazyo/cono/morny/{internal => util}/schedule/IntervalWithTimesTask.scala (93%) rename src/main/scala/cc/sukazyo/cono/morny/{internal => util}/schedule/RoutineTask.scala (86%) create mode 100644 src/main/scala/cc/sukazyo/cono/morny/util/schedule/Scheduler.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/util/schedule/Task.scala diff --git a/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala b/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala index af4bb17..f763451 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala @@ -7,7 +7,7 @@ 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.internal.schedule.Scheduler +import cc.sukazyo.cono.morny.util.schedule.Scheduler import com.pengrad.telegrambot.TelegramBot import com.pengrad.telegrambot.request.GetMe 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 c877082..b754e52 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 @@ -153,7 +153,8 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand { // language=html s"""Coeur Task Scheduler: | - scheduled tasks: ${coeur.tasks.amount} - | - current runner status: ${coeur.tasks.state} + | - scheduler status: ${coeur.tasks.state} + | - current runner status: ${coeur.tasks.runnerState} |""".stripMargin ).parseMode(ParseMode.HTML).replyToMessageId(update.message.messageId) } 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 f90a54d..7d81ebd 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/daemon/MedicationTimer.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/daemon/MedicationTimer.scala @@ -1,9 +1,9 @@ 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.internal.schedule.RoutineTask +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 com.pengrad.telegrambot.model.{Message, MessageEntity} diff --git a/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/Scheduler.scala b/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/Scheduler.scala deleted file mode 100644 index 9b71b29..0000000 --- a/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/Scheduler.scala +++ /dev/null @@ -1,119 +0,0 @@ -package cc.sukazyo.cono.morny.internal.schedule - -import scala.collection.mutable - -class Scheduler { - -// val taskList: util.TreeSet[Task] = -// Collections.synchronizedSortedSet(util.TreeSet[Task]()) - private val taskList: mutable.TreeSet[Task] = mutable.TreeSet.empty - private var exitAtNextRoutine = false - private var waitForDone = false - private var currentRunning: Task|Null = _ - private var currentRunning_isScheduledCancel = false - 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 - while (!willExit) { - - val nextMove: Task|Long = taskList.synchronized { - 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 => - Long.MaxValue - } - - nextMove match - case readyToRun: Task => - - this setName readyToRun.name - - try { - readyToRun.main - } catch case _: (Exception | Error) => {} - - this setName s"${readyToRun.name}#post" - - currentRunning match - case routine: RoutineTask => - routine.nextRoutineTimeMillis(routine.currentScheduledTimeMillis) match - case next: Long => - routine.currentScheduledTimeMillis = next - if (!currentRunning_isScheduledCancel) schedule(routine) - case _ => - case _ => - - this setName runnerName - currentRunning = null - - case needToWaitMillis: Long => - try Thread.sleep(needToWaitMillis) - catch case _: InterruptedException => {} - - } - } - - } - runtime.start() - - //noinspection ScalaWeakerAccess - def runnerName: String = - this.toString - - def ++ (task: Task): this.type = - schedule(task) - this - def schedule (task: Task): Boolean = - try taskList.synchronized: - taskList add task - finally runtime.interrupt() - - def % (task: Task): this.type = - cancel(task) - this - def cancel (task: Task): Boolean = - try { - val succeed = taskList.synchronized { taskList remove task } - if succeed then succeed - else if task == currentRunning then - currentRunning_isScheduledCancel = true - true - else false - } - finally runtime.interrupt() - - def amount: Int = - taskList.size - - def state: Thread.State = - runtime.getState - - def stop (): Unit = - exitAtNextRoutine = true - runtime.interrupt() - - def waitForStop (): Unit = - stop() - runtime.join() - - //noinspection ScalaWeakerAccess - def tagStopAtAllDone (): Unit = - waitForDone = true - - def waitForStopAtAllDone(): Unit = - tagStopAtAllDone() - runtime.join() - -} diff --git a/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/Task.scala b/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/Task.scala deleted file mode 100644 index ec1840f..0000000 --- a/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/Task.scala +++ /dev/null @@ -1,21 +0,0 @@ -package cc.sukazyo.cono.morny.internal.schedule - -trait Task extends Ordered[Task] { - - def name: String - def scheduledTimeMillis: Long - - //noinspection UnitMethodIsParameterless - def main: Unit - - override def compare (that: Task): Int = - if this.scheduledTimeMillis == that.scheduledTimeMillis then - this.hashCode - that.hashCode - else if this.scheduledTimeMillis > that.scheduledTimeMillis then - 1 - else -1 - - override def toString: String = - s"""${super.toString}{"$name": $scheduledTimeMillis}""" - -} 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..81613c9 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/util/EpochDateTime.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/util/EpochDateTime.scala @@ -5,7 +5,12 @@ 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 + /** Time duration/interval in milliseconds. */ type DurationMillis = Long object EpochMillis: diff --git a/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/DelayedTask.scala b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/DelayedTask.scala similarity index 88% rename from src/main/scala/cc/sukazyo/cono/morny/internal/schedule/DelayedTask.scala rename to src/main/scala/cc/sukazyo/cono/morny/util/schedule/DelayedTask.scala index 582e4f6..a8a9499 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/DelayedTask.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/DelayedTask.scala @@ -1,4 +1,4 @@ -package cc.sukazyo.cono.morny.internal.schedule +package cc.sukazyo.cono.morny.util.schedule trait DelayedTask ( val delayedMillis: Long diff --git a/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/IntervalTask.scala b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/IntervalTask.scala similarity index 92% rename from src/main/scala/cc/sukazyo/cono/morny/internal/schedule/IntervalTask.scala rename to src/main/scala/cc/sukazyo/cono/morny/util/schedule/IntervalTask.scala index 5b255de..cc87240 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/IntervalTask.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/IntervalTask.scala @@ -1,4 +1,4 @@ -package cc.sukazyo.cono.morny.internal.schedule +package cc.sukazyo.cono.morny.util.schedule trait IntervalTask extends RoutineTask { diff --git a/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/IntervalWithTimesTask.scala b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/IntervalWithTimesTask.scala similarity index 93% rename from src/main/scala/cc/sukazyo/cono/morny/internal/schedule/IntervalWithTimesTask.scala rename to src/main/scala/cc/sukazyo/cono/morny/util/schedule/IntervalWithTimesTask.scala index 249a95e..c2055a9 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/IntervalWithTimesTask.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/IntervalWithTimesTask.scala @@ -1,4 +1,4 @@ -package cc.sukazyo.cono.morny.internal.schedule +package cc.sukazyo.cono.morny.util.schedule trait IntervalWithTimesTask extends IntervalTask { diff --git a/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/RoutineTask.scala b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/RoutineTask.scala similarity index 86% rename from src/main/scala/cc/sukazyo/cono/morny/internal/schedule/RoutineTask.scala rename to src/main/scala/cc/sukazyo/cono/morny/util/schedule/RoutineTask.scala index 40f47ef..09ade60 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/internal/schedule/RoutineTask.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/RoutineTask.scala @@ -1,4 +1,4 @@ -package cc.sukazyo.cono.morny.internal.schedule +package cc.sukazyo.cono.morny.util.schedule trait RoutineTask extends Task { 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..190f9f7 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Scheduler.scala @@ -0,0 +1,269 @@ +package cc.sukazyo.cono.morny.util.schedule + +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 currentRunning_isScheduledCancel = false + 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 + while (!willExit) { + + runtimeStatus = State.PREPARE_RUN + + val nextMove: Task|Long|"None" = taskList.synchronized { + 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" + + if currentRunning_isScheduledCancel then {} + else { + currentRunning match + case routine: RoutineTask => + routine.nextRoutineTimeMillis(routine.currentScheduledTimeMillis) match + case next: Long => + routine.currentScheduledTimeMillis = next + if (!currentRunning_isScheduledCancel) schedule(routine) + case _ => + case _ => + } + + currentRunning = null + this setName runnerName + + case needToWaitMillis: Long => + runtimeStatus = State.WAITING + try Thread.sleep(needToWaitMillis) + catch case _: InterruptedException => {} + case _: "None" => + runtimeStatus = State.WAITING_EMPTY + try Thread.sleep(Long.MaxValue) + 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 = + this.toString + + /** 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 = + try taskList.synchronized: + taskList add task + finally runtime.interrupt() + + /** 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 current run will be done, but will + * not do the post effect of the task (like schedule the next routine + * of [[RoutineTask]]). + * + * @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 = + try { + val succeed = taskList.synchronized { taskList remove task } + if succeed then succeed + else if task == currentRunning then + currentRunning_isScheduledCancel = true + true + else false + } + finally runtime.interrupt() + + /** 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 + + /** 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 = + exitAtNextRoutine = true + runtime.interrupt() + + /** 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 = + waitForDone = true + runtime.interrupt() + + /** 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. + */ + 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..2dc4503 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Task.scala @@ -0,0 +1,57 @@ +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}""" + +} From 9f908aa88ecce044888c180f713e504151ffc116 Mon Sep 17 00:00:00 2001 From: Eyre_S Date: Sun, 5 Nov 2023 19:25:00 +0800 Subject: [PATCH 03/18] add scaladoc, change Long to EpochMillis, scheduler tests - fix wrong Telegram EpochSeconds to EpochMillis conv at OnCallMe --- .../cc/sukazyo/cono/morny/MornyCoeur.scala | 3 +- .../cono/morny/bot/event/OnCallMe.scala | 5 +- .../cono/morny/daemon/MedicationTimer.scala | 9 ++-- .../sukazyo/cono/morny/data/MornyJrrp.scala | 7 +-- .../cono/morny/util/EpochDateTime.scala | 39 +++++++++++++-- .../morny/util/schedule/DelayedTask.scala | 8 ++-- .../morny/util/schedule/IntervalTask.scala | 14 +++--- .../util/schedule/IntervalWithTimesTask.scala | 8 ++-- .../morny/util/schedule/RoutineTask.scala | 41 ++++++++++++++-- .../cono/morny/util/schedule/Scheduler.scala | 8 ++-- .../cono/morny/util/schedule/Task.scala | 10 ++++ .../morny/test/utils/EpochDateTimeTest.scala | 46 +++++++++++++++++- .../test/utils/schedule/IntervalsTest.scala | 23 +++++++++ .../test/utils/schedule/SchedulerTest.scala | 47 +++++++++++++++++++ .../test/utils/schedule/TaskBasicTest.scala | 46 ++++++++++++++++++ 15 files changed, 281 insertions(+), 33 deletions(-) create mode 100644 src/test/scala/cc/sukazyo/cono/morny/test/utils/schedule/IntervalsTest.scala create mode 100644 src/test/scala/cc/sukazyo/cono/morny/test/utils/schedule/SchedulerTest.scala create mode 100644 src/test/scala/cc/sukazyo/cono/morny/test/utils/schedule/TaskBasicTest.scala diff --git a/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala b/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala index f763451..b1cc8d1 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala @@ -8,6 +8,7 @@ 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 com.pengrad.telegrambot.TelegramBot import com.pengrad.telegrambot.request.GetMe @@ -54,7 +55,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 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..5dbbb9f 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 @@ -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/daemon/MedicationTimer.scala b/src/main/scala/cc/sukazyo/cono/morny/daemon/MedicationTimer.scala index 7d81ebd..522e6ab 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/daemon/MedicationTimer.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/daemon/MedicationTimer.scala @@ -6,6 +6,7 @@ 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.pengrad.telegrambot.model.{Message, MessageEntity} import com.pengrad.telegrambot.request.{EditMessageText, SendMessage} import com.pengrad.telegrambot.response.SendResponse @@ -30,15 +31,15 @@ class MedicationTimer (using coeur: MornyCoeur) { override def name: String = DAEMON_THREAD_NAME_DEF - def calcNextSendTime: Long = + 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: Long = + override def firstRoutineTimeMillis: EpochMillis = calcNextSendTime - override def nextRoutineTimeMillis (previousRoutineScheduledTimeMillis: Long): Long | Null = + override def nextRoutineTimeMillis (previousRoutineScheduledTimeMillis: EpochMillis): EpochMillis | Null = calcNextSendTime override def main: Unit = { @@ -85,7 +86,7 @@ class MedicationTimer (using coeur: MornyCoeur) { object MedicationTimer { @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, 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/util/EpochDateTime.scala b/src/main/scala/cc/sukazyo/cono/morny/util/EpochDateTime.scala index 81613c9..5f3d7a2 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/util/EpochDateTime.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/util/EpochDateTime.scala @@ -10,9 +10,6 @@ object EpochDateTime { * aka. Milliseconds since 00:00:00 UTC on Thursday, 1 January 1970. */ type EpochMillis = Long - /** Time duration/interval in milliseconds. */ - type DurationMillis = Long - object EpochMillis: /** convert a localtime with timezone to epoch milliseconds * @@ -31,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/schedule/DelayedTask.scala b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/DelayedTask.scala index a8a9499..130c4e3 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/DelayedTask.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/DelayedTask.scala @@ -1,16 +1,18 @@ package cc.sukazyo.cono.morny.util.schedule +import cc.sukazyo.cono.morny.util.EpochDateTime.{DurationMillis, EpochMillis} + trait DelayedTask ( - val delayedMillis: Long + val delayedMillis: DurationMillis ) extends Task { - override val scheduledTimeMillis: Long = System.currentTimeMillis + delayedMillis + override val scheduledTimeMillis: EpochMillis = System.currentTimeMillis + delayedMillis } object DelayedTask { - def apply (_name: String, delayedMillis: Long, task: =>Unit): 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 index cc87240..37667ba 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/IntervalTask.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/IntervalTask.scala @@ -1,24 +1,26 @@ package cc.sukazyo.cono.morny.util.schedule +import cc.sukazyo.cono.morny.util.EpochDateTime.{DurationMillis, EpochMillis} + trait IntervalTask extends RoutineTask { - def intervalMillis: Long + def intervalMillis: DurationMillis - override def firstRoutineTimeMillis: Long = + override def firstRoutineTimeMillis: EpochMillis = System.currentTimeMillis() + intervalMillis override def nextRoutineTimeMillis ( - previousScheduledRoutineTimeMillis: Long - ): Long|Null = + previousScheduledRoutineTimeMillis: EpochMillis + ): EpochMillis|Null = previousScheduledRoutineTimeMillis + intervalMillis } object IntervalTask { - def apply (_name: String, _intervalMillis: Long, task: =>Unit): IntervalTask = + def apply (_name: String, _intervalMillis: DurationMillis, task: =>Unit): IntervalTask = new IntervalTask: - override def intervalMillis: Long = _intervalMillis + 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 index c2055a9..5e55a8a 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/IntervalWithTimesTask.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/IntervalWithTimesTask.scala @@ -1,11 +1,13 @@ 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: Long): Long | Null = + override def nextRoutineTimeMillis (previousScheduledRoutineTimeMillis: EpochMillis): EpochMillis | Null = if currentExecutedTimes >= times then null else @@ -16,11 +18,11 @@ trait IntervalWithTimesTask extends IntervalTask { object IntervalWithTimesTask { - def apply (_name: String, _intervalMillis: Long, _times: Int, task: =>Unit): 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: Long = _intervalMillis + 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 index 09ade60..dce86fe 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/RoutineTask.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/RoutineTask.scala @@ -1,12 +1,45 @@ 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: Long = firstRoutineTimeMillis - override def scheduledTimeMillis: Long = currentScheduledTimeMillis + private[schedule] var currentScheduledTimeMillis: EpochMillis = firstRoutineTimeMillis - def firstRoutineTimeMillis: Long + /** Next running time of this task. + * + * Should be auto generated from [[firstRoutineTimeMillis]] and + * [[nextRoutineTimeMillis]]. + */ + override def scheduledTimeMillis: EpochMillis = currentScheduledTimeMillis - def nextRoutineTimeMillis (previousRoutineScheduledTimeMillis: Long): Long|Null + /** 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 index 190f9f7..411be39 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Scheduler.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Scheduler.scala @@ -1,5 +1,7 @@ package cc.sukazyo.cono.morny.util.schedule +import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis + import scala.annotation.targetName import scala.collection.mutable @@ -78,7 +80,7 @@ class Scheduler { runtimeStatus = State.PREPARE_RUN - val nextMove: Task|Long|"None" = taskList.synchronized { + val nextMove: Task|EpochMillis|"None" = taskList.synchronized { taskList.headOption match case Some(_readyToRun) if System.currentTimeMillis >= _readyToRun.scheduledTimeMillis => taskList -= _readyToRun @@ -107,7 +109,7 @@ class Scheduler { currentRunning match case routine: RoutineTask => routine.nextRoutineTimeMillis(routine.currentScheduledTimeMillis) match - case next: Long => + case next: EpochMillis => routine.currentScheduledTimeMillis = next if (!currentRunning_isScheduledCancel) schedule(routine) case _ => @@ -117,7 +119,7 @@ class Scheduler { currentRunning = null this setName runnerName - case needToWaitMillis: Long => + case needToWaitMillis: EpochMillis => runtimeStatus = State.WAITING try Thread.sleep(needToWaitMillis) catch case _: InterruptedException => {} 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 index 2dc4503..6250d9a 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Task.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Task.scala @@ -55,3 +55,13 @@ trait Task extends Ordered[Task] { 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/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/IntervalsTest.scala b/src/test/scala/cc/sukazyo/cono/morny/test/utils/schedule/IntervalsTest.scala new file mode 100644 index 0000000..c0d1d81 --- /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 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..bb9271e --- /dev/null +++ b/src/test/scala/cc/sukazyo/cono/morny/test/utils/schedule/SchedulerTest.scala @@ -0,0 +1,47 @@ +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() + var time = System.currentTimeMillis + scheduler ++ Task("task", 0, { + time = System.currentTimeMillis - time + }) + scheduler.waitForStopAtAllDone() + time should be <= 10L + info(s"Immediately Task done with time $time") + } + + "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" + + } + +} From 3d44972233bd3f9329569baa0e65263672011a72 Mon Sep 17 00:00:00 2001 From: Eyre_S Date: Thu, 9 Nov 2023 22:07:10 +0800 Subject: [PATCH 04/18] add CronTask, tests optimize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add lib cron-utils: v9.2.0 - add CronTask - add CronTask's test - change MedicationTimer using cron as time calculation backend (not using CronTask) - change OnQuestionMarkReply support `⸘` - minor SchedulerTest "immediately" test logic changes --- build.gradle | 1 + gradle.properties | 3 +- .../morny/bot/event/OnQuestionMarkReply.scala | 5 +- .../cono/morny/daemon/MedicationTimer.scala | 29 +++++++---- .../cono/morny/util/schedule/CronTask.scala | 46 +++++++++++++++++ .../bot/event/OnQuestionMarkReplyTest.scala | 13 ++++- .../test/utils/schedule/CronTaskTest.scala | 51 +++++++++++++++++++ .../test/utils/schedule/IntervalsTest.scala | 2 +- .../test/utils/schedule/SchedulerTest.scala | 12 +++-- 9 files changed, 141 insertions(+), 21 deletions(-) create mode 100644 src/main/scala/cc/sukazyo/cono/morny/util/schedule/CronTask.scala create mode 100644 src/test/scala/cc/sukazyo/cono/morny/test/utils/schedule/CronTaskTest.scala diff --git a/build.gradle b/build.gradle index 8d9f712..c3d39c3 100644 --- a/build.gradle +++ b/build.gradle @@ -90,6 +90,7 @@ dependencies { 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 implementation group: 'com.google.code.gson', name: 'gson', version: lib_gson_v + implementation group: 'com.cronutils', name: 'cron-utils', version: lib_cron_utils_v testImplementation group: 'org.scalatest', name: scala('scalatest'), version: lib_scalatest_v testImplementation group: 'org.scalatest', name: scala('scalatest-freespec'), version: lib_scalatest_v diff --git a/gradle.properties b/gradle.properties index 082346f..9dcc505 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ 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.3.0-dev1 +VERSION = 1.3.0-dev2 USE_DELTA = false VERSION_DELTA = @@ -25,5 +25,6 @@ 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_cron_utils_v = 9.2.0 lib_scalatest_v = 3.2.17 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/daemon/MedicationTimer.scala b/src/main/scala/cc/sukazyo/cono/morny/daemon/MedicationTimer.scala index 522e6ab..1171379 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/daemon/MedicationTimer.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/daemon/MedicationTimer.scala @@ -7,11 +7,14 @@ 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 @@ -85,18 +88,24 @@ class MedicationTimer (using coeur: MornyCoeur) { object MedicationTimer { + //noinspection ScalaWeakerAccess + val cronDef: CronDefinition = CronDefinitionBuilder.defineCron + .withHours.and + .instance + @throws[IllegalArgumentException] 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/util/schedule/CronTask.scala b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/CronTask.scala new file mode 100644 index 0000000..5a8de3d --- /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 transparent inline def 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/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/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 index c0d1d81..da49874 100644 --- 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 @@ -17,7 +17,7 @@ class IntervalsTest extends MornyTests { val timeUsed = System.currentTimeMillis() - startTime times shouldEqual 10 timeUsed should (be <= 2100L and be >= 1900L) - info(s"interval 200ms for 10 times used time ${timeUsed}ms") + 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 index bb9271e..c0383b0 100644 --- 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 @@ -12,13 +12,15 @@ class SchedulerTest extends MornyTests { "Task with scheduleTime smaller than current time should be executed immediately" in { val scheduler = Scheduler() - var time = System.currentTimeMillis + val time = System.currentTimeMillis + var doneTime: Option[Long] = None scheduler ++ Task("task", 0, { - time = System.currentTimeMillis - time + doneTime = Some(System.currentTimeMillis) }) - scheduler.waitForStopAtAllDone() - time should be <= 10L - info(s"Immediately Task done with time $time") + 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 { From 2c30b5ec092cfdd8c646cc54fdb78035e1e5e16b Mon Sep 17 00:00:00 2001 From: Eyre_S Date: Tue, 14 Nov 2023 13:56:23 +0800 Subject: [PATCH 05/18] add event statistics, fix CronTask - add for EventEnv a timeStartup field - cha EventListener and EventListenerManager - add for EventListener a method executeFilter used to manager if an event should be run. This replaced the condition statement inside the EventListenerManager - add for EventListener a method atEventPost, this will run at current event listener is on complete - add for MornyConfig a reportZone field - can be set by `--report-zone` - used for controlling Morny Report daemon uses the zoned time to send report. default is system default time zone. - add for MornyReport new EventStatistics and DailyReportTask - add for MornyInformation command new subcommand `event` to manually show MornyReport.EventStatistics info. - add WatchDog and MornyCoeur.watchDog, used for checking if the machine is in sleep mode and notify the MornyCoeur.tasks to avoid timing problem - fix CronTask frequency got initialize problem - add slf4j-nop for project --- build.gradle | 13 ++- gradle.properties | 3 +- .../cc/sukazyo/cono/morny/MornyCoeur.scala | 15 ++- .../cc/sukazyo/cono/morny/MornyConfig.java | 15 +++ .../cc/sukazyo/cono/morny/ServerMain.scala | 1 + .../sukazyo/cono/morny/bot/api/EventEnv.scala | 2 + .../cono/morny/bot/api/EventListener.scala | 19 ++++ .../morny/bot/api/EventListenerManager.scala | 10 +- .../morny/bot/command/MornyInformation.scala | 14 ++- .../cono/morny/daemon/MornyDaemons.scala | 5 +- .../cono/morny/daemon/MornyReport.scala | 102 +++++++++++++++++- .../cc/sukazyo/cono/morny/util/UseMath.scala | 5 + .../cono/morny/util/schedule/CronTask.scala | 2 +- .../morny/util/schedule/RoutineTask.scala | 14 ++- .../cono/morny/util/schedule/Scheduler.scala | 79 ++++++++------ .../util/statistics/NumericStatistics.scala | 101 +++++++++++++++++ .../cono/morny/util/time/WatchDog.scala | 67 ++++++++++++ 17 files changed, 412 insertions(+), 55 deletions(-) create mode 100644 src/main/scala/cc/sukazyo/cono/morny/util/statistics/NumericStatistics.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/util/time/WatchDog.scala diff --git a/build.gradle b/build.gradle index c3d39c3..22da9ba 100644 --- a/build.gradle +++ b/build.gradle @@ -83,20 +83,25 @@ 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: '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' } diff --git a/gradle.properties b/gradle.properties index 9dcc505..70ddbdf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ 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.3.0-dev2 +VERSION = 1.3.0-dev3 USE_DELTA = false VERSION_DELTA = @@ -19,6 +19,7 @@ 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 diff --git a/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala b/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala index b1cc8d1..79a5dc9 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala @@ -9,9 +9,11 @@ import cc.sukazyo.cono.morny.bot.event.{MornyEventListeners, MornyOnInlineQuery, 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 @@ -64,10 +66,10 @@ class MornyCoeur (using val config: MornyConfig) { /** [[account]]'s telegram user id */ val userid: Long = __loginResult.userid - /** current Morny's [[MornyTrusted]] instance */ - val trusted: MornyTrusted = MornyTrusted() /** Morny's task [[Scheduler]] */ val tasks: Scheduler = Scheduler() + /** current Morny's [[MornyTrusted]] instance */ + val trusted: MornyTrusted = MornyTrusted() val daemons: MornyDaemons = MornyDaemons() //noinspection ScalaWeakerAccess @@ -80,6 +82,15 @@ 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 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..43a532f 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,5 +1,6 @@ package cc.sukazyo.cono.morny.bot.api +import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis import com.pengrad.telegrambot.model.Update import scala.collection.mutable @@ -12,6 +13,7 @@ class EventEnv ( private var _isOk: Int = 0 private val variables: mutable.HashMap[Class[?], Any] = mutable.HashMap.empty + val timeStartup: EpochMillis = System.currentTimeMillis def isEventOk: Boolean = _isOk > 0 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..8834060 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,25 @@ 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.isEventOk then false else true + + 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..9643578 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 @@ -32,7 +32,9 @@ class EventListenerManager (using coeur: MornyCoeur) extends UpdatesListener { override def run (): Unit = { given env: EventEnv = EventEnv(update) boundary { for (i <- listeners) { - try { + + if (i.executeFilter) try { + updateThreadName("message") if update.message ne null then i.onMessage updateThreadName("edited-message") @@ -61,6 +63,10 @@ class EventListenerManager (using coeur: MornyCoeur) extends UpdatesListener { if update.chatMember ne null then i.onChatMemberUpdated updateThreadName("chat-join-request") if update.chatJoinRequest ne null then i.onChatJoinRequest + + updateThreadName("#post") + i.atEventPost + } catch case e => { val errorMessage = StringBuilder() errorMessage ++= "Event throws unexpected exception:\n" @@ -75,7 +81,7 @@ class EventListenerManager (using coeur: MornyCoeur) extends UpdatesListener { logger error errorMessage.toString coeur.daemons.reporter.exception(e, "on event running") } - if env.isEventOk then boundary.break() + }} } 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 b754e52..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 @@ -23,11 +23,12 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand { 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]|tasks)]" + override val paramRule: String = "[(version|runtime|stickers[.IDs]|tasks|event)]" override val description: String = "输出当前 Morny 的各种信息" override def execute (using command: InputCommand, event: Update): Unit = { @@ -44,6 +45,7 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand { case Subs.RUNTIME => echoRuntime case Subs.VERSION | Subs.VERSION_2 => echoVersion case Subs.TASKS => echoTasksStatus + case Subs.EVENTS => echoEventStatistics case _ => echo404 } @@ -159,6 +161,16 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand { ).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/daemon/MornyDaemons.scala b/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyDaemons.scala index 0a60e1a..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,9 +24,8 @@ class MornyDaemons (using val coeur: MornyCoeur) { logger notice "stopping All Morny Daemons..." - // TrackerDataManager.DAEMON.interrupt(); medicationTimer.stop() - // TrackerDataManager.trackingLock.lock(); + 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..468580f 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyReport.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyReport.scala @@ -2,17 +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 java.time.ZoneId + class MornyReport (using coeur: MornyCoeur) { private val enabled = coeur.config.reportToChat != -1 @@ -67,10 +76,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 +131,91 @@ class MornyReport (using coeur: MornyCoeur) { ).parseMode(ParseMode HTML)) } + object EventStatistics { + + private var eventTotal = 0 + private val runningTime: NumericStatistics[DurationMillis] = NumericStatistics() + + def reset (): Unit = { + eventTotal = 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 ignored = eventTotal - processed + // language=html + s""" - total event received: $eventTotal + | - event processed: (${eventTotal p processed}%) $processed + | - event ignored: (${eventTotal p ignored}%) $ignored + | - 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 = { + eventTotal += 1 + if event.isEventOk then { + val timeUsed = EventTimeUsed(System.currentTimeMillis - event.timeStartup) + event provide timeUsed + logger debug s"event consumed ${timeUsed.it}ms" + runningTime ++ timeUsed.it + } + } + } + + } + + 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/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 index 5a8de3d..c6c9607 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/CronTask.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/CronTask.scala @@ -9,7 +9,7 @@ import scala.jdk.OptionConverters.* trait CronTask extends RoutineTask { - private transparent inline def cronCalc = ExecutionTime.forCron(cron) + private lazy val cronCalc = ExecutionTime.forCron(cron) def cron: Cron 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 index dce86fe..d7904ab 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/RoutineTask.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/RoutineTask.scala @@ -13,14 +13,20 @@ import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis */ trait RoutineTask extends Task { - private[schedule] var currentScheduledTimeMillis: EpochMillis = firstRoutineTimeMillis + private[schedule] var currentScheduledTimeMillis: Option[EpochMillis] = None /** Next running time of this task. * - * Should be auto generated from [[firstRoutineTimeMillis]] and - * [[nextRoutineTimeMillis]]. + * 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 + override def scheduledTimeMillis: EpochMillis = + currentScheduledTimeMillis match + case Some(time) => time + case None => + currentScheduledTimeMillis = Some(firstRoutineTimeMillis) + currentScheduledTimeMillis.get /** The task scheduled time at initial. * 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 index 411be39..4eb066e 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Scheduler.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Scheduler.scala @@ -63,8 +63,7 @@ class Scheduler { private val taskList: mutable.TreeSet[Task] = mutable.TreeSet.empty private var exitAtNextRoutine = false private var waitForDone = false - private var currentRunning: Task|Null = _ - private var currentRunning_isScheduledCancel = false +// private var currentRunning: Task|Null = _ private var runtimeStatus = State.INIT private val runtime: Thread = new Thread { @@ -76,20 +75,19 @@ class Scheduler { if taskList.isEmpty then true else false else false - while (!willExit) { + taskList.synchronized { while (!willExit) { runtimeStatus = State.PREPARE_RUN - val nextMove: Task|EpochMillis|"None" = taskList.synchronized { + val nextMove: Task|EpochMillis|"None" = taskList.headOption match case Some(_readyToRun) if System.currentTimeMillis >= _readyToRun.scheduledTimeMillis => taskList -= _readyToRun - currentRunning = _readyToRun +// currentRunning = _readyToRun _readyToRun case Some(_notReady) => _notReady.scheduledTimeMillis - System.currentTimeMillis case None => "None" - } nextMove match case readyToRun: Task => @@ -104,31 +102,33 @@ class Scheduler { runtimeStatus = State.RUNNING_POST this setName s"${readyToRun.name}#post" - if currentRunning_isScheduledCancel then {} + // 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 { - currentRunning match + readyToRun match case routine: RoutineTask => - routine.nextRoutineTimeMillis(routine.currentScheduledTimeMillis) match + routine.nextRoutineTimeMillis(routine.currentScheduledTimeMillis.get) match case next: EpochMillis => - routine.currentScheduledTimeMillis = next - if (!currentRunning_isScheduledCancel) schedule(routine) + routine.currentScheduledTimeMillis = Some(next) + schedule(routine) case _ => case _ => } - currentRunning = null +// currentRunning = null this setName runnerName case needToWaitMillis: EpochMillis => runtimeStatus = State.WAITING - try Thread.sleep(needToWaitMillis) - catch case _: InterruptedException => {} + try taskList.wait(needToWaitMillis) + catch case _: (InterruptedException|IllegalArgumentException) => {} case _: "None" => runtimeStatus = State.WAITING_EMPTY - try Thread.sleep(Long.MaxValue) + try taskList.wait() catch case _: InterruptedException => {} - } + }} runtimeStatus = State.END } @@ -154,9 +154,9 @@ class Scheduler { * @return [[true]] if the task is added. */ def schedule (task: Task): Boolean = - try taskList.synchronized: - taskList add task - finally runtime.interrupt() + taskList.synchronized: + try taskList add task + finally taskList.notifyAll() /** Remove the task from scheduler task queue. * @@ -172,23 +172,16 @@ class Scheduler { this /** 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]]). + * 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 = - try { - val succeed = taskList.synchronized { taskList remove task } - if succeed then succeed - else if task == currentRunning then - currentRunning_isScheduledCancel = true - true - else false - } - finally runtime.interrupt() + taskList synchronized: + try taskList remove task + finally taskList.notifyAll() /** Count of tasks in the task queue. * @@ -205,6 +198,19 @@ class Scheduler { 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 @@ -217,8 +223,9 @@ class Scheduler { * runner is stopped. If you want a sync version, see [[waitForStop]]. */ def stop (): Unit = - exitAtNextRoutine = true - runtime.interrupt() + 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. @@ -251,8 +258,9 @@ class Scheduler { */ //noinspection ScalaWeakerAccess def tagStopAtAllDone (): Unit = - waitForDone = true - runtime.interrupt() + 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. @@ -264,6 +272,7 @@ class Scheduler { * 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/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/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) + +} From 720771719efdda3d5cbd0fe0a7e596f1cd41e6bc Mon Sep 17 00:00:00 2001 From: Eyre_S Date: Thu, 16 Nov 2023 20:06:15 +0800 Subject: [PATCH 06/18] add err handler for UpdateListener - add err handler for UpdateListener in MornyCoeur - for network-related exception, will only output exception basic message and not report. - for other type of exception, will output all message of exception and do report. - Scheduler's runnerName now is `$classBaseName@$hashCode` --- gradle.properties | 4 +- .../cc/sukazyo/cono/morny/MornyCoeur.scala | 37 ++++++++++++++++++- .../cono/morny/util/schedule/Scheduler.scala | 6 +-- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/gradle.properties b/gradle.properties index 70ddbdf..85b3955 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.3.0-dev3 +VERSION = 1.3.0-dev4 USE_DELTA = false VERSION_DELTA = -CODENAME = xiongan +CODENAME = guanggu # dependencies diff --git a/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala b/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala index 79a5dc9..f88d9b4 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala @@ -96,7 +96,42 @@ class MornyCoeur (using val config: MornyConfig) { daemons.start() logger info "start telegram event listening" - account setUpdatesListener eventManager + import com.pengrad.telegrambot.TelegramException + account.setUpdatesListener(eventManager, (e: TelegramException) => { + + 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 + } + + 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.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 exceptionLog(e_other) + this.daemons.reporter exception e_other + } + + }) + if config.commandLoginRefresh then logger info "resetting telegram command list" commands.automaticTGListUpdate() 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 index 4eb066e..4a07a24 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Scheduler.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Scheduler.scala @@ -141,7 +141,7 @@ class Scheduler { */ //noinspection ScalaWeakerAccess def runnerName: String = - this.toString + s"${this.getClass.getSimpleName}@${this.hashCode.toHexString}" /** Add one task to scheduler task queue. * @return this scheduler for chained call. @@ -199,11 +199,11 @@ class Scheduler { 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. */ From 7ee4a0d3c5c4753a0ba91f973b979bdd647194d1 Mon Sep 17 00:00:00 2001 From: Eyre_S Date: Fri, 17 Nov 2023 01:19:22 +0800 Subject: [PATCH 07/18] add code language support for event_hack and exception report --- gradle.properties | 2 +- .../scala/cc/sukazyo/cono/morny/bot/command/Encryptor.scala | 1 + src/main/scala/cc/sukazyo/cono/morny/daemon/EventHacker.scala | 2 +- src/main/scala/cc/sukazyo/cono/morny/daemon/MornyReport.scala | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/gradle.properties b/gradle.properties index 85b3955..ed12f09 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ 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.3.0-dev4 +VERSION = 1.3.0-dev5 USE_DELTA = false VERSION_DELTA = 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/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/MornyReport.scala b/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyReport.scala index 468580f..a7ee9f9 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyReport.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyReport.scala @@ -46,7 +46,7 @@ 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 _ => "" executeReport(SendMessage( @@ -54,7 +54,7 @@ class MornyReport (using coeur: MornyCoeur) { // 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)) } From c5c66834594c7612d2a475a13982d1781ac14e6b Mon Sep 17 00:00:00 2001 From: Eyre_S Date: Mon, 20 Nov 2023 11:18:32 +0800 Subject: [PATCH 08/18] for event, fix wrong OK stats, add CANCELED tag - Now the status of EventEnv is a State array that infers the state history - State can be OK or CANCELED, and can be set multiple times - state method can get the last state set, and status method can get the state history - Default EventListener.executeFilter implementation is changed to true if stats is null - add consume[T](T=>Unit) for EventEnv, to simplifying the old consume[T](Class[T])(T=>Unit) - changed execution sort of EventListener in EventListenerManager. Now atEventPost method will be run after all events' normal listeners complete. - cha OnMedicationNotifyApply will only tag event as OK when the refresh function works (fixed part of the wrong OK state) - cha MornyOnUpdateTimestampOffsetLock tag event CANCELED but not OK to fix part of the wrong OK state --- gradle.properties | 2 +- .../sukazyo/cono/morny/bot/api/EventEnv.scala | 32 ++++- .../cono/morny/bot/api/EventListener.scala | 9 +- .../morny/bot/api/EventListenerManager.scala | 112 +++++++++--------- .../MornyOnUpdateTimestampOffsetLock.scala | 3 +- .../cono/morny/bot/event/OnCallMe.scala | 2 +- .../bot/event/OnMedicationNotifyApply.scala | 7 +- .../morny/bot/event/OnUniMeowTrigger.scala | 5 +- .../cono/morny/daemon/MedicationTimer.scala | 5 +- .../cono/morny/daemon/MornyReport.scala | 33 ++++-- 10 files changed, 131 insertions(+), 79 deletions(-) diff --git a/gradle.properties b/gradle.properties index ed12f09..24782ed 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ 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.3.0-dev5 +VERSION = 1.3.0-dev6 USE_DELTA = false VERSION_DELTA = 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 43a532f..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,9 +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 ( @@ -11,15 +13,34 @@ class EventEnv ( ) { - private var _isOk: Int = 0 + 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) + + 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 = _isOk > 0 + 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) @@ -30,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 8834060..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 @@ -17,8 +17,15 @@ trait EventListener () { * if it should not run. */ def executeFilter (using env: EventEnv): Boolean = - if env.isEventOk then false else true + 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 = {} 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 9643578..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,59 +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) { - - if (i.executeFilter) 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 - - updateThreadName("#post") - i.atEventPost - - } 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") - } - - }} + + 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/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 5dbbb9f..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} 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/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/daemon/MedicationTimer.scala b/src/main/scala/cc/sukazyo/cono/morny/daemon/MedicationTimer.scala index 1171379..8275614 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/daemon/MedicationTimer.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/daemon/MedicationTimer.scala @@ -69,8 +69,8 @@ class MedicationTimer (using coeur: MornyCoeur) { 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] @@ -82,6 +82,7 @@ class MedicationTimer (using coeur: MornyCoeur) { edited.text + s"\n-- $editTime --" ).entities(entities toArray:_*) lastNotify_messageId = None + true } } 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 a7ee9f9..307401a 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyReport.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyReport.scala @@ -134,10 +134,13 @@ class MornyReport (using coeur: MornyCoeur) { object EventStatistics { private var eventTotal = 0 + private var eventCanceled = 0 private val runningTime: NumericStatistics[DurationMillis] = NumericStatistics() def reset (): Unit = { - eventTotal = 0; runningTime.reset() + eventTotal = 0 + eventCanceled = 0 + runningTime.reset() } private def runningTimeStatisticsHTML: String = @@ -154,11 +157,13 @@ class MornyReport (using coeur: MornyCoeur) { def eventStatisticsHTML: String = import cc.sukazyo.cono.morny.util.UseMath.percentageOf as p val processed = runningTime.count - val ignored = eventTotal - processed + val canceled = eventCanceled + val ignored = eventTotal - processed - canceled // language=html s""" - total event received: $eventTotal - | - event processed: (${eventTotal p processed}%) $processed | - 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 @@ -167,13 +172,23 @@ class MornyReport (using coeur: MornyCoeur) { //noinspection ScalaWeakerAccess case class EventTimeUsed (it: DurationMillis) override def atEventPost (using event: EventEnv): Unit = { + import event.State eventTotal += 1 - if event.isEventOk then { - val timeUsed = EventTimeUsed(System.currentTimeMillis - event.timeStartup) - event provide timeUsed - logger debug s"event consumed ${timeUsed.it}ms" - runningTime ++ timeUsed.it - } + 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 => } } From 2687c3be883234b428534b43027da2fd224454bf Mon Sep 17 00:00:00 2001 From: Eyre_S Date: Mon, 20 Nov 2023 17:12:10 +0800 Subject: [PATCH 09/18] add FixTweet, removed c.vxtwitter --- gradle.properties | 2 +- .../cono/morny/bot/query/ShareToolTwitter.scala | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/gradle.properties b/gradle.properties index 24782ed..e020e0e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ 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.3.0-dev6 +VERSION = 1.3.0-dev7 USE_DELTA = false VERSION_DELTA = 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..e8661c9 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 @@ -10,10 +10,10 @@ 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]" + private val REGEX_TWEET_LINK: Regex = "^(?:https?://)?((?:(?:c\\.)?vx|fx|www\\.)?twitter|(?:www\\.|fixup)?x)\\.com/((\\w+)/status/(\\d+)(?:/photo/(\\d+))?)/?(\\?[\\w&=-]+)?$"r override def query (event: Update): List[InlineQueryUnit[_]] | Null = { @@ -24,12 +24,12 @@ class ShareToolTwitter extends ITelegramQuery { case REGEX_TWEET_LINK(_, _path_data, _, _, _, _) => List( InlineQueryUnit(InlineQueryResultArticle( - inlineQueryId(ID_PREFIX_VX+event.inlineQuery.query), TITLE_VX, - s"https://vxtwitter.com/$_path_data" + inlineQueryId(ID_PREFIX_FX + event.inlineQuery.query), TITLE_FX, + s"https://fxtwitter.com/$_path_data" )), InlineQueryUnit(InlineQueryResultArticle( - inlineQueryId(ID_PREFIX_VX_COMBINED+event.inlineQuery.query), TITLE_VX_COMBINED, - s"https://c.vxtwitter.com/$_path_data" + inlineQueryId(ID_PREFIX_VX+event.inlineQuery.query), TITLE_VX, + s"https://vxtwitter.com/$_path_data" )) ) From f8b2d056cc4cb239c8de0575226970780b41dd97 Mon Sep 17 00:00:00 2001 From: Eyre_S Date: Tue, 21 Nov 2023 23:35:12 +0800 Subject: [PATCH 10/18] FixTweet api implement, with a /tweet command --- build.gradle | 4 + gradle.properties | 3 +- .../morny/bot/command/MornyCommands.scala | 4 +- .../cono/morny/bot/command/Tweet.scala | 86 ++++++++++++++ .../morny/bot/query/ShareToolTwitter.scala | 7 +- .../cono/morny/data/twitter/FXApi.scala | 108 ++++++++++++++++++ .../cono/morny/data/twitter/FXAuthor.scala | 33 ++++++ .../morny/data/twitter/FXExternalMedia.scala | 17 +++ .../morny/data/twitter/FXMosaicPhoto.scala | 24 ++++ .../cono/morny/data/twitter/FXPhoto.scala | 16 +++ .../cono/morny/data/twitter/FXPool.scala | 15 +++ .../morny/data/twitter/FXPoolChoice.scala | 13 +++ .../cono/morny/data/twitter/FXTranslate.scala | 13 +++ .../cono/morny/data/twitter/FXTweet.scala | 90 +++++++++++++++ .../cono/morny/data/twitter/FXVideo.scala | 21 ++++ .../cono/morny/data/twitter/package.scala | 72 ++++++++++++ .../sukazyo/cono/morny/util/SttpPublic.scala | 15 +++ 17 files changed, 536 insertions(+), 5 deletions(-) create mode 100644 src/main/scala/cc/sukazyo/cono/morny/bot/command/Tweet.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXApi.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXAuthor.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXExternalMedia.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXMosaicPhoto.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXPhoto.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXPool.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXPoolChoice.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXTranslate.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXTweet.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXVideo.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/data/twitter/package.scala diff --git a/build.gradle b/build.gradle index 22da9ba..cc32c16 100644 --- a/build.gradle +++ b/build.gradle @@ -89,6 +89,9 @@ dependencies { implementation group: 'com.softwaremill.sttp.client3', name: scala('okhttp-backend'), version: lib_sttp_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: 'com.cronutils', name: 'cron-utils', version: lib_cron_utils_v // used for disable slf4j @@ -139,6 +142,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 e020e0e..1d02577 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ 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.3.0-dev7 +VERSION = 1.3.0-dev8 USE_DELTA = false VERSION_DELTA = @@ -26,6 +26,7 @@ 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/bot/command/MornyCommands.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/command/MornyCommands.scala index e924d7e..160c811 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(), + Tweet(), + $MornyManagers.SaveData, $MornyInformation, $MornyInformationOlds.Version, $MornyInformationOlds.Runtime, - MornyOldJrrp(), $MornyManagers.Exit, Testing(), diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/command/Tweet.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/command/Tweet.scala new file mode 100644 index 0000000..247819e --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/bot/command/Tweet.scala @@ -0,0 +1,86 @@ +package cc.sukazyo.cono.morny.bot.command +import cc.sukazyo.cono.morny.data.{twitter, TelegramStickers} +import cc.sukazyo.cono.morny.util.tgapi.InputCommand +import cc.sukazyo.cono.morny.MornyCoeur +import cc.sukazyo.cono.morny.data.twitter.{FXApi, TweetUrlInformation} +import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec +import cc.sukazyo.cono.morny.Log.{exceptionLog, logger} +import com.pengrad.telegrambot.model.Update +import com.pengrad.telegrambot.model.request.{InputMedia, InputMediaPhoto, InputMediaVideo, ParseMode} +import com.pengrad.telegrambot.request.{SendMediaGroup, SendMessage, SendSticker} + +class Tweet (using coeur: MornyCoeur) extends ITelegramCommand { + + override val name: String = "tweet" + override val aliases: Array[ICommandAlias] | Null = null + override val paramRule: String = "" + override val description: String = "获取 Twitter(X) Tweet 内容" + + 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 } + + twitter.parseTweetUrl(command.args(0)) match + case None => do404() + case Some(TweetUrlInformation(_, _, screenName, statusId, _, _)) => + try { + val api = FXApi.Fetch.status(Some(screenName), statusId) + import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.escapeHtml as h + api.tweet match + case None => + coeur.account exec SendMessage( + event.message.chat.id, + // language=html + s"""❌ Fix-Tweet ${api.code} + |${h(api.message)}""".stripMargin + ).replyToMessageId(event.message.messageId).parseMode(ParseMode.HTML) + 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 + tweet.media match + case None => + coeur.account exec SendMessage( + event.message.chat.id, + content + ).replyToMessageId(event.message.messageId).parseMode(ParseMode.HTML) + case Some(media) => + val mediaGroup: List[InputMedia[?]] = + ( + media.photos match + case None => List.empty + case Some(photos) => for i <- photos yield InputMediaPhoto(i.url) + ) ::: ( + media.videos match + case None => List.empty + case Some(videos) => for i <- videos yield InputMediaVideo(i.url) + ) + mediaGroup.head.caption(content) + mediaGroup.head.parseMode(ParseMode.HTML) + coeur.account exec SendMediaGroup( + event.message.chat.id, + mediaGroup:_* + ).replyToMessageId(event.message.messageId) + } catch case e: Exception => + coeur.account exec SendSticker( + event.message.chat.id, + TelegramStickers.ID_NETWORK_ERR + ).replyToMessageId(event.message.messageId) + logger attention + "Error on requesting FixTweet API\n" + exceptionLog(e) + coeur.daemons.reporter.exception(e, "Error on requesting FixTweet API") + + } + +} 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 e8661c9..4f49d9f 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.data.twitter +import cc.sukazyo.cono.morny.data.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 @@ -13,15 +15,14 @@ class ShareToolTwitter extends ITelegramQuery { private val ID_PREFIX_VX = "[morny/share/twitter/vxtwi]" private val TITLE_FX = "[tweet] Share as Fix-Tweet" private val ID_PREFIX_FX = "[morny/share/twitter/fxtwi]" - private val REGEX_TWEET_LINK: Regex = "^(?:https?://)?((?:(?:c\\.)?vx|fx|www\\.)?twitter|(?:www\\.|fixup)?x)\\.com/((\\w+)/status/(\\d+)(?:/photo/(\\d+))?)/?(\\?[\\w&=-]+)?$"r 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, diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXApi.scala b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXApi.scala new file mode 100644 index 0000000..8e48aec --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXApi.scala @@ -0,0 +1,108 @@ +package cc.sukazyo.cono.morny.data.twitter + +import cc.sukazyo.cono.morny.util.SttpPublic +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 = basicRequest + .header(SttpPublic.Headers.UserAgent.MORNY_CURRENT) + .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) match + case Left(error) => throw error + case Right(value) => value.as[FXApi] match + case Left(error) => throw error + case Right(value) => value + + } + +} diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXAuthor.scala b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXAuthor.scala new file mode 100644 index 0000000..cc4c834 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXAuthor.scala @@ -0,0 +1,33 @@ +package cc.sukazyo.cono.morny.data.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/data/twitter/FXExternalMedia.scala b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXExternalMedia.scala new file mode 100644 index 0000000..a5ef915 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXExternalMedia.scala @@ -0,0 +1,17 @@ +package cc.sukazyo.cono.morny.data.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/data/twitter/FXMosaicPhoto.scala b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXMosaicPhoto.scala new file mode 100644 index 0000000..23f7990 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXMosaicPhoto.scala @@ -0,0 +1,24 @@ +package cc.sukazyo.cono.morny.data.twitter + +import cc.sukazyo.cono.morny.data.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/data/twitter/FXPhoto.scala b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXPhoto.scala new file mode 100644 index 0000000..336c576 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXPhoto.scala @@ -0,0 +1,16 @@ +package cc.sukazyo.cono.morny.data.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/data/twitter/FXPool.scala b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXPool.scala new file mode 100644 index 0000000..6148160 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXPool.scala @@ -0,0 +1,15 @@ +package cc.sukazyo.cono.morny.data.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/data/twitter/FXPoolChoice.scala b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXPoolChoice.scala new file mode 100644 index 0000000..0ded892 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXPoolChoice.scala @@ -0,0 +1,13 @@ +package cc.sukazyo.cono.morny.data.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/data/twitter/FXTranslate.scala b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXTranslate.scala new file mode 100644 index 0000000..a49b4e3 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXTranslate.scala @@ -0,0 +1,13 @@ +package cc.sukazyo.cono.morny.data.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/data/twitter/FXTweet.scala b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXTweet.scala new file mode 100644 index 0000000..62f7417 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXTweet.scala @@ -0,0 +1,90 @@ +package cc.sukazyo.cono.morny.data.twitter + +import cc.sukazyo.cono.morny.data.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 [[]] + * 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: 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/data/twitter/FXVideo.scala b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXVideo.scala new file mode 100644 index 0000000..0884500 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXVideo.scala @@ -0,0 +1,21 @@ +package cc.sukazyo.cono.morny.data.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/data/twitter/package.scala b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/package.scala new file mode 100644 index 0000000..ee58a65 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/package.scala @@ -0,0 +1,72 @@ +package cc.sukazyo.cono.morny.data + +import scala.util.matching.Regex + +package object twitter { + + private val REGEX_TWEET_URL: Regex = "^(?:https?://)?((?:(?:(?:c\\.)?vx|fx|www\\.)?twitter|(?:www\\.|fixup)?x)\\.com)/((\\w+)/status/(\\d+)(?:/photo/(\\d+))?)/?(\\?[\\w&=-]+)?$"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` + * - 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 + +} 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..230e5a0 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,8 @@ package cc.sukazyo.cono.morny.util +import cc.sukazyo.cono.morny.MornySystem +import sttp.model.Header + object SttpPublic { object Schemes { @@ -7,4 +10,16 @@ object SttpPublic { val HTTPS = "https" } + object Headers { + + object UserAgent { + + private val key = "User-Agent" + + val MORNY_CURRENT = Header(key, s"MornyCoeur / ${MornySystem.VERSION}") + + } + + } + } From 43cdf221d92e03088aa27e05a83a98c82d9551b1 Mon Sep 17 00:00:00 2001 From: Eyre_S Date: Wed, 22 Nov 2023 16:06:15 +0800 Subject: [PATCH 11/18] fix request client failed caused unexpected exception --- gradle.properties | 2 +- .../cc/sukazyo/cono/morny/MornyCoeur.scala | 17 +++++++++--- .../cono/morny/daemon/MornyReport.scala | 26 ++++++++++++++----- .../morny/util/tgapi/TelegramExtensions.scala | 16 +++++++----- .../tgapi/event/EventRuntimeException.scala | 20 +++++++++++++- 5 files changed, 63 insertions(+), 18 deletions(-) diff --git a/gradle.properties b/gradle.properties index 1d02577..1e4944c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ 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.3.0-dev8 +VERSION = 1.3.0-dev9 USE_DELTA = false VERSION_DELTA = diff --git a/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala b/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala index f88d9b4..3f3f2d4 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala @@ -99,6 +99,14 @@ class MornyCoeur (using val config: MornyConfig) { 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 @@ -106,6 +114,7 @@ class MornyCoeur (using val config: MornyConfig) { | server responses: |${GsonBuilder().setPrettyPrinting().create.toJson(e.response) indent 4} |""".stripMargin + this.daemons.reporter.exception(e, "Failed get updates.") } if (e.getCause != null) { @@ -119,15 +128,17 @@ class MornyCoeur (using val config: MornyConfig) { import scala.collection.mutable val log = mutable.ArrayBuffer(s"Failed get updates: Network Error") var current: Throwable = e_timeout - log += s" due to: ${current.getMessage}" + 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 exceptionLog(e_other) - this.daemons.reporter exception e_other + logger error + s"""Failed get updates: + |${exceptionLog(e_other) indent 3}""".stripMargin + this.daemons.reporter.exception(e_other, "Failed get updates.") } }) 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 307401a..2ede970 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyReport.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyReport.scala @@ -19,6 +19,7 @@ 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 @@ -32,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 } } @@ -48,6 +56,10 @@ class MornyReport (using coeur: MornyCoeur) { // language=html "\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, 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..f066792 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 @@ -12,13 +12,17 @@ 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: RuntimeException => + throw EventRuntimeException.ClientFailed(e) } }} 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) + } } From d602e1b366af3213c8b48ce5c2b82d6a1baf0a56 Mon Sep 17 00:00:00 2001 From: Eyre_S Date: Thu, 23 Nov 2023 17:57:29 +0800 Subject: [PATCH 12/18] set morny UA for all HTTP req, add twitter tests --- gradle.properties | 2 +- .../cono/morny/data/BilibiliForms.scala | 7 +- .../cono/morny/data/NbnhhshQuery.scala | 5 +- .../morny/data/ip186/IP186QueryHandler.scala | 6 +- .../cono/morny/data/twitter/FXApi.scala | 3 +- .../cono/morny/data/twitter/FXTweet.scala | 4 +- .../cono/morny/data/twitter/package.scala | 4 +- .../sukazyo/cono/morny/util/SttpPublic.scala | 7 +- .../formatting/TelegramUserInformation.scala | 5 +- .../morny/test/data/twitter/FXApiTest.scala | 82 ++++++++++++ .../morny/test/data/twitter/PackageTest.scala | 123 ++++++++++++++++++ 11 files changed, 231 insertions(+), 17 deletions(-) create mode 100644 src/test/scala/cc/sukazyo/cono/morny/test/data/twitter/FXApiTest.scala create mode 100644 src/test/scala/cc/sukazyo/cono/morny/test/data/twitter/PackageTest.scala diff --git a/gradle.properties b/gradle.properties index 1e4944c..aab8e75 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ 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.3.0-dev9 +VERSION = 1.3.0-dev10 USE_DELTA = false VERSION_DELTA = diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/BilibiliForms.scala b/src/main/scala/cc/sukazyo/cono/morny/data/BilibiliForms.scala index c2bee8e..d018119 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/BilibiliForms.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/data/BilibiliForms.scala @@ -1,9 +1,9 @@ package cc.sukazyo.cono.morny.data 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/data/NbnhhshQuery.scala index 4322739..1fd00fb 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/NbnhhshQuery.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/data/NbnhhshQuery.scala @@ -1,7 +1,8 @@ package cc.sukazyo.cono.morny.data +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/data/ip186/IP186QueryHandler.scala index 178e21d..3bc351c 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/ip186/IP186QueryHandler.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/data/ip186/IP186QueryHandler.scala @@ -1,7 +1,7 @@ package cc.sukazyo.cono.morny.data.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/twitter/FXApi.scala b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXApi.scala index 8e48aec..828aba1 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXApi.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXApi.scala @@ -1,6 +1,7 @@ package cc.sukazyo.cono.morny.data.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. @@ -89,7 +90,7 @@ object FXApi { */ @throws[SttpClientException|ParsingFailure|DecodingFailure] def status (screen_name: Option[String], id: String, translate_to: Option[String] = None): FXApi = - val get = basicRequest + val get = mornyBasicRequest .header(SttpPublic.Headers.UserAgent.MORNY_CURRENT) .get(uri_status(screen_name, id, translate_to)) .response(asString) diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXTweet.scala b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXTweet.scala index 62f7417..2bc75e3 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXTweet.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXTweet.scala @@ -16,7 +16,7 @@ import cc.sukazyo.cono.morny.util.EpochDateTime.EpochSeconds * @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 [[]] + * 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 @@ -48,7 +48,7 @@ case class FXTweet ( replying_to: Option[String], replying_to_status: Option[String], // twitter_card: "tweet"|"summary"|"summary_large_image"|"player", - twitter_card: String, + twitter_card: Option[String], author: FXAuthor, source: String, diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/package.scala b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/package.scala index ee58a65..73e6259 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/package.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/package.scala @@ -4,7 +4,7 @@ import scala.util.matching.Regex package object twitter { - private val REGEX_TWEET_URL: Regex = "^(?:https?://)?((?:(?:(?:c\\.)?vx|fx|www\\.)?twitter|(?:www\\.|fixup)?x)\\.com)/((\\w+)/status/(\\d+)(?:/photo/(\\d+))?)/?(\\?[\\w&=-]+)?$"r + private val REGEX_TWEET_URL: Regex = "^(?:https?://)?((?:(?:(?:c\\.)?vx|fx|www\\.)?twitter|(?:www\\.|fixup|fixv)?x)\\.com)/((\\w+)/status/(\\d+)(?:/photo/(\\d+))?)/?(?:\\?([\\w&=-]+))?$"r /** Messages that can contains on a tweet url. * @@ -49,7 +49,7 @@ package object twitter { * - `twitter.com` or `www.twitter.com` * - `x.com` or `www.x.com` * - `fxtwitter.com` or `fixupx.com` - * - `vxtwitter.com` or `c.vxtwitter.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 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 230e5a0..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,6 +1,7 @@ package cc.sukazyo.cono.morny.util import cc.sukazyo.cono.morny.MornySystem +import sttp.client3.basicRequest import sttp.model.Header object SttpPublic { @@ -16,10 +17,14 @@ object SttpPublic { private val key = "User-Agent" - val MORNY_CURRENT = Header(key, s"MornyCoeur / ${MornySystem.VERSION}") + 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/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/test/scala/cc/sukazyo/cono/morny/test/data/twitter/FXApiTest.scala b/src/test/scala/cc/sukazyo/cono/morny/test/data/twitter/FXApiTest.scala new file mode 100644 index 0000000..b687f86 --- /dev/null +++ b/src/test/scala/cc/sukazyo/cono/morny/test/data/twitter/FXApiTest.scala @@ -0,0 +1,82 @@ +package cc.sukazyo.cono.morny.test.data.twitter + +import cc.sukazyo.cono.morny.data.twitter.FXApi +import cc.sukazyo.cono.morny.data.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/data/twitter/PackageTest.scala b/src/test/scala/cc/sukazyo/cono/morny/test/data/twitter/PackageTest.scala new file mode 100644 index 0000000..1510dc4 --- /dev/null +++ b/src/test/scala/cc/sukazyo/cono/morny/test/data/twitter/PackageTest.scala @@ -0,0 +1,123 @@ +package cc.sukazyo.cono.morny.test.data.twitter + +import cc.sukazyo.cono.morny.data.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) + } + + } + +} From a9767ec1b07f8a667fad51cf36e0c320a803d323 Mon Sep 17 00:00:00 2001 From: Eyre_S Date: Mon, 27 Nov 2023 18:58:35 +0800 Subject: [PATCH 13/18] change /tweet to /get and added support for weibo content --- build.gradle | 1 + gradle.properties | 2 +- .../command/{Tweet.scala => GetSocial.scala} | 70 ++++++++++++-- .../morny/bot/command/MornyCommands.scala | 2 +- .../cono/morny/data/twitter/FXApi.scala | 10 +- .../sukazyo/cono/morny/data/weibo/MApi.scala | 66 +++++++++++++ .../sukazyo/cono/morny/data/weibo/MPic.scala | 33 +++++++ .../cono/morny/data/weibo/MStatus.scala | 92 +++++++++++++++++++ .../sukazyo/cono/morny/data/weibo/MUser.scala | 13 +++ .../cono/morny/data/weibo/package.scala | 40 ++++++++ .../morny/util/tgapi/TelegramExtensions.scala | 6 +- .../formatting/TelegramParseEscape.scala | 57 ++++++++++++ 12 files changed, 374 insertions(+), 18 deletions(-) rename src/main/scala/cc/sukazyo/cono/morny/bot/command/{Tweet.scala => GetSocial.scala} (53%) create mode 100644 src/main/scala/cc/sukazyo/cono/morny/data/weibo/MApi.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/data/weibo/MPic.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/data/weibo/MStatus.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/data/weibo/MUser.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/data/weibo/package.scala diff --git a/build.gradle b/build.gradle index cc32c16..a436ee8 100644 --- a/build.gradle +++ b/build.gradle @@ -92,6 +92,7 @@ dependencies { 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 diff --git a/gradle.properties b/gradle.properties index aab8e75..51fff15 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ 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.3.0-dev10 +VERSION = 1.3.0-dev11 USE_DELTA = false VERSION_DELTA = diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/command/Tweet.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/command/GetSocial.scala similarity index 53% rename from src/main/scala/cc/sukazyo/cono/morny/bot/command/Tweet.scala rename to src/main/scala/cc/sukazyo/cono/morny/bot/command/GetSocial.scala index 247819e..3b96200 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/bot/command/Tweet.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/bot/command/GetSocial.scala @@ -1,20 +1,23 @@ package cc.sukazyo.cono.morny.bot.command -import cc.sukazyo.cono.morny.data.{twitter, TelegramStickers} +import cc.sukazyo.cono.morny.data.{twitter, weibo, TelegramStickers} import cc.sukazyo.cono.morny.util.tgapi.InputCommand import cc.sukazyo.cono.morny.MornyCoeur import cc.sukazyo.cono.morny.data.twitter.{FXApi, TweetUrlInformation} import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec import cc.sukazyo.cono.morny.Log.{exceptionLog, logger} +import cc.sukazyo.cono.morny.data.weibo.StatusUrlInfo import com.pengrad.telegrambot.model.Update import com.pengrad.telegrambot.model.request.{InputMedia, InputMediaPhoto, InputMediaVideo, ParseMode} import com.pengrad.telegrambot.request.{SendMediaGroup, SendMessage, SendSticker} +import io.circe.{DecodingFailure, ParsingFailure} +import sttp.client3.{HttpError, SttpClientException} -class Tweet (using coeur: MornyCoeur) extends ITelegramCommand { +class GetSocial (using coeur: MornyCoeur) extends ITelegramCommand { - override val name: String = "tweet" + override val name: String = "get" override val aliases: Array[ICommandAlias] | Null = null - override val paramRule: String = "" - override val description: String = "获取 Twitter(X) Tweet 内容" + override val paramRule: String = "" + override val description: String = "从社交媒体分享链接获取其内容" override def execute (using command: InputCommand, event: Update): Unit = { @@ -26,9 +29,11 @@ class Tweet (using coeur: MornyCoeur) extends ITelegramCommand { if command.args.length < 1 then { do404(); return } + var succeed = 0 twitter.parseTweetUrl(command.args(0)) match - case None => do404() + case None => case Some(TweetUrlInformation(_, _, screenName, statusId, _, _)) => + succeed += 1 try { val api = FXApi.Fetch.status(Some(screenName), statusId) import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.escapeHtml as h @@ -72,15 +77,64 @@ class Tweet (using coeur: MornyCoeur) extends ITelegramCommand { event.message.chat.id, mediaGroup:_* ).replyToMessageId(event.message.messageId) - } catch case e: Exception => + } catch case e: (SttpClientException|ParsingFailure|DecodingFailure) => coeur.account exec SendSticker( event.message.chat.id, TelegramStickers.ID_NETWORK_ERR ).replyToMessageId(event.message.messageId) - logger attention + logger error "Error on requesting FixTweet API\n" + exceptionLog(e) coeur.daemons.reporter.exception(e, "Error on requesting FixTweet API") + weibo.parseWeiboStatusUrl(command.args(0)) match + case None => + case Some(StatusUrlInfo(_, id)) => + succeed += 1 + try { + val api = weibo.MApi.Fetch.statuses_show(id) + import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.{cleanupHtml as ch, escapeHtml as h} + val content = + // language=html + s"""🔸${h(api.data.user.screen_name)} + | + |${ch(api.data.text)} + | + |${h(api.data.created_at)}""".stripMargin + api.data.pics match + case None => + coeur.account exec SendMessage( + event.message.chat.id, + content + ).replyToMessageId(event.message.messageId).parseMode(ParseMode.HTML) + case Some(pics) => +// val mediaGroup = pics.map(f => +// InputMediaPhoto(weibo.PicUrl(weibo.randomPicCdn, "large", f.pid).toUrl)) + val mediaGroup = pics.map(f => InputMediaPhoto(weibo.MApi.Fetch.pic(f.large.url))) + mediaGroup.head.caption(content) + mediaGroup.head.parseMode(ParseMode.HTML) + coeur.account exec SendMediaGroup( + event.message.chat.id, + mediaGroup:_* + ).replyToMessageId(event.message.messageId) + } catch + case e: HttpError[?] => + coeur.account exec SendMessage( + event.message.chat.id, + // language=html + s"""Weibo Request Error ${e.statusCode} + |
${e.body}
""".stripMargin + ).replyToMessageId(event.message.messageId).parseMode(ParseMode.HTML) + case e: (SttpClientException|ParsingFailure|DecodingFailure) => + coeur.account exec SendSticker( + event.message.chat.id, + TelegramStickers.ID_NETWORK_ERR + ).replyToMessageId(event.message.messageId) + logger error + "Error on requesting Weibo m.API\n" + exceptionLog(e) + coeur.daemons.reporter.exception(e, "Error on requesting Weibo m.API") + + if succeed == 0 then do404() + } } 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 160c811..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 @@ -44,7 +44,7 @@ class MornyCommands (using coeur: MornyCoeur) { $IP186Query.Whois, Encryptor(), MornyOldJrrp(), - Tweet(), + GetSocial(), $MornyManagers.SaveData, $MornyInformation, diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXApi.scala b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXApi.scala index 828aba1..7f5ce51 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXApi.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXApi.scala @@ -91,18 +91,16 @@ object FXApi { @throws[SttpClientException|ParsingFailure|DecodingFailure] def status (screen_name: Option[String], id: String, translate_to: Option[String] = None): FXApi = val get = mornyBasicRequest - .header(SttpPublic.Headers.UserAgent.MORNY_CURRENT) .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) match - case Left(error) => throw error - case Right(value) => value.as[FXApi] match - case Left(error) => throw error - case Right(value) => value + parser.parse(body) + .toTry.get + .as[FXApi] + .toTry.get } diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/weibo/MApi.scala b/src/main/scala/cc/sukazyo/cono/morny/data/weibo/MApi.scala new file mode 100644 index 0000000..a576db5 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/data/weibo/MApi.scala @@ -0,0 +1,66 @@ +package cc.sukazyo.cono.morny.data.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/data/weibo/MPic.scala b/src/main/scala/cc/sukazyo/cono/morny/data/weibo/MPic.scala new file mode 100644 index 0000000..9701728 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/data/weibo/MPic.scala @@ -0,0 +1,33 @@ +package cc.sukazyo.cono.morny.data.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/data/weibo/MStatus.scala b/src/main/scala/cc/sukazyo/cono/morny/data/weibo/MStatus.scala new file mode 100644 index 0000000..b7571ab --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/data/weibo/MStatus.scala @@ -0,0 +1,92 @@ +package cc.sukazyo.cono.morny.data.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/data/weibo/MUser.scala b/src/main/scala/cc/sukazyo/cono/morny/data/weibo/MUser.scala new file mode 100644 index 0000000..8a8d8d7 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/data/weibo/MUser.scala @@ -0,0 +1,13 @@ +package cc.sukazyo.cono.morny.data.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/data/weibo/package.scala b/src/main/scala/cc/sukazyo/cono/morny/data/weibo/package.scala new file mode 100644 index 0000000..19b9dcb --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/data/weibo/package.scala @@ -0,0 +1,40 @@ +package cc.sukazyo.cono.morny.data + +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]+)/?(?:\\?([\\w&=-]+))?$"r + + def parseWeiboStatusUrl (url: String): Option[StatusUrlInfo] = + url match + case REGEX_WEIBO_STATUS_URL(_, uid, id, _) => Some(StatusUrlInfo(uid, id)) + case _ => None + + 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/tgapi/TelegramExtensions.scala b/src/main/scala/cc/sukazyo/cono/morny/util/tgapi/TelegramExtensions.scala index f066792..d4b9bb3 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 @@ -21,8 +21,10 @@ object TelegramExtensions { if onError_message isEmpty then response.errorCode toString else onError_message, response ) - } catch case e: RuntimeException => - throw EventRuntimeException.ClientFailed(e) + } catch + case e: EventRuntimeException.ActionFailed => throw e + case e: RuntimeException => + throw EventRuntimeException.ClientFailed(e) } }} 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 + } From 3d1699ea1dd9a3ac9406edd4974dd719a2a0df8d Mon Sep 17 00:00:00 2001 From: Eyre_S Date: Wed, 29 Nov 2023 00:15:13 +0800 Subject: [PATCH 14/18] move external API docking code to `extra` package from `data` --- gradle.properties | 2 +- .../cc/sukazyo/cono/morny/bot/command/GetSocial.scala | 7 ++++--- .../cc/sukazyo/cono/morny/bot/command/IP186Query.scala | 2 +- .../scala/cc/sukazyo/cono/morny/bot/command/Nbnhhsh.scala | 3 ++- .../sukazyo/cono/morny/bot/query/ShareToolBilibili.scala | 2 +- .../cc/sukazyo/cono/morny/bot/query/ShareToolTwitter.scala | 4 ++-- .../sukazyo/cono/morny/{data => extra}/BilibiliForms.scala | 2 +- .../sukazyo/cono/morny/{data => extra}/NbnhhshQuery.scala | 2 +- .../morny/{data => extra}/ip186/IP186QueryHandler.scala | 2 +- .../cono/morny/{data => extra}/ip186/IP186Response.scala | 2 +- .../sukazyo/cono/morny/{data => extra}/twitter/FXApi.scala | 2 +- .../cono/morny/{data => extra}/twitter/FXAuthor.scala | 2 +- .../morny/{data => extra}/twitter/FXExternalMedia.scala | 2 +- .../cono/morny/{data => extra}/twitter/FXMosaicPhoto.scala | 4 ++-- .../cono/morny/{data => extra}/twitter/FXPhoto.scala | 2 +- .../cono/morny/{data => extra}/twitter/FXPool.scala | 2 +- .../cono/morny/{data => extra}/twitter/FXPoolChoice.scala | 2 +- .../cono/morny/{data => extra}/twitter/FXTranslate.scala | 2 +- .../cono/morny/{data => extra}/twitter/FXTweet.scala | 4 ++-- .../cono/morny/{data => extra}/twitter/FXVideo.scala | 2 +- .../cono/morny/{data => extra}/twitter/package.scala | 2 +- .../cc/sukazyo/cono/morny/{data => extra}/weibo/MApi.scala | 2 +- .../cc/sukazyo/cono/morny/{data => extra}/weibo/MPic.scala | 2 +- .../sukazyo/cono/morny/{data => extra}/weibo/MStatus.scala | 2 +- .../sukazyo/cono/morny/{data => extra}/weibo/MUser.scala | 2 +- .../sukazyo/cono/morny/{data => extra}/weibo/package.scala | 2 +- .../morny/test/{data => extra}/BilibiliFormsTest.scala | 4 ++-- .../morny/test/{data => extra}/twitter/FXApiTest.scala | 6 +++--- .../morny/test/{data => extra}/twitter/PackageTest.scala | 4 ++-- 29 files changed, 40 insertions(+), 38 deletions(-) rename src/main/scala/cc/sukazyo/cono/morny/{data => extra}/BilibiliForms.scala (99%) rename src/main/scala/cc/sukazyo/cono/morny/{data => extra}/NbnhhshQuery.scala (96%) rename src/main/scala/cc/sukazyo/cono/morny/{data => extra}/ip186/IP186QueryHandler.scala (97%) rename src/main/scala/cc/sukazyo/cono/morny/{data => extra}/ip186/IP186Response.scala (56%) rename src/main/scala/cc/sukazyo/cono/morny/{data => extra}/twitter/FXApi.scala (99%) rename src/main/scala/cc/sukazyo/cono/morny/{data => extra}/twitter/FXAuthor.scala (95%) rename src/main/scala/cc/sukazyo/cono/morny/{data => extra}/twitter/FXExternalMedia.scala (89%) rename src/main/scala/cc/sukazyo/cono/morny/{data => extra}/twitter/FXMosaicPhoto.scala (82%) rename src/main/scala/cc/sukazyo/cono/morny/{data => extra}/twitter/FXPhoto.scala (89%) rename src/main/scala/cc/sukazyo/cono/morny/{data => extra}/twitter/FXPool.scala (89%) rename src/main/scala/cc/sukazyo/cono/morny/{data => extra}/twitter/FXPoolChoice.scala (88%) rename src/main/scala/cc/sukazyo/cono/morny/{data => extra}/twitter/FXTranslate.scala (88%) rename src/main/scala/cc/sukazyo/cono/morny/{data => extra}/twitter/FXTweet.scala (96%) rename src/main/scala/cc/sukazyo/cono/morny/{data => extra}/twitter/FXVideo.scala (92%) rename src/main/scala/cc/sukazyo/cono/morny/{data => extra}/twitter/package.scala (98%) rename src/main/scala/cc/sukazyo/cono/morny/{data => extra}/weibo/MApi.scala (97%) rename src/main/scala/cc/sukazyo/cono/morny/{data => extra}/weibo/MPic.scala (90%) rename src/main/scala/cc/sukazyo/cono/morny/{data => extra}/weibo/MStatus.scala (97%) rename src/main/scala/cc/sukazyo/cono/morny/{data => extra}/weibo/MUser.scala (83%) rename src/main/scala/cc/sukazyo/cono/morny/{data => extra}/weibo/package.scala (96%) rename src/test/scala/cc/sukazyo/cono/morny/test/{data => extra}/BilibiliFormsTest.scala (98%) rename src/test/scala/cc/sukazyo/cono/morny/test/{data => extra}/twitter/FXApiTest.scala (95%) rename src/test/scala/cc/sukazyo/cono/morny/test/{data => extra}/twitter/PackageTest.scala (97%) diff --git a/gradle.properties b/gradle.properties index 51fff15..a0939eb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ 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.3.0-dev11 +VERSION = 1.3.0-dev11.1 USE_DELTA = false VERSION_DELTA = 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 index 3b96200..fb2a0c3 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/bot/command/GetSocial.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/bot/command/GetSocial.scala @@ -1,11 +1,12 @@ package cc.sukazyo.cono.morny.bot.command -import cc.sukazyo.cono.morny.data.{twitter, weibo, TelegramStickers} +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.data.twitter.{FXApi, TweetUrlInformation} +import cc.sukazyo.cono.morny.extra.{twitter, weibo} +import cc.sukazyo.cono.morny.extra.twitter.{FXApi, TweetUrlInformation} import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec import cc.sukazyo.cono.morny.Log.{exceptionLog, logger} -import cc.sukazyo.cono.morny.data.weibo.StatusUrlInfo +import cc.sukazyo.cono.morny.extra.weibo.StatusUrlInfo import com.pengrad.telegrambot.model.Update import com.pengrad.telegrambot.model.request.{InputMedia, InputMediaPhoto, InputMediaVideo, ParseMode} import com.pengrad.telegrambot.request.{SendMediaGroup, SendMessage, SendSticker} 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/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/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/ShareToolTwitter.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/query/ShareToolTwitter.scala index 4f49d9f..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,7 +1,7 @@ package cc.sukazyo.cono.morny.bot.query -import cc.sukazyo.cono.morny.data.twitter -import cc.sukazyo.cono.morny.data.twitter.TweetUrlInformation +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 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 99% 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 d018119..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,4 +1,4 @@ -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.{mornyBasicRequest, Schemes} 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 96% 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 1fd00fb..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,4 +1,4 @@ -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 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 97% 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 3bc351c..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,4 +1,4 @@ -package cc.sukazyo.cono.morny.data.ip186 +package cc.sukazyo.cono.morny.extra.ip186 import cc.sukazyo.cono.morny.util.SttpPublic.{mornyBasicRequest, Schemes} import sttp.client3.{asString, HttpError, SttpClientException, UriContext} 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/data/twitter/FXApi.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXApi.scala similarity index 99% rename from src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXApi.scala rename to src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXApi.scala index 7f5ce51..11a4af8 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXApi.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXApi.scala @@ -1,4 +1,4 @@ -package cc.sukazyo.cono.morny.data.twitter +package cc.sukazyo.cono.morny.extra.twitter import cc.sukazyo.cono.morny.util.SttpPublic import cc.sukazyo.cono.morny.util.SttpPublic.mornyBasicRequest diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXAuthor.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXAuthor.scala similarity index 95% rename from src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXAuthor.scala rename to src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXAuthor.scala index cc4c834..1aacb81 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXAuthor.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXAuthor.scala @@ -1,4 +1,4 @@ -package cc.sukazyo.cono.morny.data.twitter +package cc.sukazyo.cono.morny.extra.twitter /** Information about the author of a tweet. * diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXExternalMedia.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXExternalMedia.scala similarity index 89% rename from src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXExternalMedia.scala rename to src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXExternalMedia.scala index a5ef915..202c55e 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXExternalMedia.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXExternalMedia.scala @@ -1,4 +1,4 @@ -package cc.sukazyo.cono.morny.data.twitter +package cc.sukazyo.cono.morny.extra.twitter /** Data for external media, currently only video. * diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXMosaicPhoto.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXMosaicPhoto.scala similarity index 82% rename from src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXMosaicPhoto.scala rename to src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXMosaicPhoto.scala index 23f7990..4fd6453 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXMosaicPhoto.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXMosaicPhoto.scala @@ -1,6 +1,6 @@ -package cc.sukazyo.cono.morny.data.twitter +package cc.sukazyo.cono.morny.extra.twitter -import cc.sukazyo.cono.morny.data.twitter.FXMosaicPhoto.formatsType +import cc.sukazyo.cono.morny.extra.twitter.FXMosaicPhoto.formatsType /** Data for the mosaic service, which stitches photos together * diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXPhoto.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXPhoto.scala similarity index 89% rename from src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXPhoto.scala rename to src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXPhoto.scala index 336c576..d759110 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXPhoto.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXPhoto.scala @@ -1,4 +1,4 @@ -package cc.sukazyo.cono.morny.data.twitter +package cc.sukazyo.cono.morny.extra.twitter /** This can help compare items in a pool of media * diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXPool.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXPool.scala similarity index 89% rename from src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXPool.scala rename to src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXPool.scala index 6148160..bfc7f80 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXPool.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXPool.scala @@ -1,4 +1,4 @@ -package cc.sukazyo.cono.morny.data.twitter +package cc.sukazyo.cono.morny.extra.twitter /** Data for a poll on a given Tweet. * diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXPoolChoice.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXPoolChoice.scala similarity index 88% rename from src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXPoolChoice.scala rename to src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXPoolChoice.scala index 0ded892..1dfe0bd 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXPoolChoice.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXPoolChoice.scala @@ -1,4 +1,4 @@ -package cc.sukazyo.cono.morny.data.twitter +package cc.sukazyo.cono.morny.extra.twitter /** Data for a single choice in a poll * diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXTranslate.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXTranslate.scala similarity index 88% rename from src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXTranslate.scala rename to src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXTranslate.scala index a49b4e3..0f6a08b 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXTranslate.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXTranslate.scala @@ -1,4 +1,4 @@ -package cc.sukazyo.cono.morny.data.twitter +package cc.sukazyo.cono.morny.extra.twitter /** Information about a requested translation for a Tweet, when asked. * diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXTweet.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXTweet.scala similarity index 96% rename from src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXTweet.scala rename to src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXTweet.scala index 2bc75e3..b4b2b57 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXTweet.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXTweet.scala @@ -1,6 +1,6 @@ -package cc.sukazyo.cono.morny.data.twitter +package cc.sukazyo.cono.morny.extra.twitter -import cc.sukazyo.cono.morny.data.twitter.FXTweet.mediaType +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. diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXVideo.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXVideo.scala similarity index 92% rename from src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXVideo.scala rename to src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXVideo.scala index 0884500..b58f133 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/FXVideo.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXVideo.scala @@ -1,4 +1,4 @@ -package cc.sukazyo.cono.morny.data.twitter +package cc.sukazyo.cono.morny.extra.twitter /** Data for a Tweet's video * diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/package.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/package.scala similarity index 98% rename from src/main/scala/cc/sukazyo/cono/morny/data/twitter/package.scala rename to src/main/scala/cc/sukazyo/cono/morny/extra/twitter/package.scala index 73e6259..05c9b03 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/twitter/package.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/package.scala @@ -1,4 +1,4 @@ -package cc.sukazyo.cono.morny.data +package cc.sukazyo.cono.morny.extra import scala.util.matching.Regex diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/weibo/MApi.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MApi.scala similarity index 97% rename from src/main/scala/cc/sukazyo/cono/morny/data/weibo/MApi.scala rename to src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MApi.scala index a576db5..c87236f 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/weibo/MApi.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MApi.scala @@ -1,4 +1,4 @@ -package cc.sukazyo.cono.morny.data.weibo +package cc.sukazyo.cono.morny.extra.weibo case class MApi [D] ( ok: Int, diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/weibo/MPic.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MPic.scala similarity index 90% rename from src/main/scala/cc/sukazyo/cono/morny/data/weibo/MPic.scala rename to src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MPic.scala index 9701728..d9d2167 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/weibo/MPic.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MPic.scala @@ -1,4 +1,4 @@ -package cc.sukazyo.cono.morny.data.weibo +package cc.sukazyo.cono.morny.extra.weibo case class MPic ( pid: String, diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/weibo/MStatus.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MStatus.scala similarity index 97% rename from src/main/scala/cc/sukazyo/cono/morny/data/weibo/MStatus.scala rename to src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MStatus.scala index b7571ab..8d52be6 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/weibo/MStatus.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MStatus.scala @@ -1,4 +1,4 @@ -package cc.sukazyo.cono.morny.data.weibo +package cc.sukazyo.cono.morny.extra.weibo case class MStatus ( diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/weibo/MUser.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MUser.scala similarity index 83% rename from src/main/scala/cc/sukazyo/cono/morny/data/weibo/MUser.scala rename to src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MUser.scala index 8a8d8d7..f01fff5 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/weibo/MUser.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MUser.scala @@ -1,4 +1,4 @@ -package cc.sukazyo.cono.morny.data.weibo +package cc.sukazyo.cono.morny.extra.weibo case class MUser ( diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/weibo/package.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/package.scala similarity index 96% rename from src/main/scala/cc/sukazyo/cono/morny/data/weibo/package.scala rename to src/main/scala/cc/sukazyo/cono/morny/extra/weibo/package.scala index 19b9dcb..80bfb5e 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/weibo/package.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/package.scala @@ -1,4 +1,4 @@ -package cc.sukazyo.cono.morny.data +package cc.sukazyo.cono.morny.extra package object weibo { 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 98% 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 6461e37..6ad92ac 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,6 +1,6 @@ -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} diff --git a/src/test/scala/cc/sukazyo/cono/morny/test/data/twitter/FXApiTest.scala b/src/test/scala/cc/sukazyo/cono/morny/test/extra/twitter/FXApiTest.scala similarity index 95% rename from src/test/scala/cc/sukazyo/cono/morny/test/data/twitter/FXApiTest.scala rename to src/test/scala/cc/sukazyo/cono/morny/test/extra/twitter/FXApiTest.scala index b687f86..7a84054 100644 --- a/src/test/scala/cc/sukazyo/cono/morny/test/data/twitter/FXApiTest.scala +++ b/src/test/scala/cc/sukazyo/cono/morny/test/extra/twitter/FXApiTest.scala @@ -1,7 +1,7 @@ -package cc.sukazyo.cono.morny.test.data.twitter +package cc.sukazyo.cono.morny.test.extra.twitter -import cc.sukazyo.cono.morny.data.twitter.FXApi -import cc.sukazyo.cono.morny.data.twitter.FXApi.Fetch +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} diff --git a/src/test/scala/cc/sukazyo/cono/morny/test/data/twitter/PackageTest.scala b/src/test/scala/cc/sukazyo/cono/morny/test/extra/twitter/PackageTest.scala similarity index 97% rename from src/test/scala/cc/sukazyo/cono/morny/test/data/twitter/PackageTest.scala rename to src/test/scala/cc/sukazyo/cono/morny/test/extra/twitter/PackageTest.scala index 1510dc4..e65189e 100644 --- a/src/test/scala/cc/sukazyo/cono/morny/test/data/twitter/PackageTest.scala +++ b/src/test/scala/cc/sukazyo/cono/morny/test/extra/twitter/PackageTest.scala @@ -1,6 +1,6 @@ -package cc.sukazyo.cono.morny.test.data.twitter +package cc.sukazyo.cono.morny.test.extra.twitter -import cc.sukazyo.cono.morny.data.twitter.{parseTweetUrl, TweetUrlInformation} +import cc.sukazyo.cono.morny.extra.twitter.{parseTweetUrl, TweetUrlInformation} import cc.sukazyo.cono.morny.test.MornyTests class PackageTest extends MornyTests { From 79d41d5e723aed425949f0fed466b2a70cbd8ba4 Mon Sep 17 00:00:00 2001 From: Eyre_S Date: Wed, 29 Nov 2023 17:16:02 +0800 Subject: [PATCH 15/18] basic inline get social function - Now supported get social content from inline - use a supported url with prefix or suffix "get" - only support twitter photos media - support all types of pure text content. - trying get non-supported medias may cause failure. --- gradle.properties | 2 +- .../cono/morny/bot/command/GetSocial.scala | 117 +----------------- .../morny/bot/event/MornyEventListeners.scala | 1 + .../cono/morny/bot/event/OnGetSocial.scala | 93 ++++++++++++++ .../cono/morny/bot/query/MornyQueries.scala | 3 +- .../bot/query/ShareToolSocialContent.scala | 43 +++++++ .../morny/data/social/SocialContent.scala | 102 +++++++++++++++ .../data/social/SocialTwitterParser.scala | 52 ++++++++ .../morny/data/social/SocialWeiboParser.scala | 29 +++++ 9 files changed, 327 insertions(+), 115 deletions(-) create mode 100644 src/main/scala/cc/sukazyo/cono/morny/bot/event/OnGetSocial.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/bot/query/ShareToolSocialContent.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/data/social/SocialContent.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/data/social/SocialTwitterParser.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/data/social/SocialWeiboParser.scala diff --git a/gradle.properties b/gradle.properties index a0939eb..f42f3ab 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ 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.3.0-dev11.1 +VERSION = 1.3.0-dev12 USE_DELTA = false VERSION_DELTA = 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 index fb2a0c3..0cb9bc7 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/bot/command/GetSocial.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/bot/command/GetSocial.scala @@ -2,16 +2,10 @@ 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.extra.{twitter, weibo} -import cc.sukazyo.cono.morny.extra.twitter.{FXApi, TweetUrlInformation} +import cc.sukazyo.cono.morny.bot.event.OnGetSocial import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec -import cc.sukazyo.cono.morny.Log.{exceptionLog, logger} -import cc.sukazyo.cono.morny.extra.weibo.StatusUrlInfo import com.pengrad.telegrambot.model.Update -import com.pengrad.telegrambot.model.request.{InputMedia, InputMediaPhoto, InputMediaVideo, ParseMode} -import com.pengrad.telegrambot.request.{SendMediaGroup, SendMessage, SendSticker} -import io.circe.{DecodingFailure, ParsingFailure} -import sttp.client3.{HttpError, SttpClientException} +import com.pengrad.telegrambot.request.SendSticker class GetSocial (using coeur: MornyCoeur) extends ITelegramCommand { @@ -30,111 +24,8 @@ class GetSocial (using coeur: MornyCoeur) extends ITelegramCommand { if command.args.length < 1 then { do404(); return } - var succeed = 0 - twitter.parseTweetUrl(command.args(0)) match - case None => - case Some(TweetUrlInformation(_, _, screenName, statusId, _, _)) => - succeed += 1 - try { - val api = FXApi.Fetch.status(Some(screenName), statusId) - import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.escapeHtml as h - api.tweet match - case None => - coeur.account exec SendMessage( - event.message.chat.id, - // language=html - s"""❌ Fix-Tweet ${api.code} - |${h(api.message)}""".stripMargin - ).replyToMessageId(event.message.messageId).parseMode(ParseMode.HTML) - 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 - tweet.media match - case None => - coeur.account exec SendMessage( - event.message.chat.id, - content - ).replyToMessageId(event.message.messageId).parseMode(ParseMode.HTML) - case Some(media) => - val mediaGroup: List[InputMedia[?]] = - ( - media.photos match - case None => List.empty - case Some(photos) => for i <- photos yield InputMediaPhoto(i.url) - ) ::: ( - media.videos match - case None => List.empty - case Some(videos) => for i <- videos yield InputMediaVideo(i.url) - ) - mediaGroup.head.caption(content) - mediaGroup.head.parseMode(ParseMode.HTML) - coeur.account exec SendMediaGroup( - event.message.chat.id, - mediaGroup:_* - ).replyToMessageId(event.message.messageId) - } catch case e: (SttpClientException|ParsingFailure|DecodingFailure) => - coeur.account exec SendSticker( - event.message.chat.id, - TelegramStickers.ID_NETWORK_ERR - ).replyToMessageId(event.message.messageId) - logger error - "Error on requesting FixTweet API\n" + exceptionLog(e) - coeur.daemons.reporter.exception(e, "Error on requesting FixTweet API") - - weibo.parseWeiboStatusUrl(command.args(0)) match - case None => - case Some(StatusUrlInfo(_, id)) => - succeed += 1 - try { - val api = weibo.MApi.Fetch.statuses_show(id) - import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.{cleanupHtml as ch, escapeHtml as h} - val content = - // language=html - s"""🔸${h(api.data.user.screen_name)} - | - |${ch(api.data.text)} - | - |${h(api.data.created_at)}""".stripMargin - api.data.pics match - case None => - coeur.account exec SendMessage( - event.message.chat.id, - content - ).replyToMessageId(event.message.messageId).parseMode(ParseMode.HTML) - case Some(pics) => -// val mediaGroup = pics.map(f => -// InputMediaPhoto(weibo.PicUrl(weibo.randomPicCdn, "large", f.pid).toUrl)) - val mediaGroup = pics.map(f => InputMediaPhoto(weibo.MApi.Fetch.pic(f.large.url))) - mediaGroup.head.caption(content) - mediaGroup.head.parseMode(ParseMode.HTML) - coeur.account exec SendMediaGroup( - event.message.chat.id, - mediaGroup:_* - ).replyToMessageId(event.message.messageId) - } catch - case e: HttpError[?] => - coeur.account exec SendMessage( - event.message.chat.id, - // language=html - s"""Weibo Request Error ${e.statusCode} - |
${e.body}
""".stripMargin - ).replyToMessageId(event.message.messageId).parseMode(ParseMode.HTML) - case e: (SttpClientException|ParsingFailure|DecodingFailure) => - coeur.account exec SendSticker( - event.message.chat.id, - TelegramStickers.ID_NETWORK_ERR - ).replyToMessageId(event.message.messageId) - logger error - "Error on requesting Weibo m.API\n" + exceptionLog(e) - coeur.daemons.reporter.exception(e, "Error on requesting Weibo m.API") - - if succeed == 0 then do404() + if !OnGetSocial.tryFetchSocial(command.args(0))(using event.message.chat.id, event.message.messageId) then + do404() } 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/OnGetSocial.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnGetSocial.scala new file mode 100644 index 0000000..eeae553 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnGetSocial.scala @@ -0,0 +1,93 @@ +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 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(messageEvent.text)(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, maybe a social url. + * @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: String)(using replyChat: Long, replyToMessage: Int)(using coeur: MornyCoeur): Boolean = { + val _text = text.trim + + var succeed = 0 + + import io.circe.{DecodingFailure, ParsingFailure} + import sttp.client3.{HttpError, SttpClientException} + import twitter.{FXApi, TweetUrlInformation} + import weibo.{MApi, StatusUrlInfo} + twitter.parseTweetUrl(_text) match + case None => + case Some(TweetUrlInformation(_, _, screenName, statusId, _, _)) => + succeed += 1 + try { + val api = FXApi.Fetch.status(Some(screenName), 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") + + weibo.parseWeiboStatusUrl(_text) match + case None => + case Some(StatusUrlInfo(_, id)) => + succeed += 1 + try { + val api = MApi.Fetch.statuses_show(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") + + succeed > 0 + + } + +} 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/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/data/social/SocialContent.scala b/src/main/scala/cc/sukazyo/cono/morny/data/social/SocialContent.scala new file mode 100644 index 0000000..84bcf80 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/data/social/SocialContent.scala @@ -0,0 +1,102 @@ +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 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, + 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 (this.medias.length == 1) && (this.medias.head.t == Photo) && this.medias.head.isInstanceOf[SocialMediaWithUrl] then + InlineQueryUnit(InlineQueryResultPhoto( + inlineQueryId(s"[$id_head/photo/0]$id_param"), + this.medias.head.asInstanceOf[SocialMediaWithUrl].url, + thumbnailOrElse(this.medias.head.asInstanceOf[SocialMediaWithUrl].url) + ).title(s"$name").caption(text_html).parseMode(ParseMode.HTML)) :: Nil + else if (this.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 + InlineQueryUnit(InlineQueryResultArticle( + inlineQueryId(s"[$id_head/text]$id_param"), s"$name", + InputTextMessageContent(this.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..21e2da9 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/data/social/SocialTwitterParser.scala @@ -0,0 +1,52 @@ +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 +import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.escapeHtml as h + +object SocialTwitterParser { + + def parseFXTweet (api: FXApi): SocialContent = { + api.tweet match + case None => + SocialContent( + // language=html + s"""❌ Fix-Tweet ${api.code} + |${h(api.message)}""".stripMargin, + 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 + tweet.media match + case None => + SocialContent(content, 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, 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..44b9482 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/data/social/SocialWeiboParser.scala @@ -0,0 +1,29 @@ +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 { + + @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)} + | + |${h(api.data.created_at)}""".stripMargin + api.data.pics match + case None => + SocialContent(content, Nil) + case Some(pics) => + val mediaGroup = pics.map(f => SocialMediaWithBytesData(MApi.Fetch.pic(f.large.url))(Photo)) + SocialContent(content, mediaGroup) + } + +} From 20c991653511ecd55c28cc3b655b8e4cd7838525 Mon Sep 17 00:00:00 2001 From: Eyre_S Date: Wed, 29 Nov 2023 21:08:08 +0800 Subject: [PATCH 16/18] add show retweet weibo --- gradle.properties | 2 +- .../cono/morny/data/social/SocialWeiboParser.scala | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index f42f3ab..c61b7ab 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ 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.3.0-dev12 +VERSION = 1.3.0-dev13 USE_DELTA = false VERSION_DELTA = 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 index 44b9482..9fac0d7 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/social/SocialWeiboParser.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/data/social/SocialWeiboParser.scala @@ -11,12 +11,23 @@ object SocialWeiboParser { @throws[HttpError[?] | SttpClientException | ParsingFailure | DecodingFailure] def parseMStatus (api: MApi[MStatus]): SocialContent = { + def retweetedMessage (retweetedStatus: Option[MStatus]): String = + retweetedStatus match + case Some(status) => + val pic_preview = if status.pic_ids.isEmpty then "" else + "\n" + (for (pic <- status.pic_ids) yield "🖼️").mkString(" ") + // language=html + s""" + |//${h(status.user.screen_name)}: + |${ch(status.text)}$pic_preview + |""".stripMargin + case None => "" val content = // language=html s"""🔸${h(api.data.user.screen_name)} | |${ch(api.data.text)} - | + |${retweetedMessage(api.data.retweeted_status)} |${h(api.data.created_at)}""".stripMargin api.data.pics match case None => From ad65ab7a7315d8d31e1e848382e883518fd5177f Mon Sep 17 00:00:00 2001 From: Eyre_S Date: Thu, 30 Nov 2023 00:13:29 +0800 Subject: [PATCH 17/18] SocialContent add non-pic alternative --- gradle.properties | 2 +- .../morny/data/social/SocialContent.scala | 28 +++++++++---- .../data/social/SocialTwitterParser.scala | 28 +++++++++---- .../morny/data/social/SocialWeiboParser.scala | 40 ++++++++++++------- 4 files changed, 67 insertions(+), 31 deletions(-) diff --git a/gradle.properties b/gradle.properties index c61b7ab..20156e4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ 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.3.0-dev13 +VERSION = 1.3.0-dev14 USE_DELTA = false VERSION_DELTA = 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 index 84bcf80..e7766bc 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/social/SocialContent.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/data/social/SocialContent.scala @@ -17,6 +17,9 @@ import com.pengrad.telegrambot.request.{SendMediaGroup, SendMessage} * @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 @@ -28,6 +31,7 @@ import com.pengrad.telegrambot.request.{SendMediaGroup, SendMessage} */ case class SocialContent ( text_html: String, + text_withPicPlaceholder: String, medias: List[SocialMedia], medias_mosaic: Option[SocialMedia] = None, thumbnail: Option[SocialMedia] = None @@ -56,22 +60,30 @@ case class SocialContent ( def genInlineQueryResults (using id_head: String, id_param: Any, name: String): List[InlineQueryUnit[?]] = { ( - if (this.medias.length == 1) && (this.medias.head.t == Photo) && this.medias.head.isInstanceOf[SocialMediaWithUrl] then - InlineQueryUnit(InlineQueryResultPhoto( - inlineQueryId(s"[$id_head/photo/0]$id_param"), - this.medias.head.asInstanceOf[SocialMediaWithUrl].url, - thumbnailOrElse(this.medias.head.asInstanceOf[SocialMediaWithUrl].url) - ).title(s"$name").caption(text_html).parseMode(ParseMode.HTML)) :: Nil - else if (this.medias_mosaic nonEmpty) && (medias_mosaic.get.t == Photo) && medias_mosaic.get.isInstanceOf[SocialMediaWithUrl] then + 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(this.text_html).parseMode(ParseMode.HTML) + InputTextMessageContent(text_html).parseMode(ParseMode.HTML) )) :: Nil ) ::: Nil } 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 index 21e2da9..b8cef11 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/social/SocialTwitterParser.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/data/social/SocialTwitterParser.scala @@ -2,20 +2,26 @@ 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 +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 => - SocialContent( + val content = // language=html s"""❌ Fix-Tweet ${api.code} - |${h(api.message)}""".stripMargin, - Nil - ) + |${h(api.message)}""".stripMargin + SocialContent(content, content, Nil) case Some(tweet) => val content: String = // language=html @@ -25,9 +31,17 @@ object SocialTwitterParser { | |💬${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, Nil) + SocialContent(content, content_withMediasPlaceholder, Nil) case Some(media) => val mediaGroup: List[SocialMedia] = ( @@ -46,7 +60,7 @@ object SocialTwitterParser { val mediaMosaic = media.mosaic match case Some(mosaic) => Some(SocialMediaWithUrl(mosaic.formats.jpeg)(Photo)) case None => None - SocialContent(content, mediaGroup, mediaMosaic, thumbnail) + 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 index 9fac0d7..7acaa08 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/data/social/SocialWeiboParser.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/data/social/SocialWeiboParser.scala @@ -9,32 +9,42 @@ 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 = { - def retweetedMessage (retweetedStatus: Option[MStatus]): String = - retweetedStatus match - case Some(status) => - val pic_preview = if status.pic_ids.isEmpty then "" else - "\n" + (for (pic <- status.pic_ids) yield "🖼️").mkString(" ") - // language=html - s""" - |//${h(status.user.screen_name)}: - |${ch(status.text)}$pic_preview - |""".stripMargin - case None => "" val content = - // language=html + // language=html s"""🔸${h(api.data.user.screen_name)} | |${ch(api.data.text)} - |${retweetedMessage(api.data.retweeted_status)} + |${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, Nil) + SocialContent(content, content_withPicPlaceholder, Nil) case Some(pics) => val mediaGroup = pics.map(f => SocialMediaWithBytesData(MApi.Fetch.pic(f.large.url))(Photo)) - SocialContent(content, mediaGroup) + SocialContent(content, content_withPicPlaceholder, mediaGroup) } } From c4632263de350e0d8bc793c10be2c87757fc629d Mon Sep 17 00:00:00 2001 From: Eyre_S Date: Sat, 2 Dec 2023 21:11:33 +0800 Subject: [PATCH 18/18] make private url share can get from content --- gradle.properties | 2 +- .../cono/morny/bot/command/GetSocial.scala | 2 +- .../cono/morny/bot/event/OnGetSocial.scala | 117 +++++++++++------- .../cono/morny/extra/twitter/package.scala | 11 +- .../cono/morny/extra/weibo/package.scala | 7 +- .../morny/util/tgapi/TelegramExtensions.scala | 10 +- .../morny/test/extra/BilibiliFormsTest.scala | 57 ++++----- 7 files changed, 126 insertions(+), 80 deletions(-) diff --git a/gradle.properties b/gradle.properties index 20156e4..33be05e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ 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.3.0-dev14 +VERSION = 1.3.0-dev15 USE_DELTA = false VERSION_DELTA = 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 index 0cb9bc7..f8bfc58 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/bot/command/GetSocial.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/bot/command/GetSocial.scala @@ -24,7 +24,7 @@ class GetSocial (using coeur: MornyCoeur) extends ITelegramCommand { if command.args.length < 1 then { do404(); return } - if !OnGetSocial.tryFetchSocial(command.args(0))(using event.message.chat.id, event.message.messageId) then + 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/event/OnGetSocial.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnGetSocial.scala index eeae553..5732d58 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnGetSocial.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/bot/event/OnGetSocial.scala @@ -8,6 +8,7 @@ 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} @@ -20,7 +21,11 @@ class OnGetSocial (using coeur: MornyCoeur) extends EventListener { if messageEvent.chat.`type` != Chat.Type.Private then return; if messageEvent.text == null then return; - if tryFetchSocial(messageEvent.text)(using messageEvent.chat.id, messageEvent.messageId) then + 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 } @@ -31,63 +36,81 @@ object OnGetSocial { /** Try fetch from url from input and output fetched social content. * - * @param text input text, maybe a social url. + * @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: String)(using replyChat: Long, replyToMessage: Int)(using coeur: MornyCoeur): Boolean = { - val _text = text.trim + def tryFetchSocial (text: Either[String, String])(using replyChat: Long, replyToMessage: Int)(using coeur: MornyCoeur): Boolean = { var succeed = 0 - import io.circe.{DecodingFailure, ParsingFailure} - import sttp.client3.{HttpError, SttpClientException} - import twitter.{FXApi, TweetUrlInformation} - import weibo.{MApi, StatusUrlInfo} - twitter.parseTweetUrl(_text) match - case None => - case Some(TweetUrlInformation(_, _, screenName, statusId, _, _)) => - succeed += 1 - try { - val api = FXApi.Fetch.status(Some(screenName), 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") - - weibo.parseWeiboStatusUrl(_text) match - case None => - case Some(StatusUrlInfo(_, id)) => - succeed += 1 - try { - val api = MApi.Fetch.statuses_show(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") + { + 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/extra/twitter/package.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/package.scala index 05c9b03..ee07551 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/package.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/extra/twitter/package.scala @@ -4,7 +4,7 @@ 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+))?)/?(?:\\?([\\w&=-]+))?$"r + 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. * @@ -69,4 +69,13 @@ package object twitter { )) 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/package.scala b/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/package.scala index 80bfb5e..c57e5dc 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/package.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/extra/weibo/package.scala @@ -23,13 +23,18 @@ package object weibo { // 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]+)/?(?:\\?([\\w&=-]+))?$"r + 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}" 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 d4b9bb3..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 @@ -66,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/test/scala/cc/sukazyo/cono/morny/test/extra/BilibiliFormsTest.scala b/src/test/scala/cc/sukazyo/cono/morny/test/extra/BilibiliFormsTest.scala index 6ad92ac..fd2d1fd 100644 --- a/src/test/scala/cc/sukazyo/cono/morny/test/extra/BilibiliFormsTest.scala +++ b/src/test/scala/cc/sukazyo/cono/morny/test/extra/BilibiliFormsTest.scala @@ -3,7 +3,6 @@ package cc.sukazyo.cono.morny.test.extra 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,32 +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"), - ("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 - } - - } + // 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 +// } +// +// } }