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: